package service import ( "crypto/rand" "errors" "math/big" "strings" "sync" "time" "git.iioio.com/freefire/jiang13-forum/model" ) const ( emailCodeLen = 6 emailCodeTTL = 10 * time.Minute emailCodeCooldown = 60 * time.Second EmailCodePurposeRegister = "register" EmailCodePurposeReset = "reset" ) // EmailCodeLen 邮箱验证码位数(供 API 告知前端) const EmailCodeLen = emailCodeLen type emailCodeEntry struct { code string expiresAt time.Time sentAt time.Time } // EmailCodeService 邮箱验证码(按 purpose 隔离) type EmailCodeService struct { mu sync.Mutex entries map[string]emailCodeEntry mail *MailService } func NewEmailCodeService(mail *MailService) *EmailCodeService { s := &EmailCodeService{ entries: make(map[string]emailCodeEntry), mail: mail, } go s.cleanup() return s } func emailCodeKey(purpose, email string) string { return purpose + ":" + NormalizeEmail(email) } // SendRegisterCode 向邮箱发送注册验证码(邮箱须未注册) func (s *EmailCodeService) SendRegisterCode(email string) error { return s.sendCode(EmailCodePurposeRegister, email) } // SendResetCode 向邮箱发送重置密码验证码(邮箱须已注册;不存在时仍返回成功以防枚举) func (s *EmailCodeService) SendResetCode(email string) error { return s.sendCode(EmailCodePurposeReset, email) } func (s *EmailCodeService) sendCode(purpose, email string) error { email = NormalizeEmail(email) if err := ValidateEmail(email); err != nil { return err } var exist model.User found := model.DB.Where("email = ?", email).First(&exist).Error == nil switch purpose { case EmailCodePurposeRegister: if found { return ErrEmailExists } case EmailCodePurposeReset: if !found { // 防邮箱枚举:假装已发送 return nil } default: return errors.New("无效的验证码用途") } key := emailCodeKey(purpose, email) s.mu.Lock() if prev, ok := s.entries[key]; ok && time.Since(prev.sentAt) < emailCodeCooldown { s.mu.Unlock() return ErrEmailCodeCooldown } s.mu.Unlock() code, err := randomDigits(emailCodeLen) if err != nil { return err } siteName := "姜十三论坛" if s.mail != nil && s.mail.settings != nil { siteName = s.mail.settings.SiteBranding().Name } var subject, textBody, htmlBody string if purpose == EmailCodePurposeReset { subject, textBody, htmlBody = BuildResetCodeMail(siteName, code, int(emailCodeTTL.Minutes())) } else { subject, textBody, htmlBody = BuildRegisterCodeMail(siteName, code, int(emailCodeTTL.Minutes())) } if err := s.mail.SendHTML(email, subject, textBody, htmlBody); err != nil { return err } s.mu.Lock() s.entries[key] = emailCodeEntry{ code: code, expiresAt: time.Now().Add(emailCodeTTL), sentAt: time.Now(), } s.mu.Unlock() return nil } // Verify 校验邮箱验证码(一次性);兼容旧调用 Verify(email, code) 视为注册用途 func (s *EmailCodeService) Verify(email, code string) bool { return s.VerifyPurpose(EmailCodePurposeRegister, email, code) } // VerifyPurpose 按用途校验验证码(一次性) func (s *EmailCodeService) VerifyPurpose(purpose, email, code string) bool { email = NormalizeEmail(email) code = strings.TrimSpace(code) if purpose == "" || email == "" || code == "" { return false } key := emailCodeKey(purpose, email) s.mu.Lock() defer s.mu.Unlock() entry, ok := s.entries[key] if !ok { return false } delete(s.entries, key) if time.Now().After(entry.expiresAt) { return false } return entry.code == code } func (s *EmailCodeService) cleanup() { ticker := time.NewTicker(time.Minute) for range ticker.C { now := time.Now() s.mu.Lock() for key, entry := range s.entries { if now.After(entry.expiresAt) { delete(s.entries, key) } } s.mu.Unlock() } } func randomDigits(n int) (string, error) { var b strings.Builder b.Grow(n) max := big.NewInt(10) for i := 0; i < n; i++ { v, err := rand.Int(rand.Reader, max) if err != nil { return "", err } b.WriteByte(byte('0' + v.Int64())) } return b.String(), nil }