Files
jiu/backend/internal/handler/public.go
T
wangjia ba127826b2 feat(backend): 新增商品属性字典表(产地/保质期/储存方式/描述文档)
- model/product_attr.go: 新增 4 张字典表 struct(TenantBase 范式)
- model/product.go: Product 增加 4 个可空外键 + belongs-to 关联
- handler/product_attr.go: 4 组 16 个 CRUD handler(shop_id 隔离)
- handler/product.go: FindOrCreate/Update 支持写入 4 个属性 ID
- handler/public.go: Preload 4 表 + 三级回退介绍 + 默认话术常量
- router/router.go: /product-options/{origins,shelf-lives,storages,description-docs} 路由
- main.go: AutoMigrate 注册 4 个新 model
- testutil/setup.go: SQLite 测试库补齐新列与新表

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 06:56:16 +08:00

184 lines
4.8 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 (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/model"
)
// 默认话术(保质期/储存方式空时使用)
const (
defaultShelfLife = "无限期(适饮)"
defaultStorage = "阴凉干燥、避光保存"
)
type PublicHandler struct {
db *gorm.DB
}
func NewPublicHandler(db *gorm.DB) *PublicHandler {
return &PublicHandler{db: db}
}
// GetProduct GET /api/v1/public/products/:public_id (no auth)
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 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
// Fetch shop public info
var shop model.Shop
shopData := gin.H{}
if err := h.db.Where("id = ?", product.ShopID).First(&shop).Error; err == nil {
shopData = gin.H{
"name": shop.Name,
"code": shop.Code,
"address": shop.Address,
"phone": shop.Phone,
"business_hours": shop.BusinessHours,
}
}
// 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 {
var pdStr *string
if inv.ProductionDate != nil {
s := inv.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"),
}
}
// 产地:空串表示无,前端不展示该行
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,
"name": product.Name,
"series": product.Series,
"spec": product.Spec,
"brand": product.Brand,
"unit": product.Unit,
"description": descBody,
"description_title": descTitle,
"description_keywords": descKeywords,
"origin": origin,
"shelf_life": shelfLife,
"storage": storage,
"images": product.Images,
"shop": shopData,
"batch": batchData,
},
})
}
// buildDescription 商品介绍三级回退逻辑
// 1. DescriptionDoc.Content(结构化介绍文档)
// 2. Product.Description(旧自由文本)
// 3. 通用中性兜底(用真实字段拼,不含品牌专属词)
func buildDescription(p model.Product) (title, body string, keywords []string) {
// 1. 描述文档
if p.DescriptionDoc != nil && p.DescriptionDoc.Content != "" {
return p.DescriptionDoc.Title, p.DescriptionDoc.Content, buildKeywords(p)
}
// 2. 旧 Description 自由文本
if p.Description != "" {
return "商品介绍", p.Description, buildKeywords(p)
}
// 3. 通用中性兜底
parts := make([]string, 0, 4)
if p.Brand != "" && p.Name != "" {
parts = append(parts, fmt.Sprintf("%s%s", p.Brand, p.Name))
} else if p.Name != "" {
parts = append(parts, p.Name)
}
if p.Series != "" {
parts = append(parts, fmt.Sprintf("%s系列", p.Series))
}
if p.Spec != "" {
parts = append(parts, fmt.Sprintf("规格%s", p.Spec))
}
intro := strings.Join(parts, "")
if intro != "" {
intro += "。"
}
intro += "本商品由门店正品供应,支持扫码验真,请认准官方渠道。"
return "商品介绍", intro, buildKeywords(p)
}
// buildKeywords 从真实商品属性派生关键词 chips
func buildKeywords(p model.Product) []string {
kws := make([]string, 0, 4)
if p.Brand != "" {
kws = append(kws, p.Brand)
}
if p.Series != "" {
kws = append(kws, p.Series)
}
kws = append(kws, "正品保障", "扫码验真")
return kws
}
// GetRelease GET /api/v1/public/release (no auth)
func (h *PublicHandler) GetRelease(c *gin.Context) {
cfg, err := loadVersionConfig()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"version": "1.0.0",
"release_notes": "",
"download_urls": gin.H{"web": "https://jiu.51yanmei.com/app"},
})
return
}
c.JSON(http.StatusOK, gin.H{
"version": cfg.Version,
"release_notes": cfg.ReleaseNotes,
"download_urls": cfg.DownloadURLs,
})
}