package handler import ( "errors" "fmt" "html" "net/http" "os" "regexp" "strconv" "strings" "github.com/gin-gonic/gin" "gorm.io/gorm" "github.com/wangjia/jiu/backend/config" "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, }) } // ProductPage GET /product/:public_id // 返回注入了基础 Open Graph 标签的 Flutter index.html,供微信/飞书等社交平台爬虫生成分享卡片。 // 找不到商品时原样返回 index.html,让 Flutter 自行展示"商品不存在";爬虫拿不到 OG 标签但页面不报错。 func (h *PublicHandler) ProductPage(c *gin.Context) { publicID := c.Param("public_id") // 读取 Flutter 构建产物 index.html idxPath := config.C.Storage.WebDir + "/index.html" idxBytes, err := os.ReadFile(idxPath) if err != nil { c.String(http.StatusInternalServerError, "index.html not found: %s", idxPath) return } idxHTML := string(idxBytes) // 查商品(只取 OG 所需字段,轻量查询) var product model.Product if err := h.db.Select("id, public_id, name, brand, series, spec, shop_id"). Where("public_id = ? AND deleted_at IS NULL", publicID). Preload("Images"). First(&product).Error; err != nil { // 查不到商品:原样返回 index.html,让 Flutter 展示"商品不存在" c.Data(http.StatusOK, "text/html; charset=utf-8", idxBytes) return } // 查门店名 var shop model.Shop shopName := "" if err := h.db.Select("name").Where("id = ?", product.ShopID).First(&shop).Error; err == nil { shopName = shop.Name } // 构造 OG 标签并注入 前 ogTags := buildProductOG(product, shopName, config.C.Storage.PublicURL) ogTitle := buildOGTitle(product) // 同时替换 标签,微信/飞书等平台有时优先读 <title> 而非 og:title // 用正则替换避免缩进空格导致字符串不匹配 titleRe := regexp.MustCompile(`<title>[^<]*`) out := titleRe.ReplaceAllString(idxHTML, ""+html.EscapeString(ogTitle)+"") out = strings.Replace(out, "", ogTags+"", 1) // 禁止缓存,确保爬虫每次都能拿到最新 OG 标签 c.Header("Cache-Control", "no-store") c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(out)) } // 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 pageURL := publicURL + "/app/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() }