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:
@@ -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"})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user