40760aa884
- server:Go 网关(WS 流式识别中继/计费配额/微信登录支付 mock/反馈/埋点),gummy provider 已真实联调 - desktop:Tauri 2(全局快捷键 push-to-talk/浮层/托盘/设置/登录购买/反馈/首启引导) - android:Compose 主 App + IME(键盘内录音直传) - ios:App + 键盘扩展(1A spike 实证键盘内不可录音,走 deep link 听写) - design/design-pipeline:设计系统 + token 导出 iOS/Android 主题 - doc:前后端设计文档(HTML);web:官网宣传页;todo:任务看板 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
147 lines
4.3 KiB
Go
147 lines
4.3 KiB
Go
// 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})
|
||
}
|