Files
jiu/backend/internal/handler/public_page.go
T

425 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"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
}
// 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,
}
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())
}