Files
jiu/backend/internal/handler/product_image.go
T
wangjia 393e227de5 feat: 商品详情页、XLS导入修复、分页选择器、导出功能
后端:
- 新增 product_images 表,支持每商品最多5张图(服务端压缩至1200px/JPEG85%)
- products 表新增 public_id(UUID)、description 字段
- 新增商品详情接口、二维码接口、公开商品接口(无鉴权)
- 修复 XLS 导入:OLE2 magic bytes 检测 + 临时文件解析,兼容 extrame/xls
- 修复商品/名称/系列/规格三张表导入数据为0(LastCol()=0 bug)
- 所有导入接口返回 total/imported/skipped 统计
- config 新增 StorageConfig,支持 STORAGE_* 环境变量覆盖
- 种子数据修复:products 补 public_id、新增 product_images TRUNCATE、schema.sql 表名修正

前端:
- 商品详情页:图片上传/删除、描述内联编辑、二维码弹窗、公开链接复制
- 公开商品页:无鉴权路由 /product/:public_id,Flutter Web SPA
- 商品详情列表(批次追踪)商品名超链接跳转详情页
- 导航「商品管理」改名「商品详情」
- 所有列表表格新增每页条数选择(10/20/50/100)
- 表格列头内嵌筛选(FilterableColumnHeader)
- 导出 Excel 功能(入库/出库/库存/财务/批次/往来单位)
- 网络恢复自动刷新 + 离线缓存展示

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:29:51 +08:00

144 lines
4.0 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"
"image"
_ "image/jpeg"
_ "image/png"
"net/http"
"os"
"path/filepath"
"strconv"
"github.com/disintegration/imaging"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
)
type ProductImageHandler struct {
db *gorm.DB
}
func NewProductImageHandler(db *gorm.DB) *ProductImageHandler {
return &ProductImageHandler{db: db}
}
// Upload POST /api/v1/products/:id/images
func (h *ProductImageHandler) Upload(c *gin.Context) {
shopID := middleware.GetShopID(c)
productID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid product id"})
return
}
// Verify product belongs to shop
var product model.Product
if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", productID, shopID).
First(&product).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "product not found"})
return
}
// Check image count limit
var count int64
h.db.Model(&model.ProductImage{}).Where("product_id = ? AND shop_id = ?", productID, shopID).Count(&count)
if count >= 5 {
c.JSON(http.StatusBadRequest, gin.H{"error": "最多上传 5 张图片"})
return
}
// Parse multipart (1MB limit)
if err := c.Request.ParseMultipartForm(1 << 20); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "文件超过 1MB 限制"})
return
}
file, _, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请上传文件(field: file"})
return
}
defer file.Close()
// Validate image format via decoding
img, _, err := image.Decode(file)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 JPEG/PNG 图片"})
return
}
// Resize if needed (max 1200px on either dimension, preserve aspect ratio)
resized := imaging.Fit(img, 1200, 1200, imaging.Lanczos)
// Prepare output path
filename := uuid.New().String() + ".jpg"
subdir := fmt.Sprintf("%s/products/%d", config.C.Storage.UploadDir, productID)
if err := os.MkdirAll(subdir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "存储目录创建失败"})
return
}
fullPath := filepath.Join(subdir, filename)
// Save as JPEG with quality 85
if err := imaging.Save(resized, fullPath, imaging.JPEGQuality(85)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "图片保存失败"})
return
}
// Relative URL served by Nginx / dev static handler
relURL := fmt.Sprintf("/images/products/%d/%s", productID, filename)
pi := model.ProductImage{
ProductID: productID,
ShopID: shopID,
URL: relURL,
}
if err := h.db.Create(&pi).Error; err != nil {
os.Remove(fullPath)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": pi})
}
// Delete DELETE /api/v1/products/:id/images/:image_id
func (h *ProductImageHandler) Delete(c *gin.Context) {
shopID := middleware.GetShopID(c)
productID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid product id"})
return
}
imageID, err := strconv.ParseUint(c.Param("image_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid image id"})
return
}
var pi model.ProductImage
if err := h.db.Where("id = ? AND product_id = ? AND shop_id = ?", imageID, productID, shopID).
First(&pi).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
// Delete file from disk
filename := filepath.Base(pi.URL)
fullPath := filepath.Join(config.C.Storage.UploadDir, fmt.Sprintf("products/%d/%s", productID, filename))
os.Remove(fullPath)
if err := h.db.Delete(&pi).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}