From 7a1d8465e5c1f708331af8e369648001093af302 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 5 Jun 2026 21:31:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=E6=84=8F=E8=A7=81=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=E6=8E=A5=E5=8F=A3=EF=BC=88=E6=8F=90=E4=BA=A4+?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E4=B8=8A=E4=BC=A0+admin=E5=88=97=E8=A1=A8?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model/feedback.go: Feedback(多租户 shop_id,images 以 JSON 存)+ StringSlice 类型 - handler/feedback.go: Submit(认证,shop/user 从 JWT,去规范化 username/shop_code)、 UploadImage(复用 product_image 压缩逻辑,存 /images/feedback//)、 List(SuperAdminOnly,分页过滤)、UpdateStatus(new/handled) - router 注册 /feedback、/feedback/images、/admin/feedback;main AutoMigrate;schema.sql 加表 - feedback_test 覆盖提交/校验/权限/列表 Co-Authored-By: Claude Opus 4.8 --- backend/internal/handler/feedback.go | 198 ++++++++++++++++++++++ backend/internal/handler/feedback_test.go | 72 ++++++++ backend/internal/model/feedback.go | 58 +++++++ backend/internal/router/router.go | 10 ++ backend/main.go | 1 + backend/schema/schema.sql | 22 +++ 6 files changed, 361 insertions(+) create mode 100644 backend/internal/handler/feedback.go create mode 100644 backend/internal/handler/feedback_test.go create mode 100644 backend/internal/model/feedback.go diff --git a/backend/internal/handler/feedback.go b/backend/internal/handler/feedback.go new file mode 100644 index 0000000..6c0f832 --- /dev/null +++ b/backend/internal/handler/feedback.go @@ -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"}) +} diff --git a/backend/internal/handler/feedback_test.go b/backend/internal/handler/feedback_test.go new file mode 100644 index 0000000..72bba70 --- /dev/null +++ b/backend/internal/handler/feedback_test.go @@ -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)) +} diff --git a/backend/internal/model/feedback.go b/backend/internal/model/feedback.go new file mode 100644 index 0000000..f8d71f8 --- /dev/null +++ b/backend/internal/model/feedback.go @@ -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 +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 9032f7b..8d244f2 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) } } } diff --git a/backend/main.go b/backend/main.go index 05a1e99..2f21217 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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) diff --git a/backend/schema/schema.sql b/backend/schema/schema.sql index 532c6b2..a171293 100644 --- a/backend/schema/schema.sql +++ b/backend/schema/schema.sql @@ -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;