package handler import ( "errors" "fmt" "html" "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) // 数据装配走 loadPublicProduct(与 SSR 商品页共用,公开字段口径单一实现)。 func (h *PublicHandler) GetProduct(c *gin.Context) { d, err := h.loadPublicProduct(c.Param("public_id")) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } product := d.Product shopData := gin.H{} if d.HasShop { shopData = gin.H{ "name": d.Shop.Name, "code": d.Shop.Code, "address": d.Shop.Address, "phone": d.Shop.Phone, "business_hours": d.Shop.BusinessHours, "wechat_id": d.Shop.WechatID, } } batchData := gin.H(nil) if d.Batch != nil { var pdStr *string if d.Batch.ProductionDate != nil { s := d.Batch.ProductionDate.Time.Format("2006-01-02") pdStr = &s } batchData = gin.H{ "production_date": pdStr, "batch_no": d.Batch.BatchNo, "in_stock_date": d.Batch.CreatedAt.Format("2006-01-02"), "quantity": d.Batch.Quantity, } } 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": d.DescBody, "description_title": d.DescTitle, "description_keywords": d.Keywords, "origin": d.Origin, "shelf_life": d.ShelfLife, "storage": d.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"}, "changelog": []changelogEntry{}, }) return } c.JSON(http.StatusOK, gin.H{ "version": cfg.Version, "release_notes": cfg.ReleaseNotes, "download_urls": cfg.DownloadURLs, "changelog": changelogOrEmpty(cfg.Changelog), }) } type publicProductImage struct { URL string `json:"url"` } type publicProductResp struct { ID uint64 `json:"id"` PublicID string `json:"public_id"` Code string `json:"code"` 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"` Quantity float64 `json:"quantity"` 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 } // 上限 20(2026-07 反爬收紧:客户端固定传 20,调大只方便爬全店) if pageSize < 1 || pageSize > 20 { pageSize = 20 } // 查询装配走 queryShopProducts(与 SSR 店铺页共用);API 不开放 keyword(现状不变) listData, total, err := h.queryShopProducts(shop.ID, page, pageSize, "") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "data": listData, "total": total, "page": page, "page_size": pageSize, }) } // ProductPage 已 SSR 化,实现移至 public_page.go(2026-07-07 公开页去 Flutter 化)。 // buildOGTitle 生成商品的分享标题:品牌 + 商品名(品牌已含在名字中时不重复)+ 系列。 func buildOGTitle(product model.Product) string { title := product.Name if product.Brand != "" && !strings.Contains(product.Name, product.Brand) { title = product.Brand + title } if product.Series != "" { title = title + "(" + product.Series + ")" } return title } // buildProductOG 构造基础 Open Graph meta 标签字符串。 // 所有值经 html.EscapeString 转义,防止 XSS 或破坏 HTML 结构。 // 无图片时不输出 og:image 行。 func buildProductOG(product model.Product, shopName, publicURL string) string { title := buildOGTitle(product) // og:description:系列(度数/香型)· 规格(容量/单位)| 门店名正品 var specParts []string if product.Series != "" { specParts = append(specParts, product.Series) } if product.Spec != "" { specParts = append(specParts, product.Spec) } desc := strings.Join(specParts, " · ") suffix := "正品" if shopName != "" { suffix = shopName + suffix } if desc != "" { desc = desc + "\n" + suffix } else { desc = suffix } // og:site_name siteName := "岩美酒库" if shopName != "" { siteName = shopName + " · " + siteName } // og:url:指向 SSR 短链(公开页规范入口) pageURL := publicURL + "/product/" + product.PublicID var sb strings.Builder sb.WriteString("\n") sb.WriteString(` ` + "\n") sb.WriteString(` ` + "\n") sb.WriteString(` ` + "\n") sb.WriteString(` ` + "\n") sb.WriteString(` ` + "\n") // og:image:仅有图片时输出 if len(product.Images) > 0 && product.Images[0].URL != "" { imgURL := publicURL + product.Images[0].URL sb.WriteString(` ` + "\n") } return sb.String() }