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:
@@ -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.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")
|
||||
|
||||
@@ -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(` <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>
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user