Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
This commit is contained in:
@@ -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
|
||||
|
||||
### 改进
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user