fix(ecommerce): 상품 카드 별점이 리뷰 유무와 무관하게 0 으로 보이던 문제

최근 본 상품과 찜 목록만 리뷰 집계를 붙이지 않아, 리뷰가 달린 상품도 카드에 별 0 개로
표시됐다. 두 조회에 다른 목록과 같은 집계를 붙였다.

값이 비었는지로 판정하던 소비 측도 함께 고쳤다. 집계를 안 한 것과 세어보니 0건인 것이
같은 0 으로 뭉개져 있었다 — 이제 집계 컬럼이 붙었는지로 판정해, 세지 않은 조회에서는
항목을 생략하고 리뷰가 0건이면 종전대로 0 으로 표기한다. 관리자 상품 목록이 그 대상이라
API 문서에 어느 조회가 통계를 싣는지 명시했다.

소비처를 필드명으로만 훑어 처음에는 "화면에 안 보인다" 고 판정했는데 틀렸다. 레이아웃은
상품 객체를 통째로 카드에 넘기고 카드가 내부에서 별점을 그린다. 그 컴포넌트를 쓰는 화면의
공급 경로를 전수로 세어 찜 목록을 추가로 찾았다.

develop 리베이스 후속 검증 결과와 충돌 해소 근거도 함께 담는다.
This commit is contained in:
HeuJung
2026-08-06 11:13:54 +09:00
parent 7b859d4200
commit bd7b31feb8
8 changed files with 231 additions and 8 deletions
+2 -2
View File
@@ -152,7 +152,7 @@
| 대상 | 진입점 | 문서/엔드포인트 |
|------|--------|----------------|
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 35 / 291 |
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 319 |
### 확장 API 레퍼런스 (13개 확장, 자동 스캔)
@@ -163,7 +163,7 @@
|------|------|--------------|----------------|
| `gnuboard7-hello_module` | 모듈 | [docs/api/](modules/_bundled/gnuboard7-hello_module/docs/api/README.md) | 1 / 7 |
| `sirsoft-board` | 모듈 | [docs/api/](modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 |
| `sirsoft-ecommerce` | 모듈 | [docs/api/](modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 231 |
| `sirsoft-ecommerce` | 모듈 | [docs/api/](modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 232 |
| `sirsoft-page` | 모듈 | [docs/api/](modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 |
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 |
| `sirsoft-gdpr` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
@@ -60,6 +60,8 @@
### Fixed
- "최근 본 상품" 과 찜 목록의 상품 카드가 리뷰가 달린 상품도 별점 0 개 · 리뷰 0건으로 표시하던 문제를 수정했습니다. 두 조회만 리뷰를 세지 않아 값이 비어 있었고, 빈 값이 0 으로 표기되어 실제 평점과 다르게 보였습니다. 이제 다른 상품 목록과 동일하게 실제 평점·리뷰 수가 표시됩니다.
- 리뷰를 세지 않는 조회 경로(관리자 상품 목록 등)에서는 평점·리뷰 수 항목을 아예 싣지 않도록 바꿨습니다. 종전에는 세지 않았을 뿐인데 0 이 실려 "리뷰 없음" 과 구분되지 않았습니다. 리뷰가 실제로 0건이면 종전대로 0 으로 표시됩니다.
- 모바일·태블릿 화면에서 쿠폰 목록의 카드 보기가 내용 없이 나오던 문제를 수정했습니다. 목록의 각 쿠폰 정보가 카드에 전달되지 않아, 쿠폰이 있어도 빈 카드만 표시됐습니다.
- 주문 검색 프리셋 관리 창에서 "프리셋 추가" 버튼을 눌러도 저장 창이 열리지 않던 문제를 수정했습니다.
- 상품 리뷰 목록의 작성자 이름과 쿠폰 목록의 쿠폰 이름을 키보드로 선택해 이동할 수 있게 했습니다. 이전에는 마우스 클릭으로만 이동할 수 있었습니다.
@@ -28,6 +28,20 @@
같은 규약이 이 모듈의 다른 목록 응답(주문·쿠폰·마일리지·문의·리뷰 등)에도 적용됩니다.
상세 규약은 [pagination.md](../../../../../docs/backend/pagination.md) 를 참고하세요.
## 목록 응답의 리뷰 통계
`review_count` 와 `rating_avg` 는 상품 표에 저장된 값이 아니라 조회 시 함께 계산되는 집계입니다.
따라서 **그 계산을 수행하는 조회에서만 응답에 실립니다.**
| 조회 | 리뷰 통계 |
| --- | --- |
| 공개 상품 목록 · 인기 · 신상품 · 최근 본 상품 · 상품 검색 | 실림 |
| 관리자 상품 목록 | 실리지 않음 (화면이 사용하지 않습니다) |
리뷰가 한 건도 없는 상품은 계산이 수행된 조회에서 `review_count: 0` · `rating_avg: 0.0` 으로
실립니다. 계산하지 않은 조회에서 두 항목이 **아예 빠지는 것**과는 다릅니다 — 값이 0 인 것과
값이 없는 것을 구분해야 하므로, 항목이 없을 때를 0 으로 간주하지 마세요.
---
@@ -6,6 +6,7 @@ use App\Http\Resources\BaseApiResource;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\MissingValue;
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
/**
@@ -117,9 +118,13 @@ class ProductListResource extends BaseApiResource
])->values()
),
// 리뷰 통계 (visibleReviews withCount/withAvg eager loading 필요)
'review_count' => (int) ($this->review_count ?? 0),
'rating_avg' => $this->rating_avg !== null ? round((float) $this->rating_avg, 1) : 0.0,
// 리뷰 통계 — 조회 시 조인으로 붙는 집계다.
// 값이 null 인지가 아니라 **집계 컬럼이 붙었는지**로 판정한다. 리뷰 0건이면 COUNT/AVG
// 별칭은 존재하고 값만 null 이므로 종전대로 0 으로 표기되고, 집계를 아예 붙이지 않은
// 경로에서는 필드를 생략한다 — 0 으로 채우면 "세어보니 0" 과 구분되지 않아
// 리뷰가 달린 상품도 평점 0.0 으로 나간다.
'review_count' => $this->resolveReviewCount(),
'rating_avg' => $this->resolveRatingAvg(),
// 날짜
'created_at' => $this->formatDateTimeStringForUser($this->created_at),
@@ -175,6 +180,34 @@ class ProductListResource extends BaseApiResource
return $this->whenLoaded('activeOptions', fn () => $this->activeOptions->count());
}
/**
* 노출 리뷰 수를 반환합니다.
*
* 집계를 붙이지 않은 조회 경로에서는 0 을 지어내지 않고 필드를 생략합니다.
*
* @return mixed 리뷰 수(int) 또는 집계 부재 시 MissingValue
*/
protected function resolveReviewCount(): mixed
{
return $this->hasAggregateAttribute('review_count')
? (int) $this->review_count
: new MissingValue;
}
/**
* 노출 리뷰의 평균 평점을 반환합니다.
*
* 리뷰가 0건이면 AVG 는 null 이고 별칭은 존재하므로 0.0 으로 표기합니다.
*
* @return mixed 평균 평점(float) 또는 집계 부재 시 MissingValue
*/
protected function resolveRatingAvg(): mixed
{
return $this->hasAggregateAttribute('rating_avg')
? round((float) $this->rating_avg, 1)
: new MissingValue;
}
/**
* 비활성을 포함한 전체 옵션 개수를 반환합니다.
*
@@ -894,11 +894,14 @@ class ProductRepository implements ProductRepositoryInterface
return $this->model->newCollection();
}
$products = $this->model->newQuery()
$query = $this->model->newQuery()
->with(['images', 'categories', 'activeLabelAssignments.label'])
->where('display_status', 'visible')
->whereIn('id', $ids)
->get();
->whereIn($this->model->getTable().'.id', $ids);
// 다른 목록 경로와 같은 집계를 붙인다. 붙이지 않으면 평점·리뷰 수 별칭이 아예 없어
// 목록 표현에서 그 필드가 빠지고, 화면은 리뷰가 달린 상품도 통계 없이 그리게 된다.
$products = $this->joinReviewAggregate($query)->get();
// 클라이언트 요청 순서 유지 (DB 독립적 정렬)
$idOrder = array_flip($ids);
@@ -68,6 +68,11 @@ class ProductWishlistRepository implements ProductWishlistRepositoryInterface
'product.categories',
'product.activeLabelAssignments.label',
'product.images:id,product_id,hash,is_thumbnail,sort_order',
// 찜 카드도 상품 카드와 같은 컴포넌트라 별점을 그린다. 집계를 붙이지 않으면
// 목록 표현이 평점·리뷰 수를 싣지 못해 리뷰가 달린 상품도 별 0 개로 보인다.
'product' => fn ($q) => $q
->withCount(['visibleReviews as review_count'])
->withAvg('visibleReviews as rating_avg', 'rating'),
])
->orderByDesc('created_at')
// 전순서 보장 — 한 번에 여러 건을 담았을 때의 created_at 동률 대비
@@ -0,0 +1,158 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Resources;
use App\Models\User;
use Modules\Sirsoft\Ecommerce\Enums\ReviewStatus;
use Modules\Sirsoft\Ecommerce\Http\Resources\ProductListResource;
use Modules\Sirsoft\Ecommerce\Http\Resources\WishlistResource;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductReview;
use Modules\Sirsoft\Ecommerce\Models\ProductWishlist;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductWishlistRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Services\ProductService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 목록 응답의 리뷰 통계 정확도 회귀 테스트 (#519)
*
* 평점·리뷰 수는 상품 표에 저장된 값이 아니라 조회 시 조인으로 붙는 집계다. 그래서 집계를
* 붙이지 않은 조회 경로에서는 그 속성이 **아예 없는** 상태로 온다.
*
* 종전 소비 판정은 `$this->rating_avg !== null` 이었다. 값 검사는 "집계를 안 했다" 와
* "집계했더니 0건이다" 를 구분하지 못하므로, 리뷰가 달린 상품도 집계를 붙이지 않은 경로에서는
* `rating_avg: 0.0` · `review_count: 0` 으로 나갔다 — 틀린 값이 사실처럼 나가고 예외도
* 경고도 남지 않는다.
*
* 두 방향을 함께 고정한다.
* 1. 최근 본 상품 조회는 집계를 붙여 **실제 값**을 돌려준다.
* 2. 집계를 붙이지 않은 경로에서는 0 을 지어내지 않고 **필드를 생략**한다.
* (집계를 붙였는데 리뷰가 0건이면 별칭은 존재하고 값만 null 이므로 종전대로 0 으로 표기)
*
* @scenario case=product_list_review_aggregate
*
* @effects recently_viewed_products_carry_real_review_stats
* @effects list_resource_omits_review_stats_when_not_aggregated
*/
class ProductListReviewAggregateTest extends ModuleTestCase
{
/**
* 최근 본 상품 조회가 실제 리뷰 통계를 싣는지 확인합니다.
*
* @effects recently_viewed_products_carry_real_review_stats
*/
public function test_recently_viewed_products_carry_real_review_stats(): void
{
$product = Product::factory()->create(['display_status' => 'visible']);
$this->makeVisibleReview($product, 5);
$this->makeVisibleReview($product, 4);
$products = app(ProductService::class)->getProductsByIds([$product->id]);
$payload = ProductListResource::collection($products)
->resolve(request());
$this->assertCount(1, $payload);
$this->assertSame(
2,
$payload[0]['review_count'] ?? null,
'최근 본 상품 응답의 리뷰 수가 실제 건수와 다릅니다 — 집계를 붙이지 않으면 0 이 나갑니다.'
);
$this->assertSame(
4.5,
$payload[0]['rating_avg'] ?? null,
'최근 본 상품 응답의 평점이 실제 평균과 다릅니다.'
);
}
/**
* 리뷰가 0건이면 집계가 붙어도 0 으로 표기되는지 확인합니다.
*/
public function test_product_without_reviews_reports_zero_stats(): void
{
$product = Product::factory()->create(['display_status' => 'visible']);
$products = app(ProductService::class)->getProductsByIds([$product->id]);
$payload = ProductListResource::collection($products)->resolve(request());
$this->assertSame(0, $payload[0]['review_count'] ?? null);
$this->assertSame(0.0, $payload[0]['rating_avg'] ?? null);
}
/**
* 집계를 붙이지 않은 모델에서는 리뷰 통계 필드가 생략되는지 확인합니다.
*
* @effects list_resource_omits_review_stats_when_not_aggregated
*/
public function test_list_resource_omits_review_stats_when_not_aggregated(): void
{
$product = Product::factory()->create(['display_status' => 'visible']);
$this->makeVisibleReview($product, 5);
// 집계 없이 그대로 읽은 모델 — 별칭 자체가 붙지 않는다.
$raw = Product::query()->findOrFail($product->id);
$payload = (new ProductListResource($raw))->resolve(request());
$this->assertArrayNotHasKey(
'review_count',
$payload,
'집계하지 않은 경로인데 리뷰 수가 실렸습니다 — 0 이 정확한 값처럼 나갑니다.'
);
$this->assertArrayNotHasKey(
'rating_avg',
$payload,
'집계하지 않은 경로인데 평점이 실렸습니다.'
);
}
/**
* 찜 목록 카드도 실제 리뷰 통계를 싣는지 확인합니다.
*
* 찜 화면은 상품 목록과 같은 카드 컴포넌트를 쓰므로 별점을 그린다. 집계를 붙이지 않으면
* 리뷰가 달린 상품도 별 0 개로 보인다.
*
* @effects wishlist_cards_carry_real_review_stats
*/
public function test_wishlist_cards_carry_real_review_stats(): void
{
$owner = User::factory()->create();
$product = Product::factory()->create(['display_status' => 'visible']);
$this->makeVisibleReview($product, 5);
$this->makeVisibleReview($product, 4);
ProductWishlist::create([
'user_id' => $owner->id,
'product_id' => $product->id,
]);
$wishlists = app(ProductWishlistRepositoryInterface::class)->getByUser($owner->id, 20);
$payload = WishlistResource::collection($wishlists->getCollection())->resolve(request());
$this->assertCount(1, $payload);
$this->assertSame(
2,
$payload[0]['product']['review_count'] ?? null,
'찜 카드의 리뷰 수가 실제 건수와 다릅니다 — 집계를 붙이지 않으면 별 0 개로 보입니다.'
);
$this->assertSame(4.5, $payload[0]['product']['rating_avg'] ?? null);
}
/**
* 노출 상태 리뷰를 1건 만듭니다.
*
* @param Product $product 대상 상품
* @param int $rating 평점
*/
private function makeVisibleReview(Product $product, int $rating): void
{
ProductReview::factory()->create([
'product_id' => $product->id,
'user_id' => User::factory()->create()->id,
'rating' => $rating,
'status' => ReviewStatus::VISIBLE->value,
]);
}
}
@@ -103,6 +103,12 @@ effects:
# 검증도 통과하므로, 운영자에게는 "바꿨는데 안 바뀐다" 로만 나타난다.
- pagination_limits_persist_from_admin_settings
- advanced_tab_persists_every_merged_category
# ── 목록 집계값의 정확도 (P1) ──
# 평점·리뷰 수는 조회 시 조인으로 붙는 집계다. 값이 비었는지로 판정하면 "집계를 안 했다" 와
# "집계했더니 0건이다" 가 같은 0 으로 뭉개져, 리뷰가 달린 상품도 평점 0.0 으로 나간다.
- recently_viewed_products_carry_real_review_stats
- list_resource_omits_review_stats_when_not_aggregated
- wishlist_cards_carry_real_review_stats
# ── 확장 다국어 키 해석 (P1) ──
# 네임스페이스 없는 키는 번역이 실패해도 예외가 나지 않고 원시 키가 화면에 그대로 나간다.
- extension_non_namespaced_key_resolves_to_core_group
@@ -183,6 +189,8 @@ test_files:
- tests/Playwright/specs/pagination/bounded-total-and-product-grid.spec.ts
# 코어 — 고급 탭 저장이 병합 대상 카테고리(상한값 포함)를 빠짐없이 분류하는지
- tests/Feature/Settings/AdvancedTabCategoryPersistenceTest.php
# 이커머스 — 목록 리뷰 통계가 실제 값인지 / 집계 없는 경로에서 0 을 지어내지 않는지
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Resources/ProductListReviewAggregateTest.php
# 코어 — 확장의 네임스페이스 없는 다국어 키가 해석되는지 (확장 소스 전수 스캔)
- tests/Unit/ExtensionTranslationKeyResolutionTest.php
# 화면 — 검색 상태의 SSoT 가 URL 쿼리인지 (레이아웃 전수 스캔)