diff --git a/CHANGELOG-server.md b/CHANGELOG-server.md index c25cfd5..2e015b6 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.70] - 2026-06-21 + +### 改进 +- 商品名称/系列/规格选择接口支持关键词搜索(按名称或编码),为客户端选择器的服务端全量搜索提供支撑 + ## [1.0.69] - 2026-06-21 ### 改进 diff --git a/backend/internal/handler/product_option.go b/backend/internal/handler/product_option.go index 63993d8..788d228 100644 --- a/backend/internal/handler/product_option.go +++ b/backend/internal/handler/product_option.go @@ -2,6 +2,7 @@ package handler import ( "net/http" + "strings" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -24,7 +25,12 @@ func NewProductOptionHandler(db *gorm.DB) *ProductOptionHandler { func (h *ProductOptionHandler) ListNames(c *gin.Context) { shopID := middleware.GetShopID(c) items := make([]model.ProductNameOption, 0) - h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items) + q := h.db.Where("shop_id = ?", shopID) + if kw := strings.TrimSpace(c.Query("keyword")); kw != "" { + like := "%" + kw + "%" + q = q.Where("name LIKE ? OR code LIKE ?", like, like) + } + q.Order("id ASC").Find(&items) util.RespondSuccess(c, items) } @@ -85,7 +91,12 @@ func (h *ProductOptionHandler) DeleteName(c *gin.Context) { func (h *ProductOptionHandler) ListSeries(c *gin.Context) { shopID := middleware.GetShopID(c) items := make([]model.ProductSeriesOption, 0) - h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items) + q := h.db.Where("shop_id = ?", shopID) + if kw := strings.TrimSpace(c.Query("keyword")); kw != "" { + like := "%" + kw + "%" + q = q.Where("name LIKE ? OR code LIKE ?", like, like) + } + q.Order("id ASC").Find(&items) util.RespondSuccess(c, items) } @@ -146,7 +157,12 @@ func (h *ProductOptionHandler) DeleteSeries(c *gin.Context) { func (h *ProductOptionHandler) ListSpecs(c *gin.Context) { shopID := middleware.GetShopID(c) items := make([]model.ProductSpecOption, 0) - h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items) + q := h.db.Where("shop_id = ?", shopID) + if kw := strings.TrimSpace(c.Query("keyword")); kw != "" { + like := "%" + kw + "%" + q = q.Where("name LIKE ? OR code LIKE ?", like, like) + } + q.Order("id ASC").Find(&items) util.RespondSuccess(c, items) } diff --git a/backend/internal/handler/product_option_test.go b/backend/internal/handler/product_option_test.go new file mode 100644 index 0000000..ef5328f --- /dev/null +++ b/backend/internal/handler/product_option_test.go @@ -0,0 +1,86 @@ +package handler + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/testutil" +) + +// setupOptionRouter 注册 product-options 名称路由(含 JWT)。 +func setupOptionRouter(db *gorm.DB) *gin.Engine { + h := NewProductOptionHandler(db) + r := gin.New() + r.Use(gin.Recovery()) + api := r.Group("/api/v1") + api.Use(middleware.JWT(db)) + names := api.Group("/product-options/names") + names.GET("", h.ListNames) + return r +} + +func TestProductOptionHandler_ListNamesKeyword(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "OPT001") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupOptionRouter(db) + + db.Create(&model.ProductNameOption{ + TenantBase: model.TenantBase{ShopID: shop.ID}, Code: "P001", Name: "茅台", + }) + db.Create(&model.ProductNameOption{ + TenantBase: model.TenantBase{ShopID: shop.ID}, Code: "P002", Name: "五粮液", + }) + + // 1. 无 keyword → 返回全部 + w := makeRequest(r, "GET", "/api/v1/product-options/names", token, nil) + assert.Equal(t, http.StatusOK, w.Code) + data := parseResponse(w)["data"].([]interface{}) + assert.Len(t, data, 2) + + // 2. keyword 命中名称 + w = makeRequest(r, "GET", "/api/v1/product-options/names?keyword=五粮", token, nil) + assert.Equal(t, http.StatusOK, w.Code) + data = parseResponse(w)["data"].([]interface{}) + assert.Len(t, data, 1) + assert.Equal(t, "五粮液", data[0].(map[string]interface{})["name"]) + + // 3. keyword 命中编码 + w = makeRequest(r, "GET", "/api/v1/product-options/names?keyword=P001", token, nil) + assert.Equal(t, http.StatusOK, w.Code) + data = parseResponse(w)["data"].([]interface{}) + assert.Len(t, data, 1) + assert.Equal(t, "茅台", data[0].(map[string]interface{})["name"]) + + // 4. keyword 无命中 → 空 + w = makeRequest(r, "GET", "/api/v1/product-options/names?keyword=不存在", token, nil) + assert.Equal(t, http.StatusOK, w.Code) + data = parseResponse(w)["data"].([]interface{}) + assert.Len(t, data, 0) +} + +func TestProductOptionHandler_ListNamesIsolation(t *testing.T) { + db := testutil.SetupTestDB() + shopA := testutil.CreateTestShop(db, "OPT_A") + userA := testutil.CreateTestUser(db, shopA.ID, "adminA", "pass", "admin") + tokenA := getAuthToken(userA.ID, shopA.ID, "admin") + shopB := testutil.CreateTestShop(db, "OPT_B") + r := setupOptionRouter(db) + + db.Create(&model.ProductNameOption{ + TenantBase: model.TenantBase{ShopID: shopB.ID}, Name: "他店商品", + }) + + // A 店即使 keyword 命中 B 店数据也查不到(shop_id 隔离) + w := makeRequest(r, "GET", "/api/v1/product-options/names?keyword=他店", tokenA, nil) + assert.Equal(t, http.StatusOK, w.Code) + data := parseResponse(w)["data"].([]interface{}) + assert.Len(t, data, 0) +} diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index e960fb2..02e9858 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -241,6 +241,37 @@ func SetupTestDB() *gorm.DB { content TEXT, remark TEXT )`, + `CREATE TABLE IF NOT EXISTS product_name_options ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME, + updated_at DATETIME, + deleted_at DATETIME, + shop_id INTEGER NOT NULL, + code TEXT, + name TEXT NOT NULL, + remark TEXT + )`, + `CREATE TABLE IF NOT EXISTS product_series_options ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME, + updated_at DATETIME, + deleted_at DATETIME, + shop_id INTEGER NOT NULL, + code TEXT, + name TEXT NOT NULL, + remark TEXT + )`, + `CREATE TABLE IF NOT EXISTS product_spec_options ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME, + updated_at DATETIME, + deleted_at DATETIME, + shop_id INTEGER NOT NULL, + code TEXT, + name TEXT NOT NULL, + quantity INTEGER DEFAULT 0, + remark TEXT + )`, `CREATE TABLE IF NOT EXISTS warehouses ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME,