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)
- // 同时替换
标签,微信/飞书等平台有时优先读 而非 og:title
- // 用正则替换避免缩进空格导致字符串不匹配
- titleRe := regexp.MustCompile(`[^<]*`)
- 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}}
+
+ {{range $i, $img := .Images}}
+

+ {{end}}
+
+ {{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 .ShopPhone}}
{{end}}
+ {{if .ShopAddr}}
地址{{.ShopAddr}}
{{end}}
+ {{if .ShopHours}}
营业时间{{.ShopHours}}
{{end}}
+ {{if .ShopWx}}
微信{{.ShopWx}}
{{end}}
+ {{if .ShopCode}}
{{end}}
+
+ {{end}}
+{{else}}
+
+
商品不存在或已下架
+
请确认链接来源,或联系门店核实。
+
+{{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}}
+
+
+
+
+
+
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")