package handler import ( "bytes" "errors" "fmt" "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/google/uuid" qrcode "github.com/skip2/go-qrcode" "gorm.io/gorm" "github.com/wangjia/jiu/backend/config" "github.com/wangjia/jiu/backend/internal/middleware" "github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/internal/util" ) type ProductHandler struct { db *gorm.DB } func NewProductHandler(db *gorm.DB) *ProductHandler { return &ProductHandler{db: db} } // List GET /api/v1/products func (h *ProductHandler) List(c *gin.Context) { shopID := middleware.GetShopID(c) page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) pageSize = util.ValidatePageSize(pageSize, 20, 200) keyword := c.Query("keyword") categoryID := c.Query("category_id") query := h.db.Model(&model.Product{}). Where("shop_id = ? AND deleted_at IS NULL", shopID) if keyword != "" { query = query.Where("name LIKE ? OR code LIKE ? OR barcode LIKE ?", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") } if categoryID != "" { query = query.Where("category_id = ?", categoryID) } var total int64 query.Count(&total) products := make([]model.Product, 0) offset := (page - 1) * pageSize query.Preload("Category").Offset(offset).Limit(pageSize). Order("id DESC").Find(&products) c.JSON(http.StatusOK, gin.H{ "data": products, "total": total, "page": page, "page_size": pageSize, }) } // nextProductCode 生成该门店下一个自动商品编码(P001、P002…)。 // 取现有 P 开头编码的最大数字序号 +1,**含软删行**(deleted_at 非空也计入,不复用已删商品占用过的号)。 // 不加行锁——并发下两请求可能算出同号,由 uk_shop_code 唯一约束 + 调用方 ErrDuplicatedKey 重试兜底。 // 用 GORM 表达式(非 MySQL 方言 SQL),sqlite 单测也能跑。 func nextProductCode(tx *gorm.DB, shopID uint64) (string, error) { var codes []string if err := tx.Model(&model.Product{}). Where("shop_id = ? AND code LIKE 'P%'", shopID). Pluck("code", &codes).Error; err != nil { return "", err } maxN := 0 for _, c := range codes { if len(c) < 2 { continue } if n, err := strconv.Atoi(c[1:]); err == nil && n > maxN { maxN = n } } return fmt.Sprintf("P%03d", maxN+1), nil } // createIndependentProduct 为入库明细新建一个独立产品(特有产品/序列号),返回新 product。 // "入库每行 = 一个特有产品"模型:每条明细建一个独立 product、发新序列号,不按名称复用。 // 含 nextProductCode 自增 + 撞 uk_shop_code 唯一约束时重试。必须在事务内调用。 func createIndependentProduct(tx *gorm.DB, shopID uint64, name, series, spec, batchNo string, prodDate *model.Date, price float64) (model.Product, error) { namePinyin, nameInitials := util.ToPinyin(name) var prod model.Product var err error for attempt := 0; attempt < 5; attempt++ { code, e := nextProductCode(tx, shopID) if e != nil { return model.Product{}, e } prod = model.Product{ TenantBase: model.TenantBase{ShopID: shopID}, PublicID: uuid.New().String(), Code: code, Name: name, Series: series, Spec: spec, BatchNo: batchNo, ProductionDate: prodDate, PurchasePrice: price, NamePinyin: namePinyin, NameInitials: nameInitials, } prod.ID = 0 if err = tx.Create(&prod).Error; err == nil || !errors.Is(err, gorm.ErrDuplicatedKey) { break } } if err != nil { return model.Product{}, err } return prod, nil } // Create POST /api/v1/products func (h *ProductHandler) Create(c *gin.Context) { shopID := middleware.GetShopID(c) var product model.Product if err := c.ShouldBindJSON(&product); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } product.ShopID = shopID product.PublicID = uuid.New().String() product.NamePinyin, product.NameInitials = util.ToPinyin(product.Name) // 未显式指定编码时自动生成(事务内 max+1);撞 uk_shop_code 唯一约束则重算下一号重试(应对并发)。 autoCode := product.Code == "" var createErr error for attempt := 0; attempt < 5; attempt++ { createErr = h.db.Transaction(func(tx *gorm.DB) error { if autoCode { code, err := nextProductCode(tx, shopID) if err != nil { return err } product.Code = code } product.ID = 0 return tx.Create(&product).Error }) if createErr == nil || !autoCode || !errors.Is(createErr, gorm.ErrDuplicatedKey) { break } } if createErr != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": createErr.Error()}) return } util.RespondCreated(c, product) } // Update PUT /api/v1/products/:id func (h *ProductHandler) Update(c *gin.Context) { shopID := middleware.GetShopID(c) id := c.Param("id") var product model.Product if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID). First(&product).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } var req model.Product if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } 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, "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 } // 重新读取完整数据返回 h.db.Preload("Category").First(&product, product.ID) util.RespondSuccess(c, product) } // Detail GET /api/v1/products/:id/detail func (h *ProductHandler) Detail(c *gin.Context) { shopID := middleware.GetShopID(c) id := c.Param("id") var product model.Product if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID). Preload("Category").Preload("Images"). Preload("Origin").Preload("ShelfLife").Preload("Storage"). First(&product).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } // 老数据可能没有 public_id,按需补生成 if product.PublicID == "" { product.PublicID = uuid.New().String() h.db.Model(&product).Update("public_id", product.PublicID) } util.RespondSuccess(c, product) } // QRCode GET /api/v1/products/:id/qrcode func (h *ProductHandler) QRCode(c *gin.Context) { shopID := middleware.GetShopID(c) id := c.Param("id") var product model.Product if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID). Select("id, public_id, code").First(&product).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } if product.PublicID == "" { product.PublicID = uuid.New().String() h.db.Model(&product).Update("public_id", product.PublicID) } url := config.C.Storage.PublicURL + "/app/product/" + product.PublicID if product.Code != "" { url += "?code=" + product.Code } png, err := qrcode.Encode(url, qrcode.Medium, 256) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.DataFromReader(http.StatusOK, int64(len(png)), "image/png", bytes.NewReader(png), nil) } // FindOrCreate POST /api/v1/products/find-or-create 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"` 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()}) return } var product model.Product 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) } util.RespondSuccess(c, product) return } if !errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } namePinyin, nameInitials := util.ToPinyin(req.Name) // 事务内 max+1 生成编码;撞 uk_shop_code 唯一约束则重算下一号重试(应对并发)。 var createErr error for attempt := 0; attempt < 5; attempt++ { createErr = h.db.Transaction(func(tx *gorm.DB) error { code, err := nextProductCode(tx, shopID) if err != nil { return err } product = model.Product{ TenantBase: model.TenantBase{ShopID: shopID}, PublicID: uuid.New().String(), Name: req.Name, Series: req.Series, Spec: req.Spec, Code: code, NamePinyin: namePinyin, NameInitials: nameInitials, OriginID: req.OriginID, ShelfLifeID: req.ShelfLifeID, StorageID: req.StorageID, DescriptionDocID: req.DescriptionDocID, } return tx.Create(&product).Error }) if createErr == nil || !errors.Is(createErr, gorm.ErrDuplicatedKey) { break } } if createErr != nil { // Race condition: 并发可能已按同 name/series/spec 建好,回查返回既有 if 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 == nil { util.RespondSuccess(c, product) return } c.JSON(http.StatusInternalServerError, gin.H{"error": createErr.Error()}) return } util.RespondCreated(c, product) } // Delete DELETE /api/v1/products/:id (软删除) func (h *ProductHandler) Delete(c *gin.Context) { shopID := middleware.GetShopID(c) id := c.Param("id") now := timeNow() result := h.db.Model(&model.Product{}). Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID). Update("deleted_at", now) if result.RowsAffected == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } c.JSON(http.StatusOK, gin.H{"message": "deleted"}) }