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:
HeuJung
2026-08-18 08:12:55 +09:00
parent ed985dd175
commit b3d0da9355
110 changed files with 1104 additions and 369 deletions
+2 -1
View File
@@ -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);
}
}
+1 -1
View File
@@ -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();
+9
View File
@@ -771,7 +771,16 @@ class SettingsService
}
foreach ($removedIds as $removedId) {
// 설정 저장은 이미 확정된 뒤다 — 파기 실패가 저장 실패(422)로 위장되면
// 운영자는 성공한 저장을 실패로 오인한다. 실패 파일은 로그로만 남긴다.
try {
$this->attachmentService->delete($removedId);
} catch (\Exception $e) {
Log::warning('사이트 로고 첨부 파기 실패 — 저장은 확정됨', [
'attachment_id' => $removedId,
'error' => $e->getMessage(),
]);
}
}
Log::info('사이트 로고 첨부 제거', ['attachment_ids' => $removedIds]);
+5 -1
View File
@@ -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 트랜잭션으로 일괄 업데이트
+24 -1
View File
@@ -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(
+1 -1
View File
@@ -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
@@ -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,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
+1 -1
View File
@@ -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(
@@ -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(
@@ -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',
@@ -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,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(
@@ -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) {
@@ -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
+1 -1
View File
@@ -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);
@@ -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,
@@ -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;
}
@@ -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
+1 -1
View File
@@ -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
@@ -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,13 +851,22 @@
"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}}"
}
}
]
@@ -865,6 +874,8 @@
]
}
]
}
]
},
{
"id": "section_contact_info",
@@ -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'}}"
}
}
]
@@ -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>
)}
File diff suppressed because one or more lines are too long
@@ -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
@@ -13,6 +13,7 @@
### Fixed
- 파일 업로더 썸네일의 업로드 진행률 막대와 오류 표시가 다크 모드에서 대비가 낮아 잘 보이지 않던 부분을 보완했습니다.
- 장바구니를 처음 열었을 때 "전체 선택" 옆 개수가 `(/27)` 처럼 선택 개수 없이 표시되던 문제를 수정했습니다. 이제 진입·새로고침·다른 화면에서 돌아온 경우 모두 `(27/27)` 로 바로 표시되며, 체크박스 상태 및 주문 요약의 "선택 상품 N / M개" 와 항상 같은 값을 가리킵니다. (#92 @koojunho 님께서 제보해주셨습니다.)
- 장바구니 옵션변경 모달의 재고 안내와 마이페이지 프로필의 입력 오류 메시지가, 표시할 값이 아직 준비되지 않은 순간에도 빈칸으로 남지 않도록 보완했습니다. 값이 준비된 뒤의 표시는 종전과 동일합니다.
- 마이페이지 게시판 알림 수신 설정(댓글·답글 등 4개 항목)의 체크 상태가 저장값과 어긋날 수 있던 가능성을 차단했습니다. 저장된 값이 어떤 형태든 화면의 체크 표시와 저장 결과가 항상 일치합니다.

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