补齐评论提及补全与通知、按作者/板块/仅标题搜索,以及邮箱验证码重置密码;同时修复 Go 提及正则并优化侧栏悬停样式。 Co-authored-by: Cursor <cursoragent@cursor.com>
174 lines
4.0 KiB
Go
174 lines
4.0 KiB
Go
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
|
|
}
|