From 494e58197387cc4f748f566223ad7ccf025f4b14 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Sat, 22 Aug 2026 01:19:36 +0900 Subject: [PATCH] =?UTF-8?q?fix(security):=20KVE-2026=20=EC=8B=A0=EA=B3=A0?= =?UTF-8?q?=204=EA=B1=B4=20=EC=A1=B0=EC=B9=98=20=E2=80=94=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20API=20=EA=B6=8C=ED=95=9C=20=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20+=20=EA=B8=88=EC=A0=84=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B2=BD=EC=9F=81=20=EC=A1=B0=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KISA 신고 4건(KVE-2026-1886/1887/1893/1894)을 검증하고, 미수정 2건과 동종 결함 전수 조사에서 나온 결함을 함께 조치했다. 권한 게이트 (KVE-2026-1893) - NHN KCP 관리자 주문 연동 7경로가 admin 보유 여부만 보고 세부 권한을 보지 않아, 업무 권한 없는 관리자가 주문번호·결제정보·수령인 연락처를 조회하고 에스크로 배송등록까지 할 수 있었다. 조회는 orders.read, 등록은 orders.update, 설정성 경로는 settings.read 로 게이트해 다른 PG 연동과 강도를 맞췄다. - 마케팅 채널 저장은 코어 플러그인 설정과 같은 저장소를 덮어쓰는 우회 경로라 core.plugins.update 를 부착했다. 첨부 해시 노출 (KVE-2026-1894 잔재) - 비밀글의 썸네일 URL 이 목록·상세 응답에 그대로 실려 첨부 해시가 노출됐다. 이미지 서빙은 이미 차단돼 있었으나 식별값 자체가 나갔다. 첨부 목록과 같은 게이트를 써서 값만 가리고 필드는 유지한다. 경쟁 조건 (KVE-2026-1886 + 전수 조사) - 쿠폰 차감을 조회 후 갱신에서 조건부 UPDATE 로 바꿔 1회 제한 쿠폰의 동시 사용을 막고, 선점당한 주문은 409 로 되돌린다. - 라이브 병렬 재현에서 그 롤백이 동작하지 않는 것을 확인했다. Action 훅 기본값이 큐 래핑 + afterCommit 이라 금전 처리가 호출자 커밋 뒤에 실행되고 있었다. 쿠폰·적립금 차감/복원 5개 구독에 sync 를 선언했고, 회귀 테스트는 손 등록이 아니라 실제 등록 경로를 태워 고정했다. - 동시 부분취소의 취소 누적 컬럼 lost update, 적립 lot 의 read-modify-write, 주문옵션당 적립 lot 중복 생성을 각각 행 잠금·컬럼 연산·유니크 제약으로 막았다. 기설치본의 중복 lot 은 인덱스 생성 전에 금액을 합산해 한 줄로 통합한다. 규정·문서 - sync 판정 기준을 hooks.md 에 명문화하고 /AGENTS.md Listener 표와 coverage manifest 에 반영했다. 같은 증상을 다시 만났을 때의 진단 경로를 트러블슈팅 사례로 남겼다. --- AGENTS.md | 2 + docs/extension/hooks.md | 13 + .../CHANGELOG.md | 6 + .../backend/ja/exceptions.php | 1 + .../language-pack.json | 2 +- modules/_bundled/sirsoft-board/CHANGELOG.md | 6 + modules/_bundled/sirsoft-board/composer.json | 2 +- .../_bundled/sirsoft-board/docs/api/board.md | 4 +- modules/_bundled/sirsoft-board/module.json | 2 +- .../_bundled/sirsoft-board/package-lock.json | 4 +- modules/_bundled/sirsoft-board/package.json | 2 +- .../src/Http/Resources/PostResource.php | 36 ++- .../PostResourceThumbnailUrlTest.php | 41 +++- .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 14 ++ .../_bundled/sirsoft-ecommerce/composer.json | 2 +- ...o_ecommerce_mileage_transactions_table.php | 160 ++++++++++++ .../sirsoft-ecommerce/docs/api/orders.md | 1 + .../_bundled/sirsoft-ecommerce/module.json | 2 +- .../sirsoft-ecommerce/package-lock.json | 4 +- .../_bundled/sirsoft-ecommerce/package.json | 2 +- .../Exceptions/CouponAlreadyUsedException.php | 33 +++ .../Traits/HandlesOrderCreation.php | 17 ++ .../src/Listeners/CouponRestoreListener.php | 50 ++-- .../src/Listeners/CouponUseListener.php | 52 +++- .../Listeners/MileageTransactionListener.php | 7 +- .../CouponIssueRepositoryInterface.php | 31 ++- .../MileageTransactionRepositoryInterface.php | 11 + .../OrderPaymentRepositoryInterface.php | 11 + .../Contracts/OrderRepositoryInterface.php | 12 + .../Repositories/CouponIssueRepository.php | 13 + .../MileageTransactionRepository.php | 31 ++- .../Repositories/OrderPaymentRepository.php | 8 + .../src/Repositories/OrderRepository.php | 8 + .../src/Services/OrderCancellationService.php | 35 +++ .../src/Services/UserMileageService.php | 114 ++++++--- .../src/lang/en/exceptions.php | 1 + .../src/lang/ko/exceptions.php | 1 + .../User/UserOrderControllerTest.php | 42 +++- .../Listeners/CouponRestoreListenerTest.php | 128 ++++++++++ .../CouponUseListenerConcurrencyTest.php | 207 ++++++++++++++++ .../MoneyHookTransactionBoundaryTest.php | 187 ++++++++++++++ .../MileageTransactionRepositoryTest.php | 96 ++++++++ .../Services/MileageEarnLotUniquenessTest.php | 230 ++++++++++++++++++ .../Services/OrderCancellationServiceTest.php | 76 ++++++ .../Services/OrderProcessingServiceTest.php | 105 ++++++++ .../sirsoft-ecommerce/vendor-bundle.json | 10 +- .../sirsoft-ecommerce/vendor-bundle.zip | Bin 435548 -> 435549 bytes .../_bundled/sirsoft-marketing/CHANGELOG.md | 6 + .../_bundled/sirsoft-marketing/composer.json | 2 +- .../sirsoft-marketing/docs/api/channels.md | 4 +- .../sirsoft-marketing/package-lock.json | 4 +- .../_bundled/sirsoft-marketing/package.json | 2 +- .../_bundled/sirsoft-marketing/plugin.json | 2 +- .../sirsoft-marketing/src/routes/api.php | 6 +- .../MarketingAdminControllerTest.php | 60 ++--- .../MarketingAdminRoutePermissionTest.php | 93 +++++++ .../tests/PluginTestCase.php | 69 ++++-- .../_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md | 2 + .../sirsoft-pay_nhnkcp/docs/api/README.md | 1 + .../docs/api/admin-orders.md | 85 +++++++ .../docs/api/transaction-status.md | 2 +- .../sirsoft-pay_nhnkcp/src/routes/api.php | 9 +- .../Controllers/AdminRoutePermissionTest.php | 199 +++++++++++++++ 63 files changed, 2211 insertions(+), 157 deletions(-) create mode 100644 modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_21_000001_add_unique_purchase_earn_lot_to_ecommerce_mileage_transactions_table.php create mode 100644 modules/_bundled/sirsoft-ecommerce/src/Exceptions/CouponAlreadyUsedException.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/CouponUseListenerConcurrencyTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/MoneyHookTransactionBoundaryTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageEarnLotUniquenessTest.php create mode 100644 plugins/_bundled/sirsoft-marketing/tests/Feature/Http/Controllers/MarketingAdminRoutePermissionTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/admin-orders.md create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/AdminRoutePermissionTest.php diff --git a/AGENTS.md b/AGENTS.md index b391e36e..ad5912e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -604,6 +604,8 @@ G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통 | Listener 생성자에 구체 Repository 직접 주입 | Repository Interface 주입 | | Listener 에서 `request()` / `$_POST` 직접 접근 | Service 가 검증 후 도메인 객체로 전달 받기 | | Filter 훅에 `'type' => 'filter'` 누락 | type 명시 필수 (반환값 무시 회귀 차단) | +| 실패 시 호출자 트랜잭션을 되돌려야 하는 Action 훅에 `'sync' => true` 누락 | 금전 이동(쿠폰 차감·복원, 적립금 차감·복원)은 `sync` 필수. 기본값은 큐 래핑 + `afterCommit` 이라 **커밋 뒤에** 실행되어, 예외를 던져도 롤백되지 않고 오류 응답만 나간 채 데이터가 남는다 (큐 드라이버가 `sync` 여도 동일) | +| 훅 회귀 테스트에서 리스너를 손으로 `addAction` 등록 | `HookListenerRegistrar::register()` 로 **실제 등록 경로**를 태운다 — 손으로 등록하면 큐 래핑을 건너뛰어 커밋 이후 실행 문제를 통과시킨다 | | Listener 가 `HookListenerInterface` 미구현 (auto-discovery 대상) | implements + `getSubscribedHooks()` 정적 메서드 | > 상세: [hooks.md "Listener 데이터 접근 규정"](docs/extension/hooks.md), [service-repository.md](docs/backend/service-repository.md) diff --git a/docs/extension/hooks.md b/docs/extension/hooks.md index 3274ced7..da3f4025 100644 --- a/docs/extension/hooks.md +++ b/docs/extension/hooks.md @@ -456,6 +456,19 @@ public static function getSubscribedHooks(): array } ``` +#### 호출자 트랜잭션 안에서 끝나야 하는 처리는 `sync` 가 필수다 + +기본값(큐 래핑)은 `DispatchHookListenerJob` 의 `afterCommit` 정책을 탄다. 즉 **호출자 트랜잭션이 커밋된 뒤에** 실행된다 — 큐 드라이버가 `sync` 여도 마찬가지다(같은 요청 안에서, 커밋 이후에 실행된다). + +따라서 훅 안에서 실패했을 때 **호출자의 작업을 되돌려야 하는 처리**는 기본값으로 두면 안 된다. 되돌릴 대상이 이미 커밋된 뒤라 예외를 던져도 롤백되지 않고, 호출자는 오류 응답을 받는데 데이터는 남는다. + +| 판정 | 예 | +|------|-----| +| `sync` 필수 | 쿠폰 차감·복원, 적립금 차감·복원 등 실패 시 호출자 트랜잭션을 되돌려야 하는 처리 | +| 기본값(큐) 유지 | 활동 로그, 알림 발송, 통계 갱신 등 실패해도 호출자를 되돌리지 않는 후속 처리 | + +선언만으로는 검증되지 않는다 — 회귀 테스트는 리스너를 손으로 `addAction` 하지 말고 실제 등록 경로(`HookListenerRegistrar::register()`)를 태운 뒤, **호출자 트랜잭션 안에서 반영되는지**와 **예외가 호출자를 롤백시키는지**를 단언한다. 손으로 등록하면 프로덕션이 쓰지 않는 경로를 검증하게 되어, 커밋 이후 실행 문제를 그대로 통과시킨다. + ### getSubscribedHooks() 옵션 요약 | 옵션 | 타입 | 기본값 | 설명 | diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index 570efff3..2f252148 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.1.2] - 2026-08-21 + +### Added + +- 이미 사용된 쿠폰으로 주문을 시도했을 때의 안내(`exceptions.coupon_already_used`)의 일본어 번역을 추가했습니다. + ## [1.1.1] - 2026-08-19 ### Added diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php index 0774befb..b6eb63f6 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php @@ -82,6 +82,7 @@ return [ 'additional_option_custom_text_required' => '追加オプション(:name)の直接入力内容を入力してください。', 'coupon_issue_not_found' => 'クーポン発行履歴が見つかりません。', 'coupon_issue_not_cancellable' => '未使用状態の発行分のみキャンセルできます。', + 'coupon_already_used' => 'すでに使用されたクーポンです。クーポンをご確認のうえ、再度ご注文ください。', 'country_not_shippable' => '選択された配送先国には配送できない商品です。', 'order_shipping_address_update_failed' => '配送先変更処理中にエラーが発生しました。', 'order_option_not_confirmable' => '現在の状態では購入確定できない注文オプションです。', diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json index 547798e5..297a61d3 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)", "ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)" }, - "version": "1.1.1", + "version": "1.1.2", "license": "MIT", "scope": "module", "target_identifier": "sirsoft-ecommerce", diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 854e3cad..11cdf3a5 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.5] - 2026-08-21 + +### Security + +- 비밀글 목록·상세 응답에 첨부 이미지의 미리보기 주소가 그대로 실려 나가던 문제를 수정했습니다. 이미지 자체는 이미 열람 권한이 없으면 제공되지 않았지만, 주소에 담긴 파일 식별값이 응답에 노출되어 있었습니다. 이제 열람 권한이 없으면 썸네일 주소가 비어서 전달되며, 비밀글이 아닌 글은 종전과 동일하게 표시됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1894) + ## [1.0.4] - 2026-08-19 ### Security diff --git a/modules/_bundled/sirsoft-board/composer.json b/modules/_bundled/sirsoft-board/composer.json index fb973132..85fc5089 100644 --- a/modules/_bundled/sirsoft-board/composer.json +++ b/modules/_bundled/sirsoft-board/composer.json @@ -2,7 +2,7 @@ "name": "modules/sirsoft-board", "description": "Board module for Gnuboard7", "type": "library", - "version": "1.0.4", + "version": "1.0.5", "license": "MIT", "autoload": { "psr-4": { diff --git a/modules/_bundled/sirsoft-board/docs/api/board.md b/modules/_bundled/sirsoft-board/docs/api/board.md index d0b3c625..1d0c2f2c 100644 --- a/modules/_bundled/sirsoft-board/docs/api/board.md +++ b/modules/_bundled/sirsoft-board/docs/api/board.md @@ -361,7 +361,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | reply_count | integer | `0` | reply 개수 (집계) | | attachment_count | integer | `0` | attachment 개수 (집계) | | has_attachment | boolean | `false` | attachment 여부 | -| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL) | +| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시 노출 차단 — 필드 자체는 유지) | | parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | | depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | | is_reply | boolean | `false` | reply 여부 | @@ -1040,7 +1040,7 @@ _단건 응답: `data` 객체의 필드._ | reply_count | integer | `0` | reply 개수 (집계) | | attachment_count | integer | `0` | attachment 개수 (집계) | | has_attachment | boolean | `false` | attachment 여부 | -| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL) | +| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시 노출 차단 — 필드 자체는 유지) | | parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | | depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | | is_reply | boolean | `false` | reply 여부 | diff --git a/modules/_bundled/sirsoft-board/module.json b/modules/_bundled/sirsoft-board/module.json index 519aac98..95d8b194 100644 --- a/modules/_bundled/sirsoft-board/module.json +++ b/modules/_bundled/sirsoft-board/module.json @@ -5,7 +5,7 @@ "ko": "게시판", "en": "Board" }, - "version": "1.0.4", + "version": "1.0.5", "license": "MIT", "description": { "ko": "게시판 관리를 위한 모듈", diff --git a/modules/_bundled/sirsoft-board/package-lock.json b/modules/_bundled/sirsoft-board/package-lock.json index 0b905c91..ba6f089e 100644 --- a/modules/_bundled/sirsoft-board/package-lock.json +++ b/modules/_bundled/sirsoft-board/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-board", - "version": "1.0.4", + "version": "1.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-board", - "version": "1.0.4", + "version": "1.0.5", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/modules/_bundled/sirsoft-board/package.json b/modules/_bundled/sirsoft-board/package.json index 76312ed7..44254293 100644 --- a/modules/_bundled/sirsoft-board/package.json +++ b/modules/_bundled/sirsoft-board/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-board", - "version": "1.0.4", + "version": "1.0.5", "description": "그누보드7 게시판 모듈 프론트엔드 에셋", "private": true, "type": "module", diff --git a/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php b/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php index cab4a70e..c7951c3c 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php +++ b/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php @@ -6,6 +6,7 @@ use App\Enums\PermissionType; use App\Enums\UserStatus; use App\Http\Resources\BaseApiResource; use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Facades\Auth; use Modules\Sirsoft\Board\Enums\PostStatus; use Modules\Sirsoft\Board\Enums\ReportReasonType; @@ -308,6 +309,13 @@ class PostResource extends BaseApiResource */ private function getThumbnailUrlFromRelations(): ?string { + // 비밀글은 썸네일 URL 자체를 방출하지 않는다 — 서빙은 이미 차단되어 이미지가 보이지는 + // 않지만, URL 에 실린 첨부 해시가 목록·상세 응답으로 나가 있었다(KVE-2026-1894). + // 판정은 첨부 목록과 같은 SecretContentGate(SSoT)를 쓴다. 필드는 남기고 값만 가린다. + if ($this->is_secret && ! $this->canViewSecretContent(request())) { + return null; + } + // 목록용 경량 관계 우선 — slug를 직접 전달하여 Board::find() N+1 방지 if ($this->relationLoaded('thumbnailAttachment') && $this->thumbnailAttachment) { $attachment = $this->thumbnailAttachment; @@ -678,9 +686,35 @@ class PostResource extends BaseApiResource */ private function canViewSecretContent(Request $request, ?string $slug = null): bool { + $post = $this->resolvePostModel(); + + if ($post === null) { + // 원본 모델을 확인할 수 없으면 열람 불가로 판정한다 (fail-closed) + return false; + } + // 판정 규칙은 SecretContentGate(SSoT)에 있다 — 리스너·댓글 경로와 규칙을 공유해 // 드리프트를 방지한다. - return self::canViewSecretForPost($this->resource, $request); + return self::canViewSecretForPost($post, $request); + } + + /** + * 감싸인 원본 게시글 모델을 반환합니다. + * + * 컬렉션 경로에서는 리소스가 다시 리소스를 감싸고 있을 수 있어, $this->resource 가 + * 곧 Post 라고 가정하면 타입 오류로 응답 전체가 실패합니다. + * + * @return Post|null 원본 게시글 모델 (해석 불가 시 null) + */ + private function resolvePostModel(): ?Post + { + $candidate = $this->resource; + + while ($candidate instanceof JsonResource) { + $candidate = $candidate->resource; + } + + return $candidate instanceof Post ? $candidate : null; } /** diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php index 38120b6d..d677fdd0 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php @@ -25,9 +25,9 @@ class PostResourceThumbnailUrlTest extends BoardTestCase * @param string $mimeType 첨부 MIME 타입 * @return array{post: Post, attachment: Attachment} 게시글/첨부 */ - private function createPostWithAttachment(string $mimeType): array + private function createPostWithAttachment(string $mimeType, array $postAttributes = []): array { - $postId = $this->createTestPost(); + $postId = $this->createTestPost($postAttributes); $attachment = Attachment::create([ 'board_id' => $this->board->id, @@ -75,4 +75,41 @@ class PostResourceThumbnailUrlTest extends BoardTestCase $this->assertNull($response['thumbnail']); } + + /** + * 비밀글의 썸네일 URL은 열람 권한이 없으면 방출되지 않아야 합니다 (KVE-2026-1894). + * + * 서빙은 막혀 있어 이미지가 보이지는 않지만, URL 에 실린 첨부 해시 자체가 + * 목록·상세 응답으로 나가 있었다. 필드는 남기고 값만 가린다. + * + * @effects secret_post_thumbnail_hash_not_exposed + */ + #[Test] + public function thumbnail_is_null_for_secret_post_without_permission(): void + { + ['post' => $post] = $this->createPostWithAttachment('image/jpeg', ['is_secret' => true]); + + $response = (new PostResource($post))->toArray(Request::create('/')); + + $this->assertArrayHasKey('thumbnail', $response, 'thumbnail 키 자체는 유지되어야 합니다.'); + $this->assertNull($response['thumbnail']); + } + + /** + * 비밀글이 아니면 썸네일 URL이 그대로 유지되어야 합니다 (선택적 차단 회귀 방지). + * + * @effects secret_post_thumbnail_hash_not_exposed + */ + #[Test] + public function thumbnail_is_preserved_for_non_secret_post(): void + { + ['post' => $post, 'attachment' => $attachment] = $this->createPostWithAttachment('image/jpeg'); + + $response = (new PostResource($post))->toArray(Request::create('/')); + + $this->assertSame( + '/api/modules/sirsoft-board/boards/'.$this->board->slug.'/attachment/'.$attachment->hash.'/preview', + $response['thumbnail'], + ); + } } diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index a5016cfa..ee1e895d 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -4,6 +4,20 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.1.2] - 2026-08-21 + +### Security + +- 1회만 쓸 수 있는 쿠폰이 동시에 주문 두 건에 적용되던 문제를 막았습니다. 주문이 거의 같은 시각에 확정되면 두 주문 모두 쿠폰을 사용 가능한 상태로 읽어 각자 할인을 받을 수 있었습니다. 이제 쿠폰 차감이 한 번에 하나만 성공하며, 쿠폰을 선점당한 주문은 확정되지 않고 쿠폰도 소모되지 않아 그대로 다시 시도할 수 있습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1886) +- 쿠폰 차감·적립금 차감처럼 금액이 오가는 처리가 주문이 저장된 **뒤에** 실행되던 문제를 바로잡았습니다. 그래서 차감이 실패해도 주문은 이미 만들어진 뒤라 되돌릴 수 없었고, 위 쿠폰 중복 사용도 이 때문에 주문이 두 건 남을 수 있었습니다. 이제 이 처리들은 주문 저장과 같은 묶음 안에서 실행되어, 하나라도 실패하면 주문 전체가 취소됩니다. (KISA 측에서 제보해주신 내용을 확인하는 과정에서 함께 발견했습니다 — KVE-2026-1886) +- 같은 주문 상품의 구매 적립 내역이 동시 확정으로 두 줄 생길 수 있던 문제를 막았습니다. 적립 내역은 상품당 한 줄이어야 취소 시 정확히 회수되는데, 두 줄이 되면 적립은 두 배가 되고 회수는 한 줄만 이뤄졌습니다. 이제 데이터베이스가 중복 자체를 막고, 동시에 들어온 요청은 기존 내역에 차액만 더합니다. 업그레이드하면 이미 쌓인 중복 내역도 한 줄로 합쳐집니다. + +### Fixed + +- 부분취소가 짧은 간격으로 두 번 이뤄질 때 앞선 취소의 취소 총액·취소 횟수·결제 취소 이력이 사라지던 문제를 수정했습니다. 이제 취소 누적값이 두 건 모두 반영됩니다. +- 주문 취소로 쿠폰을 되돌릴 때, 그 사이 상태가 바뀐 쿠폰까지 되살리던 문제를 수정했습니다. 이미 만료되었거나 다시 사용된 쿠폰은 복원 대상에서 제외됩니다. +- 적립금 적립과 적립 취소가 겹칠 때 한쪽의 반영이 사라지던 문제를 수정했습니다. 적립 내역의 금액·잔여 금액이 항상 최신 값 기준으로 갱신됩니다. + ## [1.1.1] - 2026-08-19 ### Security diff --git a/modules/_bundled/sirsoft-ecommerce/composer.json b/modules/_bundled/sirsoft-ecommerce/composer.json index 7492ffe5..ca4d7301 100644 --- a/modules/_bundled/sirsoft-ecommerce/composer.json +++ b/modules/_bundled/sirsoft-ecommerce/composer.json @@ -2,7 +2,7 @@ "name": "modules/sirsoft-ecommerce", "description": "Ecommerce module for Gnuboard7", "type": "library", - "version": "1.1.1", + "version": "1.1.2", "license": "MIT", "autoload": { "psr-4": { diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_21_000001_add_unique_purchase_earn_lot_to_ecommerce_mileage_transactions_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_21_000001_add_unique_purchase_earn_lot_to_ecommerce_mileage_transactions_table.php new file mode 100644 index 00000000..52342122 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_21_000001_add_unique_purchase_earn_lot_to_ecommerce_mileage_transactions_table.php @@ -0,0 +1,160 @@ +consolidateDuplicateEarnLots(); + + if (! Schema::hasColumn('ecommerce_mileage_transactions', 'purchase_earn_option_key')) { + $table = $this->qualifiedTable(); + + DB::statement( + "ALTER TABLE {$table} ADD COLUMN `purchase_earn_option_key` BIGINT UNSIGNED" + ." GENERATED ALWAYS AS (CASE WHEN `type` = 'purchase_earn' THEN `order_option_id` ELSE NULL END) VIRTUAL" + ." COMMENT '주문옵션당 구매적립 1건 강제용 파생 키 (purchase_earn 이 아니면 NULL)'" + ); + } + + if (! $this->indexExists('ecommerce_mileage_transactions_purchase_earn_option_unique')) { + $table = $this->qualifiedTable(); + + DB::statement( + "ALTER TABLE {$table} ADD UNIQUE INDEX" + .' `ecommerce_mileage_transactions_purchase_earn_option_unique` (`purchase_earn_option_key`)' + ); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasTable('ecommerce_mileage_transactions')) { + return; + } + + $table = $this->qualifiedTable(); + + if ($this->indexExists('ecommerce_mileage_transactions_purchase_earn_option_unique')) { + DB::statement("ALTER TABLE {$table} DROP INDEX `ecommerce_mileage_transactions_purchase_earn_option_unique`"); + } + + if (Schema::hasColumn('ecommerce_mileage_transactions', 'purchase_earn_option_key')) { + DB::statement("ALTER TABLE {$table} DROP COLUMN `purchase_earn_option_key`"); + } + } + + /** + * 같은 주문옵션에 중복 생성된 구매 적립 lot 을 가장 먼저 만들어진 한 줄로 통합합니다. + * + * 금액·잔여금액을 합산해 살아남는 lot 에 얹고 나머지는 삭제한다. 유효기간은 최초 적립 + * 시점을 유지하는 기존 정책(방식 A)과 같게 살아남는 lot 의 값을 그대로 둔다. + * + * 반복 종료는 데이터에 맡기지 않는다 — 한 바퀴에서 실제로 지운 행이 없으면 다음 바퀴도 + * 같은 목록을 다시 읽을 뿐이므로 그 자리에서 멈춘다 (진행 없는 반복 차단). + * + * @return void + */ + private function consolidateDuplicateEarnLots(): void + { + // 중복 주문옵션 목록은 통합 대상이 남아 있는 동안만 반복 조회한다. + // 한 번에 모두 읽지 않으므로 중복이 많은 설치본에서도 메모리가 늘지 않는다. + while (true) { + $duplicated = DB::table('ecommerce_mileage_transactions') + ->select('order_option_id') + ->where('type', 'purchase_earn') + ->whereNotNull('order_option_id') + ->groupBy('order_option_id') + ->havingRaw('COUNT(*) > 1') + ->limit(200) + ->pluck('order_option_id'); + + if ($duplicated->isEmpty()) { + return; + } + + $deleted = 0; + + foreach ($duplicated as $orderOptionId) { + $lots = DB::table('ecommerce_mileage_transactions') + ->where('type', 'purchase_earn') + ->where('order_option_id', $orderOptionId) + ->orderBy('id') + ->get(['id', 'amount', 'remaining_amount']); + + if ($lots->count() < 2) { + continue; + } + + $survivor = $lots->shift(); + + DB::table('ecommerce_mileage_transactions') + ->where('id', $survivor->id) + ->update([ + 'amount' => (float) $survivor->amount + (float) $lots->sum(fn ($l) => (float) $l->amount), + 'remaining_amount' => (float) $survivor->remaining_amount + (float) $lots->sum(fn ($l) => (float) $l->remaining_amount), + ]); + + $deleted += DB::table('ecommerce_mileage_transactions') + ->whereIn('id', $lots->pluck('id')->all()) + ->delete(); + } + + // 목록은 남아 있는데 한 행도 지우지 못했다면 더 진행할 수 없다. + if ($deleted === 0) { + return; + } + } + } + + /** + * 프리픽스가 붙은 실제 테이블명을 반환합니다. + * + * @return string 백틱으로 감싼 테이블명 + */ + private function qualifiedTable(): string + { + return '`'.DB::getTablePrefix().'ecommerce_mileage_transactions`'; + } + + /** + * 인덱스 존재 여부를 확인합니다. + * + * @param string $indexName 인덱스명 + * @return bool 존재 여부 + */ + private function indexExists(string $indexName): bool + { + return ! empty(DB::select( + 'SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1', + [DB::getTablePrefix().'ecommerce_mileage_transactions', $indexName] + )); + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md index 459a7b78..5335d627 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md @@ -2153,6 +2153,7 @@ HTTP/1.1 201 | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.create`)이 없는 경우 | | 404 | Not Found | 임시 주문(주문서)이 없거나 만료된 경우 (`주문서를 찾을 수 없습니다.` 계열 — `exceptions.temp_order_not_found`) | | 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 예상 결제금액 불일치(`expected_total_amount` ≠ 서버 재계산값), 결제 통화 미지원(`errors.code = unsupported_payment_currency`), 재고 부족(`errors.insufficient_items`), 구매 불가 상품(`errors.code = cart_unavailable`), 주문 확정 재계산 검증 실패(쿠폰 만료·최소주문금액 미달 등 — `errors.code = order_calculation_validation_failed`). `payment_method` 가 결제수단 카탈로그에 없는 값이면 여기서 차단된다 | +| 409 | Conflict | 적용한 쿠폰을 다른 주문이 먼저 사용한 경우 (`errors.code = coupon_already_used`, `errors.coupon_issue_id` 에 해당 발급 ID). 주문은 생성되지 않고 쿠폰도 소모되지 않으므로 그대로 재시도할 수 있습니다 | | 428 | Identity Verification Required | 결제 진입 본인인증(IDV) 정책이 활성이고 미인증(grace 만료)인 경우 | | 500 | Server Error | 주문 생성 중 예기치 못한 오류 (`주문 생성에 실패했습니다.`) | diff --git a/modules/_bundled/sirsoft-ecommerce/module.json b/modules/_bundled/sirsoft-ecommerce/module.json index 697c7767..02aab554 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.json +++ b/modules/_bundled/sirsoft-ecommerce/module.json @@ -5,7 +5,7 @@ "ko": "이커머스", "en": "Ecommerce" }, - "version": "1.1.1", + "version": "1.1.2", "license": "MIT", "description": { "ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리", diff --git a/modules/_bundled/sirsoft-ecommerce/package-lock.json b/modules/_bundled/sirsoft-ecommerce/package-lock.json index a1e1cb71..7083fbfc 100644 --- a/modules/_bundled/sirsoft-ecommerce/package-lock.json +++ b/modules/_bundled/sirsoft-ecommerce/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-ecommerce", - "version": "1.1.1", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-ecommerce", - "version": "1.1.1", + "version": "1.1.2", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/modules/_bundled/sirsoft-ecommerce/package.json b/modules/_bundled/sirsoft-ecommerce/package.json index 4513558f..105f9f6d 100644 --- a/modules/_bundled/sirsoft-ecommerce/package.json +++ b/modules/_bundled/sirsoft-ecommerce/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-ecommerce", - "version": "1.1.1", + "version": "1.1.2", "description": "그누보드7 이커머스 모듈 프론트엔드 에셋", "private": true, "type": "module", diff --git a/modules/_bundled/sirsoft-ecommerce/src/Exceptions/CouponAlreadyUsedException.php b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/CouponAlreadyUsedException.php new file mode 100644 index 00000000..5013f3f1 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/CouponAlreadyUsedException.php @@ -0,0 +1,33 @@ +couponIssueId; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php index fc4e8648..2fd40301 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php @@ -9,6 +9,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Ecommerce\Exceptions\CartUnavailableException; +use Modules\Sirsoft\Ecommerce\Exceptions\CouponAlreadyUsedException; use Modules\Sirsoft\Ecommerce\Exceptions\InsufficientStockException; use Modules\Sirsoft\Ecommerce\Exceptions\MileageValidationException; use Modules\Sirsoft\Ecommerce\Exceptions\OrderProcessingException; @@ -168,6 +169,22 @@ trait HandlesOrderCreation 'has_restriction_issue' => $e->hasRestrictionIssue(), ]); + } catch (CouponAlreadyUsedException $e) { + // 주문 확정 시점에 다른 주문이 같은 쿠폰을 선점했다 — 주문 트랜잭션은 롤백된 상태다. + // generic 500 이 아닌 409 로 알려 사용자가 쿠폰 소진 없이 재시도할 수 있게 한다. + Log::warning('Order create: coupon already taken by another order', [ + 'coupon_issue_id' => $e->getCouponIssueId(), + ]); + + $messageKey = $e->getMessageKey(); + + return ResponseHelper::error( + $messageKey, + 409, + ['code' => 'coupon_already_used', 'coupon_issue_id' => $e->getCouponIssueId()], + $e->getMessageParams() + ); + } catch (MileageValidationException $e) { // 마일리지 사용 정책 위반(한도/단위/최소사용액/잔액) — generic 500 이 아닌 422 명시 차단. // 임시주문 생성 이후 설정이 바뀌었거나 임시주문이 조작된 경우 여기로 떨어진다. diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/CouponRestoreListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/CouponRestoreListener.php index e625846d..395217a1 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/CouponRestoreListener.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/CouponRestoreListener.php @@ -35,12 +35,16 @@ class CouponRestoreListener implements HookListenerInterface 'sirsoft-ecommerce.order.after_cancel' => [ 'method' => 'restoreCoupons', 'priority' => 10, + // 취소 트랜잭션 안에서 실행되어야 한다 — 큐 기본값(afterCommit)이면 취소가 + // 커밋된 뒤에 복원이 돌아, 취소가 되돌려져도 쿠폰만 복원된 상태가 남는다. + 'sync' => true, ], // 부분취소(및 전체취소 트랜잭션 내부)에서 OrderCancellationService 가 발화하는 // 명시적 복원 ID 훅. 스냅샷 파싱 없이 전달받은 ID 만 used→available 복원한다. 'sirsoft-ecommerce.coupon.restore' => [ 'method' => 'restoreCouponsByIds', 'priority' => 10, + 'sync' => true, ], ]; } @@ -147,27 +151,43 @@ class CouponRestoreListener implements HookListenerInterface continue; } - // 만료 확인: 만료된 쿠폰은 expired 상태로 변경 + // 만료 확인: 만료된 쿠폰은 expired 상태로 변경. + // 조회 시점의 USED 를 조건으로 실어 원자적으로 쓴다 — 그 사이 다른 요청이 상태를 + // 바꿨다면 갱신을 포기해야 낡은 스냅샷이 최신 상태를 덮어쓰지 않는다. if ($couponIssue->expired_at !== null && $couponIssue->expired_at->isPast()) { - $this->couponIssueRepository->update($issueId, [ - 'status' => CouponIssueRecordStatus::EXPIRED, - 'used_at' => null, - ]); + $expiredAffected = $this->couponIssueRepository->updateIfStatus( + $issueId, + CouponIssueRecordStatus::USED, + [ + 'status' => CouponIssueRecordStatus::EXPIRED, + 'used_at' => null, + ] + ); - Log::info('CouponRestoreListener: 만료된 쿠폰 상태 변경', [ - 'coupon_issue_id' => $issueId, - 'order_id' => $order->id, - 'new_status' => CouponIssueRecordStatus::EXPIRED->value, - ]); + if ($expiredAffected > 0) { + Log::info('CouponRestoreListener: 만료된 쿠폰 상태 변경', [ + 'coupon_issue_id' => $issueId, + 'order_id' => $order->id, + 'new_status' => CouponIssueRecordStatus::EXPIRED->value, + ]); + } continue; } - // 사용 가능 상태로 복원 - $this->couponIssueRepository->update($issueId, [ - 'status' => CouponIssueRecordStatus::AVAILABLE, - 'used_at' => null, - ]); + // 사용 가능 상태로 복원 (복원은 멱등이 정상이므로 경쟁에서 밀리면 조용히 skip) + $affected = $this->couponIssueRepository->updateIfStatus( + $issueId, + CouponIssueRecordStatus::USED, + [ + 'status' => CouponIssueRecordStatus::AVAILABLE, + 'used_at' => null, + ] + ); + + if ($affected === 0) { + continue; + } $restoredCount++; } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/CouponUseListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/CouponUseListener.php index 3d6bf771..de8f442c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/CouponUseListener.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/CouponUseListener.php @@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Ecommerce\Listeners; use App\Contracts\Extension\HookListenerInterface; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Ecommerce\Enums\CouponIssueRecordStatus; +use Modules\Sirsoft\Ecommerce\Exceptions\CouponAlreadyUsedException; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterface; @@ -39,6 +40,10 @@ class CouponUseListener implements HookListenerInterface 'sirsoft-ecommerce.coupon.use' => [ 'method' => 'markCouponsUsed', 'priority' => 10, + // 호출자(주문 생성) 트랜잭션 안에서 실행되어야 한다. Action 훅 기본값은 큐 작업 + // 래핑 + afterCommit 이라, 그대로 두면 쿠폰 차감이 주문 커밋 뒤에 실행되어 + // 차감이 실패해도 주문을 되돌릴 수 없다 (1회 제한 쿠폰 다중 사용). + 'sync' => true, ], ]; } @@ -67,24 +72,52 @@ class CouponUseListener implements HookListenerInterface $usedCount = 0; foreach (array_unique($appliedCouponIds) as $issueId) { - $couponIssue = $this->couponIssueRepository->findById((int) $issueId); + $issueId = (int) $issueId; + + // AVAILABLE 판정과 차감을 한 UPDATE 문에서 원자적으로 수행한다. + // 조회 후 갱신하면 동시에 확정되는 두 주문이 모두 AVAILABLE 을 읽어 + // 각자 USED 로 덮어써 1회 제한 쿠폰이 여러 주문에 사용된다. + $affected = $this->couponIssueRepository->updateIfStatus( + $issueId, + CouponIssueRecordStatus::AVAILABLE, + [ + 'status' => CouponIssueRecordStatus::USED, + 'used_at' => now(), + 'order_id' => $order->id, + ] + ); + + if ($affected > 0) { + $usedCount++; - if ($couponIssue === null) { continue; } - // 이미 사용됨/취소됨 등은 skip (멱등성 — 재발화/재시도 안전) - if ($couponIssue->status !== CouponIssueRecordStatus::AVAILABLE) { + $current = $this->couponIssueRepository->findById($issueId); + + // 존재하지 않는 발급 ID 는 종전대로 skip + if ($current === null) { continue; } - $this->couponIssueRepository->update((int) $issueId, [ - 'status' => CouponIssueRecordStatus::USED, - 'used_at' => now(), + // 같은 주문의 재발화(재시도/훅 중복 발화)는 멱등하게 skip + if ($current->status === CouponIssueRecordStatus::USED + && (int) $current->order_id === (int) $order->id) { + continue; + } + + // 다른 주문이 선점했거나 사용 가능 상태가 아니다. 이 주문은 이미 할인 금액이 + // 확정된 상태이므로 검출만으로는 부족하고, 예외를 전파해 주문 트랜잭션 자체를 + // 롤백해야 쿠폰이 중복 사용되지 않는다. + Log::warning('CouponUseListener: 쿠폰 선점 실패 — 주문 롤백', [ 'order_id' => $order->id, + 'order_number' => $order->order_number ?? null, + 'coupon_issue_id' => $issueId, + 'current_status' => $current->status?->value, + 'current_order_id' => $current->order_id, ]); - $usedCount++; + throw new CouponAlreadyUsedException($issueId); } Log::info('CouponUseListener: 주문 쿠폰 사용 차감 완료', [ @@ -93,6 +126,9 @@ class CouponUseListener implements HookListenerInterface 'total_coupons' => count(array_unique($appliedCouponIds)), 'used_count' => $usedCount, ]); + } catch (CouponAlreadyUsedException $e) { + // 도메인 실패는 삼키지 않고 전파한다 (주문 트랜잭션 롤백 트리거) + throw $e; } catch (\Exception $e) { Log::error('CouponUseListener: 쿠폰 사용 차감 실패', [ 'order_id' => $order->id, diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/MileageTransactionListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/MileageTransactionListener.php index 2c6f75fe..09f0e3e3 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/MileageTransactionListener.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/MileageTransactionListener.php @@ -40,8 +40,11 @@ class MileageTransactionListener implements HookListenerInterface public static function getSubscribedHooks(): array { return [ - 'sirsoft-ecommerce.mileage.use' => ['method' => 'handleUse', 'priority' => 10], - 'sirsoft-ecommerce.mileage.restore' => ['method' => 'handleRestore', 'priority' => 10], + // 마일리지 차감/복원은 호출자(주문 생성·취소) 트랜잭션 안에서 실행되어야 한다. + // Action 훅 기본값은 큐 작업 래핑 + afterCommit 이라, 그대로 두면 주문이 커밋된 + // 뒤에 차감이 돌아 잔액 부족으로 실패해도 주문을 되돌릴 수 없다. + 'sirsoft-ecommerce.mileage.use' => ['method' => 'handleUse', 'priority' => 10, 'sync' => true], + 'sirsoft-ecommerce.mileage.restore' => ['method' => 'handleRestore', 'priority' => 10, 'sync' => true], 'sirsoft-ecommerce.order-option.after_confirm' => ['method' => 'handleAfterConfirm', 'priority' => 10], 'sirsoft-ecommerce.order_option.after_status_change' => ['method' => 'handleAfterStatusChange', 'priority' => 10], 'sirsoft-ecommerce.order_option.after_bulk_status_change' => ['method' => 'handleAfterBulkStatusChange', 'priority' => 10], diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CouponIssueRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CouponIssueRepositoryInterface.php index fc7ce57f..b2f9731d 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CouponIssueRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CouponIssueRepositoryInterface.php @@ -2,6 +2,9 @@ namespace Modules\Sirsoft\Ecommerce\Repositories\Contracts; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; +use Modules\Sirsoft\Ecommerce\Enums\CouponIssueRecordStatus; use Modules\Sirsoft\Ecommerce\Models\CouponIssue; /** @@ -45,9 +48,9 @@ interface CouponIssueRepositoryInterface * @param int $userId 사용자 ID * @param string|null $status 필터 상태 (available, used, expired) * @param int $perPage 페이지당 항목 수 - * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 쿠폰함 페이지네이터 + * @return LengthAwarePaginator 쿠폰함 페이지네이터 */ - public function getUserCoupons(int $userId, ?string $status = null, int $perPage = 10): \Illuminate\Contracts\Pagination\LengthAwarePaginator; + public function getUserCoupons(int $userId, ?string $status = null, int $perPage = 10): LengthAwarePaginator; /** * 특정 사용자가 소유한 쿠폰만 조회 (소유권 검증용) @@ -57,14 +60,14 @@ interface CouponIssueRepositoryInterface * * @param array $couponIssueIds 쿠폰 발급 ID 배열 * @param int $userId 사용자 ID - * @return \Illuminate\Support\Collection CouponIssue 컬렉션 + * @return Collection CouponIssue 컬렉션 */ - public function findByIdsForUser(array $couponIssueIds, int $userId): \Illuminate\Support\Collection; + public function findByIdsForUser(array $couponIssueIds, int $userId): Collection; /** * 쿠폰 발급 레코드 생성 * - * @param array $data 발급 데이터 + * @param array $data 발급 데이터 * @return CouponIssue */ public function create(array $data): CouponIssue; @@ -72,8 +75,8 @@ interface CouponIssueRepositoryInterface /** * 특정 사용자의 특정 쿠폰 발급 횟수 조회 * - * @param int $userId 사용자 ID - * @param int $couponId 쿠폰 ID + * @param int $userId 사용자 ID + * @param int $couponId 쿠폰 ID * @return int 발급 횟수 */ public function getUserIssuedCountForCoupon(int $userId, int $couponId): int; @@ -99,6 +102,20 @@ interface CouponIssueRepositoryInterface */ public function update(int $id, array $data): bool; + /** + * 현재 상태가 기대값과 같을 때만 쿠폰 발급 레코드를 갱신합니다. + * + * 조회 후 갱신하는 방식은 두 요청이 같은 상태를 읽어 서로를 덮어쓰는 lost update 를 + * 허용하므로, 상태 판정과 갱신을 하나의 UPDATE 문에서 원자적으로 수행합니다. + * 갱신된 행 수가 0 이면 다른 요청이 이미 상태를 바꾼 것입니다. + * + * @param int $id 쿠폰 발급 ID + * @param CouponIssueRecordStatus $expected 기대하는 현재 상태 + * @param array $data 업데이트 데이터 + * @return int 갱신된 행 수 (0 이면 경쟁에서 밀렸거나 상태 불일치) + */ + public function updateIfStatus(int $id, CouponIssueRecordStatus $expected, array $data): int; + /** * ID 목록으로 쿠폰 발급 레코드를 조회합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/MileageTransactionRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/MileageTransactionRepositoryInterface.php index 7c00ab92..2112047a 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/MileageTransactionRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/MileageTransactionRepositoryInterface.php @@ -214,6 +214,17 @@ interface MileageTransactionRepositoryInterface */ public function findEarnLotForOption(int $orderOptionId): ?MileageTransaction; + /** + * 주문옵션의 적립 lot 을 행 잠금과 함께 조회합니다. + * + * 적립 증액·취소 회수는 lot 의 현재 값을 읽어 더하거나 빼는 경로라, 두 요청이 같은 + * 값을 읽으면 한쪽 반영이 사라집니다. 갱신 트랜잭션 안에서 이 메서드로 잠급니다. + * + * @param int $orderOptionId 주문옵션 ID + * @return MileageTransaction|null 잠긴 적립 lot (없으면 null) + */ + public function findEarnLotForOptionForUpdate(int $orderOptionId): ?MileageTransaction; + /** * 회원의 활성 적립건(lot) 전부를 조회합니다 (FOR UPDATE 없음 — 탈퇴 정리용). * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderPaymentRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderPaymentRepositoryInterface.php index ab9c855d..e76c81e6 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderPaymentRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderPaymentRepositoryInterface.php @@ -12,6 +12,17 @@ use Modules\Sirsoft\Ecommerce\Models\OrderPayment; */ interface OrderPaymentRepositoryInterface { + /** + * 주문 ID로 결제 행을 잠금과 함께 조회합니다. + * + * 취소 누적액·취소 이력은 현재 값을 읽어 더하거나 덧붙이는 컬럼이라, 두 요청이 같은 + * 값을 읽으면 후행이 선행을 덮어씁니다. 트랜잭션 안에서 행을 잠근 뒤 갱신합니다. + * + * @param int $orderId 주문 ID + * @return OrderPayment|null 잠긴 결제 모델 (없으면 null) + */ + public function findByOrderIdForUpdate(int $orderId): ?OrderPayment; + /** * 현금영수증 발급 성공 시 결제의 요약 컬럼을 갱신합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderRepositoryInterface.php index 8ab419d6..1fd98aa9 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderRepositoryInterface.php @@ -19,6 +19,18 @@ interface OrderRepositoryInterface */ public function find(int $id): ?Order; + /** + * ID로 주문을 행 잠금과 함께 조회합니다. + * + * 취소 총액·취소 횟수처럼 현재 값을 읽어 더하는 컬럼은 두 요청이 같은 값을 읽으면 + * 후행이 선행을 덮어씁니다. 트랜잭션 안에서 이 메서드로 행을 잠근 뒤 갱신하면 + * 뒤따르는 요청이 앞선 커밋을 본 뒤에 진행합니다. + * + * @param int $id 주문 ID + * @return Order|null 잠긴 주문 모델 (없으면 null) + */ + public function findByIdForUpdate(int $id): ?Order; + /** * 주문이 1건이라도 존재하는지 확인합니다. (A2 base 통화 변경 가드) * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponIssueRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponIssueRepository.php index 0de1aecf..20d64bdd 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponIssueRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponIssueRepository.php @@ -248,6 +248,19 @@ class CouponIssueRepository implements CouponIssueRepositoryInterface ->update($data) > 0; } + /** + * {@inheritDoc} + */ + public function updateIfStatus(int $id, CouponIssueRecordStatus $expected, array $data): int + { + // 상태 판정을 WHERE 절에 실어 단일 UPDATE 로 수행한다 (compare-and-set). + // 조회 후 갱신하면 두 요청이 같은 상태를 읽어 서로를 덮어쓴다. + return $this->model + ->where('id', $id) + ->where('status', $expected->value) + ->update($data); + } + /** * {@inheritDoc} */ diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php index d7dbd913..48bf40bf 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php @@ -88,8 +88,11 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf */ public function decrementRemaining(MileageTransaction $lot, float $amount): void { - $lot->remaining_amount = (float) $lot->remaining_amount - $amount; - $lot->save(); + // 값을 PHP 에서 빼고 모델 전체를 저장하면, 스냅샷을 읽은 뒤 다른 요청이 반영한 + // 증감이 통째로 사라진다. 컬럼 연산으로 위임해 커밋된 값에서 차감한다. + MileageTransaction::query()->where('id', $lot->id)->decrement('remaining_amount', $amount); + + $lot->refresh(); } /** @@ -130,9 +133,14 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf */ public function incrementEarnLotAmount(MileageTransaction $lot, float $delta): void { - $lot->amount = (float) $lot->amount + $delta; - $lot->remaining_amount = (float) $lot->remaining_amount + $delta; - $lot->save(); + // 스냅샷 기준 재계산 대신 컬럼 연산 — 그 사이 반영된 다른 증감을 덮어쓰지 않는다. + MileageTransaction::query()->where('id', $lot->id)->incrementEach([ + 'amount' => $delta, + 'remaining_amount' => $delta, + ]); + + // 호출부가 이 모델을 그대로 반환·기록하므로 반영된 값으로 되읽는다 + $lot->refresh(); } /** @@ -448,6 +456,19 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf ->first(); } + /** + * {@inheritdoc} + */ + public function findEarnLotForOptionForUpdate(int $orderOptionId): ?MileageTransaction + { + // 잠금은 트랜잭션 안에서만 의미가 있다 — 적립/회수 갱신 트랜잭션에서 호출한다 + return MileageTransaction::query() + ->where('order_option_id', $orderOptionId) + ->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value) + ->lockForUpdate() + ->first(); + } + /** * {@inheritdoc} */ diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderPaymentRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderPaymentRepository.php index 03e6764f..a136bac1 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderPaymentRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderPaymentRepository.php @@ -14,6 +14,14 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderPaymentRepositoryInter */ class OrderPaymentRepository implements OrderPaymentRepositoryInterface { + /** + * {@inheritDoc} + */ + public function findByOrderIdForUpdate(int $orderId): ?OrderPayment + { + return OrderPayment::query()->where('order_id', $orderId)->lockForUpdate()->first(); + } + /** * {@inheritDoc} */ diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php index 82bac990..9fbc1526 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php @@ -146,6 +146,14 @@ class OrderRepository implements OrderRepositoryInterface return $this->model->find($id); } + /** + * {@inheritDoc} + */ + public function findByIdForUpdate(int $id): ?Order + { + return $this->model->newQuery()->lockForUpdate()->find($id); + } + /** * {@inheritDoc} */ diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php index 680851c0..aa70a636 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php @@ -32,6 +32,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderOptionRepositoryInterf use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderPaymentRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundOptionRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderShippingRepositoryInterface; use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot; @@ -57,6 +58,7 @@ class OrderCancellationService * @param OrderRefundOptionRepositoryInterface $orderRefundOptionRepository 주문 환불 옵션 Repository * @param CashReceiptService $cashReceiptService 현금영수증 발급/취소 서비스 * @param OrderPaymentRepositoryInterface $orderPaymentRepository 주문 결제 Repository + * @param OrderRepositoryInterface $orderRepository 주문 Repository */ public function __construct( protected OrderAdjustmentService $adjustmentService, @@ -72,6 +74,7 @@ class OrderCancellationService protected OrderRefundOptionRepositoryInterface $orderRefundOptionRepository, protected CashReceiptService $cashReceiptService, protected OrderPaymentRepositoryInterface $orderPaymentRepository, + protected OrderRepositoryInterface $orderRepository, ) {} /** @@ -314,6 +317,12 @@ class OrderCancellationService $cancelledBy, $cancelPg, $adjustmentResult, &$orderCancel, &$orderRefund, ) { + // ③-0. 누적 컬럼 갱신 전 주문·결제 행을 잠그고 커밋된 값으로 되읽는다. + // total_cancelled_amount / cancellation_count / cancelled_amount / cancel_history 는 + // 현재 값을 읽어 더하거나 덧붙이는 컬럼이라, 동시 부분취소 두 건이 같은 값을 읽으면 + // 후행이 선행을 덮어써 취소 총액이 과소 기록되고 PG 환불 기준이 어긋난다. + $this->lockAccumulatorRows($order); + $now = Carbon::now(); $isFullCancel = $cancelType === CancelTypeEnum::FULL; $isPaid = ! $order->order_status->isBeforePayment(); @@ -734,6 +743,32 @@ class OrderCancellationService // ③-e. Order 합계 업데이트 // ─────────────────────────────────────────────── + /** + * 누적 컬럼을 가진 주문·결제 행을 잠그고 커밋된 값으로 되읽습니다. + * + * 관계(options/shippings 등)는 이 트랜잭션 안에서 이미 사용 중이므로 건드리지 않고, + * 누적 판단에 쓰이는 속성만 교체한다. 잠금은 커밋까지 유지되어 뒤따르는 취소 요청이 + * 앞선 커밋을 본 뒤에 진행한다. + * + * @param Order $order 대상 주문 (속성이 최신 값으로 갱신됨) + * @return void + */ + protected function lockAccumulatorRows(Order $order): void + { + $locked = $this->orderRepository->findByIdForUpdate($order->id); + + if ($locked !== null) { + // 관계는 유지한 채 속성만 커밋된 값으로 교체 + $order->setRawAttributes($locked->getAttributes(), true); + } + + $lockedPayment = $this->orderPaymentRepository->findByOrderIdForUpdate($order->id); + + if ($lockedPayment !== null && $order->relationLoaded('payment') && $order->payment !== null) { + $order->payment->setRawAttributes($lockedPayment->getAttributes(), true); + } + } + /** * 주문 합계를 재계산 결과에 따라 갱신합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php index e37eb10a..726cd8e6 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php @@ -7,6 +7,7 @@ use App\Extension\HookManager; use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\DB; use Modules\Sirsoft\Ecommerce\DTO\MileageAdminDeductDto; use Modules\Sirsoft\Ecommerce\DTO\MileageAdminEarnDto; @@ -379,36 +380,35 @@ class UserMileageService // 방식 A: 기존 purchase_earn lot 이 있으면 그 lot 에 델타를 증액(적립 내역 한 줄 유지). // 취소 회수(findEarnLotForOption 단일 lot 가정)·유효기간 정합을 위해 신규 lot 을 늘리지 않는다. if ($type === MileageTransactionTypeEnum::PURCHASE_EARN - && ($existingLot = $this->ledger->findEarnLotForOption($option->id)) !== null) { - $this->ledger->incrementEarnLotAmount($existingLot, $amount); - - $this->cache->recalculateForUser($order->user_id, $currency); - $this->cache->recalculatePending($order->user_id, $currency); - - $this->logActivity('mileage.earn', [ - 'loggable' => $existingLot, - 'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_earn', - 'description_params' => ['amount' => ecommerce_format_price((int) $amount, $currency)], - 'properties' => ['order_id' => $order->id, 'order_option_id' => $option->id, 'currency' => $currency, 'delta' => (int) $amount], - ]); - - return $existingLot; + && $this->ledger->findEarnLotForOption($option->id) !== null) { + return $this->applyDeltaToExistingEarnLot($order, $option, $currency, $target); } $expiresAt = $this->resolveEarnExpiry(); - $tx = $this->ledger->createTransaction([ - 'user_id' => $order->user_id, - 'currency' => $currency, - 'type' => $type->value, - 'amount' => $amount, - 'remaining_amount' => $amount, - 'balance_after' => $this->ledger->getBalanceByCurrency($order->user_id, $currency) + $amount, - 'order_id' => $order->id, - 'order_option_id' => $option->id, - 'expires_at' => $expiresAt, - 'description' => __('sirsoft-ecommerce::activity_log.description.mileage_earn', ['amount' => ecommerce_format_price($amount, $currency)]), - ]); + try { + $tx = $this->ledger->createTransaction([ + 'user_id' => $order->user_id, + 'currency' => $currency, + 'type' => $type->value, + 'amount' => $amount, + 'remaining_amount' => $amount, + 'balance_after' => $this->ledger->getBalanceByCurrency($order->user_id, $currency) + $amount, + 'order_id' => $order->id, + 'order_option_id' => $option->id, + 'expires_at' => $expiresAt, + 'description' => __('sirsoft-ecommerce::activity_log.description.mileage_earn', ['amount' => ecommerce_format_price($amount, $currency)]), + ]); + } catch (UniqueConstraintViolationException $e) { + // 최초 적립이 동시에 겹쳐 다른 요청이 먼저 lot 을 만들었다. 옵션당 적립 lot 은 + // 한 줄이어야 하므로(취소 회수가 그 한 줄을 되돌린다) 새로 만들지 않고 증액 경로로 + // 흡수한다 — 델타는 잠근 행 기준으로 다시 산정되므로 이중 적립이 되지 않는다. + if ($type !== MileageTransactionTypeEnum::PURCHASE_EARN) { + throw $e; + } + + return $this->applyDeltaToExistingEarnLot($order, $option, $currency, $target); + } $this->cache->recalculateForUser($order->user_id, $currency); $this->cache->recalculatePending($order->user_id, $currency); @@ -558,17 +558,24 @@ class UserMileageService return null; } - // 해당 옵션 적립건 조회 - $earnLot = $this->ledger->findEarnLotForOption($option->id); - - if ($earnLot === null) { + // 해당 옵션 적립건 존재 확인 (실제 회수 대상은 트랜잭션 안에서 잠금과 함께 되읽는다) + if ($this->ledger->findEarnLotForOption($option->id) === null) { return null; } $currency = $this->baseCurrencyForOrder($order); - $toRecover = (float) $earnLot->amount; - return DB::transaction(function () use ($order, $option, $earnLot, $currency, $toRecover) { + return DB::transaction(function () use ($order, $option, $currency) { + // 회수액은 잠근 행의 커밋된 금액 기준이어야 한다 — 트랜잭션 밖에서 읽은 값을 쓰면 + // 그 사이 반영된 적립 증액분이 회수에서 누락된다. + $earnLot = $this->ledger->findEarnLotForOptionForUpdate($option->id); + + if ($earnLot === null) { + return null; + } + + $toRecover = (float) $earnLot->amount; + $shortfall = $this->recoverPoints($order->user_id, $currency, $toRecover, $earnLot); $tx = $this->ledger->createTransaction([ @@ -965,6 +972,49 @@ class UserMileageService }); } + /** + * 기존 구매 적립 lot 에 목표액 대비 델타만 증액합니다 (방식 A — 적립 내역 한 줄 유지). + * + * 델타는 행을 잠근 뒤 다시 산정한다 — 두 확정 요청이 같은 기적립 합계를 읽으면 같은 + * 델타를 각자 증액해 목표 적립액을 넘어선다. + * + * @param Order $order 주문 + * @param OrderOption $option 주문옵션 + * @param string $currency 기준 통화 + * @param float $target 목표 적립액 + * @return MileageTransaction|null 증액된 적립건 (대상 없으면 null) + */ + private function applyDeltaToExistingEarnLot(Order $order, OrderOption $option, string $currency, float $target): ?MileageTransaction + { + return DB::transaction(function () use ($order, $option, $currency, $target) { + $existingLot = $this->ledger->findEarnLotForOptionForUpdate($option->id); + + if ($existingLot === null) { + return null; + } + + $delta = $target - $this->ledger->sumPurchaseEarnedForOption($option->id); + + if ($delta <= 0) { + return $existingLot; + } + + $this->ledger->incrementEarnLotAmount($existingLot, $delta); + + $this->cache->recalculateForUser($order->user_id, $currency); + $this->cache->recalculatePending($order->user_id, $currency); + + $this->logActivity('mileage.earn', [ + 'loggable' => $existingLot, + 'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_earn', + 'description_params' => ['amount' => ecommerce_format_price((int) $delta, $currency)], + 'properties' => ['order_id' => $order->id, 'order_option_id' => $option->id, 'currency' => $currency, 'delta' => (int) $delta], + ]); + + return $existingLot; + }); + } + /** * 적립 회수 시 lot 잔여를 우선 차감 → 타 lot FIFO → 부족분 반환 (§2 정책) * diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php index 4e1aa6ba..4b6b5234 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php @@ -32,6 +32,7 @@ return [ 'coupon_has_issues' => 'Cannot delete coupon because it has :count issued coupons.', 'coupon_issue_not_found' => 'Coupon issue record not found.', 'coupon_issue_not_cancellable' => 'Only unused issued coupons can be cancelled.', + 'coupon_already_used' => 'This coupon has already been used. Please review your coupons and try again.', 'label_not_found' => 'Label not found.', 'product_notice_template_not_found' => 'Product notice template not found.', 'product_common_info_not_found' => 'Product common information not found.', diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php index 08e94a55..1eaffd78 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php @@ -32,6 +32,7 @@ return [ 'coupon_has_issues' => '발급된 쿠폰이 :count건 있어 삭제할 수 없습니다.', 'coupon_issue_not_found' => '쿠폰 발급 내역을 찾을 수 없습니다.', 'coupon_issue_not_cancellable' => '미사용 상태의 발급 건만 취소할 수 있습니다.', + 'coupon_already_used' => '이미 사용된 쿠폰입니다. 쿠폰을 다시 확인한 뒤 주문해 주세요.', 'label_not_found' => '라벨을 찾을 수 없습니다.', 'product_notice_template_not_found' => '상품정보제공고시 템플릿을 찾을 수 없습니다.', 'product_common_info_not_found' => '상품 공통정보를 찾을 수 없습니다.', diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserOrderControllerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserOrderControllerTest.php index 6727679a..d214f25f 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserOrderControllerTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserOrderControllerTest.php @@ -2,6 +2,8 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\User; +use App\Extension\HookManager; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum; use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum; @@ -17,6 +19,7 @@ use Modules\Sirsoft\Ecommerce\Models\ProductOption; use Modules\Sirsoft\Ecommerce\Models\TempOrder; use Modules\Sirsoft\Ecommerce\Models\UserAddress; use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService; +use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver; use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase; /** @@ -68,6 +71,33 @@ class UserOrderControllerTest extends ModuleTestCase ]); } + /** + * 기본 PG 제공자를 설정하고, 그 제공자를 레지스트리에도 등록합니다. + * + * 설정만 바꾸면 카탈로그가 그 PG 를 "사라진 PG"(`_orphaned_pg`)로 판정해 해당 결제수단이 + * 주문 불가가 되고 주문 생성이 422 로 막힙니다(#570 고아 카탈로그 차단). 실제 운영에서는 + * PG 플러그인이 이 훅으로 자신을 등록하므로, 테스트도 같은 경로로 등록해야 합니다. + * + * @param string $providerId PG 제공자 식별자 + * @return void + */ + protected function registerDefaultPgProvider(string $providerId): void + { + app(EcommerceSettingsService::class)->setSetting('order_settings.default_pg_provider', $providerId); + + HookManager::addFilter( + 'sirsoft-ecommerce.payment.registered_pg_providers', + fn (array $providers) => array_merge($providers, [[ + 'id' => $providerId, + 'name' => $providerId, + 'payment_handler' => 'sirsoft-pay_'.$providerId.'.requestPayment', + ]]) + ); + + app(PaymentMethodResolver::class)->flushCache(); + app(EcommerceSettingsService::class)->clearCache(); + } + /** * 임시 주문 생성 헬퍼 */ @@ -357,6 +387,7 @@ class UserOrderControllerTest extends ModuleTestCase * 화면은 `?with_items=1` 로 켠다. * * @scenario surface=my_page_list,option_profile=multiple + * * @effects my_page_list_default_is_representative_only */ public function test_기본_목록은_대표_아이템_1건과_개수만_싣는다(): void @@ -386,6 +417,7 @@ class UserOrderControllerTest extends ModuleTestCase * 깨지면 주문마다 상품 한 줄만 남는다. * * @scenario surface=my_page_list,option_profile=multiple + * * @effects my_page_list_enumerates_every_item_when_requested */ public function test_with_items_1_이면_아이템을_전부_싣는다(): void @@ -414,6 +446,7 @@ class UserOrderControllerTest extends ModuleTestCase * 페이로드를 줄이려다 쿼리를 늘리는 맞바꿈이 된다. * * @scenario surface=my_page_list,option_profile=multiple + * * @effects my_page_list_option_query_count_is_constant */ public function test_아이템_조회_쿼리수가_주문수에_비례하지_않는다(): void @@ -433,7 +466,7 @@ class UserOrderControllerTest extends ModuleTestCase $measure = function (): int { $count = 0; - \Illuminate\Support\Facades\DB::listen(function ($query) use (&$count) { + DB::listen(function ($query) use (&$count) { if (str_contains($query->sql, 'ecommerce_order_options')) { $count++; } @@ -465,6 +498,7 @@ class UserOrderControllerTest extends ModuleTestCase * null 로 정규화하면 오타 파라미터가 "미지정" 으로 통과해 호출자가 잘못을 알 수 없다. * * @scenario surface=my_page_list,option_profile=multiple + * * @effects my_page_list_rejects_unparseable_with_items */ public function test_with_items_에_해석불가한_값이_오면_422(): void @@ -948,7 +982,7 @@ class UserOrderControllerTest extends ModuleTestCase public function test_p_g_체크_o_n_order_meta에_플래그_저장(): void { // PG 결제가 실제로 동작하도록 기본 PG 제공자 설정 - app(EcommerceSettingsService::class)->setSetting('order_settings.default_pg_provider', 'tosspayments'); + $this->registerDefaultPgProvider('tosspayments'); $user = $this->createUser(); $this->actingAs($user); @@ -975,7 +1009,7 @@ class UserOrderControllerTest extends ModuleTestCase public function test_p_g_체크_o_n_user_address_미생성(): void { - app(EcommerceSettingsService::class)->setSetting('order_settings.default_pg_provider', 'tosspayments'); + $this->registerDefaultPgProvider('tosspayments'); $user = $this->createUser(); $this->actingAs($user); @@ -1001,7 +1035,7 @@ class UserOrderControllerTest extends ModuleTestCase public function test_p_g_체크_of_f_order_meta_미저장(): void { - app(EcommerceSettingsService::class)->setSetting('order_settings.default_pg_provider', 'tosspayments'); + $this->registerDefaultPgProvider('tosspayments'); $user = $this->createUser(); $this->actingAs($user); diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/CouponRestoreListenerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/CouponRestoreListenerTest.php index c4827c95..55293a9d 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/CouponRestoreListenerTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/CouponRestoreListenerTest.php @@ -12,6 +12,7 @@ use Modules\Sirsoft\Ecommerce\Listeners\CouponRestoreListener; use Modules\Sirsoft\Ecommerce\Models\Coupon; use Modules\Sirsoft\Ecommerce\Models\CouponIssue; use Modules\Sirsoft\Ecommerce\Models\Order; +use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterface; use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase; /** @@ -262,4 +263,131 @@ class CouponRestoreListenerTest extends ModuleTestCase $couponIssue->refresh(); $this->assertEquals(CouponIssueRecordStatus::AVAILABLE, $couponIssue->status); } + + /** + * 복원은 조건부 갱신으로 수행되어 이미 복원된 건을 다시 건드리지 않아야 합니다. + * + * 무락 조회 후 무조건 갱신하면 두 취소 요청이 같은 USED 를 읽어 각자 복원을 수행하고, + * 그 사이에 재사용된 쿠폰을 되돌려 놓을 수 있다. 복원 자체는 멱등이 정상이므로 + * 예외는 던지지 않고 조용히 skip 한다 (KVE-2026-1886 동종). + * + * @return void + */ + public function test_restore_is_idempotent_and_does_not_touch_already_restored(): void + { + $couponIssue = $this->createCouponIssue(); + $order = $this->createOrderWithCoupons([$couponIssue->id]); + + $this->listener->restoreCoupons($order); + $couponIssue->refresh(); + $this->assertEquals(CouponIssueRecordStatus::AVAILABLE, $couponIssue->status); + + $firstUpdatedAt = $couponIssue->updated_at; + + // 재발화 — 이미 AVAILABLE 이므로 아무 갱신도 일어나면 안 된다 + $this->listener->restoreCoupons($order); + + $couponIssue->refresh(); + $this->assertEquals(CouponIssueRecordStatus::AVAILABLE, $couponIssue->status); + $this->assertEquals( + $firstUpdatedAt->toIso8601String(), + $couponIssue->updated_at->toIso8601String(), + '이미 복원된 쿠폰은 다시 갱신되지 않아야 합니다.' + ); + } + + /** + * 복원 도중 쿠폰이 다시 사용되면(USED 아님) 그 건은 건드리지 않아야 합니다. + * + * @return void + */ + public function test_restore_skips_coupon_that_is_no_longer_used(): void + { + $couponIssue = $this->createCouponIssue(['status' => CouponIssueRecordStatus::CANCELLED]); + $order = $this->createOrderWithCoupons([$couponIssue->id]); + + $this->listener->restoreCoupons($order); + + $couponIssue->refresh(); + $this->assertEquals( + CouponIssueRecordStatus::CANCELLED, + $couponIssue->status, + 'USED 가 아닌 쿠폰은 복원 대상이 아닙니다.' + ); + } + + /** + * 스냅샷을 읽은 뒤 다른 요청이 상태를 바꿨다면 복원 쓰기가 거부되어야 합니다. + * + * 조회와 갱신 사이의 창을 재현한다 — 리스너가 USED 스냅샷을 손에 든 사이 DB 행은 + * 이미 EXPIRED 로 바뀐 상황. 무조건 갱신이면 만료된 쿠폰이 다시 사용 가능 상태로 + * 되살아난다 (KVE-2026-1886 동종의 lost update). + * + * @return void + */ + public function test_stale_snapshot_does_not_overwrite_concurrently_changed_row(): void + { + $couponIssue = $this->createCouponIssue(); + $order = $this->createOrderWithCoupons([$couponIssue->id]); + + // 리스너가 손에 쥔 스냅샷 (USED, 미만료) + $staleSnapshot = $couponIssue->replicate(); // @phpstan-ignore-line + $staleSnapshot->id = $couponIssue->id; + $staleSnapshot->status = CouponIssueRecordStatus::USED; + $staleSnapshot->expired_at = now()->addDays(30); + + // 그 사이 다른 요청이 행을 EXPIRED 로 바꿨다 + $real = app(CouponIssueRepositoryInterface::class); + $real->update($couponIssue->id, [ + 'status' => CouponIssueRecordStatus::EXPIRED, + 'used_at' => null, + ]); + + // findById 만 낡은 스냅샷을 돌려주고, 쓰기는 실제 저장소로 위임한다 + $stale = $this->createMock(CouponIssueRepositoryInterface::class); + $stale->method('findById')->willReturn($staleSnapshot); + $stale->method('update')->willReturnCallback( + fn (int $id, array $data) => $real->update($id, $data) + ); + $stale->method('updateIfStatus')->willReturnCallback( + fn (int $id, CouponIssueRecordStatus $expected, array $data) => $real->updateIfStatus($id, $expected, $data) + ); + $this->app->instance(CouponIssueRepositoryInterface::class, $stale); + + app(CouponRestoreListener::class)->restoreCoupons($order); + + $couponIssue->refresh(); + $this->assertEquals( + CouponIssueRecordStatus::EXPIRED, + $couponIssue->status, + '낡은 스냅샷으로 이미 바뀐 행을 덮어써서는 안 됩니다.' + ); + } + + /** + * 만료된 쿠폰의 상태 변경도 조건부 갱신이어야 합니다. + * + * @return void + */ + public function test_expired_restore_is_conditional(): void + { + $couponIssue = $this->createCouponIssue(['expired_at' => now()->subDay()]); + $order = $this->createOrderWithCoupons([$couponIssue->id]); + + $this->listener->restoreCoupons($order); + $couponIssue->refresh(); + $this->assertEquals(CouponIssueRecordStatus::EXPIRED, $couponIssue->status); + + $firstUpdatedAt = $couponIssue->updated_at; + + $this->listener->restoreCoupons($order); + + $couponIssue->refresh(); + $this->assertEquals(CouponIssueRecordStatus::EXPIRED, $couponIssue->status); + $this->assertEquals( + $firstUpdatedAt->toIso8601String(), + $couponIssue->updated_at->toIso8601String(), + '이미 만료 처리된 쿠폰은 다시 갱신되지 않아야 합니다.' + ); + } } diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/CouponUseListenerConcurrencyTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/CouponUseListenerConcurrencyTest.php new file mode 100644 index 00000000..25f08038 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/CouponUseListenerConcurrencyTest.php @@ -0,0 +1,207 @@ +listener = app(CouponUseListener::class); + } + + /** + * 조건부 차감은 기대 상태일 때만 성공하고, 두 번째 시도는 0 행을 반환해야 합니다. + * + * @return void + */ + public function test_update_if_status_is_atomic_compare_and_set(): void + { + $repo = app(CouponIssueRepositoryInterface::class); + $issue = $this->createAvailableIssue(); + $order = $this->createOrder(); + + $first = $repo->updateIfStatus($issue->id, CouponIssueRecordStatus::AVAILABLE, [ + 'status' => CouponIssueRecordStatus::USED, + 'used_at' => now(), + 'order_id' => $order->id, + ]); + + $second = $repo->updateIfStatus($issue->id, CouponIssueRecordStatus::AVAILABLE, [ + 'status' => CouponIssueRecordStatus::USED, + 'used_at' => now(), + 'order_id' => $order->id, + ]); + + $this->assertSame(1, $first, '기대 상태였던 첫 시도는 1 행을 갱신해야 합니다.'); + $this->assertSame(0, $second, '이미 상태가 바뀐 뒤의 시도는 0 행이어야 합니다.'); + } + + /** + * 다른 주문이 이미 선점한 쿠폰은 예외로 차단되어야 합니다 (경쟁에서 진 주문 롤백). + * + * @return void + */ + public function test_coupon_taken_by_another_order_throws(): void + { + $issue = $this->createAvailableIssue(); + $firstOrder = $this->createOrder(); + $secondOrder = $this->createOrder(); + + $this->listener->markCouponsUsed([$issue->id], $firstOrder); + + $this->expectException(CouponAlreadyUsedException::class); + $this->listener->markCouponsUsed([$issue->id], $secondOrder); + } + + /** + * 선점당한 쿠폰의 소유(order_id)는 선행 주문 그대로여야 합니다. + * + * @return void + */ + public function test_losing_order_does_not_overwrite_coupon_owner(): void + { + $issue = $this->createAvailableIssue(); + $firstOrder = $this->createOrder(); + $secondOrder = $this->createOrder(); + + $this->listener->markCouponsUsed([$issue->id], $firstOrder); + + try { + $this->listener->markCouponsUsed([$issue->id], $secondOrder); + } catch (CouponAlreadyUsedException) { + // 기대된 차단 + } + + $issue->refresh(); + $this->assertEquals(CouponIssueRecordStatus::USED, $issue->status); + $this->assertEquals($firstOrder->id, $issue->order_id, '쿠폰 소유는 선행 주문이어야 합니다.'); + } + + /** + * 같은 주문의 재발화는 멱등이어야 합니다 (예외 없이 skip). + * + * @return void + */ + public function test_same_order_refire_is_idempotent(): void + { + $issue = $this->createAvailableIssue(); + $order = $this->createOrder(); + + $this->listener->markCouponsUsed([$issue->id], $order); + $firstUsedAt = $issue->refresh()->used_at; + + $this->listener->markCouponsUsed([$issue->id], $order); + + $issue->refresh(); + $this->assertEquals(CouponIssueRecordStatus::USED, $issue->status); + $this->assertEquals($firstUsedAt->toIso8601String(), $issue->used_at->toIso8601String()); + $this->assertEquals($order->id, $issue->order_id); + } + + /** + * 사용 불가 상태(취소됨)의 쿠폰도 차단되어야 합니다. + * + * @return void + */ + public function test_non_available_coupon_is_blocked(): void + { + $issue = $this->createAvailableIssue(['status' => CouponIssueRecordStatus::CANCELLED]); + $order = $this->createOrder(); + + $this->expectException(CouponAlreadyUsedException::class); + $this->listener->markCouponsUsed([$issue->id], $order); + } + + /** + * 존재하지 않는 발급 ID 는 예외 없이 skip 되어야 합니다. + * + * @return void + */ + public function test_missing_issue_is_skipped(): void + { + $order = $this->createOrder(); + + $this->listener->markCouponsUsed([999999], $order); + + $this->assertTrue(true); + } + + /** + * 사용 가능 상태의 쿠폰 발급 레코드를 생성합니다. + * + * @param array $overrides 오버라이드 + * @return CouponIssue 생성된 발급 레코드 + */ + protected function createAvailableIssue(array $overrides = []): CouponIssue + { + $coupon = Coupon::create([ + 'name' => ['ko' => '테스트 쿠폰', 'en' => 'Test Coupon'], + 'target_type' => CouponTargetType::PRODUCT_AMOUNT, + 'discount_type' => CouponDiscountType::FIXED, + 'discount_value' => 1000, + 'min_order_amount' => 0, + 'target_scope' => CouponTargetScope::ALL, + 'is_combinable' => true, + 'valid_from' => now()->subDay(), + 'valid_to' => now()->addDays(30), + ]); + + $user = User::factory()->create(); + + return CouponIssue::create(array_merge([ + 'coupon_id' => $coupon->id, + 'user_id' => $user->id, + 'coupon_code' => 'RACE'.uniqid(), + 'status' => CouponIssueRecordStatus::AVAILABLE, + 'issued_at' => now(), + 'expired_at' => now()->addDays(30), + ], $overrides)); + } + + /** + * 테스트용 주문을 생성합니다. + * + * @return Order 생성된 주문 + */ + protected function createOrder(): Order + { + $user = User::factory()->create(); + + return Order::create([ + 'user_id' => $user->id, + 'order_number' => 'ORD-RACE-'.uniqid(), + 'order_status' => OrderStatusEnum::PENDING_PAYMENT, + 'currency' => 'KRW', + 'item_count' => 1, + 'ordered_at' => now(), + 'subtotal_amount' => 50000, + 'total_amount' => 49000, + 'total_paid_amount' => 49000, + ]); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/MoneyHookTransactionBoundaryTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/MoneyHookTransactionBoundaryTest.php new file mode 100644 index 00000000..6f9714a3 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/MoneyHookTransactionBoundaryTest.php @@ -0,0 +1,187 @@ + true` 로 호출자 트랜잭션 안에서 실행되어야 한다. + * + * 이 테스트는 리스너를 손으로 addAction 하지 않고 **실제 등록 경로**(HookListenerRegistrar) + * 를 그대로 태운다 — 손으로 등록하면 프로덕션이 쓰지 않는 경로를 검증하게 된다. + */ +class MoneyHookTransactionBoundaryTest extends ModuleTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + HookListenerRegistrar::clear(); + HookListenerRegistrar::register(CouponUseListener::class, 'test'); + } + + /** + * 쿠폰 차감은 호출자 트랜잭션 **안에서** 이미 반영되어야 합니다. + * + * 커밋 이후로 미뤄지면 이 단언이 실패한다. + * + * @return void + */ + public function test_coupon_deduction_is_visible_inside_caller_transaction(): void + { + $issue = $this->createAvailableIssue(); + $order = $this->createOrder(); + $observed = null; + + DB::transaction(function () use ($issue, $order, &$observed) { + HookManager::doAction('sirsoft-ecommerce.coupon.use', [$issue->id], $order); + + $observed = CouponIssue::find($issue->id)->status; + }); + + $this->assertEquals( + CouponIssueRecordStatus::USED, + $observed, + '쿠폰 차감이 호출자 트랜잭션 안에서 반영되어야 합니다 (커밋 이후 실행 금지).' + ); + } + + /** + * 선점당한 쿠폰은 호출자 트랜잭션을 롤백시켜야 합니다. + * + * 훅이 커밋 뒤에 실행되면 주문 행이 남은 채 예외만 올라온다 — 이 테스트가 그 상태를 잡는다. + * + * @return void + */ + public function test_taken_coupon_rolls_back_caller_transaction(): void + { + $winner = $this->createOrder(); + $issue = $this->createAvailableIssue([ + 'status' => CouponIssueRecordStatus::USED, + 'used_at' => now(), + 'order_id' => $winner->id, + ]); + + $ordersBefore = Order::query()->count(); + $orderNumber = 'ORD-BOUNDARY-'.uniqid(); + $thrown = null; + + try { + DB::transaction(function () use ($issue, $orderNumber) { + $loser = $this->createOrder($orderNumber); + + HookManager::doAction('sirsoft-ecommerce.coupon.use', [$issue->id], $loser); + }); + } catch (CouponAlreadyUsedException $e) { + $thrown = $e; + } + + $this->assertNotNull($thrown, '선점된 쿠폰은 예외로 차단되어야 합니다.'); + $this->assertSame( + $ordersBefore, + Order::query()->count(), + '경쟁에서 밀린 주문 행이 롤백되어야 합니다 (훅이 커밋 뒤에 실행되면 남는다).' + ); + $this->assertNull( + Order::query()->where('order_number', $orderNumber)->first(), + '롤백된 주문번호가 남아 있으면 안 됩니다.' + ); + } + + /** + * 금전 이동 리스너는 동기 실행을 명시 선언해야 합니다. + * + * @return void + */ + public function test_money_listeners_declare_sync_execution(): void + { + $cases = [ + [CouponUseListener::class, 'sirsoft-ecommerce.coupon.use'], + [CouponRestoreListener::class, 'sirsoft-ecommerce.order.after_cancel'], + [CouponRestoreListener::class, 'sirsoft-ecommerce.coupon.restore'], + [MileageTransactionListener::class, 'sirsoft-ecommerce.mileage.use'], + [MileageTransactionListener::class, 'sirsoft-ecommerce.mileage.restore'], + ]; + + foreach ($cases as [$listener, $hook]) { + $config = $listener::getSubscribedHooks()[$hook] ?? null; + + $this->assertNotNull($config, "[{$listener}] 가 [{$hook}] 를 구독해야 합니다."); + $this->assertTrue( + ! empty($config['sync']), + "[{$listener}::{$hook}] 는 'sync' => true 여야 합니다 — 큐 기본값이면 호출자 트랜잭션이 커밋된 뒤에 실행되어 금전 처리가 원자적이지 않습니다." + ); + } + } + + /** + * 사용 가능 상태의 쿠폰 발급 레코드를 생성합니다. + * + * @param array $overrides 오버라이드 + * @return CouponIssue 생성된 발급 레코드 + */ + protected function createAvailableIssue(array $overrides = []): CouponIssue + { + $coupon = Coupon::create([ + 'name' => ['ko' => '경계 점검 쿠폰', 'en' => 'Boundary Probe Coupon'], + 'target_type' => CouponTargetType::PRODUCT_AMOUNT, + 'discount_type' => CouponDiscountType::FIXED, + 'discount_value' => 1000, + 'min_order_amount' => 0, + 'target_scope' => CouponTargetScope::ALL, + 'is_combinable' => true, + 'valid_from' => now()->subDay(), + 'valid_to' => now()->addDays(30), + ]); + + return CouponIssue::create(array_merge([ + 'coupon_id' => $coupon->id, + 'user_id' => User::factory()->create()->id, + 'coupon_code' => 'BND'.uniqid(), + 'status' => CouponIssueRecordStatus::AVAILABLE, + 'issued_at' => now(), + 'expired_at' => now()->addDays(30), + ], $overrides)); + } + + /** + * 테스트용 주문을 생성합니다. + * + * @param string|null $orderNumber 주문번호 (미지정 시 자동 생성) + * @return Order 생성된 주문 + */ + protected function createOrder(?string $orderNumber = null): Order + { + return Order::create([ + 'user_id' => User::factory()->create()->id, + 'order_number' => $orderNumber ?? 'ORD-BND-'.uniqid(), + 'order_status' => OrderStatusEnum::PENDING_PAYMENT, + 'currency' => 'KRW', + 'item_count' => 1, + 'ordered_at' => now(), + 'subtotal_amount' => 50000, + 'total_amount' => 49000, + 'total_paid_amount' => 49000, + ]); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/MileageTransactionRepositoryTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/MileageTransactionRepositoryTest.php index 4964c9a1..df89aa0d 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/MileageTransactionRepositoryTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/MileageTransactionRepositoryTest.php @@ -196,4 +196,100 @@ class MileageTransactionRepositoryTest extends ModuleTestCase $desc = $this->repo->paginateWithFilters(['sort' => 'amount_desc'], 20); $this->assertSame(3000.0, (float) $desc->items()[0]->amount); } + + /** + * lot 잔여 차감은 낡은 스냅샷의 값을 덮어쓰지 않아야 합니다. + * + * 값을 PHP 에서 빼고 모델 전체를 저장하면, 그 사이 다른 요청이 반영한 증액이 + * 통째로 사라진다 (KVE-2026-1886 동종 lost update). + * + * @return void + */ + public function test_decrement_remaining_does_not_overwrite_concurrent_change(): void + { + $user = User::factory()->create(); + $lot = $this->lot($user->id, 1000); + + // 리스너가 손에 쥔 낡은 스냅샷 (remaining=1000) + $stale = MileageTransaction::find($lot->id); + + // 그 사이 다른 요청이 lot 을 증액했다 (remaining 1000 → 1500) + MileageTransaction::query()->where('id', $lot->id)->update(['remaining_amount' => 1500]); + + $this->repo->decrementRemaining($stale, 100); + + $this->assertSame( + 1400.0, + (float) MileageTransaction::find($lot->id)->remaining_amount, + '차감은 커밋된 값(1500)에서 이뤄져야 합니다.' + ); + } + + /** + * lot 증액도 낡은 스냅샷의 값을 덮어쓰지 않아야 합니다. + * + * @return void + */ + public function test_increment_earn_lot_amount_does_not_overwrite_concurrent_change(): void + { + $user = User::factory()->create(); + $lot = $this->lot($user->id, 1000); + + $stale = MileageTransaction::find($lot->id); + + MileageTransaction::query()->where('id', $lot->id)->update([ + 'amount' => 1500, + 'remaining_amount' => 1500, + ]); + + $this->repo->incrementEarnLotAmount($stale, 200); + + $fresh = MileageTransaction::find($lot->id); + $this->assertSame(1700.0, (float) $fresh->amount, '증액은 커밋된 값(1500)에 더해져야 합니다.'); + $this->assertSame(1700.0, (float) $fresh->remaining_amount); + } + + /** + * 증액 후 돌려받는 모델은 반영된 값을 들고 있어야 합니다. + * + * 호출부가 이 모델을 그대로 반환·기록하므로, 낡은 값이 남으면 화면·로그가 어긋난다. + * + * @return void + */ + public function test_increment_earn_lot_amount_refreshes_the_model(): void + { + $user = User::factory()->create(); + $lot = $this->lot($user->id, 1000); + + $this->repo->incrementEarnLotAmount($lot, 200); + + $this->assertSame(1200.0, (float) $lot->amount); + $this->assertSame(1200.0, (float) $lot->remaining_amount); + } + + /** + * 적립 lot 조회의 잠금 변형이 같은 행을 돌려줘야 합니다. + * + * @return void + */ + public function test_find_earn_lot_for_option_for_update_returns_same_row(): void + { + $user = User::factory()->create(); + $orderOptionId = 987654; + + $lot = MileageTransaction::create([ + 'user_id' => $user->id, + 'currency' => 'KRW', + 'type' => MileageTransactionTypeEnum::PURCHASE_EARN->value, + 'amount' => 500, + 'remaining_amount' => 500, + 'balance_after' => 500, + 'order_option_id' => $orderOptionId, + ]); + + $found = $this->repo->findEarnLotForOptionForUpdate($orderOptionId); + + $this->assertNotNull($found); + $this->assertSame($lot->id, $found->id); + } } diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageEarnLotUniquenessTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageEarnLotUniquenessTest.php new file mode 100644 index 00000000..3b980682 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageEarnLotUniquenessTest.php @@ -0,0 +1,230 @@ +create()->id; + $orderOptionId = 987001; + + $this->makeLot($userId, $orderOptionId, MileageTransactionTypeEnum::PURCHASE_EARN->value); + + $this->expectException(UniqueConstraintViolationException::class); + + $this->makeLot($userId, $orderOptionId, MileageTransactionTypeEnum::PURCHASE_EARN->value); + } + + /** + * 반복이 정상인 유형(부분취소마다 생기는 회수 등)은 제약에 걸리면 안 됩니다. + * + * @return void + */ + public function test_repeatable_types_are_not_constrained(): void + { + $userId = User::factory()->create()->id; + $orderOptionId = 987002; + + $this->makeLot($userId, $orderOptionId, MileageTransactionTypeEnum::EARN_CANCEL->value); + $this->makeLot($userId, $orderOptionId, MileageTransactionTypeEnum::EARN_CANCEL->value); + + $this->assertSame( + 2, + MileageTransaction::query() + ->where('order_option_id', $orderOptionId) + ->where('type', MileageTransactionTypeEnum::EARN_CANCEL->value) + ->count(), + '부분취소마다 생기는 회수 거래는 여러 건이 정상입니다.' + ); + } + + /** + * 주문옵션이 없는 거래(관리자 수동 지급 등)는 여러 건이 가능해야 합니다. + * + * @return void + */ + public function test_transactions_without_order_option_are_not_constrained(): void + { + $userId = User::factory()->create()->id; + + $this->makeLot($userId, null, MileageTransactionTypeEnum::PURCHASE_EARN->value); + $this->makeLot($userId, null, MileageTransactionTypeEnum::PURCHASE_EARN->value); + + $this->assertSame( + 2, + MileageTransaction::query()->whereNull('order_option_id')->count(), + '주문옵션이 없는 적립은 제약 대상이 아닙니다.' + ); + } + + /** + * 마이그레이션 왕복(down → up) 후에도 제약이 복원되어야 합니다. + * + * @return void + */ + public function test_constraint_survives_migration_round_trip(): void + { + $indexName = 'ecommerce_mileage_transactions_purchase_earn_option_unique'; + + $exists = fn () => ! empty(DB::select( + 'SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1', + [DB::getTablePrefix().'ecommerce_mileage_transactions', $indexName] + )); + + $this->assertTrue($exists(), '마이그레이션 적용 후 유니크 제약이 있어야 합니다.'); + } + + /** + * 최초 적립 경쟁에서 밀린 요청은 오류가 아니라 증액 경로로 흡수되어야 합니다. + * + * "아직 lot 이 없다" 고 읽은 뒤 다른 요청이 먼저 만든 상황을 재현한다 — 첫 조회만 null 을 + * 돌려주고 이후 조회는 실제 저장소에 위임한다. + * + * @return void + */ + public function test_losing_first_earn_falls_back_to_increment_instead_of_failing(): void + { + $order = $this->makeOrder(); + $option = $this->makeOrderOption($order); + + // 다른 요청이 이미 만든 lot (목표 적립액의 절반만 반영된 상태) + MileageTransaction::create([ + 'user_id' => $order->user_id, + 'currency' => 'KRW', + 'type' => MileageTransactionTypeEnum::PURCHASE_EARN->value, + 'amount' => 500, + 'remaining_amount' => 500, + 'balance_after' => 500, + 'order_id' => $order->id, + 'order_option_id' => $option->id, + ]); + + $real = app(MileageTransactionRepositoryInterface::class); + $firstLookup = true; + $stub = $this->createMock(MileageTransactionRepositoryInterface::class); + $stub->method('findEarnLotForOption')->willReturnCallback( + function (int $id) use ($real, &$firstLookup) { + if ($firstLookup) { + $firstLookup = false; + + return null; // 경쟁 상대가 만들기 직전에 읽은 상태 + } + + return $real->findEarnLotForOption($id); + } + ); + foreach (['findEarnLotForOptionForUpdate', 'sumPurchaseEarnedForOption', 'getBalanceByCurrency', 'createTransaction', 'incrementEarnLotAmount'] as $method) { + $stub->method($method)->willReturnCallback(fn (...$args) => $real->{$method}(...$args)); + } + $this->app->instance(MileageTransactionRepositoryInterface::class, $stub); + + $result = app(UserMileageService::class)->earnForOrderOption( + $order->fresh(), + $option->fresh(), + MileageTransactionTypeEnum::PURCHASE_EARN + ); + + $this->assertNotNull($result, '경쟁에서 밀린 요청도 결과를 돌려줘야 합니다.'); + $this->assertSame( + 1, + MileageTransaction::query() + ->where('order_option_id', $option->id) + ->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value) + ->count(), + '적립 lot 은 옵션당 한 줄이어야 합니다.' + ); + $this->assertSame( + 1000.0, + (float) MileageTransaction::query() + ->where('order_option_id', $option->id) + ->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value) + ->value('amount'), + '목표 적립액까지의 차액만 증액되어야 합니다 (이중 적립 금지).' + ); + } + + /** + * 테스트용 주문을 생성합니다. + * + * @return Order 생성된 주문 + */ + private function makeOrder(): Order + { + return Order::create([ + 'user_id' => User::factory()->create()->id, + 'order_number' => 'ORD-MLOT-'.uniqid(), + 'order_status' => OrderStatusEnum::PAYMENT_COMPLETE, + 'currency' => 'KRW', + 'item_count' => 1, + 'ordered_at' => now(), + 'subtotal_amount' => 10000, + 'total_amount' => 10000, + 'total_paid_amount' => 10000, + ]); + } + + /** + * 목표 적립액이 설정된 주문옵션을 생성합니다. + * + * @param Order $order 주문 + * @return OrderOption 생성된 주문옵션 + */ + private function makeOrderOption(Order $order): OrderOption + { + return OrderOptionFactory::new()->create([ + 'order_id' => $order->id, + 'quantity' => 1, + 'unit_price' => 10000, + 'subtotal_price' => 10000, + 'subtotal_earned_points_amount' => 1000, + 'option_status' => OrderStatusEnum::PAYMENT_COMPLETE, + ]); + } + + /** + * 적립 거래 한 건을 생성합니다. + * + * @param int $userId 회원 ID + * @param int|null $orderOptionId 주문옵션 ID + * @param string $type 거래 유형 + * @return MileageTransaction 생성된 거래 + */ + private function makeLot(int $userId, ?int $orderOptionId, string $type): MileageTransaction + { + return MileageTransaction::create([ + 'user_id' => $userId, + 'currency' => 'KRW', + 'type' => $type, + 'amount' => 1000, + 'remaining_amount' => 1000, + 'balance_after' => 1000, + 'order_option_id' => $orderOptionId, + ]); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCancellationServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCancellationServiceTest.php index 3611a21b..08bd9ee3 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCancellationServiceTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCancellationServiceTest.php @@ -1415,6 +1415,82 @@ class OrderCancellationServiceTest extends ModuleTestCase $this->assertGreaterThan($cancelledAfterFirst, $cancelledAfterSecond); } + /** + * 낡은 주문 스냅샷으로 두 번째 취소를 실행해도 취소 누산이 유실되지 않아야 합니다. + * + * 취소 총액·취소 횟수·결제 취소액은 현재 값을 읽어 더하는 read-modify-write 다. + * 두 부분취소가 같은 값을 읽으면 후행이 선행을 덮어써 취소 총액이 과소 기록되고 + * PG 환불 기준이 어긋난다 (KVE-2026-1886 동종 lost update). + * + * @return void + */ + public function test_concurrent_partial_cancel_does_not_lose_accumulated_totals(): void + { + $this->createShippingPolicy(); + [$pA, $oA] = $this->createProductWithOption(price: 20000); + + $input = new CalculationInput( + items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 3)], + ); + $order = $this->createOrderFromCalculation($input); + $optionA = $order->options->first(); + + // 두 번째 취소 요청이 손에 쥔, 1차 취소 이전에 읽은 주문 스냅샷 + $staleOrder = Order::with(['options', 'payment', 'shippings'])->find($order->id); + $this->assertEquals(0.0, (float) $staleOrder->total_cancelled_amount); + $this->assertEquals(0, (int) ($staleOrder->cancellation_count ?? 0)); + + // 1차 취소 (선행 요청) + $result1 = $this->cancellationService->cancelOrderOptions( + order: $order, + cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 1]], + cancelPg: false, + ); + $cancelledAfterFirst = (float) $result1->order->fresh()->total_cancelled_amount; + $paymentCancelledAfterFirst = (float) $result1->order->fresh()->payment->cancelled_amount; + $this->assertGreaterThan(0, $cancelledAfterFirst); + + // 2차 취소 (낡은 스냅샷을 든 후행 요청) — 취소 가능 상태만 되돌려 실행 조건을 맞춘다 + Order::query()->where('id', $order->id) + ->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]); + + $remainingOption = $staleOrder->options + ->where('id', '!=', $optionA->id) + ->first() ?? OrderOption::query() + ->where('order_id', $order->id) + ->where('option_status', '!=', OrderStatusEnum::CANCELLED) + ->first(); + + $result2 = $this->cancellationService->cancelOrderOptions( + order: $staleOrder, + cancelItems: [['order_option_id' => $remainingOption->id, 'cancel_quantity' => 1]], + cancelPg: false, + ); + + $finalOrder = $result2->order->fresh(['payment']); + + $this->assertGreaterThan( + $cancelledAfterFirst, + (float) $finalOrder->total_cancelled_amount, + '후행 취소가 선행 취소의 누적 취소 총액을 덮어써서는 안 됩니다.' + ); + $this->assertEquals( + 2, + (int) $finalOrder->cancellation_count, + '취소 횟수는 두 건 모두 반영되어야 합니다.' + ); + $this->assertGreaterThan( + $paymentCancelledAfterFirst, + (float) $finalOrder->payment->cancelled_amount, + '결제 취소 누적액도 덮어써지면 안 됩니다.' + ); + $this->assertCount( + 2, + $finalOrder->payment->cancel_history ?? [], + '취소 이력은 두 건 모두 남아야 합니다.' + ); + } + /** * C-2-4: 취소 시 AdjustmentResult에 환불 우선순위가 저장되는지 검증 */ diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php index fb392257..4b261889 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php @@ -14,14 +14,22 @@ use Modules\Sirsoft\Ecommerce\DTO\ItemCalculation; use Modules\Sirsoft\Ecommerce\DTO\OrderCalculationResult; use Modules\Sirsoft\Ecommerce\DTO\PromotionsSummary; use Modules\Sirsoft\Ecommerce\DTO\Summary; +use Modules\Sirsoft\Ecommerce\Enums\CouponDiscountType; +use Modules\Sirsoft\Ecommerce\Enums\CouponIssueRecordStatus; +use Modules\Sirsoft\Ecommerce\Enums\CouponTargetScope; +use Modules\Sirsoft\Ecommerce\Enums\CouponTargetType; use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum; use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum; use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum; use Modules\Sirsoft\Ecommerce\Exceptions\CartUnavailableException; +use Modules\Sirsoft\Ecommerce\Exceptions\CouponAlreadyUsedException; use Modules\Sirsoft\Ecommerce\Exceptions\OrderAmountChangedException; use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException; use Modules\Sirsoft\Ecommerce\Exceptions\UnsupportedPaymentCurrencyException; +use Modules\Sirsoft\Ecommerce\Listeners\CouponUseListener; use Modules\Sirsoft\Ecommerce\Models\Cart; +use Modules\Sirsoft\Ecommerce\Models\Coupon; +use Modules\Sirsoft\Ecommerce\Models\CouponIssue; use Modules\Sirsoft\Ecommerce\Models\MileageTransaction; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Models\OrderOption; @@ -971,6 +979,103 @@ class OrderProcessingServiceTest extends ModuleTestCase $this->assertContains(201, $capturedCouponIds); } + /** + * 이미 다른 주문이 선점한 쿠폰으로 주문을 확정하면 트랜잭션 전체가 롤백되어야 합니다. + * + * 쿠폰 차감 실패를 삼키고 주문만 생성하면, 1회 제한 쿠폰의 할인이 두 주문에 모두 + * 적용된 채로 확정된다 (KVE-2026-1886). + * + * @return void + */ + public function test_create_from_temp_order_rolls_back_when_coupon_already_taken(): void + { + $user = User::factory()->create(); + $tempOrder = $this->createTestTempOrder($user); + + // 선행 주문이 이미 사용한 쿠폰 발급 레코드 + $winningOrder = Order::create([ + 'user_id' => $user->id, + 'order_number' => 'ORD-WINNER-'.uniqid(), + 'order_status' => OrderStatusEnum::PENDING_PAYMENT, + 'currency' => 'KRW', + 'item_count' => 1, + 'ordered_at' => now(), + 'subtotal_amount' => 50000, + 'total_amount' => 45000, + 'total_paid_amount' => 45000, + ]); + + $couponModel = Coupon::create([ + 'name' => ['ko' => '선점 쿠폰', 'en' => 'Taken Coupon'], + 'target_type' => CouponTargetType::PRODUCT_AMOUNT, + 'discount_type' => CouponDiscountType::FIXED, + 'discount_value' => 5000, + 'min_order_amount' => 0, + 'target_scope' => CouponTargetScope::ALL, + 'is_combinable' => true, + 'valid_from' => now()->subDay(), + 'valid_to' => now()->addDays(30), + ]); + + $issue = CouponIssue::create([ + 'coupon_id' => $couponModel->id, + 'user_id' => $user->id, + 'coupon_code' => 'TAKEN'.uniqid(), + 'status' => CouponIssueRecordStatus::USED, + 'issued_at' => now(), + 'expired_at' => now()->addDays(30), + 'used_at' => now(), + 'order_id' => $winningOrder->id, + ]); + + $coupon = new CouponApplication( + couponId: $couponModel->id, + couponIssueId: $issue->id, + name: '선점 쿠폰', + targetType: 'product_amount', + discountType: 'fixed', + discountValue: 5000, + totalDiscount: 5000, + ); + $promotions = new PromotionsSummary( + productPromotions: new AppliedPromotions(coupons: [$coupon]) + ); + + $this->mockCalculationService($this->makeCalculationResult(103000, [ + 'promotions' => $promotions, + ])); + + // 실제 리스너를 훅에 연결 (프로덕션과 동일 경로) + $listener = app(CouponUseListener::class); + HookManager::addAction( + 'sirsoft-ecommerce.coupon.use', + fn ($couponIds, $order) => $listener->markCouponsUsed($couponIds, $order) + ); + + $ordersBefore = Order::query()->count(); + + try { + $this->service->createFromTempOrder( + $tempOrder, + ['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'], + ['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'], + 'card', + 103000 + ); + $this->fail('선점된 쿠폰으로 주문이 확정되어서는 안 됩니다.'); + } catch (CouponAlreadyUsedException $e) { + $this->assertSame($issue->id, $e->getCouponIssueId()); + } + + // 주문 트랜잭션 전체 롤백 — 주문 행이 늘지 않아야 한다 + $this->assertSame($ordersBefore, Order::query()->count(), '경쟁에서 밀린 주문은 생성되지 않아야 합니다.'); + + // 쿠폰 소유는 선행 주문 그대로 + $issue->refresh(); + $this->assertEquals(CouponIssueRecordStatus::USED, $issue->status); + $this->assertEquals($winningOrder->id, $issue->order_id); + } + public function test_create_from_temp_order_calls_mileage_use_hook(): void { $user = User::factory()->create(); diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json index 96fd99b2..419a3927 100644 --- a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json +++ b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json @@ -1,15 +1,15 @@ { "schema_version": "1.0", - "generated_at": "2026-08-12T14:41:52+00:00", + "generated_at": "2026-08-21T13:47:13+00:00", "generator": "g7 vendor-bundle:build", "target": "module:sirsoft-ecommerce", - "composer_json_sha256": "c1a7e6cb8fcf68b62af5b7817509e2a2df6cb19348b9951ec8ea30634f3030d3", + "composer_json_sha256": "6dc16ed7acb614593e17d97cf3230af0935e3e4910000026b4a7062d4f9de031", "composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d", - "zip_sha256": "afda2275ddb2a403be87062539f2ecc4b2d9136ffa5f85533128cc3eaa2724d9", - "zip_size": 435548, + "zip_sha256": "b1f6cfcb3fea1c68be7ffd55074acab8c2d51c2750e9c036a678abc40ebee405", + "zip_size": 435549, "package_count": 1, "php_requirement": "^8.2", - "g7_version": "7.0.6", + "g7_version": "7.0.8", "packages": [ { "name": "ezyang/htmlpurifier", diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip index b280f36c7de04d4aa5fbbd14bb8732fb1e79aac4..3d732ca64e2651613a1fc772d1780fb424c06c1c 100644 GIT binary patch delta 8573 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 delta 8586 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 diff --git a/plugins/_bundled/sirsoft-marketing/CHANGELOG.md b/plugins/_bundled/sirsoft-marketing/CHANGELOG.md index e40388c7..1f09afa4 100644 --- a/plugins/_bundled/sirsoft-marketing/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-marketing/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.3] - 2026-08-21 + +### Security + +- 마케팅 수신동의 채널 설정 저장에 플러그인 설정 권한 검사를 추가했습니다. 이전에는 관리자 계정이면 플러그인 설정 권한이 없어도 채널 목록을 통째로 덮어쓸 수 있었습니다. 이제 플러그인 설정 화면의 다른 저장 경로와 같은 권한이 필요합니다. + ## [1.0.2] - 2026-08-19 ### Fixed diff --git a/plugins/_bundled/sirsoft-marketing/composer.json b/plugins/_bundled/sirsoft-marketing/composer.json index 2d45226c..f7b59173 100644 --- a/plugins/_bundled/sirsoft-marketing/composer.json +++ b/plugins/_bundled/sirsoft-marketing/composer.json @@ -2,7 +2,7 @@ "name": "plugins/sirsoft-marketing", "description": "Marketing consent and subscription management plugin for Gnuboard7 platform", "type": "library", - "version": "1.0.2", + "version": "1.0.3", "autoload": { "psr-4": { "Plugins\\Sirsoft\\Marketing\\": ["src/", "./"] diff --git a/plugins/_bundled/sirsoft-marketing/docs/api/channels.md b/plugins/_bundled/sirsoft-marketing/docs/api/channels.md index c5ca259e..87379b8f 100644 --- a/plugins/_bundled/sirsoft-marketing/docs/api/channels.md +++ b/plugins/_bundled/sirsoft-marketing/docs/api/channels.md @@ -79,7 +79,7 @@ _단건 응답: `data` 객체의 필드. `data.channels` 는 저장 후 확정 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 관리자 권한이 없는 사용자가 호출한 경우 (`AdminBaseController`) | +| 403 | Forbidden | 관리자가 아니거나 `core.plugins.update` 권한이 없는 경우 | | 422 | 동의 이력 존재 | 삭제 대상 채널에 동의한 회원이 있는 경우 — "채널(:key)에 동의한 회원이 :count명 있어 삭제할 수 없습니다." | @@ -88,6 +88,8 @@ _단건 응답: `data` 객체의 필드. `data.channels` 는 저장 후 확정 관리자 환경설정 화면에서 마케팅 동의 **채널 목록 전체를 한 번에 저장**하는 엔드포인트다. 컨트롤러가 `AdminBaseController` 를 상속하므로 실제 인증은 `auth:sanctum` **에 더해 관리자(admin) 권한**을 요구한다(생성기 표기는 `auth:sanctum` 만 노출). 제출된 배열이 곧 새 상태가 되며, 개별 채널 추가/수정 엔드포인트는 없다(전량 교체 방식). +이 엔드포인트는 코어의 `PUT /api/admin/plugins/{identifier}/settings` 와 같은 `plugin_settings` 를 덮어쓰므로, 라우트에 **`permission:admin,core.plugins.update`** 가 부착되어 있다. 관리자 계정이더라도 플러그인 설정 권한이 없으면 403 이 된다(생성기 표기에는 나타나지 않는다). + **요청 파라미터**는 생성기가 배열 중첩 규칙(`channels.*`)을 평면화하지 못해 위 표에 "없음"으로 표기되나, 실제 `ChannelUpdateRequest` 는 다음 body 를 요구한다: | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | diff --git a/plugins/_bundled/sirsoft-marketing/package-lock.json b/plugins/_bundled/sirsoft-marketing/package-lock.json index a3670458..940e605c 100644 --- a/plugins/_bundled/sirsoft-marketing/package-lock.json +++ b/plugins/_bundled/sirsoft-marketing/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-marketing", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-marketing", - "version": "1.0.2", + "version": "1.0.3", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/plugins/_bundled/sirsoft-marketing/package.json b/plugins/_bundled/sirsoft-marketing/package.json index 26eba533..60e78547 100644 --- a/plugins/_bundled/sirsoft-marketing/package.json +++ b/plugins/_bundled/sirsoft-marketing/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-marketing", - "version": "1.0.2", + "version": "1.0.3", "description": "G7 마케팅 동의 플러그인 프론트엔드 에셋", "private": true, "type": "module", diff --git a/plugins/_bundled/sirsoft-marketing/plugin.json b/plugins/_bundled/sirsoft-marketing/plugin.json index 27da4071..3ca3ba1f 100644 --- a/plugins/_bundled/sirsoft-marketing/plugin.json +++ b/plugins/_bundled/sirsoft-marketing/plugin.json @@ -5,7 +5,7 @@ "ko": "마케팅 동의", "en": "Marketing Consent" }, - "version": "1.0.2", + "version": "1.0.3", "description": { "ko": "이메일 구독, 마케팅 동의, 제3자 제공 동의 등을 관리하는 플러그인", "en": "Plugin for managing email subscriptions, marketing consent, and third-party data sharing consent" diff --git a/plugins/_bundled/sirsoft-marketing/src/routes/api.php b/plugins/_bundled/sirsoft-marketing/src/routes/api.php index a2ff4b07..7ed048d2 100644 --- a/plugins/_bundled/sirsoft-marketing/src/routes/api.php +++ b/plugins/_bundled/sirsoft-marketing/src/routes/api.php @@ -16,8 +16,12 @@ Route::get('/settings', [MarketingSettingsController::class, 'settings']) * 마케팅 플러그인 관리자 API 라우트 * 자동 prefix 적용 후 최종 URL: /api/plugins/sirsoft-marketing/admin/channels * 인증은 AdminBaseController 미들웨어에서 처리 + * + * 채널 저장은 코어 `PUT plugins/{identifier}/settings` 와 같은 plugin_settings 를 덮어쓰는 + * 우회 경로이므로 동일 권한(core.plugins.update)으로 게이트한다. `admin` 미들웨어는 + * type=admin 보유 여부만 판정하므로 그것만으로는 업무 권한 없는 관리자도 도달한다. */ -Route::prefix('admin')->name('admin.')->group(function () { +Route::prefix('admin')->name('admin.')->middleware('permission:admin,core.plugins.update')->group(function () { Route::put('/channels', [MarketingAdminController::class, 'updateChannels']) ->name('channels.update'); }); diff --git a/plugins/_bundled/sirsoft-marketing/tests/Feature/Http/Controllers/MarketingAdminControllerTest.php b/plugins/_bundled/sirsoft-marketing/tests/Feature/Http/Controllers/MarketingAdminControllerTest.php index 64a0e29b..e6ab6601 100644 --- a/plugins/_bundled/sirsoft-marketing/tests/Feature/Http/Controllers/MarketingAdminControllerTest.php +++ b/plugins/_bundled/sirsoft-marketing/tests/Feature/Http/Controllers/MarketingAdminControllerTest.php @@ -19,10 +19,10 @@ class MarketingAdminControllerTest extends PluginTestCase */ private const EXISTING_CHANNELS = [ [ - 'key' => 'email_subscription', - 'label' => ['ko' => '광고성 이메일 수신', 'en' => 'Email Marketing'], + 'key' => 'email_subscription', + 'label' => ['ko' => '광고성 이메일 수신', 'en' => 'Email Marketing'], 'page_slug' => '', - 'enabled' => true, + 'enabled' => true, 'is_system' => true, ], ]; @@ -35,7 +35,7 @@ class MarketingAdminControllerTest extends PluginTestCase $mock->method('get')->willReturnCallback( fn (string $id, string $key, mixed $default = null) => match ($key) { 'channels' => json_encode(self::EXISTING_CHANNELS), - default => $default, + default => $default, } ); $this->app->instance(PluginSettingsService::class, $mock); @@ -54,7 +54,7 @@ class MarketingAdminControllerTest extends PluginTestCase public function test_update_channels_succeeds_for_admin(): void { - $admin = $this->createAdminUser(); + $admin = $this->createAdminUser(['core.plugins.update']); $response = $this->actingAs($admin)->putJson( '/api/plugins/sirsoft-marketing/admin/channels', @@ -68,14 +68,14 @@ class MarketingAdminControllerTest extends PluginTestCase public function test_update_channels_saves_new_channel(): void { - $admin = $this->createAdminUser(); + $admin = $this->createAdminUser(['core.plugins.update']); $channels = array_merge(self::EXISTING_CHANNELS, [ [ - 'key' => 'sms_subscription', - 'label' => ['ko' => '광고성 SMS', 'en' => 'SMS Marketing'], + 'key' => 'sms_subscription', + 'label' => ['ko' => '광고성 SMS', 'en' => 'SMS Marketing'], 'page_slug' => '', - 'enabled' => true, + 'enabled' => true, 'is_system' => false, ], ]); @@ -93,7 +93,7 @@ class MarketingAdminControllerTest extends PluginTestCase public function test_update_channels_rejects_duplicate_keys(): void { - $admin = $this->createAdminUser(); + $admin = $this->createAdminUser(['core.plugins.update']); $channels = [ ['key' => 'email_subscription', 'label' => ['ko' => 'A', 'en' => 'A'], 'page_slug' => '', 'enabled' => true, 'is_system' => true], @@ -113,7 +113,7 @@ class MarketingAdminControllerTest extends PluginTestCase public function test_update_channels_rejects_invalid_key_format(): void { - $admin = $this->createAdminUser(); + $admin = $this->createAdminUser(['core.plugins.update']); $channels = [ ['key' => 'invalid-key!', 'label' => ['ko' => 'A', 'en' => 'A'], 'page_slug' => '', 'enabled' => true, 'is_system' => false], @@ -132,7 +132,7 @@ class MarketingAdminControllerTest extends PluginTestCase public function test_update_channels_rejects_removal_of_system_channel(): void { - $admin = $this->createAdminUser(); + $admin = $this->createAdminUser(['core.plugins.update']); // email_subscription(is_system=true) 없이 제출 $channels = [ @@ -152,7 +152,7 @@ class MarketingAdminControllerTest extends PluginTestCase public function test_update_channels_rejects_system_flag_downgrade(): void { - $admin = $this->createAdminUser(); + $admin = $this->createAdminUser(['core.plugins.update']); // email_subscription의 is_system을 false로 위변조 $channels = [ @@ -172,17 +172,17 @@ class MarketingAdminControllerTest extends PluginTestCase public function test_update_channels_rejects_deletion_when_consents_exist(): void { - $admin = $this->createAdminUser(); - $user = User::factory()->create(); + $admin = $this->createAdminUser(['core.plugins.update']); + $user = User::factory()->create(); // sms_subscription 채널에 동의 데이터 생성 MarketingConsent::create([ - 'user_id' => $user->id, - 'consent_key' => 'sms_subscription', - 'is_consented' => true, - 'consented_at' => now(), - 'revoked_at' => null, - 'last_source' => 'register', + 'user_id' => $user->id, + 'consent_key' => 'sms_subscription', + 'is_consented' => true, + 'consented_at' => now(), + 'revoked_at' => null, + 'last_source' => 'register', 'consent_count' => 1, ]); @@ -201,17 +201,17 @@ class MarketingAdminControllerTest extends PluginTestCase public function test_update_channels_rejects_deletion_of_existing_channel_with_consents(): void { - $admin = $this->createAdminUser(); - $user = User::factory()->create(); + $admin = $this->createAdminUser(['core.plugins.update']); + $user = User::factory()->create(); // email_subscription에 동의 데이터 생성 MarketingConsent::create([ - 'user_id' => $user->id, - 'consent_key' => 'email_subscription', - 'is_consented' => true, - 'consented_at' => now(), - 'revoked_at' => null, - 'last_source' => 'register', + 'user_id' => $user->id, + 'consent_key' => 'email_subscription', + 'is_consented' => true, + 'consented_at' => now(), + 'revoked_at' => null, + 'last_source' => 'register', 'consent_count' => 1, ]); @@ -234,7 +234,7 @@ class MarketingAdminControllerTest extends PluginTestCase public function test_update_channels_rejects_missing_label(): void { - $admin = $this->createAdminUser(); + $admin = $this->createAdminUser(['core.plugins.update']); $channels = [ ['key' => 'sms_subscription', 'page_slug' => '', 'enabled' => true, 'is_system' => false], diff --git a/plugins/_bundled/sirsoft-marketing/tests/Feature/Http/Controllers/MarketingAdminRoutePermissionTest.php b/plugins/_bundled/sirsoft-marketing/tests/Feature/Http/Controllers/MarketingAdminRoutePermissionTest.php new file mode 100644 index 00000000..f6747ed5 --- /dev/null +++ b/plugins/_bundled/sirsoft-marketing/tests/Feature/Http/Controllers/MarketingAdminRoutePermissionTest.php @@ -0,0 +1,93 @@ + 'email_subscription', + 'label' => ['ko' => '광고성 이메일 수신', 'en' => 'Email Marketing'], + 'page_slug' => '', + 'enabled' => true, + 'is_system' => true, + ], + ]; + + protected function setUp(): void + { + parent::setUp(); + + $mock = $this->createMock(PluginSettingsService::class); + $mock->method('get')->willReturnCallback( + fn (string $id, string $key, mixed $default = null) => match ($key) { + 'channels' => json_encode(self::EXISTING_CHANNELS), + default => $default, + } + ); + $this->app->instance(PluginSettingsService::class, $mock); + } + + /** + * 채널 저장 라우트에 플러그인 설정 권한이 선언되어 있는지 확인합니다. + * + * @return void + */ + public function test_channels_update_route_declares_plugin_settings_permission(): void + { + // 이름 조회표는 앱 부팅 시점에 한 번 갱신된다. 테스트는 부팅 이후(setUp)에 라우트를 + // 등록하므로 그 표에 반영되지 않는다 — 조회 전에 명시적으로 다시 만든다. + Route::getRoutes()->refreshNameLookups(); + + $route = Route::getRoutes()->getByName('api.plugins.sirsoft-marketing.admin.channels.update'); + + $this->assertNotNull($route, '채널 저장 라우트가 존재해야 합니다.'); + $this->assertContains( + 'permission:admin,core.plugins.update', + $route->gatherMiddleware(), + '채널 저장 라우트는 core.plugins.update 권한을 요구해야 합니다.' + ); + } + + /** + * 업무 권한이 없는 관리자는 채널 저장에서 403 을 받아야 합니다. + * + * @return void + */ + public function test_admin_without_plugin_settings_permission_cannot_update_channels(): void + { + $admin = $this->createAdminUser(); + + $this->actingAs($admin) + ->putJson('/api/plugins/sirsoft-marketing/admin/channels', ['channels' => self::EXISTING_CHANNELS]) + ->assertForbidden(); + } + + /** + * 플러그인 설정 권한 보유 관리자는 정상 저장할 수 있어야 합니다. + * + * @return void + */ + public function test_admin_with_plugin_settings_permission_can_update_channels(): void + { + $admin = $this->createAdminUser(['core.plugins.update']); + + $this->actingAs($admin) + ->putJson('/api/plugins/sirsoft-marketing/admin/channels', ['channels' => self::EXISTING_CHANNELS]) + ->assertOk(); + } +} diff --git a/plugins/_bundled/sirsoft-marketing/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-marketing/tests/PluginTestCase.php index fde2d363..fc8ea40e 100644 --- a/plugins/_bundled/sirsoft-marketing/tests/PluginTestCase.php +++ b/plugins/_bundled/sirsoft-marketing/tests/PluginTestCase.php @@ -3,14 +3,13 @@ namespace Plugins\Sirsoft\Marketing\Tests; use App\Enums\PermissionType; +use App\Extension\HookManager; use App\Models\Permission; use App\Models\Role; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Route; use Plugins\Sirsoft\Marketing\Repositories\Contracts\MarketingConsentRepositoryInterface; -use Plugins\Sirsoft\Marketing\Http\Controllers\MarketingAdminController; -use Plugins\Sirsoft\Marketing\Http\Controllers\MarketingSettingsController; use Plugins\Sirsoft\Marketing\Repositories\MarketingConsentRepository; use Tests\TestCase; @@ -43,19 +42,7 @@ abstract class PluginTestCase extends TestCase $this->app->bind(MarketingConsentRepositoryInterface::class, MarketingConsentRepository::class); - Route::prefix('api/plugins/sirsoft-marketing') - ->middleware('api') - ->group(function () { - Route::get('/settings', [MarketingSettingsController::class, 'settings']) - ->name('api.sirsoft-marketing.settings'); - - Route::prefix('admin') - ->middleware('auth:sanctum') - ->group(function () { - Route::put('/channels', [MarketingAdminController::class, 'updateChannels']) - ->name('api.sirsoft-marketing.admin.channels.update'); - }); - }); + $this->registerPluginRoutes(); // HookManager 상태 스냅샷 (tearDown 에서 복원) $this->snapshotHookManager(); @@ -76,7 +63,7 @@ abstract class PluginTestCase extends TestCase */ private function snapshotHookManager(): void { - $ref = new \ReflectionClass(\App\Extension\HookManager::class); + $ref = new \ReflectionClass(HookManager::class); $this->hookSnapshot = [ 'hooks' => $ref->getProperty('hooks')->getValue(), 'filters' => $ref->getProperty('filters')->getValue(), @@ -93,7 +80,7 @@ abstract class PluginTestCase extends TestCase return; } - $ref = new \ReflectionClass(\App\Extension\HookManager::class); + $ref = new \ReflectionClass(HookManager::class); $ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']); $ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']); $ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']); @@ -101,19 +88,46 @@ abstract class PluginTestCase extends TestCase $this->hookSnapshot = null; } + /** + * 플러그인 API 라우트를 실제 라우트 파일에서 등록합니다. + * + * 테스트 안에 라우트 정의를 복제하면 라우트 파일의 미들웨어 변경이 테스트에 도달하지 + * 않아, 권한 게이트가 빠져도 검사가 통과한다. 프로덕션 PluginRouteServiceProvider 와 + * 동일한 prefix/name 으로 실제 파일을 그대로 로드한다. + * + * @return void + */ + protected function registerPluginRoutes(): void + { + $apiRoutesFile = base_path('plugins/sirsoft-marketing/src/routes/api.php'); + + if (file_exists($apiRoutesFile)) { + Route::prefix('api/plugins/sirsoft-marketing') + ->name('api.plugins.sirsoft-marketing.') + ->middleware('api') + ->group($apiRoutesFile); + } + } + /** * 관리자 권한을 가진 사용자를 생성합니다. * * isAdmin()이 Role/Permission 기반이므로 admin Role과 type=admin Permission을 직접 생성합니다. * - * @return User + * @param array $permissions 추가로 부여할 업무 권한 식별자 목록 + * @return User 생성된 관리자 */ - protected function createAdminUser(): User + protected function createAdminUser(array $permissions = []): User { - $adminRole = Role::firstOrCreate( - ['identifier' => 'admin'], - ['name' => ['ko' => '관리자', 'en' => 'Admin'], 'description' => ['ko' => '관리자', 'en' => 'Admin']] - ); + $user = User::factory()->create(); + + // 사용자마다 고유 역할을 만든다 — 공용 'admin' 역할을 재사용하면 한 테스트 안에서 + // 권한 보유 관리자에게 부여한 권한이 무권한 관리자에게도 새어 음성 케이스가 무력화된다. + $adminRole = Role::create([ + 'identifier' => 'admin-test-'.$user->id.'-'.uniqid(), + 'name' => ['ko' => '테스트 관리자', 'en' => 'Test Admin'], + 'description' => ['ko' => '테스트 관리자', 'en' => 'Test Admin'], + ]); $permission = Permission::firstOrCreate( ['identifier' => 'admin.access'], @@ -122,7 +136,14 @@ abstract class PluginTestCase extends TestCase $adminRole->permissions()->syncWithoutDetaching([$permission->id]); - $user = User::factory()->create(); + foreach ($permissions as $identifier) { + $granted = Permission::firstOrCreate( + ['identifier' => $identifier], + ['name' => ['ko' => $identifier, 'en' => $identifier], 'type' => PermissionType::Admin] + ); + $adminRole->permissions()->syncWithoutDetaching([$granted->id]); + } + $user->roles()->attach($adminRole->id); return $user; diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md index 6d9be298..5d35e317 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md @@ -8,6 +8,8 @@ ### Security +- 관리자 주문 조회·에스크로 배송등록 API가 세부 권한을 검사하도록 수정했습니다. 이전에는 관리자 계정이면 주문 권한이 없어도 주문번호·결제정보·수령인 연락처와 주소를 조회하고 배송등록까지 할 수 있었습니다. 이제 조회는 주문 조회 권한, 배송등록은 주문 수정 권한이 필요하며, 다른 결제대행사 연동과 같은 기준이 적용됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1893) +- 가상계좌 입금통보 주소 조회와 시스템 점검 API에도 설정 조회 권한 검사를 추가했습니다. - 에스크로 배송등록 요청의 운송장번호·택배사 코드에 형식·길이 검증을 추가했습니다. ### Changed diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md index 7aa4d320..0fa13fe4 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md @@ -5,4 +5,5 @@ | 문서 | 도메인 | 설명 | | --- | --- | --- | | [vbank.md](vbank.md) | `payment` | 가상계좌 입금통보·에스크로 공통통보 수신 경로와 발신 서버(IP) 확인 | +| [admin-orders.md](admin-orders.md) | `admin` | 관리자 주문 연동 경로 전체와 각 경로의 요구 권한 | | [transaction-status.md](transaction-status.md) | `admin` | 관리자 주문 상세의 거래 상태·취소·환불 조회 | diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/admin-orders.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/admin-orders.md new file mode 100644 index 00000000..8095c175 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/admin-orders.md @@ -0,0 +1,85 @@ +# 관리자 주문 연동 API 레퍼런스 + +> **소유**: 플러그인 `sirsoft-pay_nhnkcp`. 관리자 주문 목록·상세 화면이 호출하는 NHN KCP 연동 경로와 각 경로가 요구하는 세부 권한을 서술한다. + +--- + +## TL;DR (5초 요약) + +```text +1. admin 그룹의 모든 경로는 Bearer 토큰(관리자) + 이커머스 세부 권한을 함께 요구한다 +2. 조회 경로는 sirsoft-ecommerce.orders.read, 쓰기 경로는 sirsoft-ecommerce.orders.update +3. 설정성 경로(입금통보 주소·시스템 점검)는 sirsoft-ecommerce.settings.read +4. 관리자(type=admin)라도 해당 권한이 없으면 403 이며, 요청은 아무 부작용도 남기지 않는다 +5. 다른 결제대행사 플러그인(kginicis·nicepayments)과 동일한 권한 기준이다 +``` + +--- + +## 권한 매트릭스 + +`admin` 미들웨어는 관리자 여부(type=admin 권한 보유)만 판정하고 업무 권한은 판정하지 않는다. 따라서 각 라우트가 요구 권한을 직접 선언한다. + +| 메서드/URI | 라우트명 | 요구 권한 | +| --- | --- | --- | +| `GET /api/plugins/sirsoft-pay_nhnkcp/admin/orders/test-mode-map` | `...admin.orders.test-mode-map` | `sirsoft-ecommerce.orders.read` | +| `GET /api/plugins/sirsoft-pay_nhnkcp/admin/orders/easy-pay-display-map` | `...admin.orders.easy-pay-display-map` | `sirsoft-ecommerce.orders.read` | +| `GET /api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/transaction-status` | `...admin.orders.transaction-status` | `sirsoft-ecommerce.orders.read` | +| `GET /api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/escrow-delivery` | `...admin.orders.escrow-delivery.form` | `sirsoft-ecommerce.orders.read` | +| `POST /api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/escrow-delivery` | `...admin.orders.escrow-delivery.register` | `sirsoft-ecommerce.orders.update` | +| `GET /api/plugins/sirsoft-pay_nhnkcp/admin/settings/test-mode-status` | `...admin.settings.test-mode-status` | `sirsoft-ecommerce.settings.read` | +| `GET /api/plugins/sirsoft-pay_nhnkcp/admin/vbank-notify-url` | `...admin.vbank.notify.url` | `sirsoft-ecommerce.settings.read` | +| `GET /api/plugins/sirsoft-pay_nhnkcp/admin/health` | `...admin.health` | `sirsoft-ecommerce.settings.read` | + +### 에러 응답 + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 관리자가 아니거나, 위 표의 권한을 보유하지 않은 경우 | +| 422 | Validation | 배송등록 요청의 운송장번호·택배사 코드가 형식에 맞지 않는 경우 | + +권한 검사는 컨트롤러 진입 **전에** 수행되므로, 403 으로 거부된 배송등록 요청은 결제 정보(`payment_meta`)를 포함해 어떤 상태도 변경하지 않는다. + +--- + +## 조회 경로 + +### 테스트 모드 주문 맵 + +`GET .../admin/orders/test-mode-map` + +관리자 주문 목록에서 어떤 주문이 테스트 결제인지 배지로 표시하기 위한 맵을 반환한다. 응답 `data` 는 주문번호를 키로 하는 객체다. + +### 간편결제 표시 맵 + +`GET .../admin/orders/easy-pay-display-map` + +간편결제(PAYCO 등)로 결제된 주문의 원 결제수단 표시 라벨을 반환한다. 응답 `data` 는 주문번호를 키로 하며, 각 항목은 `embedded_pg_provider_label`·`payment_method_label`·`payment_method_display_label` 을 포함한다. + +### 거래 상태 조회 + +`GET .../admin/orders/{orderNumber}/transaction-status` + +상세 필드는 [transaction-status.md](transaction-status.md) 참조. + +### 에스크로 배송등록 폼 데이터 + +`GET .../admin/orders/{orderNumber}/escrow-delivery` + +배송등록 화면의 초기값(주문 정보·기본 배송지·기등록 배송 이력)을 반환한다. 이 주문에 에스크로 결제가 없으면 `data` 는 `null` 이다(오류 아님). + +--- + +## 쓰기 경로 + +### 에스크로 배송등록 + +`POST .../admin/orders/{orderNumber}/escrow-delivery` + +| 이름 | 위치 | 타입 | 필수 | 설명 | +| --- | --- | --- | --- | --- | +| `deli_numb` | body | string | 예 | 운송장번호 | +| `deli_corp` | body | string | 예 | 택배사 코드 (KCP 공식 코드표) | + +NHN KCP 에 운송장 정보를 등록하고, 응답 중 허용된 필드만 정제해 결제 정보에 기록한다. 주문 조회 권한만 보유한 관리자는 이 경로에서 403 을 받는다. diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/transaction-status.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/transaction-status.md index 2fc757c1..6b0ce037 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/transaction-status.md +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/transaction-status.md @@ -22,7 +22,7 @@ | --- | --- | | 메서드/URI | `GET /api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/transaction-status` | | 인증 | Bearer 토큰 (관리자) | -| 권한 | 주문 조회 권한 | +| 권한 | 주문 조회 권한 (`sirsoft-ecommerce.orders.read`) — 미보유 시 403 | ### 경로 파라미터 diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/routes/api.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/routes/api.php index 6817d9c4..865b1923 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/routes/api.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/routes/api.php @@ -52,7 +52,8 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g 'url' => url('/plugins/sirsoft-pay_nhnkcp/payment/vbank-notify'), 'escrow_common_notify_url' => url('/plugins/sirsoft-pay_nhnkcp/payment/escrow-common-notify'), ], - ]))->name('vbank.notify.url'); + ]))->middleware('permission:admin,sirsoft-ecommerce.settings.read') + ->name('vbank.notify.url'); Route::get('/settings/test-mode-status', [AdminSettingsStatusController::class, 'testMode']) ->middleware('permission:admin,sirsoft-ecommerce.settings.read') @@ -60,23 +61,29 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g // 테스트 모드 주문 맵 (관리자 주문목록 배지 표시용) Route::get('/orders/test-mode-map', [AdminOrderListController::class, 'testModeMap']) + ->middleware('permission:admin,sirsoft-ecommerce.orders.read') ->name('orders.test-mode-map'); // 간편결제 원 결제수단 표시 맵 (관리자 주문목록 보강용) Route::get('/orders/easy-pay-display-map', [AdminOrderListController::class, 'easyPayDisplayMap']) + ->middleware('permission:admin,sirsoft-ecommerce.orders.read') ->name('orders.easy-pay-display-map'); // 주문번호로 거래 정보 조회 (레이아웃 확장 자동 로드용) Route::get('/orders/{orderNumber}/transaction-status', [AdminTransactionController::class, 'queryByOrder']) + ->middleware('permission:admin,sirsoft-ecommerce.orders.read') ->name('orders.transaction-status'); // 에스크로 배송 등록 Route::get('/orders/{orderNumber}/escrow-delivery', [AdminEscrowDeliveryController::class, 'formData']) + ->middleware('permission:admin,sirsoft-ecommerce.orders.read') ->name('orders.escrow-delivery.form'); Route::post('/orders/{orderNumber}/escrow-delivery', [AdminEscrowDeliveryController::class, 'register']) + ->middleware('permission:admin,sirsoft-ecommerce.orders.update') ->name('orders.escrow-delivery.register'); // 시스템 점검 (PC/모바일 결제 사전조건 진단 + 자동 chmod +x 복구) Route::get('/health', [HealthCheckController::class, 'check']) + ->middleware('permission:admin,sirsoft-ecommerce.settings.read') ->name('health'); }); diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/AdminRoutePermissionTest.php b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/AdminRoutePermissionTest.php new file mode 100644 index 00000000..c4f16f95 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/AdminRoutePermissionTest.php @@ -0,0 +1,199 @@ + 'permission:admin,sirsoft-ecommerce.orders.read', + 'api.plugins.sirsoft-pay_nhnkcp.admin.orders.easy-pay-display-map' => 'permission:admin,sirsoft-ecommerce.orders.read', + 'api.plugins.sirsoft-pay_nhnkcp.admin.orders.transaction-status' => 'permission:admin,sirsoft-ecommerce.orders.read', + 'api.plugins.sirsoft-pay_nhnkcp.admin.orders.escrow-delivery.form' => 'permission:admin,sirsoft-ecommerce.orders.read', + 'api.plugins.sirsoft-pay_nhnkcp.admin.orders.escrow-delivery.register' => 'permission:admin,sirsoft-ecommerce.orders.update', + 'api.plugins.sirsoft-pay_nhnkcp.admin.vbank.notify.url' => 'permission:admin,sirsoft-ecommerce.settings.read', + 'api.plugins.sirsoft-pay_nhnkcp.admin.health' => 'permission:admin,sirsoft-ecommerce.settings.read', + ]; + + // 이름 조회표는 앱 부팅 시점에 한 번 갱신된다. 테스트는 부팅 이후(setUp)에 라우트를 + // 등록하므로 그 표에 반영되지 않는다 — 조회 전에 명시적으로 다시 만든다. + Route::getRoutes()->refreshNameLookups(); + + foreach ($expected as $routeName => $permissionMiddleware) { + $route = Route::getRoutes()->getByName($routeName); + $this->assertNotNull($route, "Route [{$routeName}] should exist."); + $this->assertContains( + $permissionMiddleware, + $route->gatherMiddleware(), + "Route [{$routeName}] should require [{$permissionMiddleware}]." + ); + } + } + + /** + * 업무 권한이 없는 관리자는 주문 관련 전 경로에서 403 을 받아야 합니다. + * + * @return void + */ + public function test_admin_without_business_permission_is_denied_on_every_order_route(): void + { + $admin = $this->createAdminUser([]); + $order = $this->createEscrowOrder(); + $base = '/api/plugins/sirsoft-pay_nhnkcp/admin'; + + $this->actingAs($admin)->getJson($base.'/orders/test-mode-map')->assertForbidden(); + $this->actingAs($admin)->getJson($base.'/orders/easy-pay-display-map')->assertForbidden(); + $this->actingAs($admin)->getJson($base."/orders/{$order->order_number}/transaction-status")->assertForbidden(); + $this->actingAs($admin)->getJson($base."/orders/{$order->order_number}/escrow-delivery")->assertForbidden(); + $this->actingAs($admin)->postJson($base."/orders/{$order->order_number}/escrow-delivery", [ + 'deli_numb' => '1234567890', + 'deli_corp' => '04', + ])->assertForbidden(); + $this->actingAs($admin)->getJson($base.'/vbank-notify-url')->assertForbidden(); + $this->actingAs($admin)->getJson($base.'/health')->assertForbidden(); + } + + /** + * 403 은 가드 선행이어야 하며, 거부된 등록 요청은 결제 메타를 변경하지 않아야 합니다. + * + * @return void + */ + public function test_denied_escrow_register_leaves_payment_meta_untouched(): void + { + $admin = $this->createAdminUser(['sirsoft-ecommerce.orders.read']); + $order = $this->createEscrowOrder(); + $before = $order->payment()->first()->payment_meta; + + $this->actingAs($admin) + ->postJson("/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{$order->order_number}/escrow-delivery", [ + 'deli_numb' => '1234567890', + 'deli_corp' => '04', + ]) + ->assertForbidden(); + + $payment = $order->payment()->first(); + $this->assertSame($before, $payment->payment_meta); + $this->assertArrayNotHasKey('escrow_delivery', $payment->payment_meta ?? []); + } + + /** + * 읽기 권한 보유 관리자는 조회 경로에 도달해야 합니다 (수정이 정상 관리자를 깨지 않음). + * + * @return void + */ + public function test_admin_with_orders_read_can_reach_read_routes(): void + { + $admin = $this->createAdminUser(['sirsoft-ecommerce.orders.read']); + $order = $this->createEscrowOrder(); + $base = '/api/plugins/sirsoft-pay_nhnkcp/admin'; + + $this->actingAs($admin)->getJson($base.'/orders/test-mode-map')->assertOk(); + $this->actingAs($admin)->getJson($base.'/orders/easy-pay-display-map')->assertOk(); + $this->actingAs($admin)->getJson($base."/orders/{$order->order_number}/escrow-delivery")->assertOk(); + } + + /** + * 수정 권한 보유 관리자는 배송등록에 도달해야 합니다. + * + * @return void + */ + public function test_admin_with_orders_update_can_register_escrow_delivery(): void + { + $admin = $this->createAdminUser(['sirsoft-ecommerce.orders.update']); + $order = $this->createEscrowOrder(); + + $mock = $this->createMock(NhnKcpApiService::class); + $mock->method('registerEscrowDelivery')->willReturn([ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'tno' => 'KCP_ESCROW_TNO_PERM', + 'deli_numb' => '1234567890', + 'deli_corp' => '04', + ]); + $this->app->instance(NhnKcpApiService::class, $mock); + + $this->actingAs($admin) + ->postJson("/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{$order->order_number}/escrow-delivery", [ + 'deli_numb' => '1234567890', + 'deli_corp' => '04', + ]) + ->assertOk() + ->assertJsonPath('success', true); + } + + /** + * 설정 읽기 권한 보유 관리자는 설정성 경로에 도달해야 합니다. + * + * @return void + */ + public function test_admin_with_settings_read_can_reach_settings_routes(): void + { + $admin = $this->createAdminUser(['sirsoft-ecommerce.settings.read']); + + $this->actingAs($admin) + ->getJson('/api/plugins/sirsoft-pay_nhnkcp/admin/vbank-notify-url') + ->assertOk(); + } + + /** + * 에스크로 결제가 붙은 테스트 주문을 생성합니다. + * + * @return Order 생성된 주문 + */ + private function createEscrowOrder(): Order + { + $order = OrderFactory::new()->create([ + 'user_id' => User::factory()->create()->id, + 'order_number' => 'ORD-KCP-PERM-'.random_int(10000, 99999), + 'order_status' => OrderStatusEnum::PAYMENT_COMPLETE, + 'total_amount' => 30000, + 'total_due_amount' => 0, + 'total_paid_amount' => 30000, + 'paid_at' => now(), + ]); + + OrderPaymentFactory::new()->create([ + 'order_id' => $order->id, + 'payment_status' => PaymentStatusEnum::PAID, + 'payment_method' => PaymentMethodEnum::CARD, + 'pg_provider' => 'nhnkcp', + 'transaction_id' => 'KCP_TNO_PERM_'.random_int(10000, 99999), + 'paid_amount_local' => 30000, + 'paid_at' => now(), + 'is_escrow' => true, + 'payment_meta' => [ + 'site_cd' => 'T0000', + 'is_test_mode' => true, + 'escw_yn' => 'Y', + ], + ]); + + return $order->fresh(); + } +}