40760aa884
- 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>
139 lines
4.2 KiB
Go
139 lines
4.2 KiB
Go
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)
|
||
}
|