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
+138
View File
@@ -0,0 +1,138 @@
package auth
import (
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"dudu/server/internal/store"
"dudu/server/pkg/protocol"
)
// Handlers 认证路由:扫码(5B)、微信 OAuth(5C)、logout。
type Handlers struct {
DB *gorm.DB
RDB *redis.Client
JWT *JWT
Wechat WechatClient
// QrAuthURL 二维码内容模板(真实环境为微信开放平台授权页,%s 为 state)
QrAuthURL string
}
const qrTTL = 2 * time.Minute
type qrState struct {
Status string `json:"status"` // pending | confirmed
Token string `json:"token,omitempty"`
UserID string `json:"user_id,omitempty"`
Nick string `json:"nick,omitempty"`
}
// CreateQr POST /v1/auth/qr 🔓
func (h *Handlers) CreateQr(c *gin.Context) {
state := uuid.NewString()
b, _ := json.Marshal(qrState{Status: "pending"})
if err := h.RDB.Set(c, store.KeyAuthQr(state), b, qrTTL).Err(); err != nil {
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
return
}
url := h.QrAuthURL
if url == "" {
url = "https://dudu.app/auth/qr/" // mock 占位,接入开放平台后替换
}
c.JSON(http.StatusOK, protocol.AuthQrResponse{State: state, QrURL: url + state})
}
// PollQr GET /v1/auth/qr/:state 🔓(桌面端 1s 轮询)
func (h *Handlers) PollQr(c *gin.Context) {
b, err := h.RDB.Get(c, store.KeyAuthQr(c.Param("state"))).Bytes()
if err == redis.Nil {
c.JSON(http.StatusOK, protocol.AuthQrStatusResponse{Status: "expired"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
return
}
var s qrState
_ = json.Unmarshal(b, &s)
resp := protocol.AuthQrStatusResponse{Status: s.Status}
if s.Status == "confirmed" {
resp.Token = s.Token
resp.User = &protocol.UserInfo{UserID: s.UserID, NicknameMasked: s.Nick}
// 一次性:下发后即删,防复用
h.RDB.Del(c, store.KeyAuthQr(c.Param("state")))
}
c.JSON(http.StatusOK, resp)
}
// QrCallback GET /v1/auth/wechat/callback?code=&state= 🔓
// 手机微信内授权后回调:code 换身份 → 建号 → 标记 state confirmed。
func (h *Handlers) QrCallback(c *gin.Context) {
code, state := c.Query("code"), c.Query("state")
key := store.KeyAuthQr(state)
if n, _ := h.RDB.Exists(c, key).Result(); n == 0 {
c.String(http.StatusBadRequest, "二维码已过期,请回到 dudu 重新获取")
return
}
info, err := h.Wechat.ExchangeCode(c, code, "web")
if err != nil {
c.String(http.StatusBadRequest, "微信授权失败,请重试")
return
}
user, err := FindOrCreateUser(h.DB, info, "web")
if err != nil {
c.String(http.StatusInternalServerError, "服务繁忙,请重试")
return
}
token, err := h.JWT.Sign(user.ID)
if err != nil {
c.String(http.StatusInternalServerError, "服务繁忙,请重试")
return
}
b, _ := json.Marshal(qrState{
Status: "confirmed", Token: token, UserID: user.ID, Nick: MaskNickname(user.Nickname),
})
_ = h.RDB.Set(c, key, b, qrTTL).Err()
c.String(http.StatusOK, "登录成功,回到 dudu 继续")
}
// MobileLogin POST /v1/auth/wechat 🔓(移动端 OpenSDK code
func (h *Handlers) MobileLogin(c *gin.Context) {
var req protocol.AuthWechatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest))
return
}
info, err := h.Wechat.ExchangeCode(c, req.Code, "mobile")
if err != nil {
c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest))
return
}
user, err := FindOrCreateUser(h.DB, info, "mobile")
if err != nil {
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
return
}
token, err := h.JWT.Sign(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
return
}
c.JSON(http.StatusOK, protocol.AuthTokenResponse{
Token: token,
User: protocol.UserInfo{UserID: user.ID, NicknameMasked: MaskNickname(user.Nickname)},
})
}
// Logout POST /v1/auth/logout(需登录)
func (h *Handlers) Logout(c *gin.Context) {
jti := c.GetString(CtxJTI)
_ = h.JWT.Revoke(c, jti, time.Now().Add(8*24*time.Hour)) // 覆盖最长 TTL
c.Status(http.StatusNoContent)
}
+95
View File
@@ -0,0 +1,95 @@
// Package auth JWT 签发/校验 + jti 黑名单。微信 OAuth 在 wechat.go5B/5C)。
package auth
import (
"context"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"dudu/server/internal/store"
"dudu/server/pkg/protocol"
)
type JWT struct {
secret []byte
ttl time.Duration
rdb *redis.Client
}
func NewJWT(secret string, ttl time.Duration, rdb *redis.Client) *JWT {
return &JWT{secret: []byte(secret), ttl: ttl, rdb: rdb}
}
type Claims struct {
jwt.RegisteredClaims
}
func (j *JWT) Sign(userID string) (string, error) {
now := time.Now()
claims := Claims{RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
ID: uuid.NewString(),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(j.ttl)),
}}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(j.secret)
}
// Parse 校验签名、过期与黑名单,返回 userID 与 jti。
func (j *JWT) Parse(ctx context.Context, token string) (userID, jti string, err error) {
var claims Claims
_, err = jwt.ParseWithClaims(token, &claims, func(t *jwt.Token) (any, error) {
return j.secret, nil
}, jwt.WithValidMethods([]string{"HS256"}))
if err != nil {
return "", "", err
}
if j.rdb != nil {
if n, _ := j.rdb.Exists(ctx, store.KeyJwtBlock(claims.ID)).Result(); n == 1 {
return "", "", jwt.ErrTokenExpired
}
}
return claims.Subject, claims.ID, nil
}
// Revoke 将 jti 拉黑至 token 自然过期(logout / 踢出)。
func (j *JWT) Revoke(ctx context.Context, jti string, expiresAt time.Time) error {
ttl := time.Until(expiresAt)
if ttl <= 0 {
return nil
}
return j.rdb.Set(ctx, store.KeyJwtBlock(jti), 1, ttl).Err()
}
const (
CtxUserID = "auth.user_id"
CtxJTI = "auth.jti"
)
// Middleware gin 鉴权中间件:Authorization: Bearer <JWT>。
func (j *JWT) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, protocol.NewAPIError(protocol.ErrUnauthorized))
return
}
uid, jti, err := j.Parse(c.Request.Context(), token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, protocol.NewAPIError(protocol.ErrUnauthorized))
return
}
c.Set(CtxUserID, uid)
c.Set(CtxJTI, jti)
c.Next()
}
}
// UserID 从 gin 上下文取当前用户。
func UserID(c *gin.Context) string { return c.GetString(CtxUserID) }
+39
View File
@@ -0,0 +1,39 @@
package auth
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
func TestSignParseRevoke(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
j := NewJWT("test-secret", time.Hour, rdb)
ctx := context.Background()
token, err := j.Sign("u1")
if err != nil {
t.Fatal(err)
}
uid, jti, err := j.Parse(ctx, token)
if err != nil || uid != "u1" || jti == "" {
t.Fatalf("parse failed: uid=%s jti=%s err=%v", uid, jti, err)
}
// 拉黑后解析失败
if err := j.Revoke(ctx, jti, time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
if _, _, err := j.Parse(ctx, token); err == nil {
t.Fatal("expect parse failure after revoke")
}
// 篡改签名失败
if _, _, err := j.Parse(ctx, token+"x"); err == nil {
t.Fatal("expect parse failure on tampered token")
}
}
+103
View File
@@ -0,0 +1,103 @@
package auth
import (
"context"
"errors"
"strings"
"github.com/google/uuid"
"gorm.io/gorm"
"dudu/server/internal/store"
)
// WechatIdentityInfo 微信授权换取的身份。
type WechatIdentityInfo struct {
OpenID string
UnionID string
Nickname string
Avatar string
}
// WechatClient 微信 OAuth 客户端抽象:真实实现走开放平台 API(2A 凭证就绪后接入),
// 开发期用 MockWechat。
type WechatClient interface {
// ExchangeCode code 换身份;appType: web | mobile
ExchangeCode(ctx context.Context, code, appType string) (WechatIdentityInfo, error)
}
// MockWechat 开发期 mockcode 直接映射 openid(同 code 幂等同一用户),
// unionid = openid 加前缀,昵称取 code 前 8 字符。
type MockWechat struct{}
func (MockWechat) ExchangeCode(_ context.Context, code, _ string) (WechatIdentityInfo, error) {
if code == "" {
return WechatIdentityInfo{}, errors.New("empty code")
}
nick := code
if len(nick) > 8 {
nick = nick[:8]
}
return WechatIdentityInfo{
OpenID: "mock-open-" + code,
UnionID: "mock-union-" + code,
Nickname: nick,
}, nil
}
// MaskNickname 昵称脱敏:"wangjia" → "wang***";中文取首字 + ***。
func MaskNickname(n string) string {
r := []rune(strings.TrimSpace(n))
if len(r) == 0 {
return "用户***"
}
keep := 4
if r[0] > 0x2E80 { // CJK 起始附近,中文名只保留首字
keep = 1
}
if len(r) < keep {
keep = len(r)
}
return string(r[:keep]) + "***"
}
// FindOrCreateUser unionid 优先关联(同一用户多端 openid 归一),其次 openid。
func FindOrCreateUser(db *gorm.DB, info WechatIdentityInfo, appType string) (*store.User, error) {
var ident store.WechatIdentity
err := db.Where("open_id = ?", info.OpenID).First(&ident).Error
if err == nil {
var u store.User
return &u, db.First(&u, "id = ?", ident.UserID).Error
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
var user store.User
err = db.Transaction(func(tx *gorm.DB) error {
// unionid 已存在 → 关联到既有用户
if info.UnionID != "" {
var other store.WechatIdentity
if err := tx.Where("union_id = ?", info.UnionID).First(&other).Error; err == nil {
if err := tx.First(&user, "id = ?", other.UserID).Error; err != nil {
return err
}
return tx.Create(&store.WechatIdentity{
UserID: user.ID, OpenID: info.OpenID, UnionID: info.UnionID, AppType: appType,
}).Error
}
}
// 新用户
user = store.User{
ID: strings.ReplaceAll(uuid.NewString(), "-", "")[:24],
Nickname: info.Nickname, AvatarURL: info.Avatar,
}
if err := tx.Create(&user).Error; err != nil {
return err
}
return tx.Create(&store.WechatIdentity{
UserID: user.ID, OpenID: info.OpenID, UnionID: info.UnionID, AppType: appType,
}).Error
})
return &user, err
}