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))
}
+58
View File
@@ -0,0 +1,58 @@
package model
import (
"database/sql/driver"
"encoding/json"
"fmt"
"time"
)
// StringSlice 以 JSON 数组形式存储的字符串切片(用于图片 URL 列表)
type StringSlice []string
func (s StringSlice) Value() (driver.Value, error) {
if s == nil {
return "[]", nil
}
b, err := json.Marshal(s)
return string(b), err
}
func (s *StringSlice) Scan(value interface{}) error {
if value == nil {
*s = nil
return nil
}
var b []byte
switch v := value.(type) {
case string:
b = []byte(v)
case []byte:
b = v
default:
return fmt.Errorf("cannot scan %T into StringSlice", value)
}
if len(b) == 0 {
*s = nil
return nil
}
return json.Unmarshal(b, s)
}
// Feedback 用户意见反馈(bug / 功能建议):文字 + 附图。多租户按 ShopID 隔离。
type Feedback struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
CreatedAt time.Time `json:"created_at"`
ShopID uint64 `gorm:"index;not null" json:"shop_id"`
ShopCode string `gorm:"size:50" json:"shop_code"`
UserID uint64 `gorm:"index" json:"user_id"`
Username string `gorm:"size:50" json:"username"`
Role string `gorm:"size:20" json:"role"`
Type string `gorm:"size:20;not null;index" json:"type"` // bug / suggestion
Content string `gorm:"type:text" json:"content"`
Images StringSlice `gorm:"type:json" json:"images"`
AppVersion string `gorm:"size:30" json:"app_version"`
Platform string `gorm:"size:20" json:"platform"`
ClientIP string `gorm:"size:60" json:"client_ip"`
Status string `gorm:"size:20;not null;default:new;index" json:"status"` // new / handled
}
+10
View File
@@ -34,6 +34,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
adminH := handler.NewAdminHandler(db)
shopH := handler.NewShopHandler(db)
errorReportH := handler.NewErrorReportHandler(db)
feedbackH := handler.NewFeedbackHandler(db)
// 健康检查(无需认证,用于前端连通性探测)
r.GET("/health", func(c *gin.Context) {
@@ -171,6 +172,13 @@ func Setup(r *gin.Engine, db *gorm.DB) {
shop.POST("/logo", middleware.AdminOnly(), shopH.UploadLogo)
}
// 意见反馈(文字 + 附图,直接提交后台)
feedback := api.Group("/feedback")
{
feedback.POST("", feedbackH.Submit)
feedback.POST("/images", feedbackH.UploadImage)
}
// 编号规则
numberRules := api.Group("/number-rules")
{
@@ -217,6 +225,8 @@ func Setup(r *gin.Engine, db *gorm.DB) {
{
superAdmin.POST("/clear-data", adminH.ClearData)
superAdmin.GET("/errors", errorReportH.List)
superAdmin.GET("/feedback", feedbackH.List)
superAdmin.PATCH("/feedback/:id", feedbackH.UpdateStatus)
}
}
}
+1
View File
@@ -109,6 +109,7 @@ func autoMigrate(db *gorm.DB) {
&model.ProductSpecOption{},
&model.ProductImage{},
&model.ErrorReport{},
&model.Feedback{},
)
if err != nil {
log.Fatalf("auto migrate failed: %v", err)
+22
View File
@@ -446,4 +446,26 @@ CREATE TABLE IF NOT EXISTS `product_spec_options` (
KEY `idx_deleted_at` (`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品规格选项';
CREATE TABLE IF NOT EXISTS `feedbacks` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`shop_id` BIGINT UNSIGNED NOT NULL,
`shop_code` VARCHAR(50) DEFAULT NULL,
`user_id` BIGINT UNSIGNED DEFAULT NULL,
`username` VARCHAR(50) DEFAULT NULL,
`role` VARCHAR(20) DEFAULT NULL,
`type` VARCHAR(20) NOT NULL COMMENT 'bug / suggestion',
`content` TEXT,
`images` JSON,
`app_version` VARCHAR(30) DEFAULT NULL,
`platform` VARCHAR(20) DEFAULT NULL,
`client_ip` VARCHAR(60) DEFAULT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'new',
PRIMARY KEY (`id`),
KEY `idx_shop_id` (`shop_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_type` (`type`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户意见反馈';
SET FOREIGN_KEY_CHECKS = 1;