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>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
// 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})
|
||||
}
|
||||
Reference in New Issue
Block a user