Files
wangjia 40760aa884
ci / server (push) Failing after 14s
ci / design-tokens (push) Failing after 11s
dudu MVP:五端语音输入法初始提交
- 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>
2026-06-12 00:38:37 +08:00

147 lines
4.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/feedbackmultipart,需登录)。
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})
}