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
This commit is contained in:
wangjia
2026-07-07 11:02:10 +08:00
parent 656a7459d8
commit 9ea38a9b56
6 changed files with 801 additions and 171 deletions
+29 -171
View File
@@ -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 标签并注入 </head> 前
ogTags := buildProductOG(product, shopName, config.C.Storage.PublicURL)
ogTitle := buildOGTitle(product)
// 同时替换 <title> 标签,微信/飞书等平台有时优先读 <title> 而非 og:title
// 用正则替换避免缩进空格导致字符串不匹配
titleRe := regexp.MustCompile(`<title>[^<]*</title>`)
out := titleRe.ReplaceAllString(idxHTML, "<title>"+html.EscapeString(ogTitle)+"</title>")
out = strings.Replace(out, "</head>", ogTags+"</head>", 1)
// 禁止缓存,确保爬虫每次都能拿到最新 OG 标签
c.Header("Cache-Control", "no-store")
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(out))
}
// ProductPage 已 SSR 化,实现移至 public_page.go2026-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")
+426
View File
@@ -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"
)
// 公开页 SSR2026-07-07 设计 docs/design/public-page-speedup.html):
// 商品页 /product/:public_id 与店铺页 /shop/:shop_code 由后端直出轻量 HTML~20KB),
// 顾客扫码/分享动线不再加载 Flutter Web12.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 公开商品数据装配结果——GetProductJSON API)与 ProductPageSSR)共用。
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 店铺公开商品列表查询——ListShopProductsJSON API)与 ShopPageSSR)共用。
// 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(` <meta property="og:type" content="website">` + "\n")
sb.WriteString(` <meta property="og:site_name" content="岩美酒库">` + "\n")
sb.WriteString(` <meta property="og:url" content="` + html.EscapeString(publicURL+"/shop/"+shop.Code) + `">` + "\n")
sb.WriteString(` <meta property="og:title" content="` + html.EscapeString(shop.Name) + `">` + "\n")
sb.WriteString(` <meta property="og:description" content="` + html.EscapeString(fmt.Sprintf("在售商品 %d 件 · 正品保障", total)) + `">` + "\n")
if shop.LogoURL != "" {
sb.WriteString(` <meta property="og:image" content="` + html.EscapeString(publicURL+shop.LogoURL) + `">` + "\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())
}
@@ -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, `<base href="/app/">`)
// 内容
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()
}
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.PageTitle}}</title>{{.OGTags}}
<style>
:root{--primary:#2563AC;--primary-dark:#154072;--ink:#232934;--muted:#6E7888;
--border:#DCE2EB;--paper:#F5F7FA;--head:#F0F4FF;--ok:#2E8B57;--ok-bg:#E6F3EC;}
*{box-sizing:border-box;margin:0;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
body{background:var(--paper);color:var(--ink);line-height:1.65;font-size:15px;}
.wrap{max-width:640px;margin:0 auto;padding:14px 14px 40px;}
.card{background:#fff;border:1px solid var(--border);border-radius:12px;padding:16px;margin-bottom:12px;}
.gallery{display:flex;gap:8px;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;border-radius:12px;margin-bottom:12px;}
.gallery img{width:100%;max-width:640px;flex:0 0 100%;scroll-snap-align:center;border-radius:12px;object-fit:cover;background:#fff;border:1px solid var(--border);}
h1{font-size:20px;line-height:1.4;margin-bottom:6px;}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0 2px;}
.chip{font-size:12px;padding:2px 10px;border-radius:10px;background:var(--head);color:var(--primary-dark);}
.price{font-size:22px;font-weight:700;color:var(--primary);margin-top:6px;}
.price small{font-size:12px;font-weight:400;color:var(--muted);}
.row{display:flex;justify-content:space-between;gap:12px;padding:9px 0;border-bottom:1px solid #EEF1F5;font-size:14px;}
.row:last-child{border-bottom:none;}
.row span{color:var(--muted);flex:none;}
.row b{font-weight:600;text-align:right;word-break:break-all;}
.sec-t{font-size:13px;font-weight:600;color:var(--primary-dark);margin-bottom:8px;}
.desc{font-size:14px;color:var(--ink);white-space:pre-wrap;}
.verify{display:flex;align-items:center;gap:8px;background:var(--ok-bg);color:var(--ok);border-radius:10px;padding:10px 14px;font-size:13px;font-weight:600;margin-bottom:12px;}
a{color:var(--primary);text-decoration:none;}
.shop-name{font-size:16px;font-weight:700;}
.foot{text-align:center;color:var(--muted);font-size:12px;margin-top:18px;}
.foot a{color:var(--muted);text-decoration:underline;}
.empty{text-align:center;padding:60px 20px;color:var(--muted);}
.empty h1{font-size:18px;color:var(--ink);margin-bottom:8px;}
</style>
</head>
<body>
<div class="wrap">
{{if .Found}}
{{if .Images}}
<div class="gallery">
{{range $i, $img := .Images}}
<img src="{{$img}}" alt="{{$.Name}}" decoding="async"{{if $i}} loading="lazy"{{end}}>
{{end}}
</div>
{{end}}
<div class="card">
<h1>{{.Name}}</h1>
{{if .Keywords}}<div class="chips">{{range .Keywords}}<span class="chip">{{.}}</span>{{end}}</div>{{end}}
{{if .Price}}<div class="price">{{.Price}}{{if .Unit}} <small>/ {{.Unit}}</small>{{end}}</div>{{end}}
</div>
<div class="verify">✓ 正品保障 · 扫码验真 · 官方渠道供应</div>
<div class="card">
<div class="sec-t">商品信息</div>
{{if .Code}}<div class="row"><span>商品编号</span><b>{{.Code}}</b></div>{{end}}
{{if .Series}}<div class="row"><span>系列</span><b>{{.Series}}</b></div>{{end}}
{{if .Spec}}<div class="row"><span>规格</span><b>{{.Spec}}</b></div>{{end}}
{{if .Origin}}<div class="row"><span>产地</span><b>{{.Origin}}</b></div>{{end}}
<div class="row"><span>保质期</span><b>{{.ShelfLife}}</b></div>
<div class="row"><span>储存方式</span><b>{{.Storage}}</b></div>
</div>
{{if .HasBatch}}
<div class="card">
<div class="sec-t">批次信息</div>
{{if .ProdDate}}<div class="row"><span>生产日期</span><b>{{.ProdDate}}</b></div>{{end}}
{{if .BatchNo}}<div class="row"><span>批次号</span><b>{{.BatchNo}}</b></div>{{end}}
{{if .InDate}}<div class="row"><span>入库日期</span><b>{{.InDate}}</b></div>{{end}}
</div>
{{end}}
{{if .DescBody}}
<div class="card">
<div class="sec-t">{{.DescTitle}}</div>
<div class="desc">{{.DescBody}}</div>
</div>
{{end}}
{{if .HasShop}}
<div class="card">
<div class="sec-t">门店信息</div>
<div class="row"><span>门店</span><b>{{if .ShopCode}}<a class="shop-name" href="/shop/{{.ShopCode}}">{{.ShopName}} </a>{{else}}{{.ShopName}}{{end}}</b></div>
{{if .ShopPhone}}<div class="row"><span>电话</span><b><a href="tel:{{.ShopPhone}}">{{.ShopPhone}}</a></b></div>{{end}}
{{if .ShopAddr}}<div class="row"><span>地址</span><b>{{.ShopAddr}}</b></div>{{end}}
{{if .ShopHours}}<div class="row"><span>营业时间</span><b>{{.ShopHours}}</b></div>{{end}}
{{if .ShopWx}}<div class="row"><span>微信</span><b>{{.ShopWx}}</b></div>{{end}}
{{if .ShopCode}}<div class="row"><span></span><b><a href="/shop/{{.ShopCode}}">查看本店更多商品 </a></b></div>{{end}}
</div>
{{end}}
{{else}}
<div class="card empty">
<h1>商品不存在或已下架</h1>
<p>请确认链接来源,或联系门店核实。</p>
</div>
{{end}}
<div class="foot">岩美酒库 · 正品溯源{{if .AppURL}} · <a href="{{.AppURL}}">在 App 中查看</a>{{end}}</div>
</div>
</body>
</html>
@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.PageTitle}}</title>{{.OGTags}}
<style>
:root{--primary:#2563AC;--primary-dark:#154072;--ink:#232934;--muted:#6E7888;
--border:#DCE2EB;--paper:#F5F7FA;--head:#F0F4FF;}
*{box-sizing:border-box;margin:0;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
body{background:var(--paper);color:var(--ink);line-height:1.6;font-size:15px;}
.wrap{max-width:640px;margin:0 auto;padding:14px 14px 40px;}
.head{display:flex;align-items:center;gap:12px;padding:6px 2px 14px;}
.head img{width:44px;height:44px;border-radius:10px;object-fit:cover;border:1px solid var(--border);background:#fff;}
.head h1{font-size:18px;line-height:1.3;}
.head .sub{font-size:12px;color:var(--muted);}
form{display:flex;gap:8px;margin-bottom:12px;}
input[type=search]{flex:1;border:1px solid var(--border);border-radius:10px;padding:9px 12px;font-size:14px;background:#fff;outline:none;}
input[type=search]:focus{border-color:var(--primary);}
button{border:none;background:var(--primary);color:#fff;border-radius:10px;padding:0 18px;font-size:14px;}
.item{display:flex;gap:12px;background:#fff;border:1px solid var(--border);border-radius:12px;padding:12px;margin-bottom:10px;color:var(--ink);}
.item img{width:64px;height:64px;border-radius:8px;object-fit:cover;border:1px solid var(--border);flex:none;background:var(--paper);}
.item .ph{width:64px;height:64px;border-radius:8px;border:1px solid var(--border);flex:none;background:var(--head);display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:11px;}
.item .m{flex:1;min-width:0;}
.item .nm{font-weight:600;font-size:15px;line-height:1.4;}
.item .sb{font-size:12.5px;color:var(--muted);margin-top:2px;}
.item .cd{font-size:12px;color:var(--muted);font-family:ui-monospace,Menlo,monospace;margin-top:2px;}
.item .pr{font-size:15px;font-weight:700;color:var(--primary);flex:none;align-self:center;}
a{text-decoration:none;color:inherit;}
.pager{display:flex;justify-content:space-between;align-items:center;margin-top:16px;font-size:14px;}
.pager a{color:var(--primary);font-weight:600;padding:8px 4px;}
.pager .off{color:var(--border);}
.pager .pg{color:var(--muted);font-size:12.5px;}
.empty{text-align:center;padding:60px 20px;color:var(--muted);background:#fff;border:1px solid var(--border);border-radius:12px;}
.empty h2{font-size:16px;color:var(--ink);margin-bottom:6px;}
.foot{text-align:center;color:var(--muted);font-size:12px;margin-top:18px;}
</style>
</head>
<body>
<div class="wrap">
{{if .Found}}
<div class="head">
{{if .LogoURL}}<img src="{{.LogoURL}}" alt="{{.ShopName}}" decoding="async">{{end}}
<div>
<h1>{{.ShopName}}</h1>
<div class="sub">在售商品 {{.Total}} 件 · 正品保障</div>
</div>
</div>
<form method="get" action="">
<input type="search" name="q" value="{{.Q}}" placeholder="搜索本店商品(名称 / 编号 / 拼音)">
<button type="submit">搜索</button>
</form>
{{if .Items}}
{{range .Items}}
<a class="item" href="/product/{{.PublicID}}">
{{if .Img}}<img src="{{.Img}}" alt="{{.Name}}" loading="lazy" decoding="async">{{else}}<span class="ph">暂无图</span>{{end}}
<span class="m">
<span class="nm">{{.Name}}</span>
{{if .Sub}}<span class="sb" style="display:block">{{.Sub}}</span>{{end}}
{{if .Code}}<span class="cd" style="display:block">{{.Code}}</span>{{end}}
</span>
{{if .Price}}<span class="pr">{{.Price}}</span>{{end}}
</a>
{{end}}
<div class="pager">
{{if .PrevURL}}<a href="{{.PrevURL}}"> 上一页</a>{{else}}<span class="off"> 上一页</span>{{end}}
<span class="pg">第 {{.Page}} 页 · 共 {{.Total}} 件</span>
{{if .NextURL}}<a href="{{.NextURL}}">下一页 </a>{{else}}<span class="off">下一页 </span>{{end}}
</div>
{{else}}
<div class="empty">
<h2>{{if .Q}}没有找到「{{.Q}}」相关商品{{else}}本店暂无在售商品{{end}}</h2>
{{if .Q}}<p>换个关键词试试,或清空搜索查看全部。</p>{{end}}
</div>
{{end}}
{{else}}
<div class="empty">
<h2>店铺不存在</h2>
<p>请确认链接来源。</p>
</div>
{{end}}
<div class="foot">岩美酒库 · 正品溯源</div>
</div>
</body>
</html>
+2
View File
@@ -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")