From ba127826b2324c3162f6a5a4ea45f197ba3df6d1 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 8 Jun 2026 06:56:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=E6=96=B0=E5=A2=9E=E5=95=86?= =?UTF-8?q?=E5=93=81=E5=B1=9E=E6=80=A7=E5=AD=97=E5=85=B8=E8=A1=A8=EF=BC=88?= =?UTF-8?q?=E4=BA=A7=E5=9C=B0/=E4=BF=9D=E8=B4=A8=E6=9C=9F/=E5=82=A8?= =?UTF-8?q?=E5=AD=98=E6=96=B9=E5=BC=8F/=E6=8F=8F=E8=BF=B0=E6=96=87?= =?UTF-8?q?=E6=A1=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model/product_attr.go: 新增 4 张字典表 struct(TenantBase 范式) - model/product.go: Product 增加 4 个可空外键 + belongs-to 关联 - handler/product_attr.go: 4 组 16 个 CRUD handler(shop_id 隔离) - handler/product.go: FindOrCreate/Update 支持写入 4 个属性 ID - handler/public.go: Preload 4 表 + 三级回退介绍 + 默认话术常量 - router/router.go: /product-options/{origins,shelf-lives,storages,description-docs} 路由 - main.go: AutoMigrate 注册 4 个新 model - testutil/setup.go: SQLite 测试库补齐新列与新表 Co-Authored-By: Claude Sonnet 4.6 --- backend/internal/handler/product.go | 83 ++++--- backend/internal/handler/product_attr.go | 268 +++++++++++++++++++++++ backend/internal/handler/public.go | 108 ++++++++- backend/internal/model/product.go | 22 +- backend/internal/model/product_attr.go | 34 +++ backend/internal/router/router.go | 22 ++ backend/main.go | 4 + backend/testutil/setup.go | 46 +++- 8 files changed, 543 insertions(+), 44 deletions(-) create mode 100644 backend/internal/handler/product_attr.go create mode 100644 backend/internal/model/product_attr.go diff --git a/backend/internal/handler/product.go b/backend/internal/handler/product.go index 9b3175d..8a5f334 100644 --- a/backend/internal/handler/product.go +++ b/backend/internal/handler/product.go @@ -127,22 +127,26 @@ func (h *ProductHandler) Update(c *gin.Context) { namePinyin, nameInitials := util.ToPinyin(req.Name) // 只更新业务字段,防止 Save() 覆盖 shop_id / created_at 等系统字段 if err := h.db.Model(&product).Updates(map[string]interface{}{ - "code": req.Code, - "barcode": req.Barcode, - "name": req.Name, - "series": req.Series, - "spec": req.Spec, - "unit": req.Unit, - "category_id": req.CategoryID, - "brand": req.Brand, - "purchase_price": req.PurchasePrice, - "sale_price": req.SalePrice, - "min_stock": req.MinStock, - "description": req.Description, - "remark": req.Remark, - "custom_fields": req.CustomFields, - "name_pinyin": namePinyin, - "name_initials": nameInitials, + "code": req.Code, + "barcode": req.Barcode, + "name": req.Name, + "series": req.Series, + "spec": req.Spec, + "unit": req.Unit, + "category_id": req.CategoryID, + "brand": req.Brand, + "purchase_price": req.PurchasePrice, + "sale_price": req.SalePrice, + "min_stock": req.MinStock, + "description": req.Description, + "remark": req.Remark, + "custom_fields": req.CustomFields, + "name_pinyin": namePinyin, + "name_initials": nameInitials, + "origin_id": req.OriginID, + "shelf_life_id": req.ShelfLifeID, + "storage_id": req.StorageID, + "description_doc_id": req.DescriptionDocID, }).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -208,9 +212,13 @@ func (h *ProductHandler) QRCode(c *gin.Context) { func (h *ProductHandler) FindOrCreate(c *gin.Context) { shopID := middleware.GetShopID(c) var req struct { - Name string `json:"name" binding:"required"` - Series string `json:"series"` - Spec string `json:"spec"` + Name string `json:"name" binding:"required"` + Series string `json:"series"` + Spec string `json:"spec"` + OriginID *uint64 `json:"origin_id"` + ShelfLifeID *uint64 `json:"shelf_life_id"` + StorageID *uint64 `json:"storage_id"` + DescriptionDocID *uint64 `json:"description_doc_id"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -221,6 +229,23 @@ func (h *ProductHandler) FindOrCreate(c *gin.Context) { err := h.db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL", shopID, req.Name, req.Series, req.Spec).First(&product).Error if err == nil { + // 商品已存在:如果传入了属性 ID,则更新属性关联 + attrUpdates := map[string]interface{}{} + if req.OriginID != nil { + attrUpdates["origin_id"] = req.OriginID + } + if req.ShelfLifeID != nil { + attrUpdates["shelf_life_id"] = req.ShelfLifeID + } + if req.StorageID != nil { + attrUpdates["storage_id"] = req.StorageID + } + if req.DescriptionDocID != nil { + attrUpdates["description_doc_id"] = req.DescriptionDocID + } + if len(attrUpdates) > 0 { + h.db.Model(&product).Updates(attrUpdates) + } c.JSON(http.StatusOK, gin.H{"data": product}) return } @@ -233,14 +258,18 @@ func (h *ProductHandler) FindOrCreate(c *gin.Context) { h.db.Model(&model.Product{}).Where("shop_id = ? AND deleted_at IS NULL", shopID).Count(&count) namePinyin, nameInitials := util.ToPinyin(req.Name) product = model.Product{ - TenantBase: model.TenantBase{ShopID: shopID}, - PublicID: uuid.New().String(), - Name: req.Name, - Series: req.Series, - Spec: req.Spec, - Code: fmt.Sprintf("P%03d", count+1), - NamePinyin: namePinyin, - NameInitials: nameInitials, + TenantBase: model.TenantBase{ShopID: shopID}, + PublicID: uuid.New().String(), + Name: req.Name, + Series: req.Series, + Spec: req.Spec, + Code: fmt.Sprintf("P%03d", count+1), + NamePinyin: namePinyin, + NameInitials: nameInitials, + OriginID: req.OriginID, + ShelfLifeID: req.ShelfLifeID, + StorageID: req.StorageID, + DescriptionDocID: req.DescriptionDocID, } if createErr := h.db.Create(&product).Error; createErr != nil { // Race condition: try to find the record created by another request diff --git a/backend/internal/handler/product_attr.go b/backend/internal/handler/product_attr.go new file mode 100644 index 0000000..46395b3 --- /dev/null +++ b/backend/internal/handler/product_attr.go @@ -0,0 +1,268 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/model" +) + +// ProductAttrHandler 处理商品属性字典(产地/保质期/储存方式/描述文档) +type ProductAttrHandler struct { + db *gorm.DB +} + +func NewProductAttrHandler(db *gorm.DB) *ProductAttrHandler { + return &ProductAttrHandler{db: db} +} + +// ── 产地(origins) ────────────────────────────────────────────── + +func (h *ProductAttrHandler) ListOrigins(c *gin.Context) { + shopID := middleware.GetShopID(c) + items := make([]model.ProductOriginOption, 0) + h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items) + c.JSON(http.StatusOK, gin.H{"data": items}) +} + +func (h *ProductAttrHandler) CreateOrigin(c *gin.Context) { + shopID := middleware.GetShopID(c) + var req struct { + Code string `json:"code"` + Name string `json:"name" binding:"required"` + Remark string `json:"remark"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + item := model.ProductOriginOption{ + TenantBase: model.TenantBase{ShopID: shopID}, + Code: req.Code, + Name: req.Name, + Remark: req.Remark, + } + if err := h.db.Create(&item).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": item}) +} + +func (h *ProductAttrHandler) UpdateOrigin(c *gin.Context) { + shopID := middleware.GetShopID(c) + var req struct { + Code string `json:"code"` + Name string `json:"name" binding:"required"` + Remark string `json:"remark"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var item model.ProductOriginOption + if err := h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).First(&item).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + item.Code = req.Code + item.Name = req.Name + item.Remark = req.Remark + h.db.Save(&item) + c.JSON(http.StatusOK, gin.H{"data": item}) +} + +func (h *ProductAttrHandler) DeleteOrigin(c *gin.Context) { + shopID := middleware.GetShopID(c) + h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductOriginOption{}) + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} + +// ── 保质期(shelf-lives) ───────────────────────────────────────── + +func (h *ProductAttrHandler) ListShelfLives(c *gin.Context) { + shopID := middleware.GetShopID(c) + items := make([]model.ProductShelfLifeOption, 0) + h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items) + c.JSON(http.StatusOK, gin.H{"data": items}) +} + +func (h *ProductAttrHandler) CreateShelfLife(c *gin.Context) { + shopID := middleware.GetShopID(c) + var req struct { + Code string `json:"code"` + Name string `json:"name" binding:"required"` + Remark string `json:"remark"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + item := model.ProductShelfLifeOption{ + TenantBase: model.TenantBase{ShopID: shopID}, + Code: req.Code, + Name: req.Name, + Remark: req.Remark, + } + if err := h.db.Create(&item).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": item}) +} + +func (h *ProductAttrHandler) UpdateShelfLife(c *gin.Context) { + shopID := middleware.GetShopID(c) + var req struct { + Code string `json:"code"` + Name string `json:"name" binding:"required"` + Remark string `json:"remark"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var item model.ProductShelfLifeOption + if err := h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).First(&item).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + item.Code = req.Code + item.Name = req.Name + item.Remark = req.Remark + h.db.Save(&item) + c.JSON(http.StatusOK, gin.H{"data": item}) +} + +func (h *ProductAttrHandler) DeleteShelfLife(c *gin.Context) { + shopID := middleware.GetShopID(c) + h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductShelfLifeOption{}) + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} + +// ── 储存方式(storages) ────────────────────────────────────────── + +func (h *ProductAttrHandler) ListStorages(c *gin.Context) { + shopID := middleware.GetShopID(c) + items := make([]model.ProductStorageOption, 0) + h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items) + c.JSON(http.StatusOK, gin.H{"data": items}) +} + +func (h *ProductAttrHandler) CreateStorage(c *gin.Context) { + shopID := middleware.GetShopID(c) + var req struct { + Code string `json:"code"` + Name string `json:"name" binding:"required"` + Remark string `json:"remark"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + item := model.ProductStorageOption{ + TenantBase: model.TenantBase{ShopID: shopID}, + Code: req.Code, + Name: req.Name, + Remark: req.Remark, + } + if err := h.db.Create(&item).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": item}) +} + +func (h *ProductAttrHandler) UpdateStorage(c *gin.Context) { + shopID := middleware.GetShopID(c) + var req struct { + Code string `json:"code"` + Name string `json:"name" binding:"required"` + Remark string `json:"remark"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var item model.ProductStorageOption + if err := h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).First(&item).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + item.Code = req.Code + item.Name = req.Name + item.Remark = req.Remark + h.db.Save(&item) + c.JSON(http.StatusOK, gin.H{"data": item}) +} + +func (h *ProductAttrHandler) DeleteStorage(c *gin.Context) { + shopID := middleware.GetShopID(c) + h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductStorageOption{}) + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} + +// ── 描述文档(description-docs) ───────────────────────────────── + +func (h *ProductAttrHandler) ListDescriptionDocs(c *gin.Context) { + shopID := middleware.GetShopID(c) + items := make([]model.ProductDescriptionDoc, 0) + h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items) + c.JSON(http.StatusOK, gin.H{"data": items}) +} + +func (h *ProductAttrHandler) CreateDescriptionDoc(c *gin.Context) { + shopID := middleware.GetShopID(c) + var req struct { + Title string `json:"title" binding:"required"` + Content string `json:"content"` + Remark string `json:"remark"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + item := model.ProductDescriptionDoc{ + TenantBase: model.TenantBase{ShopID: shopID}, + Title: req.Title, + Content: req.Content, + Remark: req.Remark, + } + if err := h.db.Create(&item).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": item}) +} + +func (h *ProductAttrHandler) UpdateDescriptionDoc(c *gin.Context) { + shopID := middleware.GetShopID(c) + var req struct { + Title string `json:"title" binding:"required"` + Content string `json:"content"` + Remark string `json:"remark"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var item model.ProductDescriptionDoc + if err := h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).First(&item).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + item.Title = req.Title + item.Content = req.Content + item.Remark = req.Remark + h.db.Save(&item) + c.JSON(http.StatusOK, gin.H{"data": item}) +} + +func (h *ProductAttrHandler) DeleteDescriptionDoc(c *gin.Context) { + shopID := middleware.GetShopID(c) + h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductDescriptionDoc{}) + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} diff --git a/backend/internal/handler/public.go b/backend/internal/handler/public.go index bf70267..0f81d88 100644 --- a/backend/internal/handler/public.go +++ b/backend/internal/handler/public.go @@ -1,7 +1,9 @@ package handler import ( + "fmt" "net/http" + "strings" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -9,6 +11,12 @@ import ( "github.com/wangjia/jiu/backend/internal/model" ) +// 默认话术(保质期/储存方式空时使用) +const ( + defaultShelfLife = "无限期(适饮)" + defaultStorage = "阴凉干燥、避光保存" +) + type PublicHandler struct { db *gorm.DB } @@ -24,6 +32,10 @@ func (h *PublicHandler) GetProduct(c *gin.Context) { var product model.Product if err := h.db.Where("public_id = ? AND deleted_at IS NULL", publicID). Preload("Images"). + Preload("Origin"). + Preload("ShelfLife"). + Preload("Storage"). + Preload("DescriptionDoc"). First(&product).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -60,22 +72,98 @@ func (h *PublicHandler) GetProduct(c *gin.Context) { } } + // 产地:空串表示无,前端不展示该行 + origin := "" + if product.Origin != nil { + origin = product.Origin.Name + } + + // 保质期:无关联时用默认话术 + shelfLife := defaultShelfLife + if product.ShelfLife != nil { + shelfLife = product.ShelfLife.Name + } + + // 储存方式:无关联时用默认话术 + storage := defaultStorage + if product.Storage != nil { + storage = product.Storage.Name + } + + // 介绍三级回退:描述文档 → 旧 Description → 通用中性兜底 + descTitle, descBody, descKeywords := buildDescription(product) + c.JSON(http.StatusOK, gin.H{ "data": gin.H{ - "id": product.ID, - "name": product.Name, - "series": product.Series, - "spec": product.Spec, - "brand": product.Brand, - "unit": product.Unit, - "description": product.Description, - "images": product.Images, - "shop": shopData, - "batch": batchData, + "id": product.ID, + "name": product.Name, + "series": product.Series, + "spec": product.Spec, + "brand": product.Brand, + "unit": product.Unit, + "description": descBody, + "description_title": descTitle, + "description_keywords": descKeywords, + "origin": origin, + "shelf_life": shelfLife, + "storage": storage, + "images": product.Images, + "shop": shopData, + "batch": batchData, }, }) } +// buildDescription 商品介绍三级回退逻辑 +// 1. DescriptionDoc.Content(结构化介绍文档) +// 2. Product.Description(旧自由文本) +// 3. 通用中性兜底(用真实字段拼,不含品牌专属词) +func buildDescription(p model.Product) (title, body string, keywords []string) { + // 1. 描述文档 + if p.DescriptionDoc != nil && p.DescriptionDoc.Content != "" { + return p.DescriptionDoc.Title, p.DescriptionDoc.Content, buildKeywords(p) + } + + // 2. 旧 Description 自由文本 + if p.Description != "" { + return "商品介绍", p.Description, buildKeywords(p) + } + + // 3. 通用中性兜底 + parts := make([]string, 0, 4) + if p.Brand != "" && p.Name != "" { + parts = append(parts, fmt.Sprintf("%s%s", p.Brand, p.Name)) + } else if p.Name != "" { + parts = append(parts, p.Name) + } + if p.Series != "" { + parts = append(parts, fmt.Sprintf("%s系列", p.Series)) + } + if p.Spec != "" { + parts = append(parts, fmt.Sprintf("规格%s", p.Spec)) + } + intro := strings.Join(parts, ",") + if intro != "" { + intro += "。" + } + intro += "本商品由门店正品供应,支持扫码验真,请认准官方渠道。" + + return "商品介绍", intro, buildKeywords(p) +} + +// buildKeywords 从真实商品属性派生关键词 chips +func buildKeywords(p model.Product) []string { + kws := make([]string, 0, 4) + if p.Brand != "" { + kws = append(kws, p.Brand) + } + if p.Series != "" { + kws = append(kws, p.Series) + } + kws = append(kws, "正品保障", "扫码验真") + return kws +} + // GetRelease GET /api/v1/public/release (no auth) func (h *PublicHandler) GetRelease(c *gin.Context) { cfg, err := loadVersionConfig() diff --git a/backend/internal/model/product.go b/backend/internal/model/product.go index 6912497..7599695 100644 --- a/backend/internal/model/product.go +++ b/backend/internal/model/product.go @@ -22,11 +22,21 @@ type Product struct { SalePrice float64 `gorm:"type:decimal(12,2)" json:"sale_price"` MinStock int `gorm:"default:0" json:"min_stock"` Description string `gorm:"type:text" json:"description"` - CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` - Remark string `gorm:"size:500" json:"remark"` - NamePinyin string `gorm:"size:400;index" json:"-"` - NameInitials string `gorm:"size:100;index" json:"-"` + CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` + Remark string `gorm:"size:500" json:"remark"` + NamePinyin string `gorm:"size:400;index" json:"-"` + NameInitials string `gorm:"size:100;index" json:"-"` - Category *ProductCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"` - Images []ProductImage `gorm:"foreignKey:ProductID" json:"images,omitempty"` + // 商品属性字典外键(可空,公开页展示用) + OriginID *uint64 `json:"origin_id"` + ShelfLifeID *uint64 `json:"shelf_life_id"` + StorageID *uint64 `json:"storage_id"` + DescriptionDocID *uint64 `json:"description_doc_id"` + + Category *ProductCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"` + Images []ProductImage `gorm:"foreignKey:ProductID" json:"images,omitempty"` + Origin *ProductOriginOption `gorm:"foreignKey:OriginID" json:"origin,omitempty"` + ShelfLife *ProductShelfLifeOption `gorm:"foreignKey:ShelfLifeID" json:"shelf_life,omitempty"` + Storage *ProductStorageOption `gorm:"foreignKey:StorageID" json:"storage,omitempty"` + DescriptionDoc *ProductDescriptionDoc `gorm:"foreignKey:DescriptionDocID" json:"description_doc,omitempty"` } diff --git a/backend/internal/model/product_attr.go b/backend/internal/model/product_attr.go new file mode 100644 index 0000000..3109141 --- /dev/null +++ b/backend/internal/model/product_attr.go @@ -0,0 +1,34 @@ +package model + +// ProductOriginOption 产地字典(门店级) +type ProductOriginOption struct { + TenantBase + Code string `gorm:"size:50" json:"code"` + Name string `gorm:"size:200;not null" json:"name"` + Remark string `gorm:"size:500" json:"remark"` +} + +// ProductShelfLifeOption 保质期字典(门店级) +type ProductShelfLifeOption struct { + TenantBase + Code string `gorm:"size:50" json:"code"` + Name string `gorm:"size:200;not null" json:"name"` // 如「无限期(适饮)」「24个月」 + Remark string `gorm:"size:500" json:"remark"` +} + +// ProductStorageOption 储存方式字典(门店级) +type ProductStorageOption struct { + TenantBase + Code string `gorm:"size:50" json:"code"` + Name string `gorm:"size:200;not null" json:"name"` // 如「阴凉干燥、避光保存」 + Remark string `gorm:"size:500" json:"remark"` +} + +// ProductDescriptionDoc 商品描述文档字典(门店级) +// Title = 酒类型(如「酱香型白酒」),Content = 商品介绍正文 +type ProductDescriptionDoc struct { + TenantBase + Title string `gorm:"size:200;not null" json:"title"` + Content string `gorm:"type:text" json:"content"` + Remark string `gorm:"size:500" json:"remark"` +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 8d244f2..dc67c4b 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -27,6 +27,7 @@ func Setup(r *gin.Engine, db *gorm.DB) { importH := handler.NewImportHandler(db) userH := handler.NewUserHandler(db) productOptH := handler.NewProductOptionHandler(db) + productAttrH := handler.NewProductAttrHandler(db) productImageH := handler.NewProductImageHandler(db) financeH := handler.NewFinanceHandler(db) numberRuleH := handler.NewNumberRuleHandler(db) @@ -217,6 +218,27 @@ func Setup(r *gin.Engine, db *gorm.DB) { opts.POST("/specs", productOptH.CreateSpec) opts.PUT("/specs/:id", productOptH.UpdateSpec) opts.DELETE("/specs/:id", productOptH.DeleteSpec) + + // 商品属性字典(产地/保质期/储存方式/描述文档) + opts.GET("/origins", productAttrH.ListOrigins) + opts.POST("/origins", productAttrH.CreateOrigin) + opts.PUT("/origins/:id", productAttrH.UpdateOrigin) + opts.DELETE("/origins/:id", productAttrH.DeleteOrigin) + + opts.GET("/shelf-lives", productAttrH.ListShelfLives) + opts.POST("/shelf-lives", productAttrH.CreateShelfLife) + opts.PUT("/shelf-lives/:id", productAttrH.UpdateShelfLife) + opts.DELETE("/shelf-lives/:id", productAttrH.DeleteShelfLife) + + opts.GET("/storages", productAttrH.ListStorages) + opts.POST("/storages", productAttrH.CreateStorage) + opts.PUT("/storages/:id", productAttrH.UpdateStorage) + opts.DELETE("/storages/:id", productAttrH.DeleteStorage) + + opts.GET("/description-docs", productAttrH.ListDescriptionDocs) + opts.POST("/description-docs", productAttrH.CreateDescriptionDoc) + opts.PUT("/description-docs/:id", productAttrH.UpdateDescriptionDoc) + opts.DELETE("/description-docs/:id", productAttrH.DeleteDescriptionDoc) } // 超级管理员专属 diff --git a/backend/main.go b/backend/main.go index 2f21217..e26197f 100644 --- a/backend/main.go +++ b/backend/main.go @@ -107,6 +107,10 @@ func autoMigrate(db *gorm.DB) { &model.ProductNameOption{}, &model.ProductSeriesOption{}, &model.ProductSpecOption{}, + &model.ProductOriginOption{}, + &model.ProductShelfLifeOption{}, + &model.ProductStorageOption{}, + &model.ProductDescriptionDoc{}, &model.ProductImage{}, &model.ErrorReport{}, &model.Feedback{}, diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index 351fe08..40aec2a 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -124,7 +124,51 @@ func SetupTestDB() *gorm.DB { custom_fields TEXT, remark TEXT, name_pinyin TEXT DEFAULT '', - name_initials TEXT DEFAULT '' + name_initials TEXT DEFAULT '', + origin_id INTEGER, + shelf_life_id INTEGER, + storage_id INTEGER, + description_doc_id INTEGER + )`, + `CREATE TABLE IF NOT EXISTS product_origin_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_shelf_life_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_storage_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_description_docs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME, + updated_at DATETIME, + deleted_at DATETIME, + shop_id INTEGER NOT NULL, + title TEXT NOT NULL, + content TEXT, + remark TEXT )`, `CREATE TABLE IF NOT EXISTS warehouses ( id INTEGER PRIMARY KEY AUTOINCREMENT,