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
+316
View File
@@ -0,0 +1,316 @@
package httpapi
import (
"bytes"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"dudu/server/internal/auth"
"dudu/server/internal/config"
"dudu/server/internal/quota"
"dudu/server/internal/store"
"dudu/server/pkg/protocol"
)
func newAPI(t *testing.T) (*httptest.Server, *gorm.DB) {
t.Helper()
gin.SetMode(gin.TestMode)
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(store.AllModels()...); err != nil {
t.Fatal(err)
}
if err := store.SeedPacks(db); err != nil {
t.Fatal(err)
}
cfg := config.Config{} // 全部外部依赖走 mock
jwt := auth.NewJWT("test-secret", time.Hour, rdb)
r := gin.New()
Register(r, &Deps{Cfg: cfg, DB: db, RDB: rdb, JWT: jwt, Quota: quota.New(rdb, db)})
srv := httptest.NewServer(r)
t.Cleanup(srv.Close)
return srv, db
}
func postJSON(t *testing.T, url, token string, body any) *http.Response {
t.Helper()
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", url, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
func getJSON(t *testing.T, url, token string, out any) int {
t.Helper()
req, _ := http.NewRequest("GET", url, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if out != nil {
_ = json.NewDecoder(resp.Body).Decode(out)
}
return resp.StatusCode
}
func login(t *testing.T, srv *httptest.Server, code string) string {
t.Helper()
resp := postJSON(t, srv.URL+"/v1/auth/wechat", "", protocol.AuthWechatRequest{Code: code})
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("login failed: %d", resp.StatusCode)
}
var tr protocol.AuthTokenResponse
_ = json.NewDecoder(resp.Body).Decode(&tr)
return tr.Token
}
func TestMobileLoginAndMe(t *testing.T) {
srv, _ := newAPI(t)
token := login(t, srv, "wangjia99")
var me protocol.MeResponse
if code := getJSON(t, srv.URL+"/v1/me", token, &me); code != 200 {
t.Fatalf("me failed: %d", code)
}
if me.AccountState != protocol.AccountTrial || me.BalanceSeconds != 0 {
t.Fatalf("new user should be trial: %+v", me)
}
if !strings.HasSuffix(me.NicknameMasked, "***") {
t.Fatalf("nickname not masked: %s", me.NicknameMasked)
}
// 同 code 再登录 = 同一用户
token2 := login(t, srv, "wangjia99")
var me2 protocol.MeResponse
getJSON(t, srv.URL+"/v1/me", token2, &me2)
if me2.UserID != me.UserID {
t.Fatal("same code should map to same user")
}
// 未带 token 拒绝
if code := getJSON(t, srv.URL+"/v1/me", "", nil); code != 401 {
t.Fatalf("want 401, got %d", code)
}
}
func TestQrLoginFlow(t *testing.T) {
srv, _ := newAPI(t)
resp := postJSON(t, srv.URL+"/v1/auth/qr", "", nil)
var qr protocol.AuthQrResponse
_ = json.NewDecoder(resp.Body).Decode(&qr)
resp.Body.Close()
if qr.State == "" || !strings.Contains(qr.QrURL, qr.State) {
t.Fatalf("bad qr resp: %+v", qr)
}
// pending
var st protocol.AuthQrStatusResponse
getJSON(t, srv.URL+"/v1/auth/qr/"+qr.State, "", &st)
if st.Status != "pending" {
t.Fatalf("want pending, got %s", st.Status)
}
// 手机授权回调
if code := getJSON(t, srv.URL+"/v1/auth/wechat/callback?code=abc&state="+qr.State, "", nil); code != 200 {
t.Fatalf("callback failed: %d", code)
}
// confirmed + token 可用
getJSON(t, srv.URL+"/v1/auth/qr/"+qr.State, "", &st)
if st.Status != "confirmed" || st.Token == "" {
t.Fatalf("want confirmed with token, got %+v", st)
}
if code := getJSON(t, srv.URL+"/v1/me", st.Token, nil); code != 200 {
t.Fatalf("token unusable: %d", code)
}
// 一次性:再次轮询已失效
getJSON(t, srv.URL+"/v1/auth/qr/"+qr.State, "", &st)
if st.Status != "expired" {
t.Fatalf("state should be one-shot, got %s", st.Status)
}
}
func TestOrderAndPayNotifyIdempotent(t *testing.T) {
srv, db := newAPI(t)
token := login(t, srv, "buyer1")
// packs
var packs protocol.PacksResponse
getJSON(t, srv.URL+"/v1/packs", "", &packs)
if len(packs.Packs) != 3 {
t.Fatalf("want 3 packs, got %d", len(packs.Packs))
}
// 下单 500 分钟
resp := postJSON(t, srv.URL+"/v1/orders", token, protocol.CreateOrderRequest{PackID: "pack_500", Channel: "native"})
var order protocol.CreateOrderResponse
_ = json.NewDecoder(resp.Body).Decode(&order)
resp.Body.Close()
if order.OrderID == "" || !strings.Contains(order.CodeURL, order.OrderID) {
t.Fatalf("bad order resp: %+v", order)
}
// 金额不符 → 拒绝入账
bad := postJSON(t, srv.URL+"/v1/pay/notify", "", map[string]any{
"order_id": order.OrderID, "transaction_id": "txn-bad", "amount_cents": 100,
})
bad.Body.Close()
if bad.StatusCode != 500 {
t.Fatalf("amount mismatch should fail, got %d", bad.StatusCode)
}
// 正确回调 → paid + 入账 30000s
notify := map[string]any{"order_id": order.OrderID, "transaction_id": "txn-1", "amount_cents": 3900}
ok := postJSON(t, srv.URL+"/v1/pay/notify", "", notify)
ok.Body.Close()
if ok.StatusCode != 200 {
t.Fatalf("notify failed: %d", ok.StatusCode)
}
var ostat protocol.OrderStatusResponse
getJSON(t, srv.URL+"/v1/orders/"+order.OrderID, token, &ostat)
if ostat.Status != "paid" || ostat.BalanceSeconds != 30000 {
t.Fatalf("want paid 30000s, got %+v", ostat)
}
// 重复回调幂等:余额不变
dup := postJSON(t, srv.URL+"/v1/pay/notify", "", notify)
dup.Body.Close()
var me protocol.MeResponse
getJSON(t, srv.URL+"/v1/me", token, &me)
if me.BalanceSeconds != 30000 || me.AccountState != protocol.AccountOK {
t.Fatalf("idempotency broken: %+v", me)
}
var n int64
db.Model(&store.BalanceLedger{}).Where("reason = ?", store.LedgerPurchase).Count(&n)
if n != 1 {
t.Fatalf("want 1 purchase ledger, got %d", n)
}
}
// minimalPNG 合法 PNG 头部的最小字节序列(仅用于 magic bytes 校验)。
var minimalPNG = []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0}
func postFeedback(t *testing.T, srv *httptest.Server, token, content string, images ...[]byte) *http.Response {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
_ = w.WriteField("content", content)
_ = w.WriteField("diagnostics", `{"app_version":"0.1.0"}`)
for i, img := range images {
fw, _ := w.CreateFormFile("images[]", fmt.Sprintf("s%d.png", i))
_, _ = fw.Write(img)
}
w.Close()
req, _ := http.NewRequest("POST", srv.URL+"/v1/feedback", &buf)
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
func TestFeedback(t *testing.T) {
srv, db := newAPI(t)
token := login(t, srv, "fbuser")
t.Cleanup(func() { _ = removeUploads() })
// 正常提交(1 张合法 PNG
resp := postFeedback(t, srv, token, "浮层有时不消失", minimalPNG)
if resp.StatusCode != 200 {
t.Fatalf("feedback failed: %d", resp.StatusCode)
}
var fr protocol.FeedbackResponse
_ = json.NewDecoder(resp.Body).Decode(&fr)
resp.Body.Close()
var fb store.Feedback
if err := db.First(&fb, "id = ?", fr.FeedbackID).Error; err != nil {
t.Fatal(err)
}
if !strings.Contains(string(fb.Images), fr.FeedbackID) {
t.Fatalf("images keys missing: %s", fb.Images)
}
// 伪图片拒绝
resp = postFeedback(t, srv, token, "假图", []byte("not an image"))
resp.Body.Close()
if resp.StatusCode != 400 {
t.Fatalf("fake image should 400, got %d", resp.StatusCode)
}
// 空内容拒绝
resp = postFeedback(t, srv, token, " ")
resp.Body.Close()
if resp.StatusCode != 400 {
t.Fatalf("empty content should 400, got %d", resp.StatusCode)
}
// 限频:已用 3 次(含失败计数),再提交 7 次到第 10 次 ok,第 11 次 429
for i := 0; i < 7; i++ {
r := postFeedback(t, srv, token, "继续反馈")
r.Body.Close()
if r.StatusCode != 200 {
t.Fatalf("feedback %d failed: %d", i, r.StatusCode)
}
}
r := postFeedback(t, srv, token, "第 11 条")
r.Body.Close()
if r.StatusCode != 429 {
t.Fatalf("11th should 429, got %d", r.StatusCode)
}
}
func removeUploads() error { return nil } // LocalStorage 写入 var/uploads,测试容忍残留
func TestMetricsBatchAndAggregate(t *testing.T) {
srv, db := newAPI(t)
body := protocol.MetricsBatchRequest{
DeviceID: "dev-m1", Platform: "mac", AppVersion: "0.1.0",
Events: []protocol.MetricEvent{
{Event: "asr.first_partial_ms", Props: map[string]any{"ms": 320}, ClientTs: 1},
{Event: "asr.first_partial_ms", Props: map[string]any{"ms": 480}, ClientTs: 2},
{Event: "unknown.event", Props: map[string]any{"x": 1}, ClientTs: 3}, // 白名单外丢弃
},
}
resp := postJSON(t, srv.URL+"/v1/metrics/batch", "", body)
resp.Body.Close()
if resp.StatusCode != 204 {
t.Fatalf("want 204, got %d", resp.StatusCode)
}
// 等异步 writer 落库
var n int64
for i := 0; i < 60; i++ {
db.Model(&store.MetricEvent{}).Count(&n)
if n == 2 {
break
}
time.Sleep(100 * time.Millisecond)
}
if n != 2 {
t.Fatalf("want 2 events stored (whitelist), got %d", n)
}
}
+100
View File
@@ -0,0 +1,100 @@
// Package httpapi 路由装配。各业务模块在各自包内实现 handler,这里只负责挂载,
// 并按配置在真实外部依赖(微信/支付/OSS/ASR)与 mock 之间切换。
package httpapi
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"dudu/server/internal/asr"
"dudu/server/internal/auth"
"dudu/server/internal/billing"
"dudu/server/internal/config"
"dudu/server/internal/feedback"
"dudu/server/internal/gateway"
"dudu/server/internal/quota"
"dudu/server/internal/telemetry"
"dudu/server/internal/user"
)
type Deps struct {
Cfg config.Config
DB *gorm.DB
RDB *redis.Client
JWT *auth.JWT
Quota *quota.Manager
}
// pickProvider 按配置选择 ASR Providergummy 在 6B 实现 + DASHSCOPE_API_KEY 就绪后接入)。
func pickProvider(cfg config.Config) asr.Provider {
switch cfg.ASRProvider {
default:
return asr.NewMock()
}
}
// pickWechat 微信凭证未配置(#2A 申请中)时使用 mock。
func pickWechat(cfg config.Config) auth.WechatClient {
if cfg.WechatWebAppID == "" {
return auth.MockWechat{}
}
return auth.MockWechat{} // TODO(5C-真实): 开放平台实现,凭证就绪后替换
}
// pickPay 商户号未配置(#2B 申请中)时使用 mock。
func pickPay(cfg config.Config) billing.PayClient {
if cfg.WxPayMchID == "" {
return billing.MockPay{}
}
return billing.MockPay{} // TODO(7D-真实): wechatpay-go 实现,商户号就绪后替换
}
// pickStorage OSS 未配置时图片落本地盘。
func pickStorage(cfg config.Config) feedback.Storage {
if cfg.OSSBucket == "" {
return feedback.LocalStorage{Dir: "var/uploads"}
}
return feedback.LocalStorage{Dir: "var/uploads"} // TODO(8C-真实): OSS 实现
}
func Register(r *gin.Engine, d *Deps) {
r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
authH := &auth.Handlers{DB: d.DB, RDB: d.RDB, JWT: d.JWT, Wechat: pickWechat(d.Cfg)}
billH := &billing.Handlers{DB: d.DB, Pay: pickPay(d.Cfg), Quota: d.Quota}
userH := &user.Handlers{DB: d.DB, Quota: d.Quota}
fbH := &feedback.Handlers{DB: d.DB, RDB: d.RDB, Storage: pickStorage(d.Cfg)}
telH := telemetry.New(d.DB, d.RDB)
gwH := &gateway.Handler{Provider: pickProvider(d.Cfg), Quota: d.Quota, RDB: d.RDB}
// 后台任务
bg := context.Background()
billH.StartWatchdog(bg)
d.Quota.StartDailyReconcile(bg)
telH.StartDailyAggregate(bg)
v1 := r.Group("/v1")
// 🔓 无需登录
v1.POST("/auth/qr", authH.CreateQr)
v1.GET("/auth/qr/:state", authH.PollQr)
v1.GET("/auth/wechat/callback", authH.QrCallback)
v1.POST("/auth/wechat", authH.MobileLogin)
v1.GET("/packs", billH.Packs)
v1.POST("/pay/notify", billH.Notify)
v1.POST("/metrics/batch", telH.Batch)
v1.GET("/app/latest", userH.AppLatest)
// 需登录
authed := v1.Group("", d.JWT.Middleware())
authed.POST("/auth/logout", authH.Logout)
authed.GET("/me", userH.Me)
authed.POST("/orders", billH.CreateOrder)
authed.GET("/orders/:id", billH.OrderStatus)
authed.POST("/feedback", fbH.Submit)
authed.GET("/asr/stream", gwH.HandleWS)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 B