// Package feedback 用户反馈:文字 + 图片(≤3 张,magic bytes 校验)+ 诊断信息。 // Storage 抽象对象存储:生产为阿里云 OSS,开发期 LocalStorage 落本地盘。 // 设计见 doc/backend-architecture.html 3.5。 package feedback import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/redis/go-redis/v9" "gorm.io/datatypes" "gorm.io/gorm" "dudu/server/internal/auth" "dudu/server/internal/store" "dudu/server/pkg/protocol" ) type Storage interface { Save(ctx context.Context, key string, data []byte, contentType string) error } // LocalStorage 开发期实现:写入本地目录(生产替换为 OSS 实现)。 type LocalStorage struct{ Dir string } func (s LocalStorage) Save(_ context.Context, key string, data []byte, _ string) error { path := filepath.Join(s.Dir, key) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } return os.WriteFile(path, data, 0o644) } // sniffImage magic bytes 验真实格式(伪造扩展名拒绝)。 func sniffImage(data []byte) (ext, contentType string, ok bool) { switch { case bytes.HasPrefix(data, []byte{0xFF, 0xD8, 0xFF}): return "jpg", "image/jpeg", true case bytes.HasPrefix(data, []byte{0x89, 'P', 'N', 'G'}): return "png", "image/png", true case len(data) > 12 && bytes.Equal(data[0:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")): return "webp", "image/webp", true } return "", "", false } type Handlers struct { DB *gorm.DB RDB *redis.Client Storage Storage } // Submit POST /v1/feedback(multipart,需登录)。 func (h *Handlers) Submit(c *gin.Context) { uid := auth.UserID(c) // 限频:每用户 10 条/天 day := store.Day(time.Now()) n, err := store.IncrDailyCounter(c, h.RDB, store.KeyRateFb(uid, day)) if err != nil { c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal)) return } if n > protocol.FeedbackDailyLimit { c.JSON(http.StatusTooManyRequests, protocol.NewAPIError(protocol.ErrFeedbackRateLimited)) return } content := strings.TrimSpace(c.PostForm("content")) if l := len([]rune(content)); l == 0 || l > protocol.FeedbackMaxContentLen { c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest)) return } form, err := c.MultipartForm() if err != nil { c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest)) return } files := form.File["images[]"] if len(files) == 0 { files = form.File["images"] } if len(files) > protocol.FeedbackMaxImages { c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest)) return } id := "fb" + strings.ReplaceAll(uuid.NewString(), "-", "")[:22] var keys []string for i, fh := range files { if fh.Size > protocol.FeedbackMaxImageBytes { c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest)) return } f, err := fh.Open() if err != nil { c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest)) return } data, err := io.ReadAll(io.LimitReader(f, protocol.FeedbackMaxImageBytes+1)) f.Close() if err != nil || len(data) > protocol.FeedbackMaxImageBytes { c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest)) return } ext, ct, ok := sniffImage(data) if !ok { c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest)) return } key := fmt.Sprintf("fb/%s/%d.%s", id, i+1, ext) if err := h.Storage.Save(c, key, data, ct); err != nil { c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal)) return } keys = append(keys, key) } imagesJSON, _ := json.Marshal(keys) var diagJSON datatypes.JSON if d := c.PostForm("diagnostics"); d != "" && len(d) < 64<<10 && json.Valid([]byte(d)) { diagJSON = datatypes.JSON(d) } fb := store.Feedback{ ID: id, UserID: uid, Content: content, Images: datatypes.JSON(imagesJSON), Diagnostics: diagJSON, Platform: c.GetHeader("X-Platform"), AppVersion: c.GetHeader("X-App-Version"), Status: store.FeedbackNew, } if err := h.DB.Create(&fb).Error; err != nil { c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal)) return } c.JSON(http.StatusOK, protocol.FeedbackResponse{FeedbackID: id}) }