feat(backend): 意见反馈接口(提交+图片上传+admin列表)

- 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>
This commit is contained in:
wangjia
2026-06-05 21:31:46 +08:00
parent dd09f1a526
commit 7a1d8465e5
6 changed files with 361 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
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"})
}
+72
View File
@@ -0,0 +1,72 @@
package handler
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/testutil"
)
func TestFeedbackHandler_SubmitAndList(t *testing.T) {
db := testutil.SetupTestDB()
require.NoError(t, db.AutoMigrate(&model.Feedback{}))
shop := testutil.CreateTestShop(db, "FB001")
user := testutil.CreateTestUser(db, shop.ID, "fbuser", "pass", "admin")
token := getAuthToken(user.ID, shop.ID, "admin")
fh := NewFeedbackHandler(db)
r := gin.New()
r.Use(gin.Recovery())
api := r.Group("/api/v1")
api.Use(middleware.JWT())
api.POST("/feedback", fh.Submit)
adminG := api.Group("/admin")
adminG.Use(middleware.SuperAdminOnly())
adminG.GET("/feedback", fh.List)
// 1. 正常提交(文字 + 图片)
w := makeRequest(r, "POST", "/api/v1/feedback", token, map[string]interface{}{
"type": "bug",
"content": "打印时卡住了",
"images": []string{"/images/feedback/1/a.jpg"},
})
require.Equal(t, http.StatusCreated, w.Code)
assert.NotZero(t, parseResponse(w)["id"])
// 去规范化字段已写入
var fb model.Feedback
require.NoError(t, db.Order("id DESC").First(&fb).Error)
assert.Equal(t, "fbuser", fb.Username)
assert.Equal(t, "FB001", fb.ShopCode)
assert.Equal(t, "new", fb.Status)
assert.Equal(t, 1, len(fb.Images))
// 2. 内容与图片都为空 → 400
w = makeRequest(r, "POST", "/api/v1/feedback", token, map[string]interface{}{
"type": "suggestion",
})
assert.Equal(t, http.StatusBadRequest, w.Code)
// 3. 非法 type → 400
w = makeRequest(r, "POST", "/api/v1/feedback", token, map[string]interface{}{
"type": "other",
"content": "x",
})
assert.Equal(t, http.StatusBadRequest, w.Code)
// 4. 普通管理员访问 admin 列表 → 403
w = makeRequest(r, "GET", "/api/v1/admin/feedback", token, nil)
assert.Equal(t, http.StatusForbidden, w.Code)
// 5. 超级管理员可列出
superToken := getAuthToken(user.ID, shop.ID, "superadmin")
w = makeRequest(r, "GET", "/api/v1/admin/feedback", superToken, nil)
require.Equal(t, http.StatusOK, w.Code)
assert.GreaterOrEqual(t, parseResponse(w)["total"].(float64), float64(1))
}