db9afb952c
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
425 lines
12 KiB
Go
425 lines
12 KiB
Go
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
|
||
}
|
||
|
||
// 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())
|
||
}
|