chore: release server-v1.0.71
Deploy Server / release-deploy-server (push) Successful in 56s

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
This commit is contained in:
wangjia
2026-06-21 18:14:27 +08:00
parent 6d70bd3e37
commit 203cc7ce21
4 changed files with 172 additions and 2 deletions
+5
View File
@@ -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
### 改进
+34 -2
View File
@@ -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,
}
}
+125
View File
@@ -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_idCreateTestProduct 默认不设)
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"])
}
+8
View File
@@ -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,