package handler import ( "errors" "fmt" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "gorm.io/gorm" "github.com/wangjia/jiu/backend/internal/model" ) // 默认话术(保质期/储存方式空时使用) const ( defaultShelfLife = "无限期(适饮)" defaultStorage = "阴凉干燥、避光保存" ) type PublicHandler struct { db *gorm.DB } func NewPublicHandler(db *gorm.DB) *PublicHandler { return &PublicHandler{db: db} } // GetProduct GET /api/v1/public/products/:public_id (no auth) func (h *PublicHandler) GetProduct(c *gin.Context) { publicID := c.Param("public_id") 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 } // Fetch shop public info var shop model.Shop shopData := gin.H{} if err := h.db.Where("id = ?", product.ShopID).First(&shop).Error; err == nil { shopData = gin.H{ "name": shop.Name, "code": shop.Code, "address": shop.Address, "phone": shop.Phone, "business_hours": shop.BusinessHours, "wechat_id": shop.WechatID, } } // Fetch latest inventory batch for this product var inv model.Inventory batchData := gin.H(nil) if err := h.db.Where("product_id = ? AND quantity > 0 AND deleted_at IS NULL", product.ID). Order("created_at DESC"). First(&inv).Error; err == nil { var pdStr *string if inv.ProductionDate != nil { s := inv.ProductionDate.Time.Format("2006-01-02") pdStr = &s } batchData = gin.H{ "production_date": pdStr, "batch_no": inv.BatchNo, "in_stock_date": inv.CreatedAt.Format("2006-01-02"), "quantity": inv.Quantity, } } // 产地:空串表示无,前端不展示该行 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, "code": product.Code, "barcode": product.Barcode, "series": product.Series, "spec": product.Spec, "brand": product.Brand, "unit": product.Unit, "sale_price": product.SalePrice, "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() if err != nil { c.JSON(http.StatusOK, gin.H{ "version": "1.0.0", "release_notes": "", "download_urls": gin.H{"web": "https://jiu.51yanmei.com/app"}, }) return } c.JSON(http.StatusOK, gin.H{ "version": cfg.Version, "release_notes": cfg.ReleaseNotes, "download_urls": cfg.DownloadURLs, }) } type publicProductImage struct { URL string `json:"url"` } type publicProductResp struct { ID uint64 `json:"id"` PublicID string `json:"public_id"` 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"` Images []publicProductImage `json:"images"` } // ListShopProducts GET /api/v1/public/shops/:shop_code/products (no auth) func (h *PublicHandler) ListShopProducts(c *gin.Context) { shopCode := c.Param("shop_code") var shop model.Shop if err := h.db.Where("code = ? AND deleted_at IS NULL", shopCode).First(&shop).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "shop not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 50 { pageSize = 20 } 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) var total int64 if err := query.Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var products []model.Product offset := (page - 1) * pageSize if err := query.Preload("Images"). Offset(offset). Limit(pageSize). Order("id DESC"). Find(&products).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } listData := make([]publicProductResp, len(products)) for i, p := range products { imgs := make([]publicProductImage, len(p.Images)) for j, img := range p.Images { imgs[j] = publicProductImage{URL: img.URL} } listData[i] = publicProductResp{ ID: p.ID, PublicID: p.PublicID, Name: p.Name, Series: p.Series, Spec: p.Spec, Brand: p.Brand, Unit: p.Unit, SalePrice: p.SalePrice, Images: imgs, } } c.JSON(http.StatusOK, gin.H{ "data": listData, "total": total, "page": page, "page_size": pageSize, }) }