fix(release): 7.0.7 릴리즈 검증 발견 결함 전수 처리 및 출시일 8/18 정렬
7.0.7 페이로드 전수 검증에서 발견된 error 4건·warn 14건을 릴리즈 전에 처리했다. 미출시 분량의 자체 결함(회원 통합검색 500, 관리자 스코프 403 의 500 위장 등)이므로 페이로드에 합류시키고, 출시 대상 26개 CHANGELOG 의 버전 섹션 날짜를 2026-08-18 로 정렬했다. - 통합검색 import 복원 + 이커머스 관리자 typed 403 catch 대칭(테스트 동반) - admin 판정 스니핑 사설 사본 전수 수렴(BoardResource) + 조건 스캔 테스트 - 스케줄 인터프리터 변형 표기(확장자·버전 접미사) 정규화 차단 - ckeditor5 참조 판정 배치화·fail-safe, 결제 플러그인 진단 페이로드 대칭 - 템플릿 다크 대비·상태 Select value-제어 + --production 재빌드 - Upgrade_7_0_7 잔존 중복 색인 정리 스텝, API 문서·i18n(ko/en/ja) 동기
This commit is contained in:
+2
-1
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [7.0.7] - 2026-08-11
|
||||
## [7.0.7] - 2026-08-18
|
||||
|
||||
### Security
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 데이터베이스 테이블 접두어를 기본값(`g7_`)이 아닌 값으로 설치한 사이트에서, 7.0.6 업그레이드의 활동 로그 색인 교체가 옛 색인을 지우지 못하고 남겨 두던 문제를 수정했습니다. 남은 옛 색인은 조회 이득 없이 기록 쓰기 비용만 늘리므로, 이번 업그레이드에서 자동으로 정리됩니다.
|
||||
- FULLTEXT 인덱스가 없는 테이블을 검색하면 오류 대신 부분일치 검색으로 자동 전환되고, 그 사실이 기록으로 남습니다. 이전에는 오류가 화면에 드러나지 않아 "검색 결과 0건" 으로만 보였습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 통합검색에서 일부 항목의 검색이 실패하면 "검색 결과 없음" 이 아니라 오류 안내로 구분해 표시되도록, 항목별 실패 여부를 응답에 실어 내립니다. 실패 원인은 서버 기록에 상세히 남습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 언어팩 업데이트 확인이 특정 조건(요청에 페이지 이동 파라미터가 실려 있는 경우)에서 아무 팩도 확인하지 않고 끝나던 문제를 수정했습니다. (#102 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
// audit:allow api-doc-coverage reason: 응답 계약 불변 — lang 키에서 :error 플레이스홀더가 제거되어 사문화된 messageParams 인자만 정리 (키·상태코드·payload 형태 무변경)
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Exceptions\CannotDeleteSuperAdminException;
|
||||
@@ -133,7 +135,7 @@ class UserController extends AdminBaseController
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('user.update_failed', 422, $e->errors());
|
||||
} catch (Exception $e) {
|
||||
return $this->error('user.update_failed', 500, $e, ['error' => $e->getMessage()]);
|
||||
return $this->error('user.update_failed', 500, $e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +161,7 @@ class UserController extends AdminBaseController
|
||||
} catch (CannotModifySuperAdminException $e) {
|
||||
return $this->error('exceptions.cannot_modify_super_admin', 403);
|
||||
} catch (Exception $e) {
|
||||
return $this->error('user.update_failed', 500, $e, ['error' => $e->getMessage()]);
|
||||
return $this->error('user.update_failed', 500, $e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ class MenuRepository implements MenuRepositoryInterface
|
||||
public function findByIds(array $ids): Collection
|
||||
{
|
||||
if (empty($ids)) {
|
||||
return Menu::query()->whereRaw('1 = 0')->get();
|
||||
return new Collection;
|
||||
}
|
||||
|
||||
return Menu::whereIn('id', $ids)->get();
|
||||
|
||||
@@ -771,7 +771,16 @@ class SettingsService
|
||||
}
|
||||
|
||||
foreach ($removedIds as $removedId) {
|
||||
$this->attachmentService->delete($removedId);
|
||||
// 설정 저장은 이미 확정된 뒤다 — 파기 실패가 저장 실패(422)로 위장되면
|
||||
// 운영자는 성공한 저장을 실패로 오인한다. 실패 파일은 로그로만 남긴다.
|
||||
try {
|
||||
$this->attachmentService->delete($removedId);
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('사이트 로고 첨부 파기 실패 — 저장은 확정됨', [
|
||||
'attachment_id' => $removedId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Log::info('사이트 로고 첨부 제거', ['attachment_ids' => $removedIds]);
|
||||
|
||||
@@ -760,7 +760,11 @@ class UserService
|
||||
if (empty($userIds)) {
|
||||
HookManager::doAction('sirsoft-core.user.after_bulk_update', $uuids, $status, 0);
|
||||
|
||||
return ['updated_count' => 0];
|
||||
return [
|
||||
'updated_count' => 0,
|
||||
'failed_count' => 0,
|
||||
'failed_reasons' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// DB 트랜잭션으로 일괄 업데이트
|
||||
|
||||
@@ -126,7 +126,7 @@ class ScheduleCommandValidator
|
||||
return self::shellVerdict(false, self::SHELL_REASON_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
$normalized = strtolower($binary);
|
||||
$normalized = self::normalizeShellBinary($binary);
|
||||
|
||||
// 완전 거부형 — 화이트리스트에 등재돼 있어도 실행하지 않는다.
|
||||
if (in_array($normalized, self::shellList('reject_binaries'), true)) {
|
||||
@@ -224,6 +224,29 @@ class ScheduleCommandValidator
|
||||
* @param string $key `schedule_security.shell` 하위 키
|
||||
* @return array<int, string>
|
||||
*/
|
||||
/**
|
||||
* 인터프리터/거부형 분류용으로 바이너리 이름을 정규화합니다.
|
||||
*
|
||||
* 분류 목록(`script_interpreters`/`reject_binaries`)은 기본 이름만 담는다.
|
||||
* 운영자가 Windows 관례(`python.exe`)나 버전 접미사(`python3.12`, `php8.2`)로
|
||||
* 화이트리스트에 등재하면 정확 일치 분류가 비켜가 인터프리터가 "일반 스크립트 —
|
||||
* 통과" 분기를 타고, `-c` 인라인 코드 차단(KVE-2026-1653)이 무력화된다.
|
||||
* 화이트리스트 대조 자체는 운영자가 적은 원문 그대로 유지한다 — 여기서 접는
|
||||
* 것은 위험 분류뿐이다.
|
||||
*
|
||||
* @param string $binary 첫 토큰의 basename
|
||||
* @return string 소문자 + 실행 확장자(.exe/.bat/.cmd/.com) 제거 + 후행 버전 숫자 제거
|
||||
*/
|
||||
private static function normalizeShellBinary(string $binary): string
|
||||
{
|
||||
$name = strtolower($binary);
|
||||
$name = (string) preg_replace('/\.(exe|bat|cmd|com)$/', '', $name);
|
||||
|
||||
// 후행 버전 접미사(python3.12 → python, php8.2 → php)를 접는다.
|
||||
// python3 이 python 으로 접혀도 두 이름 모두 분류 목록에 있어 판정은 동일하다.
|
||||
return (string) preg_replace('/[0-9][0-9.]*$/', '', $name);
|
||||
}
|
||||
|
||||
private static function shellList(string $key): array
|
||||
{
|
||||
return array_values(array_filter(array_map(
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.6] - 2026-08-13
|
||||
## [1.0.6] - 2026-08-18
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.3] - 2026-08-17
|
||||
## [1.0.3] - 2026-08-18
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.1] - 2026-08-17
|
||||
## [1.1.1] - 2026-08-18
|
||||
|
||||
### Added
|
||||
|
||||
- 리뷰 작성 불가 안내의 사유 라벨(`messages.reviews.reasons.*` — 주문 내역 없음·본인 주문 아님·구매확정 전·기한 경과·중복 작성) 일본어 번역을 추가했습니다.
|
||||
- 확정 불가 상태의 주문 옵션 구매확정 시도 안내(`exceptions.order_option_not_confirmable`)의 일본어 번역을 추가했습니다.
|
||||
- 이미 답변된 상품 문의에 답변을 재등록할 때의 거절 안내(`inquiries.reply_already_exists`) 일본어 번역을 추가했습니다.
|
||||
- 이용할 수 없는 결제수단 선택 시의 주문 거절 안내 일본어 번역을 추가했습니다 (`validation.order.payment_method_unavailable`).
|
||||
|
||||
@@ -1044,6 +1044,14 @@ return [
|
||||
'can_write_checked' => 'レビュー作成可否を確認しました。',
|
||||
'can_write_check_failed' => 'レビュー作成可否の確認に失敗しました。',
|
||||
'cannot_write' => 'レビューを作成できません。(理由: :reason)',
|
||||
// 作成不可理由ラベル — :reason 置換子に生の識別子ではなく翻訳文を載せる
|
||||
'reasons' => [
|
||||
'order_option_not_found' => '注文内容が見つかりません',
|
||||
'not_own_order' => 'ご本人の注文ではありません',
|
||||
'not_confirmed' => '購入確定前です',
|
||||
'deadline_passed' => '作成期限が過ぎています',
|
||||
'already_written' => 'すでに作成済みのレビューです',
|
||||
],
|
||||
'forbidden' => '権限がありません。',
|
||||
'deleted' => 'レビューが削除されました。',
|
||||
'delete_failed' => 'レビュー削除に失敗しました。',
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.1] - 2026-08-12
|
||||
## [1.0.1] - 2026-08-18
|
||||
|
||||
### Added
|
||||
|
||||
- 일괄 삭제 부분 실패 안내(`messages.uploads.bulk_partially_deleted`)와 참조 판정 불완전 시 정리 건너뜀 안내(`messages.cleanup.sources_incomplete`), 이미지 삭제 실패 토스트(`admin.uploads.delete.failed`)의 일본어 번역을 추가했습니다.
|
||||
- 에디터 설정의 "공개 자산 디스크" 설정 문구 일본어 번역을 추가했습니다.
|
||||
- 「에디터 업로드 이미지」 관리 화면의 문구 일본어 번역을 추가했습니다 (`admin.uploads.*`) — 목록 제목·열 이름·참조 상태 배지·필터·삭제 확인 창과 판정 범위 안내가 일본어 로케일에서 표시됩니다.
|
||||
- 미사용 이미지 자동 정리 설정의 라벨·도움말 일본어 번역을 추가했습니다 (`settings.section_cleanup`, `settings.cleanup.*`).
|
||||
|
||||
@@ -19,9 +19,11 @@ return [
|
||||
'ids_required' => '削除する画像を選択してください。',
|
||||
'deleted' => '画像を削除しました。',
|
||||
'bulk_deleted' => '選択した画像 :deleted件を削除しました。',
|
||||
'bulk_partially_deleted' => '選択した画像のうち :deleted件を削除し、:failed件は削除できませんでした。失敗した項目はリストに残っています。',
|
||||
'ids_invalid' => '選択した画像の中に存在しないアイテムがあります。リストをリフレッシュした後、再度選択してください。',
|
||||
],
|
||||
'cleanup' => [
|
||||
'retention_disabled' => '保存期間が1日未満に設定されているため、クリーンアップを実行しませんでした。',
|
||||
'sources_incomplete' => '無効状態のモジュールがあり参照判定が不完全になる可能性があるため、未参照画像のクリーンアップをスキップしました。モジュールを有効化または削除してから再実行してください。',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -93,7 +93,8 @@
|
||||
"button": "削除",
|
||||
"confirm_title": "画像を削除しますか?",
|
||||
"confirm_message": "選択した画像のファイルと記録が一緒に削除されます。この操作は取り消せません。",
|
||||
"success": "画像を削除しました。"
|
||||
"success": "画像を削除しました。",
|
||||
"failed": "画像の削除に失敗しました。"
|
||||
},
|
||||
"bulk_delete": {
|
||||
"button": "選択削除",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.1] - 2026-08-17
|
||||
## [1.1.1] - 2026-08-18
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.3] - 2026-08-17
|
||||
## [1.0.3] - 2026-08-18
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
{
|
||||
"identifier": "g7-plugin-sirsoft-pay_nhnkcp-ja",
|
||||
"namespace": "g7",
|
||||
"vendor": "sirsoft",
|
||||
"name": {
|
||||
"ko": "G7 플러그인 (sirsoft-pay_nhnkcp) 일본어 언어팩",
|
||||
"en": "G7 plugin (sirsoft-pay_nhnkcp) Japanese language pack",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_nhnkcp) 日本語 言語パック"
|
||||
},
|
||||
"description": {
|
||||
"ko": "G7 플러그인 (sirsoft-pay_nhnkcp) 일본어 언어팩 (번들)",
|
||||
"en": "G7 plugin (sirsoft-pay_nhnkcp) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_nhnkcp) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-pay_nhnkcp",
|
||||
"locale": "ja",
|
||||
"locale_name": "Japanese",
|
||||
"locale_native_name": "日本語",
|
||||
"text_direction": "ltr",
|
||||
"g7_version": ">=7.0.0-beta.4",
|
||||
"requires": {
|
||||
"target_version": null,
|
||||
"depends_on_core_locale": true
|
||||
},
|
||||
"github_url": "",
|
||||
"github_changelog_url": ""
|
||||
"identifier": "g7-plugin-sirsoft-pay_nhnkcp-ja",
|
||||
"namespace": "g7",
|
||||
"vendor": "sirsoft",
|
||||
"name": {
|
||||
"ko": "G7 플러그인 (sirsoft-pay_nhnkcp) 일본어 언어팩",
|
||||
"en": "G7 plugin (sirsoft-pay_nhnkcp) Japanese language pack",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_nhnkcp) 日本語 言語パック"
|
||||
},
|
||||
"description": {
|
||||
"ko": "G7 플러그인 (sirsoft-pay_nhnkcp) 일본어 언어팩 (번들)",
|
||||
"en": "G7 plugin (sirsoft-pay_nhnkcp) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_nhnkcp) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-pay_nhnkcp",
|
||||
"locale": "ja",
|
||||
"locale_name": "Japanese",
|
||||
"locale_native_name": "日本語",
|
||||
"text_direction": "ltr",
|
||||
"g7_version": ">=7.0.0-beta.4",
|
||||
"requires": {
|
||||
"target_version": null,
|
||||
"depends_on_core_locale": true
|
||||
},
|
||||
"github_url": "",
|
||||
"github_changelog_url": ""
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.3] - 2026-08-17
|
||||
## [1.0.3] - 2026-08-18
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
{
|
||||
"identifier": "g7-plugin-sirsoft-pay_nicepayments-ja",
|
||||
"namespace": "g7",
|
||||
"vendor": "sirsoft",
|
||||
"name": {
|
||||
"ko": "G7 플러그인 (sirsoft-pay_nicepayments) 일본어 언어팩",
|
||||
"en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック"
|
||||
},
|
||||
"description": {
|
||||
"ko": "G7 플러그인 (sirsoft-pay_nicepayments) 일본어 언어팩 (번들)",
|
||||
"en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-pay_nicepayments",
|
||||
"locale": "ja",
|
||||
"locale_name": "Japanese",
|
||||
"locale_native_name": "日本語",
|
||||
"text_direction": "ltr",
|
||||
"g7_version": ">=7.0.0-beta.4",
|
||||
"requires": {
|
||||
"target_version": null,
|
||||
"depends_on_core_locale": true
|
||||
},
|
||||
"github_url": "",
|
||||
"github_changelog_url": ""
|
||||
"identifier": "g7-plugin-sirsoft-pay_nicepayments-ja",
|
||||
"namespace": "g7",
|
||||
"vendor": "sirsoft",
|
||||
"name": {
|
||||
"ko": "G7 플러그인 (sirsoft-pay_nicepayments) 일본어 언어팩",
|
||||
"en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック"
|
||||
},
|
||||
"description": {
|
||||
"ko": "G7 플러그인 (sirsoft-pay_nicepayments) 일본어 언어팩 (번들)",
|
||||
"en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-pay_nicepayments",
|
||||
"locale": "ja",
|
||||
"locale_name": "Japanese",
|
||||
"locale_native_name": "日本語",
|
||||
"text_direction": "ltr",
|
||||
"g7_version": ">=7.0.0-beta.4",
|
||||
"requires": {
|
||||
"target_version": null,
|
||||
"depends_on_core_locale": true
|
||||
},
|
||||
"github_url": "",
|
||||
"github_changelog_url": ""
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.2] - 2026-08-14
|
||||
## [1.0.2] - 2026-08-18
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.5] - 2026-08-12
|
||||
## [1.0.5] - 2026-08-18
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.1] - 2026-08-16
|
||||
## [1.1.1] - 2026-08-18
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.4] - 2026-08-17
|
||||
## [1.0.4] - 2026-08-18
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -4377,6 +4377,7 @@ HTTP/1.1 201
|
||||
| postId | path | string | 예 | — | 대상 post의 식별자 |
|
||||
| commentId | path | string | 예 | — | 대상 comment의 식별자 |
|
||||
| password | body | string | 아니오 | min 4, max 20 | 비회원 댓글 삭제 시 소유권 확인용 비밀번호. 형식(문자열, 4~20자)은 `DestroyCommentRequest` 가 검증하며, 배열 주입 등 형식 위반은 422. 소유권 판정 자체는 서비스(`canDelete`)가 수행 |
|
||||
| verification_token | body | string | 아니오 | max 255 | 비회원 댓글 삭제 시 비밀번호 재입력을 대체하는 임시 검증 토큰 (verify-password 로 발급, 1회 소비). 문자열 형식 위반(배열 주입 등)은 422 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
@@ -4432,6 +4433,9 @@ HTTP/1.1 200
|
||||
| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) |
|
||||
| postId | path | string | 예 | — | 대상 post의 식별자 |
|
||||
| commentId | path | string | 예 | — | 대상 comment의 식별자 |
|
||||
| content | body | string | 예 | 게시판 설정의 min/max 길이 | 수정할 댓글 본문 (금지 키워드 검사 포함) |
|
||||
| password | body | string | 조건부 | min 4, max 20 | 비회원 댓글 수정 시 소유권 확인용 비밀번호 (검증 토큰이 있으면 생략 가능) |
|
||||
| verification_token | body | string | 아니오 | max 255 | 비회원 댓글 수정 시 비밀번호 재입력을 대체하는 임시 검증 토큰 (verify-password 로 발급, 1회 소비) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.comment.update_validation_rules`).
|
||||
|
||||
|
||||
@@ -256,12 +256,12 @@ class CommentController extends PublicBaseController
|
||||
);
|
||||
|
||||
// 비회원 댓글: 평문 비밀번호 재전송 대신 검증 토큰으로도 본인 확인 (게시글과 동형)
|
||||
$verificationToken = request()->input('verification_token');
|
||||
$verificationToken = $request->validated('verification_token');
|
||||
if (! $canDelete && ! empty($verificationToken)) {
|
||||
$canDelete = $this->commentService->consumeCommentVerifyToken(
|
||||
$slug,
|
||||
$commentId,
|
||||
(string) $verificationToken
|
||||
$verificationToken
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ class DestroyCommentRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'password' => ['nullable', 'string', 'min:4', 'max:20'],
|
||||
'verification_token' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ class UpdateCommentRequest extends FormRequest
|
||||
'status' => ['nullable', 'string', Rule::in(PostStatus::values())],
|
||||
// 비회원인 경우 비밀번호 필수 (수정 권한 검증용). 단, 검증 토큰이 있으면 선택.
|
||||
'password' => [$isGuest && ! $hasVerificationToken ? 'required' : 'nullable', 'string', 'min:4', 'max:20'],
|
||||
'verification_token' => ['nullable', 'string'],
|
||||
'verification_token' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
|
||||
// 훅: 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 필터 제공
|
||||
|
||||
@@ -404,7 +404,7 @@ class BoardResource extends BaseApiResource
|
||||
/** @var User|null $user */
|
||||
$user = Auth::user();
|
||||
$slug = $this->getValue('slug');
|
||||
$isAdminRoute = $this->isAdminRoute($request);
|
||||
$isAdminRoute = $this->isAdminRequest($request);
|
||||
|
||||
// 컨텍스트에 맞는 permissionMap 구성 (PostResource.resolveAbilities()와 동일 키)
|
||||
$permissionMap = $isAdminRoute
|
||||
@@ -493,23 +493,4 @@ class BoardResource extends BaseApiResource
|
||||
|
||||
return $guestRole->permissions()->where('permissions.id', $permission->id)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin 라우트 여부를 확인합니다.
|
||||
*
|
||||
* Controller 네임스페이스로 판단합니다.
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @return bool Admin 라우트 여부
|
||||
*/
|
||||
private function isAdminRoute(Request $request): bool
|
||||
{
|
||||
$controller = $request->route()?->getController();
|
||||
|
||||
if (! $controller) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_contains(get_class($controller), '\\Admin\\');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,25 +234,6 @@ class CommentResource extends BaseApiResource
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin 요청 여부를 확인합니다.
|
||||
*
|
||||
* Controller 네임스페이스로 판단합니다.
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @return bool Admin 요청 여부
|
||||
*/
|
||||
private function isAdminRequest(Request $request): bool
|
||||
{
|
||||
$controller = $request->route()?->getController();
|
||||
|
||||
if (! $controller) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_contains(get_class($controller), '\\Admin\\');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 콘텐츠 필터링 메서드
|
||||
// =========================================================================
|
||||
|
||||
@@ -339,23 +339,4 @@ class PostCollection extends BaseApiCollection
|
||||
'can_access_admin' => $this->checkBoardPermission($slug, 'admin.manage'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin 요청 여부를 확인합니다.
|
||||
*
|
||||
* Controller 네임스페이스로 판단합니다.
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @return bool Admin 요청 여부
|
||||
*/
|
||||
private function isAdminRequest(Request $request): bool
|
||||
{
|
||||
$controller = $request->route()?->getController();
|
||||
|
||||
if (! $controller) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_contains(get_class($controller), '\\Admin\\');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,23 +656,6 @@ class PostResource extends BaseApiResource
|
||||
return $abilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin 요청 여부를 확인합니다.
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @return bool Admin 요청 여부
|
||||
*/
|
||||
private function isAdminRequest(Request $request): bool
|
||||
{
|
||||
$controller = $request->route()?->getController();
|
||||
|
||||
if (! $controller) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_contains(get_class($controller), '\\Admin\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀글 내용 열람 가능 여부를 확인합니다.
|
||||
*
|
||||
|
||||
@@ -511,11 +511,11 @@ class EcommerceInquiryHookListener implements HookListenerInterface
|
||||
* 해제 판정에 사용합니다. SoftDeletes 전역 스코프가 삭제된 답변을 제외하므로
|
||||
* "살아있는 답변" 만 집계됩니다.
|
||||
*
|
||||
* @param mixed $carry 이전 필터 결과 (초기값: 0)
|
||||
* @param mixed $carry 이전 필터 결과 (초기값: null = 판정 불가)
|
||||
* @param int $parentPostId 부모 문의 Post ID
|
||||
* @return int 살아있는 답변 수 (조회 실패 시 이전 필터 결과 유지)
|
||||
* @return int|null 살아있는 답변 수 (조회 실패 시 이전 필터 결과 유지 — null 이면 소비측이 판정 불가로 no-op)
|
||||
*/
|
||||
public function countReplies(mixed $carry, int $parentPostId): int
|
||||
public function countReplies(mixed $carry, int $parentPostId): ?int
|
||||
{
|
||||
try {
|
||||
return $this->postRepository->countRepliesByParentId($parentPostId);
|
||||
@@ -525,7 +525,9 @@ class EcommerceInquiryHookListener implements HookListenerInterface
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return (int) $carry;
|
||||
// 실패를 0(답변 없음)으로 접으면 소비측이 답변완료를 오해제한다 —
|
||||
// 판정 불가(null)를 그대로 흘려보낸다.
|
||||
return is_numeric($carry) ? (int) $carry : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1075,7 +1075,8 @@ class PostRepository implements PostRepositoryInterface
|
||||
$activityType = $filters['activity_type'] ?? 'authored';
|
||||
$sort = $filters['sort'] ?? 'latest'; // latest, oldest, views
|
||||
// 열람자 관점. 이 값이 대상 사용자와 다르면(비로그인 포함) **타인 관점**이므로
|
||||
// 비밀글·미발행글을 내보내지 않는다.
|
||||
// 비밀글·미발행글의 **본문(content_plain)만 비운다** — 행·제목·배지는 목록 화면과
|
||||
// 동일하게 유지한다(제목 공개 정책 2026-02-04, 행 제거가 아니라 본문 마스킹이 의도).
|
||||
//
|
||||
// 종전에는 `is_public` 옵트인 플래그로 이 판정을 했는데, 그 키를 설정하는 코드가
|
||||
// 저장소 어디에도 없어서 필터가 한 번도 적용되지 않았다(사문). 옵트인은 호출부가
|
||||
@@ -1776,6 +1777,7 @@ class PostRepository implements PostRepositoryInterface
|
||||
$descendants = [];
|
||||
|
||||
while ($frontier !== []) {
|
||||
// audit:allow query-unbounded-get reason: 대상은 한 원글의 답글 트리 한 레벨 — 삭제/복원은 트리 전체가 하나의 의사표시라 단일 트랜잭션에서 전량 확보해야 하며(부분 반영 시 원글과 답글 상태가 어긋남), visited 가드가 유한 종료를 보장한다
|
||||
$children = Post::withTrashed()
|
||||
->where('board_id', $board->id)
|
||||
->whereIn('parent_id', $frontier)
|
||||
@@ -2008,7 +2010,7 @@ class PostRepository implements PostRepositoryInterface
|
||||
->whereNull('deleted_at')
|
||||
->whereNull('parent_id')
|
||||
// 노출 제한 필터 — 미발행(블라인드·삭제)·비활성 게시판 글은 대시보드 최신글에서 제외한다.
|
||||
// 비밀글은 제목 공개 정책(2026-01-02)에 따라 관리자에게 제목을 노출한다(제외하지 않음).
|
||||
// 비밀글은 제목 공개 정책(2026-02-04)에 따라 관리자에게 제목을 노출한다(제외하지 않음).
|
||||
->where('status', PostStatus::Published->value)
|
||||
->whereHas('board', fn ($q) => $q->where('is_active', true))
|
||||
->with(['board', 'user'])
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Helpers\PermissionHelper;
|
||||
use App\Models\Menu;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Repositories\Concerns\ResolvesSortSpec;
|
||||
use App\Services\MenuService;
|
||||
use App\Services\RoleService;
|
||||
|
||||
@@ -81,21 +81,4 @@ class SecretContentGate
|
||||
{
|
||||
return $request->route('slug') ?? ($post->relationLoaded('board') ? $post->board?->slug : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin 요청 여부를 확인합니다.
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @return bool Admin 요청 여부
|
||||
*/
|
||||
private function isAdminRequest(Request $request): bool
|
||||
{
|
||||
$controller = $request->route()?->getController();
|
||||
|
||||
if (! $controller) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_contains(get_class($controller), '\\Admin\\');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Board\Traits;
|
||||
use App\Enums\PermissionType;
|
||||
use App\Http\Middleware\PermissionMiddleware;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Modules\Sirsoft\Board\Support\BoardPermissionCacheKeys;
|
||||
|
||||
@@ -16,6 +17,26 @@ use Modules\Sirsoft\Board\Support\BoardPermissionCacheKeys;
|
||||
*/
|
||||
trait ChecksBoardPermission
|
||||
{
|
||||
/**
|
||||
* Admin 화면 요청 여부를 컨트롤러 네임스페이스로 판정합니다.
|
||||
*
|
||||
* 판정 규칙의 단일 지점 — Resource/게이트마다 사설 복제본을 두면 규칙 변경 시
|
||||
* 한 곳만 누락되어 경로별 판정이 갈린다.
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @return bool Admin 요청 여부
|
||||
*/
|
||||
protected function isAdminRequest(Request $request): bool
|
||||
{
|
||||
$controller = $request->route()?->getController();
|
||||
|
||||
if (! $controller) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_contains(get_class($controller), '\\Admin\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판 권한을 확인합니다.
|
||||
*
|
||||
|
||||
@@ -334,4 +334,46 @@ class ChecksBoardPermissionTest extends ModuleTestCase
|
||||
// Then: false 반환 (guest role에 권한 미할당)
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin 요청 판정(컨트롤러 네임스페이스 스니핑)은 트레이트 단일 지점이어야 한다
|
||||
*
|
||||
* 판정 규칙이 Resource/게이트마다 사설 복제되면 규칙 변경 시 한 곳만 누락되어
|
||||
* 경로별 판정이 갈린다. 모듈 src 전체를 스캔하여 `getController()` 호출이
|
||||
* ChecksBoardPermission 트레이트에만 존재함을 고정한다 (열거가 아닌 조건 스캔).
|
||||
*/
|
||||
#[Test]
|
||||
public function admin_요청_판정은_트레이트_단일_지점이다(): void
|
||||
{
|
||||
$srcDir = dirname(__DIR__, 3).'/src';
|
||||
$allowed = str_replace('\\', '/', realpath($srcDir.'/Traits/ChecksBoardPermission.php'));
|
||||
$violations = [];
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($srcDir, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = str_replace('\\', '/', $file->getRealPath());
|
||||
|
||||
if ($path === $allowed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_contains((string) file_get_contents($path), 'getController(')) {
|
||||
$violations[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame(
|
||||
[],
|
||||
$violations,
|
||||
'Admin 요청 판정 사설 복제본 발견 — ChecksBoardPermission::isAdminRequest() 를 사용할 것: '
|
||||
.implode(', ', $violations)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.1] - 2026-08-17
|
||||
## [1.1.1] - 2026-08-18
|
||||
|
||||
### Security
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 리뷰를 작성할 수 없을 때의 안내 문구에 사유가 영문 식별자(`already_written` 등) 그대로 표시되던 문제를 수정했습니다. 이제 "이미 작성한 리뷰", "구매확정 전" 처럼 번역된 사유가 표시됩니다.
|
||||
|
||||
#### 상품 문의 답변
|
||||
|
||||
- 이미 답변이 등록된 상품 문의에 답변을 다시 등록하면 답변이 중복으로 쌓이던 문제를 수정했습니다. 이제 안내와 함께 거절되며, 기존 답변을 수정하거나 삭제한 뒤 다시 등록할 수 있습니다. (#106 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
|
||||
@@ -312,13 +312,13 @@ HTTP/1.1 200
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.delete`)이 없거나, 대상 브랜드가 요청자의 **스코프 밖**인 경우 (`auth.scope_denied`). 존재하지 않는 ID 도 스코프 판정에서 먼저 걸려 403 이 된다 |
|
||||
| 400 | Bad Request | 연결된 상품이 있어 삭제가 차단된 경우(`exceptions.brand_has_products` — 상품 수 포함), 또는 삭제 처리 중 예외 발생 (`exceptions.operation_failed`) |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.delete`)이 없거나, 대상 브랜드가 요청자의 **스코프 밖**인 경우 (`auth.scope_denied`) |
|
||||
| 400 | Bad Request | 해당 `id` 의 브랜드가 없는 경우(`exceptions.brand_not_found`), 연결된 상품이 있어 삭제가 차단된 경우(`exceptions.brand_has_products` — 상품 수 포함), 또는 삭제 처리 중 도메인 규칙 위반 |
|
||||
| 404 | Not Found | path 파라미터 형식이 라우트에 매칭되지 않는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 브랜드 1건을 삭제합니다. `auth:sanctum` + `sirsoft-ecommerce.brands.delete` 권한이 필요하며, `BrandService::deleteBrand()`가 삭제 전 연결된 상품 수를 확인합니다. 연결된 상품이 1개 이상이면 예외가 발생해 삭제가 차단되고 400 오류가 반환되므로, 해당 브랜드의 상품을 먼저 정리하거나 다른 브랜드로 이전해야 삭제할 수 있습니다. 대상이 없으면 404를 반환합니다.
|
||||
**설명** 브랜드 1건을 삭제합니다. `auth:sanctum` + `sirsoft-ecommerce.brands.delete` 권한이 필요하며, `BrandService::deleteBrand()`가 삭제 전 연결된 상품 수를 확인합니다. 연결된 상품이 1개 이상이면 예외가 발생해 삭제가 차단되고 400 오류가 반환되므로, 해당 브랜드의 상품을 먼저 정리하거나 다른 브랜드로 이전해야 삭제할 수 있습니다. 존재하지 않는 ID 는 400(`brand_not_found`)을 반환하고, 존재하지만 요청자의 스코프 밖인 대상은 403(`auth.scope_denied`)을 반환합니다.
|
||||
|
||||
|
||||
### PUT /api/modules/sirsoft-ecommerce/admin/brands/{brand}
|
||||
@@ -422,13 +422,13 @@ HTTP/1.1 200
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.update`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.update`)이 없거나, 대상 브랜드가 요청자의 **스코프 밖**인 경우 (`auth.scope_denied`) |
|
||||
| 400 | Bad Request | 해당 `brand` 의 브랜드가 없는 경우(`exceptions.brand_not_found`) 또는 수정 처리 중 도메인 규칙 위반 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 기존 브랜드(path의 `brand`)를 수정합니다. `auth:sanctum` + `sirsoft-ecommerce.brands.update` 권한이 필요하며, `BrandService::updateBrand()`가 검증된 데이터로 갱신한 뒤 `BrandResource`를 반환합니다. `name`은 필수이고 `slug`·`website`·`sort_order`·`is_active`는 선택입니다. 대상이 없거나 처리 중 예외가 발생하면 404 또는 400을 반환하며, `sirsoft-ecommerce.brand.update_validation_rules` 필터로 확장이 검증 규칙을 추가할 수 있습니다.
|
||||
**설명** 기존 브랜드(path의 `brand`)를 수정합니다. `auth:sanctum` + `sirsoft-ecommerce.brands.update` 권한이 필요하며, `BrandService::updateBrand()`가 검증된 데이터로 갱신한 뒤 `BrandResource`를 반환합니다. `name`은 필수이고 `slug`·`website`·`sort_order`·`is_active`는 선택입니다. 존재하지 않는 ID 는 400(`brand_not_found`), 스코프 밖 대상은 403(`auth.scope_denied`)을 반환하며, `sirsoft-ecommerce.brand.update_validation_rules` 필터로 확장이 검증 규칙을 추가할 수 있습니다.
|
||||
|
||||
|
||||
### GET /api/modules/sirsoft-ecommerce/admin/brands/{id}
|
||||
@@ -602,8 +602,8 @@ HTTP/1.1 200
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.update`)이 없는 경우 |
|
||||
| 400 | Bad Request | 해당 `id` 의 브랜드가 없거나 토글 처리 중 예외 발생 (`exceptions.operation_failed`) |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.update`)이 없거나, 대상 브랜드가 요청자의 **스코프 밖**인 경우 (`auth.scope_denied`) |
|
||||
| 400 | Bad Request | 해당 `id` 의 브랜드가 없는 경우(`exceptions.brand_not_found`) 또는 토글 처리 중 도메인 규칙 위반 |
|
||||
| 404 | Not Found | path 파라미터 형식이 라우트에 매칭되지 않는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
@@ -402,7 +402,7 @@ HTTP/1.1 201
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 400 | Bad Request | 파일 저장 중 예외 발생 (`exceptions.operation_failed`) |
|
||||
| 400 | Bad Request | 파일 저장 중 도메인 규칙 위반 (대상 카테고리 부재 등 — `exceptions.category_not_found` 같은 예외별 구체 키로 응답) |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `file` 은 필수이며 `jpeg,png,jpg,gif,svg,webp` 이미지만 허용, 최대 10MB |
|
||||
@@ -466,7 +466,7 @@ HTTP/1.1 200
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 400 | Bad Request | 순서 갱신 처리 중 예외 발생 (`exceptions.operation_failed`) |
|
||||
| 400 | Bad Request | 순서 갱신 처리 중 도메인 규칙 위반 (`exceptions.category_not_found` 같은 예외별 구체 키로 응답) |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `order` 는 1건 이상 필수이며 각 항목에 정수 `id` 와 0 이상의 정수 `order` 가 필요 |
|
||||
@@ -521,7 +521,7 @@ HTTP/1.1 200
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 400 | Bad Request | 이미지 삭제 처리 중 예외 발생 (`exceptions.operation_failed`) |
|
||||
| 400 | Bad Request | 이미지 삭제 처리 중 도메인 규칙 위반 (`exceptions.category_not_found` 같은 예외별 구체 키로 응답) |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 |
|
||||
| 404 | Not Found | path 의 이미지 `id` 에 해당하는 카테고리 이미지가 없는 경우 (`messages.category_images.not_found`) |
|
||||
@@ -589,7 +589,7 @@ HTTP/1.1 200
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 400 | Bad Request | 순서 갱신 트랜잭션 중 예외 발생 (`exceptions.operation_failed`) |
|
||||
| 400 | Bad Request | 순서 갱신 트랜잭션 중 도메인 규칙 위반 (순환 참조·대상 부재 등 — 예외별 구체 키로 응답) |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `parent_menus` / `child_menus` 중 최소 하나가 필요하며, 각 항목의 `id` 는 실제 존재하는 카테고리여야 하고 `order` 는 0 이상의 정수 |
|
||||
@@ -816,7 +816,7 @@ HTTP/1.1 201
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 400 | Bad Request | 파일 저장 중 예외 발생 (`exceptions.operation_failed`) |
|
||||
| 400 | Bad Request | 파일 저장 중 도메인 규칙 위반 (대상 카테고리 부재 등 — `exceptions.category_not_found` 같은 예외별 구체 키로 응답) |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
@@ -879,12 +879,13 @@ HTTP/1.1 200
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.delete`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
| 400 | Bad Request | 해당 카테고리가 없거나(`exceptions.category_not_found`), 하위 카테고리(`exceptions.category_has_children`)·연결 상품(`exceptions.category_has_products`)이 있어 삭제가 차단된 경우 |
|
||||
| 404 | Not Found | path 파라미터 형식이 라우트에 매칭되지 않는 경우 |
|
||||
| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 카테고리 1건을 삭제합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.delete` 권한이 필요하며, `CategoryService::deleteCategory()`가 삭제 전 안전 검사를 수행합니다. 하위 카테고리가 있거나 연결된 상품이 존재하면 예외가 발생해 삭제가 차단되고 400 오류가 반환되므로, 자식과 상품을 먼저 정리해야 삭제할 수 있습니다. 대상이 없으면 404를 반환합니다.
|
||||
**설명** 카테고리 1건을 삭제합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.delete` 권한이 필요하며, `CategoryService::deleteCategory()`가 삭제 전 안전 검사를 수행합니다. 하위 카테고리가 있거나 연결된 상품이 존재하면 예외가 발생해 삭제가 차단되고 400 오류(사유별 구체 키)가 반환되므로, 자식과 상품을 먼저 정리해야 삭제할 수 있습니다. 대상이 없으면 400(`category_not_found`)을 반환합니다.
|
||||
|
||||
|
||||
### PUT /api/modules/sirsoft-ecommerce/admin/categories/{category}
|
||||
@@ -1007,7 +1008,7 @@ HTTP/1.1 200
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 400 | Bad Request | 대상 카테고리가 없거나 수정 트랜잭션 중 예외 발생 (컨트롤러가 예외를 잡아 `exceptions.operation_failed` 로 응답) |
|
||||
| 400 | Bad Request | 대상 카테고리가 없거나(`exceptions.category_not_found`) 수정 트랜잭션 중 도메인 규칙 위반 (예외별 구체 키로 응답) |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
@@ -1172,12 +1173,13 @@ HTTP/1.1 200
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
| 400 | Bad Request | 해당 카테고리가 없는 경우(`exceptions.category_not_found`) 또는 토글 처리 중 도메인 규칙 위반 |
|
||||
| 404 | Not Found | path 파라미터 형식이 라우트에 매칭되지 않는 경우 |
|
||||
| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 카테고리의 활성 상태를 토글합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, path의 `id`로 `CategoryService::toggleStatus()`를 호출해 현재 `is_active` 값을 반전시킨 뒤 갱신된 `CategoryResource`를 반환합니다. 관리자 목록에서 노출/비노출을 빠르게 전환할 때 사용하며, 대상이 없거나 처리 중 예외가 발생하면 404 또는 400을 반환합니다.
|
||||
**설명** 카테고리의 활성 상태를 토글합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, path의 `id`로 `CategoryService::toggleStatus()`를 호출해 현재 `is_active` 값을 반전시킨 뒤 갱신된 `CategoryResource`를 반환합니다. 관리자 목록에서 노출/비노출을 빠르게 전환할 때 사용하며, 대상이 없으면 400(`category_not_found`), 도메인 규칙 위반 시 사유별 구체 키의 400 을 반환합니다.
|
||||
|
||||
|
||||
### GET /api/modules/sirsoft-ecommerce/categories
|
||||
|
||||
@@ -18,8 +18,32 @@ class CategoryOperationException extends RuntimeException
|
||||
* @param string $messageKey 다국어 메시지 키 (sirsoft-ecommerce::exceptions.*)
|
||||
* @param array<string, mixed> $replace 메시지 치환 파라미터
|
||||
*/
|
||||
public function __construct(string $messageKey, array $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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,4 +32,21 @@ class ReviewNotWritableException extends RuntimeException
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* 작성 불가 사유의 번역 라벨을 반환합니다.
|
||||
*
|
||||
* 사유 식별자를 그대로 :reason 치환자에 실으면 최종 사용자 화면에 원시
|
||||
* 식별자(already_written 등)가 노출된다. 라벨 키가 없는 미지의 사유는
|
||||
* 식별자를 그대로 반환한다 (라벨 미정의가 표시 자체를 깨면 안 된다).
|
||||
*
|
||||
* @return string 번역된 사유 라벨
|
||||
*/
|
||||
public function getReasonLabel(): string
|
||||
{
|
||||
$key = 'sirsoft-ecommerce::messages.reviews.reasons.'.$this->reason;
|
||||
$label = __($key);
|
||||
|
||||
return $label === $key ? $this->reason : $label;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\UpdateBrandRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\BrandCollection;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\BrandResource;
|
||||
use Modules\Sirsoft\Ecommerce\Services\BrandService;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
/**
|
||||
* 브랜드 관리 컨트롤러
|
||||
@@ -100,12 +101,19 @@ class BrandController extends AdminBaseController
|
||||
new BrandResource($updatedBrand)
|
||||
);
|
||||
} catch (BrandOperationException $e) {
|
||||
// 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
'exceptions.operation_failed',
|
||||
400
|
||||
// 도메인 규칙 위반 — 기존 400 유지. 구체 사유(미존재/상품 연결)를 전용
|
||||
// 키로 안내한다 — generic 키로 접으면 운영자가 원인을 알 수 없다
|
||||
// (형제 ClaimReason/ShippingCarrier/ProductLabel destroy 와 동형).
|
||||
$messageKey = $e->getMessageKey();
|
||||
|
||||
return ResponseHelper::error(
|
||||
$messageKey,
|
||||
400,
|
||||
null,
|
||||
$e->getMessageParams()
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
@@ -133,12 +141,19 @@ class BrandController extends AdminBaseController
|
||||
new BrandResource($updatedBrand)
|
||||
);
|
||||
} catch (BrandOperationException $e) {
|
||||
// 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
'exceptions.operation_failed',
|
||||
400
|
||||
// 도메인 규칙 위반 — 기존 400 유지. 구체 사유(미존재/상품 연결)를 전용
|
||||
// 키로 안내한다 — generic 키로 접으면 운영자가 원인을 알 수 없다
|
||||
// (형제 ClaimReason/ShippingCarrier/ProductLabel destroy 와 동형).
|
||||
$messageKey = $e->getMessageKey();
|
||||
|
||||
return ResponseHelper::error(
|
||||
$messageKey,
|
||||
400,
|
||||
null,
|
||||
$e->getMessageParams()
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
@@ -166,12 +181,19 @@ class BrandController extends AdminBaseController
|
||||
$result
|
||||
);
|
||||
} catch (BrandOperationException $e) {
|
||||
// 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
'exceptions.operation_failed',
|
||||
400
|
||||
// 도메인 규칙 위반 — 기존 400 유지. 구체 사유(미존재/상품 연결)를 전용
|
||||
// 키로 안내한다 — generic 키로 접으면 운영자가 원인을 알 수 없다
|
||||
// (형제 ClaimReason/ShippingCarrier/ProductLabel destroy 와 동형).
|
||||
$messageKey = $e->getMessageKey();
|
||||
|
||||
return ResponseHelper::error(
|
||||
$messageKey,
|
||||
400,
|
||||
null,
|
||||
$e->getMessageParams()
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
|
||||
+63
-35
@@ -127,11 +127,15 @@ class CategoryController extends AdminBaseController
|
||||
new CategoryResource($updatedCategory)
|
||||
);
|
||||
} catch (CategoryOperationException $e) {
|
||||
// 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
'exceptions.operation_failed',
|
||||
400
|
||||
// 도메인 규칙 위반 — 기존 400 유지. 구체 사유(미존재/하위·상품 연결)를
|
||||
// 전용 키로 안내한다 (형제 Brand/ClaimReason destroy 와 동형).
|
||||
$messageKey = $e->getMessageKey();
|
||||
|
||||
return ResponseHelper::error(
|
||||
$messageKey,
|
||||
400,
|
||||
null,
|
||||
$e->getMessageParams()
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
@@ -160,11 +164,15 @@ class CategoryController extends AdminBaseController
|
||||
$result
|
||||
);
|
||||
} catch (CategoryOperationException $e) {
|
||||
// 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
'exceptions.operation_failed',
|
||||
400
|
||||
// 도메인 규칙 위반 — 기존 400 유지. 구체 사유(미존재/하위·상품 연결)를
|
||||
// 전용 키로 안내한다 (형제 Brand/ClaimReason destroy 와 동형).
|
||||
$messageKey = $e->getMessageKey();
|
||||
|
||||
return ResponseHelper::error(
|
||||
$messageKey,
|
||||
400,
|
||||
null,
|
||||
$e->getMessageParams()
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
@@ -217,11 +225,15 @@ class CategoryController extends AdminBaseController
|
||||
201
|
||||
);
|
||||
} catch (CategoryOperationException $e) {
|
||||
// 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
'exceptions.operation_failed',
|
||||
400
|
||||
// 도메인 규칙 위반 — 기존 400 유지. 구체 사유(미존재/하위·상품 연결)를
|
||||
// 전용 키로 안내한다 (형제 Brand/ClaimReason destroy 와 동형).
|
||||
$messageKey = $e->getMessageKey();
|
||||
|
||||
return ResponseHelper::error(
|
||||
$messageKey,
|
||||
400,
|
||||
null,
|
||||
$e->getMessageParams()
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
@@ -257,11 +269,15 @@ class CategoryController extends AdminBaseController
|
||||
'messages.category_images.deleted'
|
||||
);
|
||||
} catch (CategoryOperationException $e) {
|
||||
// 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
'exceptions.operation_failed',
|
||||
400
|
||||
// 도메인 규칙 위반 — 기존 400 유지. 구체 사유(미존재/하위·상품 연결)를
|
||||
// 전용 키로 안내한다 (형제 Brand/ClaimReason destroy 와 동형).
|
||||
$messageKey = $e->getMessageKey();
|
||||
|
||||
return ResponseHelper::error(
|
||||
$messageKey,
|
||||
400,
|
||||
null,
|
||||
$e->getMessageParams()
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
@@ -291,11 +307,15 @@ class CategoryController extends AdminBaseController
|
||||
'messages.category_images.reordered'
|
||||
);
|
||||
} catch (CategoryOperationException $e) {
|
||||
// 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
'exceptions.operation_failed',
|
||||
400
|
||||
// 도메인 규칙 위반 — 기존 400 유지. 구체 사유(미존재/하위·상품 연결)를
|
||||
// 전용 키로 안내한다 (형제 Brand/ClaimReason destroy 와 동형).
|
||||
$messageKey = $e->getMessageKey();
|
||||
|
||||
return ResponseHelper::error(
|
||||
$messageKey,
|
||||
400,
|
||||
null,
|
||||
$e->getMessageParams()
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
@@ -355,11 +375,15 @@ class CategoryController extends AdminBaseController
|
||||
new CategoryResource($category)
|
||||
);
|
||||
} catch (CategoryOperationException $e) {
|
||||
// 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
'exceptions.operation_failed',
|
||||
400
|
||||
// 도메인 규칙 위반 — 기존 400 유지. 구체 사유(미존재/하위·상품 연결)를
|
||||
// 전용 키로 안내한다 (형제 Brand/ClaimReason destroy 와 동형).
|
||||
$messageKey = $e->getMessageKey();
|
||||
|
||||
return ResponseHelper::error(
|
||||
$messageKey,
|
||||
400,
|
||||
null,
|
||||
$e->getMessageParams()
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
@@ -440,11 +464,15 @@ class CategoryController extends AdminBaseController
|
||||
'messages.categories.order_updated'
|
||||
);
|
||||
} catch (CategoryOperationException $e) {
|
||||
// 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
'exceptions.operation_failed',
|
||||
400
|
||||
// 도메인 규칙 위반 — 기존 400 유지. 구체 사유(미존재/하위·상품 연결)를
|
||||
// 전용 키로 안내한다 (형제 Brand/ClaimReason destroy 와 동형).
|
||||
$messageKey = $e->getMessageKey();
|
||||
|
||||
return ResponseHelper::error(
|
||||
$messageKey,
|
||||
400,
|
||||
null,
|
||||
$e->getMessageParams()
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
|
||||
@@ -17,6 +17,7 @@ use Modules\Sirsoft\Ecommerce\Http\Resources\CouponIssueCollection;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\CouponIssueResource;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\CouponResource;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CouponService;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
/**
|
||||
* 쿠폰 관리 컨트롤러
|
||||
@@ -114,6 +115,8 @@ class CouponController extends AdminBaseController
|
||||
'exceptions.operation_failed',
|
||||
400
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
@@ -147,6 +150,8 @@ class CouponController extends AdminBaseController
|
||||
'exceptions.operation_failed',
|
||||
400
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
@@ -212,6 +217,8 @@ class CouponController extends AdminBaseController
|
||||
'exceptions.operation_failed',
|
||||
400
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
@@ -250,6 +257,8 @@ class CouponController extends AdminBaseController
|
||||
null,
|
||||
$e->getMessageParams()
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
@@ -294,6 +303,8 @@ class CouponController extends AdminBaseController
|
||||
null,
|
||||
$e->getMessageParams()
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
|
||||
+15
@@ -14,6 +14,7 @@ use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ExtraFeeTemplateUpdateRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\ExtraFeeTemplateCollection;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\ExtraFeeTemplateResource;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ExtraFeeTemplateService;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
/**
|
||||
* 추가배송비 템플릿 관리 컨트롤러
|
||||
@@ -86,6 +87,8 @@ class ExtraFeeTemplateController extends AdminBaseController
|
||||
new ExtraFeeTemplateResource($template),
|
||||
201
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
@@ -123,6 +126,8 @@ class ExtraFeeTemplateController extends AdminBaseController
|
||||
'messages.extra_fee_template.updated',
|
||||
new ExtraFeeTemplateResource($updatedTemplate)
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
@@ -158,6 +163,8 @@ class ExtraFeeTemplateController extends AdminBaseController
|
||||
'sirsoft-ecommerce',
|
||||
'messages.extra_fee_template.deleted'
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
@@ -194,6 +201,8 @@ class ExtraFeeTemplateController extends AdminBaseController
|
||||
'messages.extra_fee_template.toggled',
|
||||
new ExtraFeeTemplateResource($updatedTemplate)
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
@@ -220,6 +229,8 @@ class ExtraFeeTemplateController extends AdminBaseController
|
||||
'messages.extra_fee_template.bulk_deleted',
|
||||
['deleted_count' => $count]
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
@@ -250,6 +261,8 @@ class ExtraFeeTemplateController extends AdminBaseController
|
||||
'messages.extra_fee_template.bulk_toggled',
|
||||
['updated_count' => $count]
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
@@ -277,6 +290,8 @@ class ExtraFeeTemplateController extends AdminBaseController
|
||||
['created_count' => $count],
|
||||
201
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
|
||||
@@ -34,6 +34,7 @@ use Modules\Sirsoft\Ecommerce\Services\OrderCancellationService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderOptionService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderService;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
/**
|
||||
* 주문 관리 컨트롤러
|
||||
@@ -70,6 +71,8 @@ class OrderController extends AdminBaseController
|
||||
'messages.orders.fetch_success',
|
||||
$collection->withStatistics($statistics)
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -103,6 +106,8 @@ class OrderController extends AdminBaseController
|
||||
'messages.orders.fetch_success',
|
||||
new OrderResource($order)
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -135,6 +140,8 @@ class OrderController extends AdminBaseController
|
||||
'messages.orders.update_failed',
|
||||
422
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -167,6 +174,8 @@ class OrderController extends AdminBaseController
|
||||
'messages.orders.bulk_update_failed',
|
||||
422
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -193,6 +202,8 @@ class OrderController extends AdminBaseController
|
||||
'messages.orders.deleted',
|
||||
['deleted' => true]
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -250,6 +261,8 @@ class OrderController extends AdminBaseController
|
||||
'messages.orders.option_status_change_failed',
|
||||
400
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -276,6 +289,8 @@ class OrderController extends AdminBaseController
|
||||
'sirsoft-ecommerce',
|
||||
'messages.orders.email_sent'
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -307,6 +322,8 @@ class OrderController extends AdminBaseController
|
||||
'messages.orders.estimate_refund_success',
|
||||
$result->toPreviewArray()
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
Log::error('환불 예상금액 계산 실패', [
|
||||
'order_id' => $order->id,
|
||||
@@ -365,12 +382,17 @@ class OrderController extends AdminBaseController
|
||||
new OrderResource($updatedOrder)
|
||||
);
|
||||
} catch (OrderCancellationException $e) {
|
||||
// 취소 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 422
|
||||
// 취소 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 422.
|
||||
// detail 은 PG 환불 거절 사유 등 조치 근거다 — 관리자 전용 면의 errors
|
||||
// 페이로드는 진단 정보로 허용된다(감추면 서버 로그 없이는 조치 불가).
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
'messages.orders.cancel_failed',
|
||||
422
|
||||
422,
|
||||
['detail' => $e->getMessage()]
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
Log::error('주문 취소 실패 (관리자)', [
|
||||
'order_id' => $order->id,
|
||||
@@ -422,12 +444,17 @@ class OrderController extends AdminBaseController
|
||||
['detail' => $e->getMessage()]
|
||||
);
|
||||
} catch (OrderProcessingException|OrderAmountChangedException $e) {
|
||||
// 입금확인 도메인 규칙 위반(전이 불가 상태/결제예정금액 변동) — 422
|
||||
// 입금확인 도메인 규칙 위반(전이 불가 상태/결제예정금액 변동) — 422.
|
||||
// detail 은 같은 메서드의 PaymentAmountMismatchException 과 동일한 관리자
|
||||
// 진단 통로다 — 한 메서드 안에서 노출 수준이 갈리면 안 된다.
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
'messages.orders.deposit_confirm_failed',
|
||||
422
|
||||
422,
|
||||
['detail' => $e->getMessage()]
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
Log::error('무통장 입금확인 실패 (관리자)', [
|
||||
'order_id' => $order->id,
|
||||
@@ -473,6 +500,8 @@ class OrderController extends AdminBaseController
|
||||
'sirsoft-ecommerce',
|
||||
'messages.orders.guest_password_reset_success'
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
Log::error('비회원 조회 비밀번호 재설정 실패', [
|
||||
'order_id' => $order->id,
|
||||
@@ -505,6 +534,8 @@ class OrderController extends AdminBaseController
|
||||
'messages.orders.logs_fetch_success',
|
||||
ActivityLogResource::collection($logs)->response()->getData(true)
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
|
||||
@@ -31,6 +31,7 @@ use Modules\Sirsoft\Ecommerce\Http\Resources\ProductResource;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductImageService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductService;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
/**
|
||||
* 상품 관리 컨트롤러
|
||||
@@ -64,6 +65,8 @@ class ProductController extends AdminBaseController
|
||||
'messages.products.fetch_success',
|
||||
$collection->withStatistics($statistics)
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -111,6 +114,8 @@ class ProductController extends AdminBaseController
|
||||
'product_ids' => $result['product_ids'],
|
||||
]
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
Log::error('상품 옵션 배치 조회 실패', ['message' => $e->getMessage()]);
|
||||
|
||||
@@ -152,6 +157,8 @@ class ProductController extends AdminBaseController
|
||||
'messages.products.fetch_success',
|
||||
new ProductResource($product)
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -187,6 +194,8 @@ class ProductController extends AdminBaseController
|
||||
422,
|
||||
$e->errors()
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
Log::error('상품 등록 실패', [
|
||||
'message' => $e->getMessage(),
|
||||
@@ -228,6 +237,8 @@ class ProductController extends AdminBaseController
|
||||
422,
|
||||
$e->errors()
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
Log::error('상품 수정 실패', [
|
||||
'product_id' => $product->id,
|
||||
@@ -262,6 +273,8 @@ class ProductController extends AdminBaseController
|
||||
'messages.products.can_delete_checked',
|
||||
$result
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -312,6 +325,8 @@ class ProductController extends AdminBaseController
|
||||
null,
|
||||
['count' => $count]
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -350,6 +365,8 @@ class ProductController extends AdminBaseController
|
||||
'messages.products.bulk_update_failed',
|
||||
422
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -389,6 +406,8 @@ class ProductController extends AdminBaseController
|
||||
'messages.products.bulk_price_update_failed',
|
||||
422
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -427,6 +446,8 @@ class ProductController extends AdminBaseController
|
||||
'messages.products.bulk_stock_update_failed',
|
||||
422
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -473,6 +494,8 @@ class ProductController extends AdminBaseController
|
||||
'messages.products.bulk_update_failed',
|
||||
422
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -497,6 +520,8 @@ class ProductController extends AdminBaseController
|
||||
'messages.products.code_generated',
|
||||
['product_code' => $code]
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -530,6 +555,8 @@ class ProductController extends AdminBaseController
|
||||
'messages.products.fetch_success',
|
||||
new ProductResource($product)
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -572,6 +599,8 @@ class ProductController extends AdminBaseController
|
||||
'messages.products.update_failed',
|
||||
422
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -605,6 +634,8 @@ class ProductController extends AdminBaseController
|
||||
'messages.products.fetch_success',
|
||||
$formData
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -672,6 +703,8 @@ class ProductController extends AdminBaseController
|
||||
'messages.products.fetch_success',
|
||||
$copyData
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -731,6 +764,8 @@ class ProductController extends AdminBaseController
|
||||
['max' => $e->maxImages],
|
||||
'sirsoft-ecommerce'
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
// 개수 상한 초과는 위에서 typed 로 분기된다 — 여기 도달하면 서버 결함/인프라 장애다
|
||||
Log::error('상품 이미지 업로드 실패', [
|
||||
@@ -777,6 +812,8 @@ class ProductController extends AdminBaseController
|
||||
'exceptions.operation_failed',
|
||||
400
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
@@ -810,6 +847,8 @@ class ProductController extends AdminBaseController
|
||||
'exceptions.operation_failed',
|
||||
400
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
@@ -843,6 +882,8 @@ class ProductController extends AdminBaseController
|
||||
'exceptions.operation_failed',
|
||||
400
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
@@ -888,6 +929,8 @@ class ProductController extends AdminBaseController
|
||||
'messages.products.logs_fetch_success',
|
||||
ActivityLogResource::collection($logs)->response()->getData(true)
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
|
||||
+15
@@ -14,6 +14,7 @@ use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\UpdateReviewStatusRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\ProductReviewResource;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductReview;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductReviewService;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
/**
|
||||
* 상품 리뷰 관리 컨트롤러 (관리자)
|
||||
@@ -44,6 +45,8 @@ class ProductReviewController extends AdminBaseController
|
||||
'messages.reviews.fetch_success',
|
||||
ProductReviewResource::collection($reviews)->response()->getData(true)
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -69,6 +72,8 @@ class ProductReviewController extends AdminBaseController
|
||||
'messages.reviews.fetch_success',
|
||||
new ProductReviewResource($review)
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -96,6 +101,8 @@ class ProductReviewController extends AdminBaseController
|
||||
'messages.reviews.updated',
|
||||
new ProductReviewResource($updated)
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -123,6 +130,8 @@ class ProductReviewController extends AdminBaseController
|
||||
'messages.reviews.reply_saved',
|
||||
new ProductReviewResource($updated)
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -148,6 +157,8 @@ class ProductReviewController extends AdminBaseController
|
||||
'messages.reviews.reply_deleted',
|
||||
new ProductReviewResource($updated)
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -175,6 +186,8 @@ class ProductReviewController extends AdminBaseController
|
||||
'messages.reviews.deleted',
|
||||
['deleted' => true]
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -215,6 +228,8 @@ class ProductReviewController extends AdminBaseController
|
||||
'messages.reviews.bulk_updated',
|
||||
['updated_count' => $count]
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
|
||||
+15
@@ -15,6 +15,7 @@ use Modules\Sirsoft\Ecommerce\Http\Resources\ShippingPolicyCollection;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\ShippingPolicyResource;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderCalculationService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ShippingPolicyService;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
/**
|
||||
* 배송정책 관리 컨트롤러
|
||||
@@ -63,6 +64,8 @@ class ShippingPolicyController extends AdminBaseController
|
||||
new ShippingPolicyResource($shippingPolicy),
|
||||
201
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -126,6 +129,8 @@ class ShippingPolicyController extends AdminBaseController
|
||||
'messages.shipping_policy.updated',
|
||||
new ShippingPolicyResource($updated)
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -185,6 +190,8 @@ class ShippingPolicyController extends AdminBaseController
|
||||
'sirsoft-ecommerce',
|
||||
'messages.shipping_policy.deleted'
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
@@ -221,6 +228,8 @@ class ShippingPolicyController extends AdminBaseController
|
||||
'messages.shipping_policy.toggled',
|
||||
new ShippingPolicyResource($updatedPolicy)
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
@@ -247,6 +256,8 @@ class ShippingPolicyController extends AdminBaseController
|
||||
'messages.shipping_policy.bulk_deleted',
|
||||
['deleted_count' => $count]
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
@@ -277,6 +288,8 @@ class ShippingPolicyController extends AdminBaseController
|
||||
'messages.shipping_policy.bulk_toggled',
|
||||
['updated_count' => $count]
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
@@ -337,6 +350,8 @@ class ShippingPolicyController extends AdminBaseController
|
||||
'messages.shipping_policy.set_default_success',
|
||||
new ShippingPolicyResource($updatedPolicy)
|
||||
);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return ResponseHelper::forbidden('auth.scope_denied');
|
||||
} catch (\Exception $e) {
|
||||
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
|
||||
return ResponseHelper::moduleError(
|
||||
|
||||
+2
-1
@@ -73,11 +73,12 @@ class ProductReviewController extends AuthBaseController
|
||||
} catch (ReviewNotWritableException $e) {
|
||||
// 작성 자격 미충족은 도메인 검증 실패 → 422 + 전용 메시지 (메시지 키 + 사유 파라미터).
|
||||
// 예외 메시지 원문을 키 자리에 넘기면 키 해석에 실패해 원문이 그대로 노출된다.
|
||||
// 사유는 원시 식별자(already_written)가 아니라 번역 라벨로 싣는다.
|
||||
return ResponseHelper::error(
|
||||
'messages.reviews.cannot_write',
|
||||
422,
|
||||
null,
|
||||
['reason' => $e->getReason()],
|
||||
['reason' => $e->getReasonLabel()],
|
||||
'sirsoft-ecommerce'
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
|
||||
+1
@@ -50,6 +50,7 @@ class StoreShippingPolicyRequest extends FormRequest
|
||||
'country_settings.*.shipping_method' => [
|
||||
'required',
|
||||
'string',
|
||||
// audit:allow query-unbounded-get reason: 배송유형은 운영자 등록 설정성 테이블 — 허용 어휘 구성용 전량 조회이며 행 수가 데이터 증가에 비례하지 않는다
|
||||
Rule::in(app(ShippingTypeRepositoryInterface::class)->getAll()->pluck('code')->toArray()),
|
||||
],
|
||||
'country_settings.*.custom_shipping_name' => ['nullable', 'array'],
|
||||
|
||||
@@ -114,13 +114,20 @@ class ProductInquiryBoardListener implements HookListenerInterface
|
||||
$pivot = $this->repository->findByInquirable(get_class($post), (int) $post->parent_id);
|
||||
|
||||
if ($pivot && $pivot->is_answered) {
|
||||
$remaining = (int) HookManager::applyFilters(
|
||||
// 기본값을 null(판정 불가)로 두고, 공급자(board 모듈 리스너)가 실제
|
||||
// 집계값을 실어야만 해제를 진행한다. 기본값 0 이면 공급자 부재
|
||||
// (board 비활성/구버전)가 "답변 없음" 으로 읽혀 답변완료가 오해제된다.
|
||||
$remaining = HookManager::applyFilters(
|
||||
'sirsoft-ecommerce.inquiry.count_replies',
|
||||
0,
|
||||
null,
|
||||
(int) $post->parent_id
|
||||
);
|
||||
|
||||
if ($remaining === 0) {
|
||||
if ($remaining === null || ! is_numeric($remaining)) {
|
||||
return; // 공급자 부재 — 판정 불가 시 상태를 건드리지 않는다
|
||||
}
|
||||
|
||||
if ((int) $remaining === 0) {
|
||||
$this->repository->unmarkAnswered($pivot);
|
||||
|
||||
Log::debug('ProductInquiryBoardListener: 답변 글 삭제 → 답변완료 해제', [
|
||||
|
||||
@@ -160,6 +160,7 @@ class ExtraFeeTemplateRepository implements ExtraFeeTemplateRepositoryInterface
|
||||
*/
|
||||
public function getActiveList(): Collection
|
||||
{
|
||||
// audit:allow query-unbounded-get reason: 추가배송비 템플릿은 운영자 등록 설정성 테이블 — 행 수가 운영자 행위(도서산간 우편번호 등록)에 묶여 데이터 증가에 비례하지 않는다
|
||||
return $this->model
|
||||
->active()
|
||||
->orderBy('zipcode')
|
||||
@@ -171,6 +172,7 @@ class ExtraFeeTemplateRepository implements ExtraFeeTemplateRepositoryInterface
|
||||
*/
|
||||
public function getAllAsExtraFeeSettings(): array
|
||||
{
|
||||
// audit:allow query-unbounded-get reason: 추가배송비 템플릿은 운영자 등록 설정성 테이블 — 행 수가 운영자 행위(도서산간 우편번호 등록)에 묶여 데이터 증가에 비례하지 않는다
|
||||
return $this->model
|
||||
->active()
|
||||
->orderBy('zipcode')
|
||||
|
||||
@@ -10,7 +10,6 @@ use Modules\Sirsoft\Ecommerce\Exceptions\BrandOperationException;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Brand;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\BrandRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Traits\ReappliesPermissionScope;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
/**
|
||||
* 브랜드 서비스
|
||||
@@ -112,18 +111,14 @@ class BrandService
|
||||
*/
|
||||
public function updateBrand(int $id, array $data): Brand
|
||||
{
|
||||
$brandForScope = $this->repository->findById($id);
|
||||
if (! $brandForScope) {
|
||||
throw new AccessDeniedHttpException(__('auth.scope_denied'));
|
||||
}
|
||||
$this->assertWithinScope($brandForScope, 'sirsoft-ecommerce.brands.update');
|
||||
|
||||
$brand = $this->repository->findById($id);
|
||||
|
||||
if (! $brand) {
|
||||
throw new BrandOperationException('sirsoft-ecommerce::exceptions.brand_not_found');
|
||||
}
|
||||
|
||||
$this->assertWithinScope($brand, 'sirsoft-ecommerce.brands.update');
|
||||
|
||||
// Before 훅
|
||||
HookManager::doAction('sirsoft-ecommerce.brand.before_update', $id, $data);
|
||||
|
||||
@@ -159,18 +154,14 @@ class BrandService
|
||||
*/
|
||||
public function toggleStatus(int $id): Brand
|
||||
{
|
||||
$brandForScope = $this->repository->findById($id);
|
||||
if (! $brandForScope) {
|
||||
throw new AccessDeniedHttpException(__('auth.scope_denied'));
|
||||
}
|
||||
$this->assertWithinScope($brandForScope, 'sirsoft-ecommerce.brands.update');
|
||||
|
||||
$brand = $this->repository->findById($id);
|
||||
|
||||
if (! $brand) {
|
||||
throw new BrandOperationException('sirsoft-ecommerce::exceptions.brand_not_found');
|
||||
}
|
||||
|
||||
$this->assertWithinScope($brand, 'sirsoft-ecommerce.brands.update');
|
||||
|
||||
// Before 훅
|
||||
HookManager::doAction('sirsoft-ecommerce.brand.before_toggle_status', $brand);
|
||||
|
||||
@@ -196,18 +187,14 @@ class BrandService
|
||||
*/
|
||||
public function deleteBrand(int $id): array
|
||||
{
|
||||
$brandForScope = $this->repository->findById($id);
|
||||
if (! $brandForScope) {
|
||||
throw new AccessDeniedHttpException(__('auth.scope_denied'));
|
||||
}
|
||||
$this->assertWithinScope($brandForScope, 'sirsoft-ecommerce.brands.delete');
|
||||
|
||||
$brand = $this->repository->findById($id);
|
||||
|
||||
if (! $brand) {
|
||||
throw new BrandOperationException('sirsoft-ecommerce::exceptions.brand_not_found');
|
||||
}
|
||||
|
||||
$this->assertWithinScope($brand, 'sirsoft-ecommerce.brands.delete');
|
||||
|
||||
// 연결된 상품 수 확인
|
||||
$productsCount = $this->repository->getProductCount($id);
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ use Modules\Sirsoft\Ecommerce\Models\CouponIssue;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Traits\ReappliesPermissionScope;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
/**
|
||||
* 쿠폰 서비스
|
||||
@@ -144,18 +143,14 @@ class CouponService
|
||||
*/
|
||||
public function updateCoupon(int $id, array $data): Coupon
|
||||
{
|
||||
$coupon = $this->repository->findById($id);
|
||||
if (! $coupon) {
|
||||
throw new AccessDeniedHttpException(__('auth.scope_denied'));
|
||||
}
|
||||
$this->assertWithinScope($coupon, 'sirsoft-ecommerce.promotion-coupon.update');
|
||||
|
||||
$coupon = $this->repository->findById($id);
|
||||
|
||||
if (! $coupon) {
|
||||
throw new CouponOperationException('sirsoft-ecommerce::exceptions.coupon_not_found');
|
||||
}
|
||||
|
||||
$this->assertWithinScope($coupon, 'sirsoft-ecommerce.promotion-coupon.update');
|
||||
|
||||
// Before 훅
|
||||
HookManager::doAction('sirsoft-ecommerce.coupon.before_update', $id, $data);
|
||||
|
||||
@@ -207,18 +202,14 @@ class CouponService
|
||||
*/
|
||||
public function deleteCoupon(int $id): array
|
||||
{
|
||||
$coupon = $this->repository->findById($id);
|
||||
if (! $coupon) {
|
||||
throw new AccessDeniedHttpException(__('auth.scope_denied'));
|
||||
}
|
||||
$this->assertWithinScope($coupon, 'sirsoft-ecommerce.promotion-coupon.delete');
|
||||
|
||||
$coupon = $this->repository->findById($id);
|
||||
|
||||
if (! $coupon) {
|
||||
throw new CouponOperationException('sirsoft-ecommerce::exceptions.coupon_not_found');
|
||||
}
|
||||
|
||||
$this->assertWithinScope($coupon, 'sirsoft-ecommerce.promotion-coupon.delete');
|
||||
|
||||
// Before 훅
|
||||
HookManager::doAction('sirsoft-ecommerce.coupon.before_delete', $coupon);
|
||||
|
||||
@@ -284,9 +275,11 @@ class CouponService
|
||||
public function issueDirectly(int $couponId, array $userIds): array
|
||||
{
|
||||
$coupon = $this->repository->findById($couponId);
|
||||
|
||||
if (! $coupon) {
|
||||
throw new AccessDeniedHttpException(__('auth.scope_denied'));
|
||||
throw new CouponOperationException('sirsoft-ecommerce::exceptions.coupon_not_found');
|
||||
}
|
||||
|
||||
$this->assertWithinScope($coupon, 'sirsoft-ecommerce.promotion-coupon.update');
|
||||
|
||||
HookManager::doAction('sirsoft-ecommerce.coupon.before_direct_issue', $couponId, $userIds);
|
||||
|
||||
@@ -566,13 +566,15 @@ class ProductInquiryService
|
||||
// ② 잔여 답변이 없을 때만 피벗 is_answered=false 업데이트.
|
||||
// 기설치본에는 과거 결함(#106)으로 답변이 여러 건 쌓인 문의가 있을 수 있다 —
|
||||
// 첫 답변만 지웠는데 무조건 해제하면 "답변이 남았는데 미답변" 표기가 된다.
|
||||
$remaining = (int) HookManager::applyFilters(
|
||||
// 기본값 null = 판정 불가: 공급자(board 리스너) 부재 시 0 으로 접혀
|
||||
// 답변완료가 오해제되는 fail-open 을 막는다.
|
||||
$remaining = HookManager::applyFilters(
|
||||
'sirsoft-ecommerce.inquiry.count_replies',
|
||||
0,
|
||||
null,
|
||||
$inquiry->inquirable_id
|
||||
);
|
||||
|
||||
if ($remaining === 0) {
|
||||
if (is_numeric($remaining) && (int) $remaining === 0) {
|
||||
$this->repository->unmarkAnswered($inquiry);
|
||||
}
|
||||
|
||||
|
||||
@@ -1157,6 +1157,14 @@ return [
|
||||
'can_write_checked' => 'Review eligibility checked.',
|
||||
'can_write_check_failed' => 'Failed to check review eligibility.',
|
||||
'cannot_write' => 'Cannot write a review. (Reason: :reason)',
|
||||
// 작성 불가 사유 라벨 — :reason 치환자에 원시 식별자 대신 번역문을 싣는다
|
||||
'reasons' => [
|
||||
'order_option_not_found' => 'order item not found',
|
||||
'not_own_order' => 'not your order',
|
||||
'not_confirmed' => 'purchase not confirmed yet',
|
||||
'deadline_passed' => 'review period has ended',
|
||||
'already_written' => 'review already written',
|
||||
],
|
||||
'forbidden' => 'You do not have permission.',
|
||||
'deleted' => 'Review has been deleted.',
|
||||
'delete_failed' => 'Failed to delete review.',
|
||||
|
||||
@@ -1146,6 +1146,14 @@ return [
|
||||
'can_write_checked' => '리뷰 작성 가능 여부를 확인했습니다.',
|
||||
'can_write_check_failed' => '리뷰 작성 가능 여부 확인에 실패했습니다.',
|
||||
'cannot_write' => '리뷰를 작성할 수 없습니다. (사유: :reason)',
|
||||
// 작성 불가 사유 라벨 — :reason 치환자에 원시 식별자 대신 번역문을 싣는다
|
||||
'reasons' => [
|
||||
'order_option_not_found' => '주문 내역을 찾을 수 없음',
|
||||
'not_own_order' => '본인 주문이 아님',
|
||||
'not_confirmed' => '구매확정 전',
|
||||
'deadline_passed' => '작성 기한 경과',
|
||||
'already_written' => '이미 작성한 리뷰',
|
||||
],
|
||||
'forbidden' => '권한이 없습니다.',
|
||||
'deleted' => '리뷰가 삭제되었습니다.',
|
||||
'delete_failed' => '리뷰 삭제에 실패했습니다.',
|
||||
|
||||
@@ -159,6 +159,57 @@ class ServiceScopeReapplyTest extends ModuleTestCase
|
||||
$this->assertFalse((bool) $theirs->fresh()->is_active);
|
||||
}
|
||||
|
||||
/**
|
||||
* (계약 표면) 스코프 거부는 HTTP 403 으로 표면화되어야 한다.
|
||||
*
|
||||
* 서비스가 던지는 AccessDeniedHttpException 이 컨트롤러 generic catch 에 삼켜지면
|
||||
* 권한 거부가 500 서버 장애로 위장된다 — 서비스 층 단언만으로는 이 회귀를 못 본다.
|
||||
*
|
||||
* @scenario route_shape=static_bulk
|
||||
*
|
||||
* @effects scope_denial_surfaces_as_403_over_http
|
||||
*/
|
||||
public function test_scope_denied_bulk_toggle_surfaces_as_403_over_http(): void
|
||||
{
|
||||
$actor = $this->adminWithScope('sirsoft-ecommerce.shipping-policies.update', 'self');
|
||||
$owner = User::factory()->create();
|
||||
|
||||
$mine = $this->makeTemplate($actor->id, '63004');
|
||||
$theirs = $this->makeTemplate($owner->id, '63005');
|
||||
|
||||
$response = $this->actingAs($actor)->patchJson(
|
||||
'/api/modules/sirsoft-ecommerce/admin/extra-fee-templates/bulk-toggle-active',
|
||||
['ids' => [$mine->id, $theirs->id], 'is_active' => false]
|
||||
);
|
||||
|
||||
$response->assertStatus(403);
|
||||
|
||||
// 전량 거부 — 상태 불변까지 단언한다.
|
||||
$this->assertTrue((bool) $mine->fresh()->is_active);
|
||||
$this->assertTrue((bool) $theirs->fresh()->is_active);
|
||||
}
|
||||
|
||||
/**
|
||||
* (계약 복원) 존재하지 않는 대상은 스코프 거부가 아니라 not_found 400 으로 응답한다.
|
||||
*
|
||||
* 스코프 게이트 도입이 "리소스 부재 = 스코프 거부(→ 500 위장)" 로 기존 400 계약을
|
||||
* 바꾸면 안 된다.
|
||||
*
|
||||
* @scenario route_shape=param_name_mismatch
|
||||
*
|
||||
* @effects missing_target_keeps_not_found_contract
|
||||
*/
|
||||
public function test_missing_brand_returns_not_found_not_scope_denied(): void
|
||||
{
|
||||
$actor = $this->adminWithScope('sirsoft-ecommerce.brands.delete', null);
|
||||
|
||||
$response = $this->actingAs($actor)->deleteJson(
|
||||
'/api/modules/sirsoft-ecommerce/admin/brands/999999'
|
||||
);
|
||||
|
||||
$response->assertStatus(400);
|
||||
}
|
||||
|
||||
/**
|
||||
* 소유자를 지정해 배송정책을 만듭니다 (팩토리 부재 — 기존 테스트와 같은 직접 생성).
|
||||
*
|
||||
|
||||
@@ -30,6 +30,9 @@ effects:
|
||||
# static_bulk — 일괄 경로
|
||||
- static_bulk_route_reapplies_scope_gate
|
||||
- static_bulk_route_allows_global_scoped_actor
|
||||
# HTTP 표면 — 컨트롤러 typed catch (거부가 500 으로 위장되지 않는다)
|
||||
- scope_denial_surfaces_as_403_over_http
|
||||
- missing_target_keeps_not_found_contract
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Security/ServiceScopeReapplyTest.php
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.3] - 2026-08-17
|
||||
## [1.0.3] - 2026-08-18
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.2] - 2026-08-17
|
||||
## [1.0.2] - 2026-08-18
|
||||
|
||||
### Security
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
- 에디터에 올렸지만 어떤 콘텐츠에서도 더 이상 사용되지 않는 이미지를 자동으로 정리할 수 있습니다. 플러그인 설정에서 자동 정리를 켜면(기본 꺼짐) 설정한 보존기간(기본 30일)이 지난 미사용 이미지를 파일과 기록까지 매일 삭제합니다. (#115 @Tuwasduliebst 님께서 건의해주셨습니다.)
|
||||
- 「에디터 업로드 이미지」 관리 화면이 추가되었습니다. 업로드한 이미지를 미리보기·업로더·용량과 함께 확인하고, 사용 중인지 여부를 배지로 구분해 필요한 것만 골라 삭제할 수 있습니다(개별·선택 삭제).
|
||||
- 사용 여부는 게시글·페이지·상품 설명 등 등록된 콘텐츠를 훑어 판정합니다. 자체 콘텐츠를 가진 확장은 판정 대상에 자기 콘텐츠를 등록할 수 있어, 그 확장에서만 쓰이는 이미지도 「사용 중」으로 올바르게 인식됩니다.
|
||||
- 사용 여부는 게시글·페이지·상품 설명 등 등록된 콘텐츠를 훑어 판정합니다. 자체 콘텐츠를 가진 확장은 판정 대상에 자기 콘텐츠를 등록할 수 있어, 그 확장에서만 쓰이는 이미지도 「사용 중」으로 올바르게 인식됩니다. 비활성 상태의 모듈이 있으면 그 콘텐츠를 확인할 수 없으므로 자동 정리는 안전하게 건너뛰고, 관리 화면에도 판정이 불완전할 수 있음을 알립니다.
|
||||
- 무엇이 지워질지 먼저 확인하려면 서버에서 `php artisan sirsoft-ckeditor5:prune-unused-images --dry-run` 을 실행하면 됩니다.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -68,6 +68,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
| pagination.to | integer\|null | `3` | 현재 페이지 마지막 항목 순번 (비어 있으면 null) |
|
||||
| meta.scan_limited | boolean | `false` | 참조 상태 필터가 스캔 윈도우 상한에 걸렸는지. true 면 그보다 오래된 이미지는 목록에 포함되지 않는다 |
|
||||
| meta.scan_window | integer | `500` | 참조 상태 필터가 훑는 최신 업로드 건수 상한 |
|
||||
| meta.reference_sources_incomplete | boolean | `false` | 설치돼 있으나 비활성인 모듈이 있어 참조 판정이 불완전할 수 있는지. true 면 그 모듈 콘텐츠에서만 쓰이는 이미지가 「미참조」로 오판될 수 있으므로 화면은 경고를 표시해야 한다 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -100,7 +101,8 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
},
|
||||
"meta": {
|
||||
"scan_limited": false,
|
||||
"scan_window": 500
|
||||
"scan_window": 500,
|
||||
"reference_sources_incomplete": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,4 +252,4 @@ Content-Type: application/json
|
||||
관리 화면에서 체크한 이미지를 한 번에 삭제한다.
|
||||
|
||||
- 선택 범위는 **화면에 보이는 페이지**로 한정된다(화면 계약). 검색·필터·페이지 이동으로 목록에서 빠진 행의 선택이 남아 대상에 실리면, 운영자가 보지도 체크하지도 않은 파일이 지워지기 때문이다.
|
||||
- 일부만 실패한 경우 요청 자체는 성공(200)으로 응답하고 `failed` 로 보고한다. 전부 실패한 경우에만 500 이다 — 부분 성공을 실패로 되돌릴 방법이 없기 때문이다.
|
||||
- 일부만 실패한 경우 요청 자체는 성공(200)으로 응답하고 `failed` 로 보고하며, 응답 메시지도 "N건 삭제, M건 실패" 형태(`messages.uploads.bulk_partially_deleted`)로 실패 사실을 함께 안내한다. 전부 실패한 경우에만 500 이다 — 부분 성공을 실패로 되돌릴 방법이 없기 때문이다.
|
||||
|
||||
@@ -20,8 +20,10 @@ return [
|
||||
'ids_invalid' => 'Some of the selected images no longer exist. Refresh the list and select again.',
|
||||
'deleted' => 'The image has been deleted.',
|
||||
'bulk_deleted' => 'Deleted :deleted selected image(s).',
|
||||
'bulk_partially_deleted' => 'Deleted :deleted of the selected images; :failed could not be deleted and remain in the list.',
|
||||
],
|
||||
'cleanup' => [
|
||||
'retention_disabled' => 'The retention period is less than 1 day, so no cleanup was performed.',
|
||||
'sources_incomplete' => 'Unreferenced-image cleanup was skipped because an inactive module may hide content references. Activate or remove the module, then run again.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -20,8 +20,10 @@ return [
|
||||
'ids_invalid' => '선택한 이미지 중 더 이상 존재하지 않는 항목이 있습니다. 목록을 새로고침한 뒤 다시 선택해 주세요.',
|
||||
'deleted' => '이미지를 삭제했습니다.',
|
||||
'bulk_deleted' => '선택한 이미지 :deleted건을 삭제했습니다.',
|
||||
'bulk_partially_deleted' => '선택한 이미지 중 :deleted건을 삭제했고 :failed건은 삭제하지 못했습니다. 실패한 항목은 목록에 남아 있습니다.',
|
||||
],
|
||||
'cleanup' => [
|
||||
'retention_disabled' => '보존기간이 1일 미만으로 설정되어 정리를 수행하지 않았습니다.',
|
||||
'sources_incomplete' => '비활성 상태의 모듈이 있어 참조 판정이 불완전할 수 있으므로 미참조 이미지 정리를 건너뛰었습니다. 모듈을 활성화하거나 삭제한 뒤 다시 실행해 주세요.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"button": "Delete",
|
||||
"confirm_title": "Delete this image?",
|
||||
"confirm_message": "The image file and its record will both be deleted. This cannot be undone.",
|
||||
"success": "The image has been deleted."
|
||||
"success": "The image has been deleted.",
|
||||
"failed": "Failed to delete the image."
|
||||
},
|
||||
"bulk_delete": {
|
||||
"button": "Delete selected",
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"button": "삭제",
|
||||
"confirm_title": "이미지를 삭제할까요?",
|
||||
"confirm_message": "선택한 이미지의 파일과 기록이 함께 삭제됩니다. 이 작업은 되돌릴 수 없습니다.",
|
||||
"success": "이미지를 삭제했습니다."
|
||||
"success": "이미지를 삭제했습니다.",
|
||||
"failed": "이미지 삭제에 실패했습니다."
|
||||
},
|
||||
"bulk_delete": {
|
||||
"button": "선택 삭제",
|
||||
|
||||
@@ -512,7 +512,7 @@
|
||||
"type": "composite",
|
||||
"name": "DataGrid",
|
||||
"props": {
|
||||
"data": "{{ckeditor5Uploads?.data?.data}}",
|
||||
"data": "{{ckeditor5Uploads?.data?.data ?? []}}",
|
||||
"pagination": true,
|
||||
"serverSidePagination": true,
|
||||
"serverCurrentPage": "{{ckeditor5Uploads?.data?.pagination?.current_page ?? 1}}",
|
||||
@@ -849,7 +849,7 @@
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{error.message}}"
|
||||
"message": "{{error.message ?? '$t:sirsoft-ckeditor5.admin.uploads.delete.failed'}}"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1020,7 +1020,7 @@
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{error.message}}"
|
||||
"message": "{{error.message ?? '$t:sirsoft-ckeditor5.admin.uploads.delete.failed'}}"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -74,6 +74,13 @@ class PruneUnusedImagesCommand extends Command
|
||||
|
||||
$result = $this->cleanupService->pruneUnused($days, $limit, $isDryRun);
|
||||
|
||||
// 판정 불가로 건너뛴 회차 — "정리 완료(0건)" 로 보이면 운영자가 원인을 모른다.
|
||||
if (($result['skipped_reason'] ?? null) === 'sources_incomplete') {
|
||||
$this->warn(__('sirsoft-ckeditor5::messages.cleanup.sources_incomplete'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->renderDryRun($result, $days);
|
||||
|
||||
|
||||
+12
@@ -50,6 +50,7 @@ class ImageUploadAdminController extends AdminBaseController
|
||||
'meta' => [
|
||||
'scan_limited' => $result['scan_limited'],
|
||||
'scan_window' => ImageUploadAdminService::SCAN_WINDOW,
|
||||
'reference_sources_incomplete' => $result['reference_sources_incomplete'] ?? false,
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -89,6 +90,17 @@ class ImageUploadAdminController extends AdminBaseController
|
||||
return ResponseHelper::error('messages.uploads.file_delete_failed', 500, $result, domain: 'sirsoft-ckeditor5');
|
||||
}
|
||||
|
||||
// 부분 실패를 성공 문구("N건 삭제")로 접으면 운영자가 남은 파일을 모른다 —
|
||||
// 실패 건수를 문구에 함께 싣는다 (실패 상세는 data 페이로드).
|
||||
if ($result['failed'] > 0) {
|
||||
return ResponseHelper::success(
|
||||
'messages.uploads.bulk_partially_deleted',
|
||||
$result,
|
||||
messageParams: ['deleted' => $result['deleted'], 'failed' => $result['failed']],
|
||||
domain: 'sirsoft-ckeditor5',
|
||||
);
|
||||
}
|
||||
|
||||
return ResponseHelper::success(
|
||||
'messages.uploads.bulk_deleted',
|
||||
$result,
|
||||
|
||||
+13
@@ -29,4 +29,17 @@ interface ImageReferenceSourceRepositoryInterface
|
||||
* @return bool 하나라도 등장하면 true
|
||||
*/
|
||||
public function containsAnyToken(string $table, array $columns, array $tokens): bool;
|
||||
|
||||
/**
|
||||
* 지정 소스에 등장하는 토큰들을 한 번의 순회로 찾아 반환합니다.
|
||||
*
|
||||
* 토큰별로 LIKE 전체 스캔을 반복하면 비용이 (토큰 수 × 테이블 크기) 로 커진다 —
|
||||
* 일괄 판정(관리 화면 미참조 필터·prune)은 이 메서드로 소스당 1회 순회만 수행한다.
|
||||
*
|
||||
* @param string $table 테이블명 (프리픽스 제외)
|
||||
* @param array<int, string> $columns 검사할 컬럼 목록
|
||||
* @param array<int, string> $tokens 판정 토큰
|
||||
* @return array<int, string> 소스에 등장하는 토큰 목록 (부분집합)
|
||||
*/
|
||||
public function findTokensInSource(string $table, array $columns, array $tokens): array;
|
||||
}
|
||||
|
||||
+49
@@ -50,6 +50,55 @@ class ImageReferenceSourceRepository implements ImageReferenceSourceRepositoryIn
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function findTokensInSource(string $table, array $columns, array $tokens): array
|
||||
{
|
||||
if ($columns === [] || $tokens === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// id 키셋 순회가 불가능한 테이블은 토큰별 존재 확인으로 폴백한다.
|
||||
if (! Schema::hasColumn($table, 'id')) {
|
||||
return array_values(array_filter(
|
||||
$tokens,
|
||||
fn (string $token) => $this->containsAnyToken($table, $columns, [$token])
|
||||
));
|
||||
}
|
||||
|
||||
$pending = array_values(array_unique($tokens));
|
||||
$found = [];
|
||||
|
||||
// LIKE 는 인덱스를 못 쓰므로 토큰별 쿼리는 (토큰 수 × 전체 스캔) 이 된다.
|
||||
// 대신 소스를 키셋 청크로 1회만 읽고 PHP strpos 로 대조한다 — 비용이
|
||||
// (테이블 크기 1회 + 행×잔여토큰 strpos) 로 고정되고, 토큰이 모두 발견되면
|
||||
// 조기 종료한다.
|
||||
DB::table($table)
|
||||
->select(array_merge(['id'], $columns))
|
||||
->lazyById(200)
|
||||
->each(function ($row) use ($columns, &$pending, &$found) {
|
||||
foreach ($columns as $column) {
|
||||
$content = (string) ($row->{$column} ?? '');
|
||||
|
||||
if ($content === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($pending as $index => $token) {
|
||||
if (str_contains($content, $token)) {
|
||||
$found[] = $token;
|
||||
unset($pending[$index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $pending !== []; // 전 토큰 발견 시 순회 중단
|
||||
});
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* LIKE 와일드카드를 이스케이프합니다.
|
||||
*
|
||||
|
||||
@@ -36,10 +36,26 @@ class ImageCleanupService
|
||||
* @param int $days 보존기간(일)
|
||||
* @param int $limit 한 회차에 스캔할 최대 후보 수
|
||||
* @param bool $dryRun true 면 판정만 하고 삭제하지 않음
|
||||
* @return array{scanned: int, referenced: int, deleted: int, failed: int, items: array<int, array<string, mixed>>}
|
||||
* @return array{scanned: int, referenced: int, deleted: int, failed: int, items: array<int, array<string, mixed>>, skipped_reason?: string}
|
||||
*/
|
||||
public function pruneUnused(int $days, int $limit, bool $dryRun = false): array
|
||||
{
|
||||
// 비활성 모듈이 있으면 그 모듈이 등록했을 참조 소스가 이번 판정에 빠져 있을 수
|
||||
// 있다 — "미참조" 판정을 신뢰할 수 없으므로 실삭제 회차를 통째로 건너뛴다.
|
||||
// (dry-run 은 삭제가 없으므로 판정 미리보기 용도로 계속 허용한다.)
|
||||
if (! $dryRun && $this->scanner->hasPotentiallyMissingSources()) {
|
||||
Log::warning('CKEditor5 미참조 정리 건너뜀 — 비활성 모듈이 있어 참조 소스가 불완전할 수 있습니다.');
|
||||
|
||||
return [
|
||||
'scanned' => 0,
|
||||
'referenced' => 0,
|
||||
'deleted' => 0,
|
||||
'failed' => 0,
|
||||
'items' => [],
|
||||
'skipped_reason' => 'sources_incomplete',
|
||||
];
|
||||
}
|
||||
|
||||
$threshold = Carbon::now()->subDays($days);
|
||||
$candidates = $this->repository->findOlderThan($threshold, $limit);
|
||||
|
||||
@@ -51,8 +67,12 @@ class ImageCleanupService
|
||||
'items' => [],
|
||||
];
|
||||
|
||||
// 행별 isReferenced 반복 대신 일괄 판정 — 소스당 1회 순회로 비용을 고정한다.
|
||||
$referencedMap = $this->scanner->mapReferenced($candidates);
|
||||
|
||||
foreach ($candidates as $upload) {
|
||||
if ($this->scanner->isReferenced($upload)) {
|
||||
// 판정 누락 행은 안전 방향(참조됨)으로 본다.
|
||||
if ($referencedMap[(int) $upload->id] ?? true) {
|
||||
$result['referenced']++;
|
||||
|
||||
continue;
|
||||
@@ -98,8 +118,22 @@ class ImageCleanupService
|
||||
return $this->repository->delete($upload);
|
||||
}
|
||||
|
||||
// 행 disk 가 더 이상 등록돼 있지 않으면(공급 플러그인 비활성 등) withDisk 가
|
||||
// InvalidArgumentException 을 던져 삭제/일괄삭제/prune 루프 전체가 500 으로
|
||||
// 중단된다 — ImageServeService::serve 와 동일하게 존재 검증 후 폴백한다.
|
||||
$disk = (string) ($upload->storage_disk ?? '');
|
||||
$storage = $disk !== '' ? $this->storage->withDisk($disk) : $this->storage;
|
||||
$useRowDisk = $disk !== ''
|
||||
&& $disk !== $this->storage->getDisk()
|
||||
&& config("filesystems.disks.{$disk}") !== null;
|
||||
|
||||
if ($disk !== '' && ! $useRowDisk && $disk !== $this->storage->getDisk()) {
|
||||
Log::warning('CKEditor5 업로드의 저장 disk 가 미등록 상태 — 기본 스토리지로 폴백해 정리를 시도합니다.', [
|
||||
'id' => $upload->id,
|
||||
'disk' => $disk,
|
||||
]);
|
||||
}
|
||||
|
||||
$storage = $useRowDisk ? $this->storage->withDisk($disk) : $this->storage;
|
||||
|
||||
if (! $storage->exists($category, $relativePath)) {
|
||||
// 파일이 이미 없는 고아 레코드 — 재시도 루프를 만들지 않도록 레코드를 정리한다.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace Plugins\Sirsoft\Ckeditor5\Services;
|
||||
|
||||
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
||||
use App\Enums\ExtensionStatus;
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -56,11 +58,31 @@ class ImageReferenceScanService
|
||||
|
||||
/**
|
||||
* @param ImageReferenceSourceRepositoryInterface $sourceRepository 참조 소스 조회 리포지토리
|
||||
* @param ModuleRepositoryInterface $moduleRepository 모듈 상태 조회 리포지토리 (소스 불완전 감지)
|
||||
*/
|
||||
public function __construct(
|
||||
protected ImageReferenceSourceRepositoryInterface $sourceRepository
|
||||
protected ImageReferenceSourceRepositoryInterface $sourceRepository,
|
||||
protected ModuleRepositoryInterface $moduleRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 참조 소스가 불완전할 수 있는 상태인지 판정합니다.
|
||||
*
|
||||
* 참조 소스는 **활성** 모듈이 훅으로 등록한다 — 설치돼 있으나 비활성인 모듈이 있으면
|
||||
* 그 모듈 콘텐츠(게시글 본문 등)가 이번 판정에 빠져, 실제로 쓰이는 이미지가
|
||||
* "미참조" 로 오판된다(fail-open). 삭제(uninstalled)는 콘텐츠도 함께 정리되는
|
||||
* 상태이므로 대상이 아니다.
|
||||
*
|
||||
* @return bool 비활성 설치 모듈이 하나라도 있으면 true
|
||||
*/
|
||||
public function hasPotentiallyMissingSources(): bool
|
||||
{
|
||||
// audit:allow query-unbounded-get reason: modules 는 설치된 확장 수에 묶인 설정성 테이블 — 행 수가 데이터 증가에 비례하지 않는다
|
||||
return $this->moduleRepository->getAll()
|
||||
->contains(fn ($module) => ! $module->isActive()
|
||||
&& $module->status !== ExtensionStatus::Uninstalled->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조 스캔 대상 소스 목록을 반환합니다.
|
||||
*
|
||||
@@ -113,15 +135,61 @@ class ImageReferenceScanService
|
||||
/**
|
||||
* 업로드 목록의 참조 여부를 일괄 판정합니다.
|
||||
*
|
||||
* 행별로 isReferenced 를 반복하면 비용이 (행 수 × 소스 수 × LIKE 전체 스캔) 으로
|
||||
* 커진다 — 여기서는 전 행의 토큰을 모아 소스당 1회 순회로 등장 토큰을 수집한다
|
||||
* (비용 상한: 소스 크기 합 1회 + 조기 종료).
|
||||
*
|
||||
* @param Collection<int, Ckeditor5ImageUpload>|iterable<Ckeditor5ImageUpload> $uploads 업로드 목록
|
||||
* @return array<int, bool> [업로드 ID => 참조 여부]
|
||||
*/
|
||||
public function mapReferenced(iterable $uploads): array
|
||||
{
|
||||
$map = [];
|
||||
$tokenOwners = [];
|
||||
|
||||
foreach ($uploads as $upload) {
|
||||
$map[(int) $upload->id] = $this->isReferenced($upload);
|
||||
$id = (int) $upload->id;
|
||||
$tokens = $this->buildTokens($upload);
|
||||
|
||||
if ($tokens === []) {
|
||||
// 판정 토큰을 만들 수 없는 행은 안전 방향(참조됨)으로 본다.
|
||||
$map[$id] = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$map[$id] = false;
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
$tokenOwners[$token][] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
if ($tokenOwners === []) {
|
||||
return $map;
|
||||
}
|
||||
|
||||
$pending = $tokenOwners;
|
||||
|
||||
foreach ($this->getReferenceSources() as $source) {
|
||||
if ($pending === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
// PHP 배열 키는 순수 숫자 토큰을 int 로 접으므로 문자열로 복원해 넘긴다.
|
||||
$foundTokens = $this->sourceRepository->findTokensInSource(
|
||||
$source['table'],
|
||||
$source['columns'],
|
||||
array_map('strval', array_keys($pending))
|
||||
);
|
||||
|
||||
foreach ($foundTokens as $token) {
|
||||
foreach ($pending[$token] ?? [] as $id) {
|
||||
$map[$id] = true;
|
||||
}
|
||||
|
||||
unset($pending[$token]);
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
|
||||
@@ -68,6 +68,7 @@ class ImageUploadAdminService
|
||||
'to' => $paginator->lastItem(),
|
||||
],
|
||||
'scan_limited' => false,
|
||||
'reference_sources_incomplete' => false,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -133,6 +134,15 @@ class ImageUploadAdminService
|
||||
->filter(fn (Ckeditor5ImageUpload $upload) => (bool) $upload->referenced === $wantReferenced)
|
||||
->values();
|
||||
|
||||
// 스캔 윈도우는 최신순 고정으로 확보하지만, 화면이 요청한 정렬(검증 완료 값)은
|
||||
// 윈도우 안에서 그대로 적용한다 — 무시하면 "파일 크기순 + 미참조" 조합이
|
||||
// 오류 없이 최신순으로만 나와 정렬이 동작하는 것처럼 보인다.
|
||||
$sortBy = (string) ($filters['sort_by'] ?? 'created_at');
|
||||
$sortDesc = strtolower((string) ($filters['sort_order'] ?? 'desc')) !== 'asc';
|
||||
$matched = $matched
|
||||
->sortBy([[$sortBy, $sortDesc ? 'desc' : 'asc'], ['id', 'desc']])
|
||||
->values();
|
||||
|
||||
$total = $matched->count();
|
||||
$lastPage = max(1, (int) ceil($total / max(1, $perPage)));
|
||||
$currentPage = max(1, min($page, $lastPage));
|
||||
@@ -149,6 +159,9 @@ class ImageUploadAdminService
|
||||
'to' => $items->isEmpty() ? null : ($currentPage - 1) * $perPage + $items->count(),
|
||||
],
|
||||
'scan_limited' => $scanLimited,
|
||||
// 비활성 모듈이 있으면 그 모듈의 콘텐츠가 판정에서 빠져 "미참조" 가
|
||||
// 신뢰 불가다 — 화면이 경고를 표시할 수 있도록 메타로 알린다.
|
||||
'reference_sources_incomplete' => $this->scanner->hasPotentiallyMissingSources(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Contracts\Extension\StorageInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Support\ImageResizer;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Plugins\Sirsoft\Ckeditor5\Models\Ckeditor5ImageUpload;
|
||||
use Plugins\Sirsoft\Ckeditor5\Repositories\Contracts\ImageUploadRepositoryInterface;
|
||||
@@ -38,8 +39,17 @@ class ImageUploadService
|
||||
// 훅: 이미지 업로드 전 (본인인증 등 확장 지점)
|
||||
HookManager::doAction('sirsoft-ckeditor5.image.before_upload', $file, $uploadedBy);
|
||||
|
||||
// 필터 훅 - 파일 데이터 변형 (압축, 리사이즈 등 확장 포인트)
|
||||
$file = HookManager::applyFilters('sirsoft-ckeditor5.image.filter_upload_file', $file);
|
||||
// 필터 훅 - 파일 데이터 변형 (압축, 리사이즈 등 확장 포인트).
|
||||
// 잘못 작성된 리스너가 UploadedFile 이 아닌 값을 반환하면 원본으로 폴백한다 —
|
||||
// 확장 결함이 코어 업로드 전면 장애(TypeError → 500)로 위장되면 안 된다.
|
||||
$filtered = HookManager::applyFilters('sirsoft-ckeditor5.image.filter_upload_file', $file);
|
||||
if ($filtered instanceof UploadedFile) {
|
||||
$file = $filtered;
|
||||
} else {
|
||||
Log::warning('sirsoft-ckeditor5.image.filter_upload_file 필터가 UploadedFile 이 아닌 값을 반환해 원본을 사용합니다.', [
|
||||
'returned_type' => get_debug_type($filtered),
|
||||
]);
|
||||
}
|
||||
|
||||
$extension = $file->guessExtension() ?? $file->getClientOriginalExtension();
|
||||
$storedFilename = Str::uuid().'.'.$extension;
|
||||
|
||||
@@ -274,6 +274,7 @@ class ImageCleanupServiceTest extends PluginTestCase
|
||||
$upload = $this->makeUpload('777777777777', 'locked.png');
|
||||
|
||||
$storage = \Mockery::mock(StorageInterface::class);
|
||||
$storage->shouldReceive('getDisk')->andReturn('local');
|
||||
$storage->shouldReceive('withDisk')->andReturnSelf();
|
||||
$storage->shouldReceive('exists')->andReturnTrue();
|
||||
$storage->shouldReceive('delete')->andReturnFalse();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.2] - 2026-08-17
|
||||
## [1.0.2] - 2026-08-18
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.3] - 2026-08-12
|
||||
## [1.0.3] - 2026-08-18
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -296,6 +296,10 @@ class GdprConsentService
|
||||
$activeConsents = $this->statusRepository->getActiveByUserId($userId);
|
||||
|
||||
// 루프 중간에 실패하면 일부 항목만 철회된 채 남는다 — 전부 철회되거나 전부 취소되게 한다.
|
||||
// updateConsent 내부의 consent.before/after_update 훅도 이 트랜잭션 안에서
|
||||
// 발화된다(의도): 리스너 예외 시 철회 전체가 롤백되는 것이 "전부 또는 전무"
|
||||
// 계약이며, 외부 부수효과(알림 등)를 내는 리스너는 롤백 불가를 감안해
|
||||
// 멱등하게 작성해야 한다.
|
||||
DB::transaction(function () use ($userId, $activeConsents) {
|
||||
foreach ($activeConsents as $consent) {
|
||||
$this->updateConsent($userId, null, $consent->consent_key, false, 'withdraw');
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.2] - 2026-08-17
|
||||
## [1.0.2] - 2026-08-18
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.1] - 2026-08-17
|
||||
## [1.1.1] - 2026-08-18
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
+2
-2
@@ -66,7 +66,7 @@ class UserEscrowConfirmController extends Controller
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
*/
|
||||
// audit:allow controller-base-request-injection reason: PG(이니시스)가 POST 하는 콜백 표면 — 필드 구성이 PG 소유라 계약을 닫을 수 없고, 값은 문자열 캐스팅 후 대조에만 사용
|
||||
// audit:allow controller-base-request-injection reason: PG(이니시스)가 POST 하는 콜백 표면 — 필드 구성이 PG 소유라 계약을 닫을 수 없다. 값은 문자열 캐스팅 후 tid 대조를 거쳐 payment_meta 에 영속된다(서명 검증 없는 무인증 콜백이라 기록 위조 여지가 있는 사전 상태 — 표시용 메타이며 금액·상태 전이에는 미사용)
|
||||
public function pcReturn(Request $request): Response
|
||||
{
|
||||
$resultCode = (string) $request->input('ResultCode', '');
|
||||
@@ -109,7 +109,7 @@ class UserEscrowConfirmController extends Controller
|
||||
* @param Request $request
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
// audit:allow controller-base-request-injection reason: PG(이니시스 모바일)가 POST 하는 콜백 표면 — 필드 구성이 PG 소유라 계약을 닫을 수 없고, 값은 문자열 캐스팅 후 대조에만 사용
|
||||
// audit:allow controller-base-request-injection reason: PG(이니시스 모바일)가 POST 하는 콜백 표면 — 필드 구성이 PG 소유라 계약을 닫을 수 없다. 값은 문자열 캐스팅 후 tid 대조를 거쳐 payment_meta 에 영속된다(서명 검증 없는 무인증 콜백이라 기록 위조 여지가 있는 사전 상태 — 표시용 메타이며 금액·상태 전이에는 미사용)
|
||||
public function mobileReturn(Request $request): RedirectResponse
|
||||
{
|
||||
$status = (string) $request->input('P_STATUS', '');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
// audit:allow api-doc-coverage reason: docblock 서술 정정만 수행 — 검증 규칙/요청·응답 계약 무변경
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Http\Requests;
|
||||
@@ -13,7 +15,9 @@ use Plugins\Sirsoft\PayKginicis\Support\PaymentLimits;
|
||||
* 국내 짝(SignatureRequest)과 동일 강도 + timestamp/checkout_token.
|
||||
*
|
||||
* 계약 변경: 종전에는 checkout_token 이 비어 있으면 토큰 검증 단계에서 403 이었으나,
|
||||
* 필수 검증으로 승격되어 422 가 된다. 클라이언트는 `.ok` 만 검사하므로 안전(실측).
|
||||
* 필수 검증으로 승격되어 422 가 된다. CBT 클라이언트(requestPayment.ts)는
|
||||
* `G7Core.api.post` 경유라 403/422 모두 실패 분기로 동일 취급되어 안전(실측 —
|
||||
* raw fetch `.ok` 검사는 nicepayments signData 쪽 클라이언트의 방식).
|
||||
*/
|
||||
class CbtHashDataRequest extends FormRequest
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.2] - 2026-08-17
|
||||
## [1.0.2] - 2026-08-18
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
|
||||
|
||||
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 테이블명 리터럴을 모델 파생으로 정리한 내부 리팩토링 (#571)
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -38,7 +36,6 @@ class AdminEscrowDeliveryController extends AdminBaseController
|
||||
'deli_corp',
|
||||
];
|
||||
|
||||
/** KCP 에스크로 택배사 코드표 */
|
||||
/** 택배사 코드 → 택배사명 매핑 (KCP 공식 코드표 — EscrowDeliveryRegisterRequest 의 허용값 SSoT) */
|
||||
public const COURIER_CODES = [
|
||||
'04' => 'CJ대한통운',
|
||||
|
||||
@@ -6,8 +6,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
|
||||
|
||||
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 테이블명 리터럴을 모델 파생으로 정리한 내부 리팩토링 (#571)
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Services\PluginSettingsService;
|
||||
|
||||
@@ -6,8 +6,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
|
||||
|
||||
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 테이블명 리터럴을 모델 파생으로 정리한 내부 리팩토링 (#571)
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Services\PluginSettingsService;
|
||||
|
||||
@@ -6,8 +6,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
|
||||
|
||||
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 테이블명 리터럴을 모델 파생으로 정리한 내부 리팩토링 (#571)
|
||||
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.2] - 2026-08-17
|
||||
## [1.0.2] - 2026-08-18
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -103,14 +103,17 @@ class AdminEscrowController extends AdminBaseController
|
||||
|
||||
return ResponseHelper::success('common.success', $result);
|
||||
} catch (\Exception $e) {
|
||||
// 원문은 로그로만 — 응답 메시지 키 자리에 넘기면 키 해석에 실패해
|
||||
// 예외 원문(스택 힌트 포함)이 그대로 관리자 화면에 나간다.
|
||||
// 메시지 키 자리에는 원문 금지 — 키 해석에 실패해 원문이 그대로 나간다.
|
||||
// 단 errors 페이로드는 관리자 전용 면의 진단 통로다(형제 KCP/이니시스
|
||||
// 에스크로와 동형) — 비우면 운영자가 서버 로그 없이는 실패 원인을 모른다.
|
||||
Log::error('NicePayments: escrow delivery registration failed', [
|
||||
'tid' => $validated['tid'],
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ResponseHelper::error('common.failed', 502, null);
|
||||
return ResponseHelper::error('common.failed', 502, [
|
||||
'message' => [$e->getMessage()],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.2] - 2026-08-17
|
||||
## [1.0.2] - 2026-08-18
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ All notable changes to this plugin will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [1.0.3] - 2026-08-17
|
||||
## [1.0.3] - 2026-08-18
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ All notable changes to this plugin will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [1.0.1] - 2026-08-17
|
||||
## [1.0.1] - 2026-08-18
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
// audit:allow api-doc-coverage reason: docblock 서술 정정만 수행 (web GET 브리지의 검증 실패 모드 서술) — 검증 규칙/요청·응답 계약 무변경. 이 브리지는 web 전용 HTML 내비게이션이라 API 문서 대상도 아니다
|
||||
|
||||
namespace Plugins\Sirsoft\VerificationNhnkcp\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@@ -8,7 +10,9 @@ use Illuminate\Foundation\Http\FormRequest;
|
||||
* KCP 인증 결과 브리지 페이지 진입 쿼리를 검증합니다.
|
||||
*
|
||||
* 콜백 컨트롤러가 302 redirect 로 붙여 보내는 값만 받습니다. 배열 주입
|
||||
* (`?verification_token[]=x`)은 string 규칙이 422 로 차단합니다 — 종전에는
|
||||
* (`?verification_token[]=x`)은 string 규칙이 차단합니다 — 이 라우트는 web GET
|
||||
* HTML 내비게이션이라 검증 실패는 422 JSON 이 아니라 redirect-back 으로 끝나며,
|
||||
* 어느 쪽이든 배열이 브리지 payload 에 유입되지 않는 것이 목적입니다. 종전에는
|
||||
* `(string)` 캐스팅이 배열을 "Array" 문자열로 바꿔 브리지 payload 에 유입됐습니다.
|
||||
* 길이 상한을 두지 않는 이유는 토큰 길이가 코어 발급 정책에 종속되어 있기 때문입니다
|
||||
* (kginicis InicisPopupBridgeRequest 선례).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.5] - 2026-08-17
|
||||
## [1.0.5] - 2026-08-18
|
||||
|
||||
### Added
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 회원 등록 화면의 상태 선택란이 기본값(활성) 없이 빈 칸으로 표시되던 문제를 수정했습니다. 표시만 비어 있었을 뿐 저장 시에는 '활성' 으로 등록되어, 화면 표시와 실제 저장값이 어긋나 보였습니다.
|
||||
- 파일 업로더 썸네일의 업로드 진행률 막대와 오류 표시가 다크 모드에서 대비가 낮아 잘 보이지 않던 부분을 보완했습니다.
|
||||
- 언어팩 재활성화 안내 창의 아이콘이 의도한 크기와 다르게 보이던 문제를 수정했습니다.
|
||||
- 표 하단 합계 영역의 값이 계산되지 않고 표현식 원문 그대로 표시되던 문제를 다시 수정했습니다. 1.0.4 에 수록 예정이던 수정이 빌드 산출물에 반영되지 않은 채 배포되어, 주문 상세의 합계 행에서 수량·적립금·배송비 자리에 값 대신 알 수 없는 문자열이 보였습니다. (#98 @hwaryeon1234 님께서 제보해주셨습니다.)
|
||||
- 회원 정보 수정 화면에서 회원 정보가 화면에 채워지기 전에 저장을 누르면 "이름은 필수입니다" 같은 오류로 저장이 거절되던 문제를 수정했습니다. 이제 회원 정보를 불러오는 동안에는 저장 버튼이 잠시 비활성화됩니다.
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -80,7 +80,7 @@
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{error.message}}"
|
||||
"message": "{{error.message ?? '$t:common.error_occurred'}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -851,14 +851,25 @@
|
||||
"text": "$t:admin.users.form.fields.status"
|
||||
},
|
||||
{
|
||||
"comment": "커스텀 Select 는 value-제어 전용 — defaultValue 는 렌더되지 않아 create 모드 표시가 비었다. 미전송 시 서버(CreateUserRequest)가 active 로 보정하므로 표시 기본값과 서버 기본값이 일치한다.",
|
||||
"type": "composite",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "status",
|
||||
"className": "w-full",
|
||||
"defaultValue": "active",
|
||||
"value": "{{_local.form?.status ?? 'active'}}",
|
||||
"options": "{{route?.id ? [{label: '$t:admin.users.form.status_options.active', value: 'active'}, {label: '$t:admin.users.form.status_options.inactive', value: 'inactive'}, {label: '$t:admin.users.form.status_options.blocked', value: 'blocked'}, {label: '$t:admin.users.form.status_options.withdrawn', value: 'withdrawn'}] : [{label: '$t:admin.users.form.status_options.active', value: 'active'}, {label: '$t:admin.users.form.status_options.inactive', value: 'inactive'}, {label: '$t:admin.users.form.status_options.blocked', value: 'blocked'}] ?? []}}"
|
||||
}
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"form.status": "{{$event.target.value}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1551,7 +1562,7 @@
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{error.message}}"
|
||||
"message": "{{error.message ?? '$t:common.error_occurred'}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1571,7 +1582,7 @@
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{error.message}}"
|
||||
"message": "{{error.message ?? '$t:common.error_occurred'}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1862,7 +1873,7 @@
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{error.message}}"
|
||||
"message": "{{error.message ?? '$t:common.error_occurred'}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1882,7 +1893,7 @@
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{error.message}}"
|
||||
"message": "{{error.message ?? '$t:common.error_occurred'}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+3
-3
@@ -166,9 +166,9 @@ export const SortableThumbnailItem: React.FC<SortableThumbnailItemProps> = ({
|
||||
{/* 업로드/압축 진행률 오버레이 */}
|
||||
{(isUploading || isCompressing) && (
|
||||
<Div className="absolute inset-0 bg-black/50 flex flex-col items-center justify-center">
|
||||
<Div className="w-3/4 h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||
<Div className="w-3/4 h-2 bg-gray-700 dark:bg-gray-600 rounded-full overflow-hidden">
|
||||
<Div
|
||||
className="h-full bg-blue-500 transition-all"
|
||||
className="h-full bg-blue-500 dark:bg-blue-400 transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</Div>
|
||||
@@ -178,7 +178,7 @@ export const SortableThumbnailItem: React.FC<SortableThumbnailItemProps> = ({
|
||||
|
||||
{/* 에러 오버레이 */}
|
||||
{hasError && (
|
||||
<Div className="absolute inset-0 bg-red-500/50 flex items-center justify-center">
|
||||
<Div className="absolute inset-0 bg-red-500/50 dark:bg-red-600/50 flex items-center justify-center">
|
||||
<I className="fa-solid fa-exclamation-triangle text-2xl text-white" />
|
||||
</Div>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user