// 邮箱验证码登录(桌面端第二登录方式): // POST /v1/auth/email/code 发送 6 位验证码(60s 冷却,码 10 分钟有效) // POST /v1/auth/email 校验验证码 → 建号/登录 → JWT // 邮件发送经 Mailer 接口;SMTP 未配置时装配 MockMailer(验证码打日志,供联调)。 package auth import ( "context" "crypto/rand" "crypto/subtle" "errors" "log/slog" "net/http" "strings" "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" ) const ( emailCodeTTL = 10 * time.Minute emailCodeCooldown = 60 * time.Second ) // Mailer 验证码邮件发送。 type Mailer interface { SendCode(ctx context.Context, email, code string) error } // MockMailer 开发期 mock:验证码打日志不真发(⚠️ 上线前必须替换 SMTP 实现)。 type MockMailer struct{} func (MockMailer) SendCode(_ context.Context, email, code string) error { slog.Info("mock mailer: email login code", "email", email, "code", code) return nil } // randCode 6 位数字验证码(crypto/rand)。 func randCode() string { b := make([]byte, 6) _, _ = rand.Read(b) digits := make([]byte, 6) for i, v := range b { digits[i] = '0' + v%10 } return string(digits) } // EmailCode POST /v1/auth/email/code 🔓 func (h *Handlers) EmailCode(c *gin.Context) { var req protocol.AuthEmailCodeRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest)) return } email := strings.ToLower(strings.TrimSpace(req.Email)) // 60s 冷却(SET NX),防轰炸 ok, err := h.RDB.SetNX(c, store.KeyAuthEmailCd(email), 1, emailCodeCooldown).Result() if err != nil { c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal)) return } if !ok { c.JSON(http.StatusTooManyRequests, protocol.NewAPIError(protocol.ErrRateLimited)) return } code := randCode() // ⚠️ 开发期便利:AUTH_EMAIL_DEV_CODE 显式设置时验证码固定(run-dev.sh 设 888888), // 免翻日志。仅开发脚本会设置该变量;生产部署不设即为随机码,启动时若设有醒目告警。 if h.DevCode != "" { code = h.DevCode } if err := h.RDB.Set(c, store.KeyAuthEmail(email), code, emailCodeTTL).Err(); err != nil { c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal)) return } if err := h.Mailer.SendCode(c, email, code); err != nil { slog.Error("send email code failed", "email", email, "err", err) c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal)) return } c.Status(http.StatusNoContent) } // EmailLogin POST /v1/auth/email 🔓 func (h *Handlers) EmailLogin(c *gin.Context) { var req protocol.AuthEmailRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest)) return } email := strings.ToLower(strings.TrimSpace(req.Email)) want, err := h.RDB.Get(c, store.KeyAuthEmail(email)).Result() if err == redis.Nil || subtle.ConstantTimeCompare([]byte(want), []byte(req.Code)) != 1 { c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest)) return } if err != nil && err != redis.Nil { c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal)) return } // 一次性:校验通过即删,防复用 h.RDB.Del(c, store.KeyAuthEmail(email)) user, err := findOrCreateUserByEmail(h.DB, email) 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)}, }) } // findOrCreateUserByEmail 邮箱身份建号/登录(昵称取邮箱前缀)。 func findOrCreateUserByEmail(db *gorm.DB, email string) (*store.User, error) { var ident store.EmailIdentity err := db.Where("email = ?", email).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 } nick := email if i := strings.IndexByte(email, '@'); i > 0 { nick = email[:i] } user := store.User{ID: strings.ReplaceAll(uuid.NewString(), "-", "")[:24], Nickname: nick} err = db.Transaction(func(tx *gorm.DB) error { if err := tx.Create(&user).Error; err != nil { return err } return tx.Create(&store.EmailIdentity{UserID: user.ID, Email: email}).Error }) if err != nil { return nil, err } return &user, nil }