fix(ecommerce): 장바구니 담기·바로구매 옵션 식별을 product_option_id 기반으로 전환
담기/바로구매가 메인 옵션을 로케일 값 조합(option_values)으로 전송하고 서버가 문자열 동등비교로 역매칭하던 방식은, 클라·서버 로케일 불일치나 관리자의 옵션 값 텍스트 수정 시 매칭 실패를 유발했다. 클라이언트가 이미 보유한 옵션 ID(optionId)를 전송하고, 서버는 요청 상품의 옵션 집합 내에서 ID로만 조회하도록 전환한다. 추가옵션(value_id/custom_text)은 이미 ID 기반이라 변경 없음.
This commit is contained in:
1 parent
8522245137
commit
0cdb97862f
21 files changed
+187
-46
No files matched your search
@@ -16,6 +16,10 @@
|
||||
- 사이트맵을 만들 때 상품과 카테고리를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 상품이 많은 쇼핑몰에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다.
|
||||
- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 상품을 장바구니에 담거나 바로 구매할 때, 표시 언어(로케일)나 관리자의 옵션 이름 수정과 무관하게 선택한 옵션이 정확히 담기도록 개선했습니다. 특정 상황에서 "해당 옵션 조합을 찾을 수 없습니다" 오류로 담기가 실패하던 문제가 해소됩니다.
|
||||
|
||||
## [1.0.4] - 2026-07-16
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -108,7 +108,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| item_ids | body | array | 아니오 | min 1 | item 식별자 배열 |
|
||||
| direct_items | body | array | 아니오 | min 1 | 바로 구매 항목 배열 (장바구니 미경유 — 항목별 product_id/option_values/quantity, item_ids와 택일) |
|
||||
| direct_items | body | array | 아니오 | min 1 | 바로 구매 항목 배열 (장바구니 미경유 — 항목별 product_id/product_option_id/quantity, item_ids와 택일) |
|
||||
| coupon_issue_ids | body | array | 아니오 | — | coupon issue 식별자 배열 |
|
||||
| use_points | body | integer | 아니오 | min 0 | 사용할 마일리지(적립금) 포인트 |
|
||||
|
||||
|
||||
+2
-1
@@ -34,7 +34,8 @@ class BulkAddToCartRequest extends FormRequest
|
||||
$rules = [
|
||||
'product_id' => ['required', 'integer', Rule::exists(Product::class, 'id')],
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.option_values' => 'nullable|array',
|
||||
// 메인 옵션 식별자 (옵션 ID 기반). 옵션 없는 상품은 미전송 → 서버가 기본 옵션 자동 해석
|
||||
'items.*.product_option_id' => ['nullable', 'integer'],
|
||||
'items.*.quantity' => 'required|integer|min:1|max:9999',
|
||||
// 추가옵션 선택 (서버에서 value_id 기준 가격 재조회·검증 — 클라 가격 신뢰 금지)
|
||||
'items.*.additional_option_selections' => 'nullable|array',
|
||||
|
||||
@@ -39,7 +39,8 @@ class CheckoutRequest extends FormRequest
|
||||
'item_ids.*' => ['integer', Rule::exists(Cart::class, 'id')],
|
||||
'direct_items' => [Rule::requiredIf($isDirect), 'array', 'min:1'],
|
||||
'direct_items.*.product_id' => ['required_with:direct_items', 'integer'],
|
||||
'direct_items.*.option_values' => ['nullable', 'array'],
|
||||
// 메인 옵션 식별자 (옵션 ID 기반). 옵션 없는 상품은 미전송 → 서버가 기본 옵션 자동 해석
|
||||
'direct_items.*.product_option_id' => ['nullable', 'integer'],
|
||||
'direct_items.*.quantity' => ['required_with:direct_items', 'integer', 'min:1'],
|
||||
// 바로구매 추가옵션 선택 (서버에서 value_id 기준 검증/가격 재조회)
|
||||
'direct_items.*.additional_option_selections' => ['nullable', 'array'],
|
||||
|
||||
@@ -247,9 +247,9 @@ class CartService
|
||||
* 장바구니 일괄 담기
|
||||
*
|
||||
* 하나의 상품에 대해 여러 옵션 조합을 한 번에 장바구니에 담습니다.
|
||||
* option_values가 없는 경우(옵션 없는 상품)는 기본 옵션을 자동 조회합니다.
|
||||
* product_option_id가 없는 경우(옵션 없는 상품)는 기본 옵션을 자동 조회합니다.
|
||||
*
|
||||
* @param array $data {product_id, items: [{option_values?, quantity}], user_id?, cart_key?}
|
||||
* @param array $data {product_id, items: [{product_option_id?, quantity}], user_id?, cart_key?}
|
||||
* @return array{items: array<Cart>, cart_count: int} 추가된 아이템 목록과 장바구니 총 수량
|
||||
*/
|
||||
public function bulkAddToCart(array $data): array
|
||||
@@ -272,17 +272,15 @@ class CartService
|
||||
$addedItems = [];
|
||||
|
||||
foreach ($data['items'] as $item) {
|
||||
$optionValues = $item['option_values'] ?? null;
|
||||
$requestedOptionId = $item['product_option_id'] ?? null;
|
||||
$quantity = $item['quantity'] ?? 1;
|
||||
|
||||
// option_values로 product_option_id 조회 (로케일 기준 값으로 매칭)
|
||||
if (! empty($optionValues)) {
|
||||
$matchedOption = $productOptions->first(function ($option) use ($optionValues) {
|
||||
return $option->getLocalizedOptionValues() == $optionValues;
|
||||
});
|
||||
// 옵션 ID 기반 식별. 상품 소속 옵션 집합 내에서만 조회해 임의 옵션 주입을 차단
|
||||
if (! empty($requestedOptionId)) {
|
||||
$matchedOption = $productOptions->firstWhere('id', (int) $requestedOptionId);
|
||||
|
||||
if (! $matchedOption) {
|
||||
throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_values_not_found'));
|
||||
throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_not_found'));
|
||||
}
|
||||
|
||||
$productOptionId = $matchedOption->id;
|
||||
|
||||
@@ -169,7 +169,7 @@ class TempOrderService
|
||||
* 생성합니다. 판매상태·재고·구매수량 한도·구매대상제한 검증은 장바구니 담기와 동일하게
|
||||
* 적용하되, 구매수량 한도는 장바구니 기존 수량과 합산하지 않고 이번 선택 수량만으로 판정합니다.
|
||||
*
|
||||
* @param array $items 직접 항목 배열 [{product_id, option_values?, quantity}]
|
||||
* @param array $items 직접 항목 배열 [{product_id, product_option_id?, quantity}]
|
||||
* @param int|null $userId 회원 ID
|
||||
* @param string|null $cartKey 비회원 장바구니 키
|
||||
* @param int $usePoints 사용할 마일리지
|
||||
@@ -196,10 +196,10 @@ class TempOrderService
|
||||
/**
|
||||
* 직접 항목 배열을 미저장 Cart 모델 컬렉션으로 변환합니다.
|
||||
*
|
||||
* option_values 로 옵션을 매칭하고(없으면 기본 옵션), product/productOption 관계를
|
||||
* product_option_id 로 옵션을 매칭하고(없으면 기본 옵션), product/productOption 관계를
|
||||
* set 한 미저장 Cart 모델을 만들어 기존 검증/계산/직렬화 로직을 그대로 재사용합니다.
|
||||
*
|
||||
* @param array $items 직접 항목 배열 [{product_id, option_values?, quantity}]
|
||||
* @param array $items 직접 항목 배열 [{product_id, product_option_id?, quantity}]
|
||||
* @param int|null $userId 회원 ID
|
||||
* @param string|null $cartKey 비회원 장바구니 키
|
||||
* @return Collection<int, Cart> 미저장 Cart 컬렉션
|
||||
@@ -212,19 +212,17 @@ class TempOrderService
|
||||
|
||||
foreach ($items as $item) {
|
||||
$productId = (int) ($item['product_id'] ?? 0);
|
||||
$optionValues = $item['option_values'] ?? null;
|
||||
$requestedOptionId = $item['product_option_id'] ?? null;
|
||||
$quantity = max(1, (int) ($item['quantity'] ?? 1));
|
||||
|
||||
// 상품 옵션 매칭 (option_values 기준, 없으면 기본 옵션)
|
||||
// 상품 옵션 매칭 (옵션 ID 기준, 없으면 기본 옵션)
|
||||
$productOptions = $this->productOptionRepository->getByProductId($productId);
|
||||
|
||||
if (! empty($optionValues)) {
|
||||
$matchedOption = $productOptions->first(
|
||||
fn ($option) => $option->getLocalizedOptionValues() == $optionValues
|
||||
);
|
||||
if (! empty($requestedOptionId)) {
|
||||
$matchedOption = $productOptions->firstWhere('id', (int) $requestedOptionId);
|
||||
|
||||
if (! $matchedOption) {
|
||||
throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_values_not_found'));
|
||||
throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_not_found'));
|
||||
}
|
||||
|
||||
$optionId = $matchedOption->id;
|
||||
|
||||
@@ -942,7 +942,6 @@ return [
|
||||
'quantity_max' => 'Quantity cannot exceed 9,999.',
|
||||
'items_required' => 'Please select items to add to cart.',
|
||||
'items_min' => 'Please select at least one item.',
|
||||
'option_values_not_found' => 'Matching option combination not found.',
|
||||
],
|
||||
|
||||
// Wishlist validation messages
|
||||
|
||||
@@ -942,7 +942,6 @@ return [
|
||||
'quantity_max' => '수량은 최대 9,999개까지 가능합니다.',
|
||||
'items_required' => '장바구니에 담을 상품을 선택해주세요.',
|
||||
'items_min' => '최소 1개 이상의 상품을 선택해주세요.',
|
||||
'option_values_not_found' => '해당 옵션 조합을 찾을 수 없습니다.',
|
||||
],
|
||||
|
||||
// 찜 검증 메시지
|
||||
|
||||
+42
-12
@@ -7,6 +7,7 @@ use Modules\Sirsoft\Ecommerce\Database\Factories\CartFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductOptionFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\ChargePolicyEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Cart;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ShippingPolicy;
|
||||
@@ -135,7 +136,7 @@ class CartControllerTest extends ModuleTestCase
|
||||
$this->postJson('/api/modules/sirsoft-ecommerce/cart', [
|
||||
'product_id' => $data['product']->id,
|
||||
'items' => [
|
||||
['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 1],
|
||||
['product_option_id' => $data['option']->id, 'quantity' => 1],
|
||||
],
|
||||
], ['X-Cart-Key' => $cartKey])->assertStatus(201);
|
||||
|
||||
@@ -162,7 +163,7 @@ class CartControllerTest extends ModuleTestCase
|
||||
$this->postJson('/api/modules/sirsoft-ecommerce/cart', [
|
||||
'product_id' => $data['product']->id,
|
||||
'items' => [
|
||||
['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 1],
|
||||
['product_option_id' => $data['option']->id, 'quantity' => 1],
|
||||
],
|
||||
], ['X-Cart-Key' => $cartKey])->assertStatus(201);
|
||||
|
||||
@@ -221,7 +222,7 @@ class CartControllerTest extends ModuleTestCase
|
||||
$addResponse = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
|
||||
'product_id' => $data['product']->id,
|
||||
'items' => [
|
||||
['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 2],
|
||||
['product_option_id' => $data['option']->id, 'quantity' => 2],
|
||||
],
|
||||
], [
|
||||
'X-Cart-Key' => $cartKey,
|
||||
@@ -274,7 +275,7 @@ class CartControllerTest extends ModuleTestCase
|
||||
$addResponse = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
|
||||
'product_id' => $data['product']->id,
|
||||
'items' => [
|
||||
['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 2],
|
||||
['product_option_id' => $data['option']->id, 'quantity' => 2],
|
||||
],
|
||||
], [
|
||||
'X-Cart-Key' => $cartKey,
|
||||
@@ -307,7 +308,7 @@ class CartControllerTest extends ModuleTestCase
|
||||
$addResponse = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
|
||||
'product_id' => $data['product']->id,
|
||||
'items' => [
|
||||
['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 2],
|
||||
['product_option_id' => $data['option']->id, 'quantity' => 2],
|
||||
],
|
||||
], [
|
||||
'X-Cart-Key' => $cartKey,
|
||||
@@ -355,8 +356,8 @@ class CartControllerTest extends ModuleTestCase
|
||||
$response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
|
||||
'product_id' => $product->id,
|
||||
'items' => [
|
||||
['option_values' => ['색상' => '빨강', '사이즈' => 'L'], 'quantity' => 2],
|
||||
['option_values' => ['색상' => '파랑', '사이즈' => 'M'], 'quantity' => 1],
|
||||
['product_option_id' => $option1->id, 'quantity' => 2],
|
||||
['product_option_id' => $option2->id, 'quantity' => 1],
|
||||
],
|
||||
], [
|
||||
'X-Cart-Key' => $cartKey,
|
||||
@@ -384,7 +385,7 @@ class CartControllerTest extends ModuleTestCase
|
||||
$data = $this->createProductWithOption();
|
||||
$cartKey = 'ck_'.str_repeat('f', 32);
|
||||
|
||||
// When: option_values 없이 담기
|
||||
// When: product_option_id 없이 담기
|
||||
$response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
|
||||
'product_id' => $data['product']->id,
|
||||
'items' => [
|
||||
@@ -399,6 +400,35 @@ class CartControllerTest extends ModuleTestCase
|
||||
$this->assertEquals(1, $response->json('data.cart_count'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 다른 상품에 속한 product_option_id 를 주입하면 담기가 거부되고 장바구니에 반영되지 않습니다.
|
||||
*
|
||||
* 회귀: 옵션 식별을 product_option_id 기반으로 전환하면서, 요청한 상품(product_id)의 옵션
|
||||
* 집합(getByProductId) 안에서만 옵션 ID 를 조회해야 한다. 타 상품 옵션 ID 주입 시 거부되어야
|
||||
* 서버가 임의 옵션(다른 상품/가격/재고)을 장바구니에 담는 위변조를 차단한다.
|
||||
*/
|
||||
public function test_bulk_add_rejects_option_id_from_another_product(): void
|
||||
{
|
||||
// Given: 각각 옵션을 가진 두 상품
|
||||
$target = $this->createProductWithOption();
|
||||
$foreign = $this->createProductWithOption();
|
||||
$cartKey = 'ck_'.str_repeat('h', 32);
|
||||
|
||||
// When: target 상품에 foreign 상품의 옵션 ID 를 실어 담기 시도
|
||||
$response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
|
||||
'product_id' => $target['product']->id,
|
||||
'items' => [
|
||||
['product_option_id' => $foreign['option']->id, 'quantity' => 1],
|
||||
],
|
||||
], [
|
||||
'X-Cart-Key' => $cartKey,
|
||||
]);
|
||||
|
||||
// Then: 담기 실패(비-201) + 장바구니에 어떤 행도 생성되지 않음
|
||||
$this->assertNotSame(201, $response->getStatusCode());
|
||||
$this->assertSame(0, Cart::where('cart_key', $cartKey)->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 담기 시 items가 비어있으면 422를 반환합니다.
|
||||
*/
|
||||
@@ -513,7 +543,7 @@ class CartControllerTest extends ModuleTestCase
|
||||
$response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
|
||||
'product_id' => $product->id,
|
||||
'items' => [
|
||||
['option_values' => $option->getLocalizedOptionValues(), 'quantity' => 1],
|
||||
['product_option_id' => $option->id, 'quantity' => 1],
|
||||
],
|
||||
], [
|
||||
'X-Cart-Key' => $cartKey,
|
||||
@@ -553,7 +583,7 @@ class CartControllerTest extends ModuleTestCase
|
||||
$response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
|
||||
'product_id' => $product->id,
|
||||
'items' => [
|
||||
['option_values' => $option->getLocalizedOptionValues(), 'quantity' => 6],
|
||||
['product_option_id' => $option->id, 'quantity' => 6],
|
||||
],
|
||||
], [
|
||||
'X-Cart-Key' => $cartKey,
|
||||
@@ -591,7 +621,7 @@ class CartControllerTest extends ModuleTestCase
|
||||
$response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
|
||||
'product_id' => $product->id,
|
||||
'items' => [
|
||||
['option_values' => $option->getLocalizedOptionValues(), 'quantity' => 1],
|
||||
['product_option_id' => $option->id, 'quantity' => 1],
|
||||
],
|
||||
], ['X-Cart-Key' => $cartKey]);
|
||||
|
||||
@@ -620,7 +650,7 @@ class CartControllerTest extends ModuleTestCase
|
||||
$response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
|
||||
'product_id' => $product->id,
|
||||
'items' => [
|
||||
['option_values' => $option->getLocalizedOptionValues(), 'quantity' => 5],
|
||||
['product_option_id' => $option->id, 'quantity' => 5],
|
||||
],
|
||||
], ['X-Cart-Key' => $cartKey]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user