fix(ecommerce): 장바구니 담기·바로구매 옵션 식별을 product_option_id 기반으로 전환

담기/바로구매가 메인 옵션을 로케일 값 조합(option_values)으로 전송하고 서버가
문자열 동등비교로 역매칭하던 방식은, 클라·서버 로케일 불일치나 관리자의 옵션 값
텍스트 수정 시 매칭 실패를 유발했다. 클라이언트가 이미 보유한 옵션 ID(optionId)를
전송하고, 서버는 요청 상품의 옵션 집합 내에서 ID로만 조회하도록 전환한다.
추가옵션(value_id/custom_text)은 이미 ID 기반이라 변경 없음.
This commit is contained in:
HeuJung committed 2026-07-19 23:20:18 +09:00
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 | 사용할 마일리지(적립금) 포인트 |
@@ -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' => '해당 옵션 조합을 찾을 수 없습니다.',
],
// 찜 검증 메시지
@@ -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]);