From 9ea38a9b56a9f896d3088c0a110786da52c71783 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Tue, 7 Jul 2026 11:02:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=E5=85=AC=E5=BC=80=E9=A1=B5=20?= =?UTF-8?q?SSR=20=E5=8C=96=E2=80=94=E2=80=94=E5=95=86=E5=93=81=E9=A1=B5/?= =?UTF-8?q?=E5=BA=97=E9=93=BA=E9=A1=B5=E5=90=8E=E7=AB=AF=E7=9B=B4=E5=87=BA?= =?UTF-8?q?=EF=BC=8C=E9=A1=BE=E5=AE=A2=E5=8A=A8=E7=BA=BF=E9=9B=B6=20Flutte?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o --- backend/internal/handler/public.go | 200 ++------ backend/internal/handler/public_page.go | 426 ++++++++++++++++++ backend/internal/handler/public_page_test.go | 156 +++++++ .../handler/templates/public_product.html | 101 +++++ .../handler/templates/public_shop.html | 87 ++++ backend/internal/router/router.go | 2 + 6 files changed, 801 insertions(+), 171 deletions(-) create mode 100644 backend/internal/handler/public_page.go create mode 100644 backend/internal/handler/public_page_test.go create mode 100644 backend/internal/handler/templates/public_product.html create mode 100644 backend/internal/handler/templates/public_shop.html diff --git a/backend/internal/handler/public.go b/backend/internal/handler/public.go index f2f1c6d..2acff46 100644 --- a/backend/internal/handler/public.go +++ b/backend/internal/handler/public.go @@ -5,15 +5,12 @@ import ( "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" ) @@ -32,75 +29,42 @@ func NewPublicHandler(db *gorm.DB) *PublicHandler { } // GetProduct GET /api/v1/public/products/:public_id (no auth) +// 数据装配走 loadPublicProduct(与 SSR 商品页共用,公开字段口径单一实现)。 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 { + d, err := h.loadPublicProduct(c.Param("public_id")) + if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } + product := d.Product - // Fetch shop public info - var shop model.Shop shopData := gin.H{} - if err := h.db.Where("id = ?", product.ShopID).First(&shop).Error; err == nil { + if d.HasShop { shopData = gin.H{ - "name": shop.Name, - "code": shop.Code, - "address": shop.Address, - "phone": shop.Phone, - "business_hours": shop.BusinessHours, - "wechat_id": shop.WechatID, + "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, } } - // 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 { + if d.Batch != nil { var pdStr *string - if inv.ProductionDate != nil { - s := inv.ProductionDate.Time.Format("2006-01-02") + if d.Batch.ProductionDate != nil { + s := d.Batch.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, + "batch_no": d.Batch.BatchNo, + "in_stock_date": d.Batch.CreatedAt.Format("2006-01-02"), + "quantity": d.Batch.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, @@ -112,12 +76,12 @@ func (h *PublicHandler) GetProduct(c *gin.Context) { "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, + "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, @@ -237,75 +201,13 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) { pageSize = 20 } - // 仅列「有库存」的商品:JOIN 库存按 product 聚合(数量>0)的子查询。 - stockSub := h.db.Model(&model.Inventory{}). - Select("product_id, SUM(quantity) AS qty"). - Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0", shop.ID). - Group("product_id") - - query := h.db.Model(&model.Product{}). - Joins("JOIN (?) AS stk ON stk.product_id = products.id", stockSub). - Where("products.shop_id = ? AND products.public_id IS NOT NULL AND products.public_id != '' AND products.deleted_at IS NULL", shop.ID) - - var total int64 - if err := query.Count(&total).Error; err != nil { + // 查询装配走 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 } - var products []model.Product - offset := (page - 1) * pageSize - if err := query.Preload("Images"). - Offset(offset). - Limit(pageSize). - Order("products.id DESC"). - Find(&products).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // 取本页商品的在库总量(IN 限定在本页 ≤pageSize 个 id,开销小) - qtyMap := make(map[uint64]float64, len(products)) - if len(products) > 0 { - pageIDs := make([]uint64, len(products)) - for i, p := range products { - pageIDs[i] = p.ID - } - var stockRows []struct { - ProductID uint64 - Qty float64 - } - h.db.Model(&model.Inventory{}). - Select("product_id, SUM(quantity) AS qty"). - Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0 AND product_id IN ?", shop.ID, pageIDs). - Group("product_id"). - Scan(&stockRows) - for _, s := range stockRows { - qtyMap[s.ProductID] = s.Qty - } - } - - 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, - Code: p.Code, - Name: p.Name, - Series: p.Series, - Spec: p.Spec, - Brand: p.Brand, - Unit: p.Unit, - SalePrice: p.SalePrice, - Quantity: qtyMap[p.ID], - Images: imgs, - } - } - c.JSON(http.StatusOK, gin.H{ "data": listData, "total": total, @@ -314,51 +216,7 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) { }) } -// 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)) -} +// ProductPage 已 SSR 化,实现移至 public_page.go(2026-07-07 公开页去 Flutter 化)。 // buildOGTitle 生成商品的分享标题:品牌 + 商品名(品牌已含在名字中时不重复)+ 系列。 func buildOGTitle(product model.Product) string { @@ -403,8 +261,8 @@ func buildProductOG(product model.Product, shopName, publicURL string) string { siteName = shopName + " · " + siteName } - // og:url - pageURL := publicURL + "/app/product/" + product.PublicID + // og:url:指向 SSR 短链(公开页规范入口) + pageURL := publicURL + "/product/" + product.PublicID var sb strings.Builder sb.WriteString("\n") diff --git a/backend/internal/handler/public_page.go b/backend/internal/handler/public_page.go new file mode 100644 index 0000000..a71d99a --- /dev/null +++ b/backend/internal/handler/public_page.go @@ -0,0 +1,426 @@ +package handler + +import ( + "bytes" + "embed" + "fmt" + "html" + "html/template" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/wangjia/jiu/backend/config" + "github.com/wangjia/jiu/backend/internal/model" +) + +// 公开页 SSR(2026-07-07 设计 docs/design/public-page-speedup.html): +// 商品页 /product/:public_id 与店铺页 /shop/:shop_code 由后端直出轻量 HTML(~20KB), +// 顾客扫码/分享动线不再加载 Flutter Web(12.5MB 首包在 3Mbps 带宽下约 30s)。 +// 数据装配与公开 API 共用同一函数(loadPublicProduct / queryShopProducts),字段口径零漂移。 + +//go:embed templates/public_product.html templates/public_shop.html +var pageTemplates embed.FS + +var ( + productPageTpl = template.Must(template.ParseFS(pageTemplates, "templates/public_product.html")) + shopPageTpl = template.Must(template.ParseFS(pageTemplates, "templates/public_shop.html")) +) + +// publicProductData 公开商品数据装配结果——GetProduct(JSON API)与 ProductPage(SSR)共用。 +type publicProductData struct { + Product model.Product + Shop model.Shop + HasShop bool + Batch *model.Inventory + Origin string + ShelfLife string + Storage string + DescTitle string + DescBody string + Keywords []string +} + +// loadPublicProduct 按 public_id 装配公开商品数据(公开字段口径的单一实现)。 +func (h *PublicHandler) loadPublicProduct(publicID string) (*publicProductData, error) { + 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 { + return nil, err + } + + d := &publicProductData{Product: product, ShelfLife: defaultShelfLife, Storage: defaultStorage} + + if err := h.db.Where("id = ?", product.ShopID).First(&d.Shop).Error; err == nil { + d.HasShop = true + } + + // 最近一条有量库存作为批次信息 + var inv model.Inventory + 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 { + d.Batch = &inv + } + + if product.Origin != nil { + d.Origin = product.Origin.Name + } + if product.ShelfLife != nil { + d.ShelfLife = product.ShelfLife.Name + } + if product.Storage != nil { + d.Storage = product.Storage.Name + } + d.DescTitle, d.DescBody, d.Keywords = buildDescription(product) + return d, nil +} + +// queryShopProducts 店铺公开商品列表查询——ListShopProducts(JSON API)与 ShopPage(SSR)共用。 +// keyword 为空时不过滤(API 现状);非空时按 名称/编号/全拼/首字母 模糊匹配(SSR 搜索框)。 +func (h *PublicHandler) queryShopProducts(shopID uint64, page, pageSize int, keyword string) ([]publicProductResp, int64, error) { + // 仅列「有库存」的商品:JOIN 库存按 product 聚合(数量>0)的子查询。 + stockSub := h.db.Model(&model.Inventory{}). + Select("product_id, SUM(quantity) AS qty"). + Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0", shopID). + Group("product_id") + + query := h.db.Model(&model.Product{}). + Joins("JOIN (?) AS stk ON stk.product_id = products.id", stockSub). + Where("products.shop_id = ? AND products.public_id IS NOT NULL AND products.public_id != '' AND products.deleted_at IS NULL", shopID) + + if kw := strings.TrimSpace(keyword); kw != "" { + like := "%" + kw + "%" + query = query.Where( + "products.name LIKE ? OR products.code LIKE ? OR products.name_pinyin LIKE ? OR products.name_initials LIKE ?", + like, like, like, like) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var products []model.Product + if err := query.Preload("Images"). + Offset((page - 1) * pageSize). + Limit(pageSize). + Order("products.id DESC"). + Find(&products).Error; err != nil { + return nil, 0, err + } + + // 取本页商品的在库总量(IN 限定在本页 ≤pageSize 个 id,开销小) + qtyMap := make(map[uint64]float64, len(products)) + if len(products) > 0 { + pageIDs := make([]uint64, len(products)) + for i, p := range products { + pageIDs[i] = p.ID + } + var stockRows []struct { + ProductID uint64 + Qty float64 + } + h.db.Model(&model.Inventory{}). + Select("product_id, SUM(quantity) AS qty"). + Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0 AND product_id IN ?", shopID, pageIDs). + Group("product_id"). + Scan(&stockRows) + for _, s := range stockRows { + qtyMap[s.ProductID] = s.Qty + } + } + + 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, + Code: p.Code, + Name: p.Name, + Series: p.Series, + Spec: p.Spec, + Brand: p.Brand, + Unit: p.Unit, + SalePrice: p.SalePrice, + Quantity: qtyMap[p.ID], + Images: imgs, + } + } + return listData, total, nil +} + +// ── 商品公开页 SSR ── + +type productPageVM struct { + PageTitle string + OGTags template.HTML + Found bool + Name string + Code string + Series string + Spec string + Unit string + Price string + Images []string + Keywords []string + DescTitle string + DescBody string + Origin string + ShelfLife string + Storage string + HasBatch bool + ProdDate string + BatchNo string + InDate string + HasShop bool + ShopName string + ShopCode string + ShopPhone string + ShopAddr string + ShopHours string + ShopWx string + AppURL string +} + +// ProductPage GET /product/:public_id +// 后端直出轻量商品页(含 OG 标签,微信/飞书分享卡片与人看同一页)。 +// 找不到商品时渲染同风格「商品不存在」页(HTTP 200,对爬虫友好的历史行为保持)。 +func (h *PublicHandler) ProductPage(c *gin.Context) { + publicID := c.Param("public_id") + + d, err := h.loadPublicProduct(publicID) + if err != nil { + renderPage(c, productPageTpl, productPageVM{PageTitle: "商品不存在 · 岩美酒库"}) + return + } + + p := d.Product + shopName := "" + if d.HasShop { + shopName = d.Shop.Name + } + + // 页面主标题:品牌前缀(名称未含品牌时)+ 名称;系列走信息行与 chips + name := p.Name + if p.Brand != "" && !strings.Contains(p.Name, p.Brand) { + name = p.Brand + p.Name + } + + vm := productPageVM{ + PageTitle: buildOGTitle(p) + " · " + siteNameFor(shopName), + OGTags: template.HTML(buildProductOG(p, shopName, config.C.Storage.PublicURL)), // #nosec G203 -- buildProductOG 内部对所有值 html.EscapeString + Found: true, + Name: name, + Code: p.Code, + Series: p.Series, + Spec: p.Spec, + Unit: p.Unit, + Price: fmtYuan(p.SalePrice), + Keywords: d.Keywords, + DescTitle: d.DescTitle, + DescBody: d.DescBody, + Origin: d.Origin, + ShelfLife: d.ShelfLife, + Storage: d.Storage, + AppURL: "/app/product/" + p.PublicID, + } + for _, img := range p.Images { + if img.URL != "" { + vm.Images = append(vm.Images, img.URL) + } + } + if d.Batch != nil { + vm.HasBatch = true + if d.Batch.ProductionDate != nil { + vm.ProdDate = d.Batch.ProductionDate.Time.Format("2006-01-02") + } + vm.BatchNo = d.Batch.BatchNo + vm.InDate = d.Batch.CreatedAt.Format("2006-01-02") + } + if d.HasShop { + vm.HasShop = true + vm.ShopName = d.Shop.Name + vm.ShopCode = d.Shop.Code + vm.ShopPhone = d.Shop.Phone + vm.ShopAddr = d.Shop.Address + vm.ShopHours = d.Shop.BusinessHours + vm.ShopWx = d.Shop.WechatID + } + renderPage(c, productPageTpl, vm) +} + +// ── 店铺公开页 SSR ── + +type shopPageItemVM struct { + PublicID string + Name string + Sub string + Code string + Img string + Price string +} + +type shopPageVM struct { + PageTitle string + OGTags template.HTML + Found bool + ShopName string + LogoURL string + Total int64 + Q string + Page int + Items []shopPageItemVM + PrevURL string + NextURL string +} + +// ShopPage GET /shop/:shop_code +// 店铺公开商品列表直出页:搜索(?q=)+ 分页(?page=),商品卡链接到 /product/:public_id。 +func (h *PublicHandler) ShopPage(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 { + renderPage(c, shopPageTpl, shopPageVM{PageTitle: "店铺不存在 · 岩美酒库"}) + return + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + const pageSize = 20 // 与公开 API 上限一致(反爬口径) + q := strings.TrimSpace(c.Query("q")) + + items, total, err := h.queryShopProducts(shop.ID, page, pageSize, q) + if err != nil { + c.String(http.StatusInternalServerError, "查询失败") + return + } + + vm := shopPageVM{ + PageTitle: shop.Name + " · 在售商品 · 岩美酒库", + OGTags: template.HTML(buildShopOG(shop, total, config.C.Storage.PublicURL)), // #nosec G203 -- buildShopOG 内部对所有值 html.EscapeString + Found: true, + ShopName: shop.Name, + LogoURL: shop.LogoURL, + Total: total, + Q: q, + Page: page, + } + for _, it := range items { + sub := make([]string, 0, 2) + if it.Series != "" { + sub = append(sub, it.Series) + } + if it.Spec != "" { + sub = append(sub, it.Spec) + } + img := "" + if len(it.Images) > 0 { + img = it.Images[0].URL + } + name := it.Name + if it.Brand != "" && !strings.Contains(it.Name, it.Brand) { + name = it.Brand + it.Name + } + vm.Items = append(vm.Items, shopPageItemVM{ + PublicID: it.PublicID, + Name: name, + Sub: strings.Join(sub, " · "), + Code: it.Code, + Img: img, + Price: fmtYuan(it.SalePrice), + }) + } + if page > 1 { + vm.PrevURL = shopPageURL(q, page-1) + } + if int64(page*pageSize) < total { + vm.NextURL = shopPageURL(q, page+1) + } + renderPage(c, shopPageTpl, vm) +} + +// buildShopOG 店铺页 OG 标签(值全部转义,同 buildProductOG 约定)。 +func buildShopOG(shop model.Shop, total int64, publicURL string) string { + var sb strings.Builder + sb.WriteString("\n") + sb.WriteString(` ` + "\n") + sb.WriteString(` ` + "\n") + sb.WriteString(` ` + "\n") + sb.WriteString(` ` + "\n") + sb.WriteString(` ` + "\n") + if shop.LogoURL != "" { + sb.WriteString(` ` + "\n") + } + return sb.String() +} + +func shopPageURL(q string, page int) string { + v := url.Values{} + if q != "" { + v.Set("q", q) + } + if page > 1 { + v.Set("page", strconv.Itoa(page)) + } + if enc := v.Encode(); enc != "" { + return "?" + enc + } + return "?" +} + +func siteNameFor(shopName string) string { + if shopName != "" { + return shopName + } + return "岩美酒库" +} + +// fmtYuan 价格显示:0 或负数返回空串(模板隐藏该行);整数不带小数,千分位分组。 +func fmtYuan(v float64) string { + if v <= 0 { + return "" + } + s := strconv.FormatFloat(v, 'f', 2, 64) + s = strings.TrimSuffix(s, ".00") + parts := strings.SplitN(s, ".", 2) + digits := parts[0] + var b strings.Builder + for i, ch := range digits { + if i > 0 && (len(digits)-i)%3 == 0 { + b.WriteByte(',') + } + b.WriteRune(ch) + } + out := "¥" + b.String() + if len(parts) > 1 { + out += "." + parts[1] + } + return out +} + +func renderPage(c *gin.Context, tpl *template.Template, vm interface{}) { + var buf bytes.Buffer + if err := tpl.Execute(&buf, vm); err != nil { + c.String(http.StatusInternalServerError, "render error") + return + } + // 内容含库存批次等动态信息,禁缓存(与旧 OG 注入行为一致,爬虫每次拿最新) + c.Header("Cache-Control", "no-store") + c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes()) +} diff --git a/backend/internal/handler/public_page_test.go b/backend/internal/handler/public_page_test.go new file mode 100644 index 0000000..e5e1b23 --- /dev/null +++ b/backend/internal/handler/public_page_test.go @@ -0,0 +1,156 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/testutil" +) + +// SSR 公开页(2026-07-07 去 Flutter 化):商品页 /product/:id 与店铺页 /shop/:code +// 由后端直出轻量 HTML;断言内容、OG 标签、敏感字段零暴露、搜索与分页。 + +func setupPageRouter(db *gorm.DB) *gin.Engine { + h := NewPublicHandler(db) + r := gin.New() + r.Use(gin.Recovery()) + r.GET("/product/:public_id", h.ProductPage) + r.GET("/shop/:shop_code", h.ShopPage) + return r +} + +func getPage(r *gin.Engine, path string) *httptest.ResponseRecorder { + req := httptest.NewRequest("GET", path, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func TestProductPage_SSR(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "PPG01") + wh := testutil.CreateTestWarehouse(db, shop.ID, "仓") + r := setupPageRouter(db) + + p := testutil.CreateTestProduct(db, shop.ID, "茅台飞天53度") + setPublicID(db, p.ID, "pub-ssr-001") + addInventory(db, shop.ID, wh.ID, p.ID, 6) + + w := getPage(r, "/product/pub-ssr-001") + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "text/html") + assert.Equal(t, "no-store", w.Header().Get("Cache-Control")) + + body := w.Body.String() + // 轻量页:不再是 Flutter 壳 + assert.NotContains(t, body, "flutter_bootstrap") + assert.NotContains(t, body, ``) + // 内容 + assert.Contains(t, body, "茅台飞天53度") + assert.Contains(t, body, shop.Name) + // OG 标签(og:url 指向 SSR 短链) + assert.Contains(t, body, `property="og:title"`) + assert.Contains(t, body, "/product/pub-ssr-001") + // 门店互跳 + App 兜底链接 + assert.Contains(t, body, "/shop/"+shop.Code) + assert.Contains(t, body, "/app/product/pub-ssr-001") + // 敏感字段零暴露(成本/进价永不出现在公开页) + for _, s := range []string{"cost", "purchase_price", "profit"} { + assert.NotContains(t, body, s, "公开页不得出现敏感字段名 %s", s) + } +} + +func TestProductPage_NotFound(t *testing.T) { + db := testutil.SetupTestDB() + r := setupPageRouter(db) + + w := getPage(r, "/product/no-such-id") + // 历史行为保持:HTTP 200 + 友好文案(对爬虫不报错) + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "商品不存在") +} + +func TestShopPage_SSR_SearchAndPaging(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SPG01") + wh := testutil.CreateTestWarehouse(db, shop.ID, "仓") + r := setupPageRouter(db) + + // 25 个有库存商品 → 2 页;外加一个名称可搜索的 + for i := 0; i < 25; i++ { + p := testutil.CreateTestProduct(db, shop.ID, "批量酒"+strings.Repeat("x", i%3)) + setPublicID(db, p.ID, "sp-"+strings.Repeat("a", 1)+string(rune('A'+i%26))+strings.Repeat("b", i/26+1)) + addInventory(db, shop.ID, wh.ID, p.ID, 3) + } + target := testutil.CreateTestProduct(db, shop.ID, "五粮液普五") + setPublicID(db, target.ID, "sp-target") + addInventory(db, shop.ID, wh.ID, target.ID, 2) + + // 第 1 页:20 条 + 下一页链接 + w := getPage(r, "/shop/"+shop.Code) + assert.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + assert.Contains(t, body, shop.Name) + assert.Contains(t, body, "26 件") + assert.Contains(t, body, "page=2") + assert.Equal(t, 20, strings.Count(body, `class="item"`), "第一页应有 20 张商品卡") + assert.NotContains(t, body, "flutter_bootstrap") + + // 第 2 页:6 条 + 上一页链接 + w = getPage(r, "/shop/"+shop.Code+"?page=2") + body = w.Body.String() + assert.Equal(t, 6, strings.Count(body, `class="item"`)) + assert.Contains(t, body, "上一页") + + // 搜索:只命中目标商品,商品卡链接到 SSR 商品页 + w = getPage(r, "/shop/"+shop.Code+"?q="+urlQueryEscape("五粮液")) + body = w.Body.String() + assert.Equal(t, 1, strings.Count(body, `class="item"`)) + assert.Contains(t, body, "/product/sp-target") + + // 搜索无结果:空态文案 + w = getPage(r, "/shop/"+shop.Code+"?q=NOPE") + assert.Contains(t, w.Body.String(), "没有找到") +} + +func TestShopPage_NotFound(t *testing.T) { + db := testutil.SetupTestDB() + r := setupPageRouter(db) + w := getPage(r, "/shop/NOSHOP") + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "店铺不存在") +} + +// API 与 SSR 共用装配后的回归:ListShopProducts 行为不变(不受 keyword 影响) +func TestListShopProducts_UnaffectedByRefactor(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SPG02") + wh := testutil.CreateTestWarehouse(db, shop.ID, "仓") + p := testutil.CreateTestProduct(db, shop.ID, "剑南春") + setPublicID(db, p.ID, "sp2-x") + addInventory(db, shop.ID, wh.ID, p.ID, 1) + r := setupPublicRouter(db) + + w := getPage(r, "/api/v1/public/shops/"+shop.Code+"/products") + assert.Equal(t, http.StatusOK, w.Code) + resp := parseResponse(w) + assert.Equal(t, float64(1), resp["total"].(float64)) +} + +func urlQueryEscape(s string) string { + // 测试用最小转义(仅中文场景) + var b strings.Builder + for _, c := range []byte(s) { + b.WriteString("%") + const hex = "0123456789ABCDEF" + b.WriteByte(hex[c>>4]) + b.WriteByte(hex[c&0xF]) + } + return b.String() +} diff --git a/backend/internal/handler/templates/public_product.html b/backend/internal/handler/templates/public_product.html new file mode 100644 index 0000000..962eb3d --- /dev/null +++ b/backend/internal/handler/templates/public_product.html @@ -0,0 +1,101 @@ + + + + + +{{.PageTitle}}{{.OGTags}} + + + +
+{{if .Found}} + {{if .Images}} + + {{end}} + +
+

{{.Name}}

+ {{if .Keywords}}
{{range .Keywords}}{{.}}{{end}}
{{end}} + {{if .Price}}
{{.Price}}{{if .Unit}} / {{.Unit}}{{end}}
{{end}} +
+ +
✓ 正品保障 · 扫码验真 · 官方渠道供应
+ +
+
商品信息
+ {{if .Code}}
商品编号{{.Code}}
{{end}} + {{if .Series}}
系列{{.Series}}
{{end}} + {{if .Spec}}
规格{{.Spec}}
{{end}} + {{if .Origin}}
产地{{.Origin}}
{{end}} +
保质期{{.ShelfLife}}
+
储存方式{{.Storage}}
+
+ + {{if .HasBatch}} +
+
批次信息
+ {{if .ProdDate}}
生产日期{{.ProdDate}}
{{end}} + {{if .BatchNo}}
批次号{{.BatchNo}}
{{end}} + {{if .InDate}}
入库日期{{.InDate}}
{{end}} +
+ {{end}} + + {{if .DescBody}} +
+
{{.DescTitle}}
+
{{.DescBody}}
+
+ {{end}} + + {{if .HasShop}} +
+
门店信息
+
门店{{if .ShopCode}}{{.ShopName}} ›{{else}}{{.ShopName}}{{end}}
+ {{if .ShopPhone}}{{end}} + {{if .ShopAddr}}
地址{{.ShopAddr}}
{{end}} + {{if .ShopHours}}
营业时间{{.ShopHours}}
{{end}} + {{if .ShopWx}}
微信{{.ShopWx}}
{{end}} + {{if .ShopCode}}{{end}} +
+ {{end}} +{{else}} +
+

商品不存在或已下架

+

请确认链接来源,或联系门店核实。

+
+{{end}} +
岩美酒库 · 正品溯源{{if .AppURL}} · 在 App 中查看{{end}}
+
+ + diff --git a/backend/internal/handler/templates/public_shop.html b/backend/internal/handler/templates/public_shop.html new file mode 100644 index 0000000..30a8717 --- /dev/null +++ b/backend/internal/handler/templates/public_shop.html @@ -0,0 +1,87 @@ + + + + + +{{.PageTitle}}{{.OGTags}} + + + +
+{{if .Found}} +
+ {{if .LogoURL}}{{.ShopName}}{{end}} +
+

{{.ShopName}}

+
在售商品 {{.Total}} 件 · 正品保障
+
+
+ +
+ + +
+ + {{if .Items}} + {{range .Items}} + + {{if .Img}}{{.Name}}{{else}}暂无图{{end}} + + {{.Name}} + {{if .Sub}}{{.Sub}}{{end}} + {{if .Code}}{{.Code}}{{end}} + + {{if .Price}}{{.Price}}{{end}} + + {{end}} +
+ {{if .PrevURL}}‹ 上一页{{else}}‹ 上一页{{end}} + 第 {{.Page}} 页 · 共 {{.Total}} 件 + {{if .NextURL}}下一页 ›{{else}}下一页 ›{{end}} +
+ {{else}} +
+

{{if .Q}}没有找到「{{.Q}}」相关商品{{else}}本店暂无在售商品{{end}}

+ {{if .Q}}

换个关键词试试,或清空搜索查看全部。

{{end}} +
+ {{end}} +{{else}} +
+

店铺不存在

+

请确认链接来源。

+
+{{end}} +
岩美酒库 · 正品溯源
+
+ + diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 80e7d67..a2e8061 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -65,6 +65,8 @@ func Setup(r *gin.Engine, db *gorm.DB) { // 公开商品详情页(注入 OG 标签的 Flutter index.html,供社交分享爬虫读取) r.GET("/product/:public_id", publicReadIP, dqProduct, publicH.ProductPage) + // 店铺公开页 SSR(挂与 shops API 同一套闸:分钟限流 + 日配额) + r.GET("/shop/:shop_code", shopListIP, dqShopList, publicH.ShopPage) v1 := r.Group("/api/v1")