Files
jiu/backend/internal/handler/public.go
T
wangjia 9ea38a9b56 feat(backend): 公开页 SSR 化——商品页/店铺页后端直出,顾客动线零 Flutter
- /product/:public_id 从「Flutter 壳注入 OG」改为 go:embed 模板直出轻量页
  (~5KB vs 12.5MB 首包,3Mbps 带宽下 30s → <0.5s),OG 分享卡片行为不变
- 新增 /shop/:shop_code 店铺公开页短链:搜索(?q=)+分页(?page=),商品卡互跳
- 数据装配与公开 API 共用(loadPublicProduct / queryShopProducts),口径零漂移
- 图片异步拉取:首图 decoding=async、其余 loading=lazy
- 设计:docs/design/public-page-speedup.html

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 11:02:10 +08:00

281 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.go2026-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(` <meta property="og:type" content="website">` + "\n")
sb.WriteString(` <meta property="og:site_name" content="` + html.EscapeString(siteName) + `">` + "\n")
sb.WriteString(` <meta property="og:url" content="` + html.EscapeString(pageURL) + `">` + "\n")
sb.WriteString(` <meta property="og:title" content="` + html.EscapeString(title) + `">` + "\n")
sb.WriteString(` <meta property="og:description" content="` + html.EscapeString(desc) + `">` + "\n")
// og:image:仅有图片时输出
if len(product.Images) > 0 && product.Images[0].URL != "" {
imgURL := publicURL + product.Images[0].URL
sb.WriteString(` <meta property="og:image" content="` + html.EscapeString(imgURL) + `">` + "\n")
}
return sb.String()
}