dudu MVP:五端语音输入法初始提交
ci / server (push) Failing after 14s
ci / design-tokens (push) Failing after 11s

- 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:
wangjia
2026-06-12 00:38:37 +08:00
commit 40760aa884
252 changed files with 40789 additions and 0 deletions
+146
View File
@@ -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/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})
}