chore: release v1.0.40
Deploy / build-linux-web (push) Successful in 48s
Deploy / build-windows (push) Successful in 1m41s
Deploy / build-macos (push) Successful in 1m22s
Deploy / build-android (push) Successful in 1m9s
Deploy / build-ios (push) Successful in 6s
Deploy / release-deploy (push) Successful in 1m49s
Deploy / build-linux-web (push) Successful in 48s
Deploy / build-windows (push) Successful in 1m41s
Deploy / build-macos (push) Successful in 1m22s
Deploy / build-android (push) Successful in 1m9s
Deploy / build-ios (push) Successful in 6s
Deploy / release-deploy (push) Successful in 1m49s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,7 @@ type StorageConfig struct {
|
||||
UploadDir string `mapstructure:"upload_dir"`
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
PublicURL string `mapstructure:"public_url"` // 商品公开页基础 URL,用于生成二维码
|
||||
WebDir string `mapstructure:"web_dir"` // Flutter web 构建产物目录,用于 OG 标签注入
|
||||
}
|
||||
|
||||
var C Config
|
||||
@@ -66,6 +67,7 @@ func Load() {
|
||||
_ = viper.BindEnv("storage.upload_dir", "STORAGE_UPLOAD_DIR")
|
||||
_ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL")
|
||||
_ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_URL")
|
||||
_ = viper.BindEnv("storage.web_dir", "STORAGE_WEB_DIR")
|
||||
|
||||
// 默认值
|
||||
viper.SetDefault("server.port", "8080")
|
||||
@@ -78,6 +80,7 @@ func Load() {
|
||||
viper.SetDefault("storage.upload_dir", "./uploads/images")
|
||||
viper.SetDefault("storage.base_url", "http://localhost:8080/images")
|
||||
viper.SetDefault("storage.public_url", "http://localhost:8081")
|
||||
viper.SetDefault("storage.web_dir", "./client/build/web")
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
log.Println("[config] no config file found, using defaults and env vars")
|
||||
|
||||
@@ -3,13 +3,16 @@ package handler
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
@@ -274,3 +277,91 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
out := strings.Replace(idxHTML, "</head>", ogTags+"</head>", 1)
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(out))
|
||||
}
|
||||
|
||||
// buildProductOG 构造基础 Open Graph meta 标签字符串。
|
||||
// 所有值经 html.EscapeString 转义,防止 XSS 或破坏 HTML 结构。
|
||||
// 无图片时不输出 og:image 行。
|
||||
func buildProductOG(product model.Product, shopName, publicURL string) string {
|
||||
// og:title:品牌 + 商品名(品牌已含在名字中时不重复)+ 系列
|
||||
title := product.Name
|
||||
if product.Brand != "" && !strings.Contains(product.Name, product.Brand) {
|
||||
title = product.Brand + title
|
||||
}
|
||||
if product.Series != "" {
|
||||
title = title + "(" + product.Series + ")"
|
||||
}
|
||||
|
||||
// og:description:规格(度数/香型/容量)| 门店名正品 · 扫码验真
|
||||
desc := product.Spec
|
||||
suffix := "正品 · 扫码验真"
|
||||
if shopName != "" {
|
||||
suffix = shopName + suffix
|
||||
}
|
||||
if desc != "" {
|
||||
desc = desc + " | " + suffix
|
||||
} else {
|
||||
desc = suffix
|
||||
}
|
||||
|
||||
// og:site_name
|
||||
siteName := "岩美酒库"
|
||||
if shopName != "" {
|
||||
siteName = shopName + " · " + siteName
|
||||
}
|
||||
|
||||
// og:url
|
||||
pageURL := publicURL + "/product/" + product.PublicID
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(` <meta property="og:type" content="website">` + "\n")
|
||||
sb.WriteString(` <meta property="og:site_name" content="` + html.EscapeString(siteName) + `">` + "\n")
|
||||
sb.WriteString(` <meta property="og:url" content="` + html.EscapeString(pageURL) + `">` + "\n")
|
||||
sb.WriteString(` <meta property="og:title" content="` + html.EscapeString(title) + `">` + "\n")
|
||||
sb.WriteString(` <meta property="og:description" content="` + html.EscapeString(desc) + `">` + "\n")
|
||||
// og:image:仅有图片时输出
|
||||
if len(product.Images) > 0 && product.Images[0].URL != "" {
|
||||
imgURL := publicURL + product.Images[0].URL
|
||||
sb.WriteString(` <meta property="og:image" content="` + html.EscapeString(imgURL) + `">` + "\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
// 版本信息(无需认证,用于客户端更新检查)
|
||||
r.GET("/version", handler.GetVersion)
|
||||
|
||||
// 公开商品详情页(注入 OG 标签的 Flutter index.html,供社交分享爬虫读取)
|
||||
r.GET("/product/:public_id", publicH.ProductPage)
|
||||
|
||||
v1 := r.Group("/api/v1")
|
||||
|
||||
// 公开路由(无需登录)
|
||||
|
||||
Reference in New Issue
Block a user