7a1d8465e5
- model/feedback.go: Feedback(多租户 shop_id,images 以 JSON 存)+ StringSlice 类型 - handler/feedback.go: Submit(认证,shop/user 从 JWT,去规范化 username/shop_code)、 UploadImage(复用 product_image 压缩逻辑,存 /images/feedback/<shop>/)、 List(SuperAdminOnly,分页过滤)、UpdateStatus(new/handled) - router 注册 /feedback、/feedback/images、/admin/feedback;main AutoMigrate;schema.sql 加表 - feedback_test 覆盖提交/校验/权限/列表 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
199 lines
5.3 KiB
Go
199 lines
5.3 KiB
Go
package handler
|
||
|
||
import (
|
||
"fmt"
|
||
"image"
|
||
_ "image/jpeg"
|
||
_ "image/png"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"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 FeedbackHandler struct{ db *gorm.DB }
|
||
|
||
func NewFeedbackHandler(db *gorm.DB) *FeedbackHandler {
|
||
return &FeedbackHandler{db: db}
|
||
}
|
||
|
||
type submitFeedbackRequest struct {
|
||
Type string `json:"type" binding:"required,oneof=bug suggestion"`
|
||
Content string `json:"content"`
|
||
Images []string `json:"images"`
|
||
AppVersion string `json:"app_version"`
|
||
Platform string `json:"platform"`
|
||
}
|
||
|
||
// Submit POST /api/v1/feedback (需登录)
|
||
func (h *FeedbackHandler) Submit(c *gin.Context) {
|
||
shopID := middleware.GetShopID(c)
|
||
userID := middleware.GetUserID(c)
|
||
role := c.GetString(middleware.CtxRole)
|
||
|
||
var req submitFeedbackRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if strings.TrimSpace(req.Content) == "" && len(req.Images) == 0 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写反馈内容或添加图片"})
|
||
return
|
||
}
|
||
if len(req.Images) > 9 {
|
||
req.Images = req.Images[:9]
|
||
}
|
||
|
||
// 去规范化:从 JWT 身份补全 username / shop_code,便于后台查看
|
||
var username string
|
||
var u model.User
|
||
if err := h.db.Select("username").First(&u, userID).Error; err == nil {
|
||
username = u.Username
|
||
}
|
||
var shopCode string
|
||
var shop model.Shop
|
||
if err := h.db.Select("code").First(&shop, shopID).Error; err == nil {
|
||
shopCode = shop.Code
|
||
}
|
||
|
||
fb := model.Feedback{
|
||
ShopID: shopID,
|
||
ShopCode: shopCode,
|
||
UserID: userID,
|
||
Username: username,
|
||
Role: role,
|
||
Type: req.Type,
|
||
Content: strings.TrimSpace(req.Content),
|
||
Images: req.Images,
|
||
AppVersion: req.AppVersion,
|
||
Platform: req.Platform,
|
||
ClientIP: c.ClientIP(),
|
||
Status: "new",
|
||
}
|
||
if err := h.db.Create(&fb).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "submit failed"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusCreated, gin.H{"id": fb.ID})
|
||
}
|
||
|
||
// UploadImage POST /api/v1/feedback/images (需登录)
|
||
func (h *FeedbackHandler) UploadImage(c *gin.Context) {
|
||
shopID := middleware.GetShopID(c)
|
||
|
||
// 限制 5MB
|
||
if err := c.Request.ParseMultipartForm(5 << 20); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "文件超过 5MB 限制"})
|
||
return
|
||
}
|
||
file, _, err := c.Request.FormFile("file")
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "请上传文件(field: file)"})
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
img, _, err := image.Decode(file)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 JPEG/PNG 图片"})
|
||
return
|
||
}
|
||
resized := imaging.Fit(img, 1600, 1600, imaging.Lanczos)
|
||
|
||
filename := uuid.New().String() + ".jpg"
|
||
subdir := fmt.Sprintf("%s/feedback/%d", config.C.Storage.UploadDir, shopID)
|
||
if err := os.MkdirAll(subdir, 0755); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "存储目录创建失败"})
|
||
return
|
||
}
|
||
fullPath := filepath.Join(subdir, filename)
|
||
if err := imaging.Save(resized, fullPath, imaging.JPEGQuality(85)); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "图片保存失败"})
|
||
return
|
||
}
|
||
|
||
relURL := fmt.Sprintf("/images/feedback/%d/%s", shopID, filename)
|
||
c.JSON(http.StatusCreated, gin.H{"url": relURL})
|
||
}
|
||
|
||
// List GET /api/v1/admin/feedback (仅超级管理员)
|
||
func (h *FeedbackHandler) List(c *gin.Context) {
|
||
var q struct {
|
||
Type string `form:"type"`
|
||
Status string `form:"status"`
|
||
ShopID uint64 `form:"shop_id"`
|
||
Page int `form:"page,default=1"`
|
||
PageSize int `form:"page_size,default=50"`
|
||
}
|
||
if err := c.ShouldBindQuery(&q); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if q.PageSize <= 0 || q.PageSize > 200 {
|
||
q.PageSize = 50
|
||
}
|
||
if q.Page <= 0 {
|
||
q.Page = 1
|
||
}
|
||
|
||
db := h.db.Model(&model.Feedback{})
|
||
if q.Type != "" {
|
||
db = db.Where("type = ?", q.Type)
|
||
}
|
||
if q.Status != "" {
|
||
db = db.Where("status = ?", q.Status)
|
||
}
|
||
if q.ShopID != 0 {
|
||
db = db.Where("shop_id = ?", q.ShopID)
|
||
}
|
||
|
||
var total int64
|
||
db.Count(&total)
|
||
|
||
var list []model.Feedback
|
||
db.Order("id DESC").Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize).Find(&list)
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"data": list,
|
||
"total": total,
|
||
"page": q.Page,
|
||
"page_size": q.PageSize,
|
||
})
|
||
}
|
||
|
||
// UpdateStatus PATCH /api/v1/admin/feedback/:id (仅超级管理员)
|
||
func (h *FeedbackHandler) UpdateStatus(c *gin.Context) {
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||
return
|
||
}
|
||
var req struct {
|
||
Status string `json:"status" binding:"required,oneof=new handled"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
res := h.db.Model(&model.Feedback{}).Where("id = ?", id).Update("status", req.Status)
|
||
if res.Error != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": res.Error.Error()})
|
||
return
|
||
}
|
||
if res.RowsAffected == 0 {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"message": "updated"})
|
||
}
|