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,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)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package auth JWT 签发/校验 + jti 黑名单。微信 OAuth 在 wechat.go(5B/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) }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 开发期 mock:code 直接映射 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
|
||||
}
|
||||
Reference in New Issue
Block a user