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")) 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, }) } // 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) // Auto-generate product code if not provided (e.g. P001, P002) // Retry up to 5 times on duplicate key to handle concurrent creates if product.Code == "" { var count int64 h.db.Model(&model.Product{}). Where("shop_id = ? AND deleted_at IS NULL", shopID). Count(&count) product.Code = fmt.Sprintf("P%03d", count+1) } var createErr error for attempt := 0; attempt < 5; attempt++ { if createErr = h.db.Create(&product).Error; createErr == nil { break } if !errors.Is(createErr, gorm.ErrDuplicatedKey) { break } // Duplicate code: try next slot var count int64 h.db.Model(&model.Product{}). Where("shop_id = ? AND deleted_at IS NULL", shopID). Count(&count) product.ID = 0 product.Code = fmt.Sprintf("P%03d", count+int64(attempt)+2) } if createErr != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": createErr.Error()}) return } c.JSON(http.StatusCreated, gin.H{"data": 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) c.JSON(http.StatusOK, gin.H{"data": 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) } c.JSON(http.StatusOK, gin.H{"data": 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 + "/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) } c.JSON(http.StatusOK, gin.H{"data": product}) return } if !errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var count int64 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, 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 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 { c.JSON(http.StatusOK, gin.H{"data": product}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": createErr.Error()}) return } c.JSON(http.StatusCreated, gin.H{"data": 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"}) }