From 203cc7ce211fdb004c6c080bf1469ec3a37e365a Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sun, 21 Jun 2026 18:14:27 +0800 Subject: [PATCH] chore: release server-v1.0.71 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx --- CHANGELOG-server.md | 5 + backend/internal/handler/public.go | 36 ++++++- backend/internal/handler/public_test.go | 125 ++++++++++++++++++++++++ backend/testutil/setup.go | 8 ++ 4 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 backend/internal/handler/public_test.go diff --git a/CHANGELOG-server.md b/CHANGELOG-server.md index 2e015b6..c73e824 100644 --- a/CHANGELOG-server.md +++ b/CHANGELOG-server.md @@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.71] - 2026-06-21 + +### 改进 +- 公开店铺商品页只展示「当前有库存」的商品,并返回每个商品的在库数量与商品编码(序列号),无货商品不再露出 + ## [1.0.70] - 2026-06-21 ### 改进 diff --git a/backend/internal/handler/public.go b/backend/internal/handler/public.go index f389ce1..9b3f4ee 100644 --- a/backend/internal/handler/public.go +++ b/backend/internal/handler/public.go @@ -202,12 +202,14 @@ type publicProductImage struct { type publicProductResp struct { ID uint64 `json:"id"` PublicID string `json:"public_id"` + Code string `json:"code"` Name string `json:"name"` Series string `json:"series"` Spec string `json:"spec"` Brand string `json:"brand"` Unit string `json:"unit"` SalePrice float64 `json:"sale_price"` + Quantity float64 `json:"quantity"` Images []publicProductImage `json:"images"` } @@ -234,8 +236,15 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) { pageSize = 20 } + // 仅列「有库存」的商品:JOIN 库存按 product 聚合(数量>0)的子查询。 + stockSub := h.db.Model(&model.Inventory{}). + Select("product_id, SUM(quantity) AS qty"). + Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0", shop.ID). + Group("product_id") + query := h.db.Model(&model.Product{}). - Where("shop_id = ? AND public_id IS NOT NULL AND public_id != '' AND deleted_at IS NULL", shop.ID) + Joins("JOIN (?) AS stk ON stk.product_id = products.id", stockSub). + Where("products.shop_id = ? AND products.public_id IS NOT NULL AND products.public_id != '' AND products.deleted_at IS NULL", shop.ID) var total int64 if err := query.Count(&total).Error; err != nil { @@ -248,12 +257,33 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) { if err := query.Preload("Images"). Offset(offset). Limit(pageSize). - Order("id DESC"). + Order("products.id DESC"). Find(&products).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + // 取本页商品的在库总量(IN 限定在本页 ≤pageSize 个 id,开销小) + qtyMap := make(map[uint64]float64, len(products)) + if len(products) > 0 { + pageIDs := make([]uint64, len(products)) + for i, p := range products { + pageIDs[i] = p.ID + } + var stockRows []struct { + ProductID uint64 + Qty float64 + } + h.db.Model(&model.Inventory{}). + Select("product_id, SUM(quantity) AS qty"). + Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0 AND product_id IN ?", shop.ID, pageIDs). + Group("product_id"). + Scan(&stockRows) + for _, s := range stockRows { + qtyMap[s.ProductID] = s.Qty + } + } + listData := make([]publicProductResp, len(products)) for i, p := range products { imgs := make([]publicProductImage, len(p.Images)) @@ -263,12 +293,14 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) { listData[i] = publicProductResp{ ID: p.ID, PublicID: p.PublicID, + Code: p.Code, Name: p.Name, Series: p.Series, Spec: p.Spec, Brand: p.Brand, Unit: p.Unit, SalePrice: p.SalePrice, + Quantity: qtyMap[p.ID], Images: imgs, } } diff --git a/backend/internal/handler/public_test.go b/backend/internal/handler/public_test.go new file mode 100644 index 0000000..3c6310d --- /dev/null +++ b/backend/internal/handler/public_test.go @@ -0,0 +1,125 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/testutil" +) + +func setupPublicRouter(db *gorm.DB) *gin.Engine { + h := NewPublicHandler(db) + r := gin.New() + r.Use(gin.Recovery()) + r.GET("/api/v1/public/shops/:shop_code/products", h.ListShopProducts) + return r +} + +// 给商品补 public_id(CreateTestProduct 默认不设) +func setPublicID(db *gorm.DB, productID uint64, pub string) { + require := func(err error) { + if err != nil { + panic(err) + } + } + require(db.Model(&model.Product{}).Where("id = ?", productID).Update("public_id", pub).Error) +} + +func addInventory(db *gorm.DB, shopID, warehouseID, productID uint64, qty float64) { + wid := warehouseID + pid := productID + if err := db.Create(&model.Inventory{ + ShopID: shopID, + WarehouseID: &wid, + ProductID: &pid, + Quantity: qty, + }).Error; err != nil { + panic(err) + } +} + +func TestPublicHandler_ListShopProducts_InStockOnly(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "PUB001") + wh := testutil.CreateTestWarehouse(db, shop.ID, "仓") + r := setupPublicRouter(db) + + // A:有库存 5 → 应出现,quantity=5 + pa := testutil.CreateTestProduct(db, shop.ID, "茅台A") + setPublicID(db, pa.ID, "pub-a") + addInventory(db, shop.ID, wh.ID, pa.ID, 5) + + // B:有 public_id 但无库存 → 不出现 + pb := testutil.CreateTestProduct(db, shop.ID, "五粮液B") + setPublicID(db, pb.ID, "pub-b") + + // C:两条库存 3+2 → 出现,quantity=5 + pc := testutil.CreateTestProduct(db, shop.ID, "汾酒C") + setPublicID(db, pc.ID, "pub-c") + addInventory(db, shop.ID, wh.ID, pc.ID, 3) + addInventory(db, shop.ID, wh.ID, pc.ID, 2) + + // D:有库存但无 public_id → 不出现 + pd := testutil.CreateTestProduct(db, shop.ID, "无公开D") + addInventory(db, shop.ID, wh.ID, pd.ID, 9) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/public/shops/PUB001/products", nil) + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + + // 只 A、C 两个有库存 + 有 public_id + assert.Equal(t, float64(2), resp["total"]) + data := resp["data"].([]interface{}) + require.Len(t, data, 2) + + byName := map[string]map[string]interface{}{} + for _, it := range data { + m := it.(map[string]interface{}) + byName[m["name"].(string)] = m + } + + require.Contains(t, byName, "茅台A") + require.Contains(t, byName, "汾酒C") + assert.NotContains(t, byName, "五粮液B") // 无库存 + assert.NotContains(t, byName, "无公开D") // 无 public_id + + // 数量正确(C 聚合 3+2=5) + assert.Equal(t, float64(5), byName["茅台A"]["quantity"]) + assert.Equal(t, float64(5), byName["汾酒C"]["quantity"]) + // 带上了序列号 code + assert.Equal(t, "P-茅台A", byName["茅台A"]["code"]) +} + +func TestPublicHandler_ListShopProducts_Isolation(t *testing.T) { + db := testutil.SetupTestDB() + shopA := testutil.CreateTestShop(db, "PUBA") + shopB := testutil.CreateTestShop(db, "PUBB") + whB := testutil.CreateTestWarehouse(db, shopB.ID, "仓B") + r := setupPublicRouter(db) + + // B 店有个有库存商品 + pb := testutil.CreateTestProduct(db, shopB.ID, "他店商品") + setPublicID(db, pb.ID, "pub-x") + addInventory(db, shopB.ID, whB.ID, pb.ID, 7) + + // 查 A 店:看不到 B 店商品 + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/public/shops/"+shopA.Code+"/products", nil) + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, float64(0), resp["total"]) +} diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index 02e9858..7000a65 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -241,6 +241,14 @@ func SetupTestDB() *gorm.DB { content TEXT, remark TEXT )`, + `CREATE TABLE IF NOT EXISTS product_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER NOT NULL, + shop_id INTEGER NOT NULL, + url TEXT NOT NULL, + sort_order INTEGER DEFAULT 0, + created_at DATETIME + )`, `CREATE TABLE IF NOT EXISTS product_name_options ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME,