Files
backend/internal/service/email_code_service.go
lan 4d8f2ec997 Initial backend repository commit.
Set up project files and add .gitignore to exclude local build/runtime artifacts.

Made-with: Cursor
2026-03-09 21:28:58 +08:00

235 lines
7.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"math/big"
"strings"
"time"
"carrot_bbs/internal/cache"
"carrot_bbs/internal/pkg/utils"
)
const (
verifyCodeTTL = 10 * time.Minute
verifyCodeRateLimitTTL = 60 * time.Second
)
const (
CodePurposeRegister = "register"
CodePurposePasswordReset = "password_reset"
CodePurposeEmailVerify = "email_verify"
CodePurposeChangePassword = "change_password"
)
type verificationCodePayload struct {
Code string `json:"code"`
Purpose string `json:"purpose"`
Email string `json:"email"`
ExpiresAt int64 `json:"expires_at"`
}
type EmailCodeService interface {
SendCode(ctx context.Context, purpose, email string) error
VerifyCode(purpose, email, code string) error
}
type emailCodeServiceImpl struct {
emailService EmailService
cache cache.Cache
}
func NewEmailCodeService(emailService EmailService, cacheBackend cache.Cache) EmailCodeService {
if cacheBackend == nil {
cacheBackend = cache.GetCache()
}
return &emailCodeServiceImpl{
emailService: emailService,
cache: cacheBackend,
}
}
func verificationCodeCacheKey(purpose, email string) string {
return fmt.Sprintf("auth:verify_code:%s:%s", purpose, strings.ToLower(strings.TrimSpace(email)))
}
func verificationCodeRateLimitKey(purpose, email string) string {
return fmt.Sprintf("auth:verify_code_rate_limit:%s:%s", purpose, strings.ToLower(strings.TrimSpace(email)))
}
func generateNumericCode(length int) (string, error) {
if length <= 0 {
return "", fmt.Errorf("invalid code length")
}
max := big.NewInt(10)
result := make([]byte, length)
for i := 0; i < length; i++ {
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
result[i] = byte('0' + n.Int64())
}
return string(result), nil
}
func (s *emailCodeServiceImpl) SendCode(ctx context.Context, purpose, email string) error {
if strings.TrimSpace(email) == "" || !utils.ValidateEmail(email) {
return ErrInvalidEmail
}
if s.emailService == nil || !s.emailService.IsEnabled() {
return ErrEmailServiceUnavailable
}
if s.cache == nil {
return ErrVerificationCodeUnavailable
}
rateLimitKey := verificationCodeRateLimitKey(purpose, email)
if s.cache.Exists(rateLimitKey) {
return ErrVerificationCodeTooFrequent
}
code, err := generateNumericCode(6)
if err != nil {
return fmt.Errorf("generate verification code failed: %w", err)
}
payload := verificationCodePayload{
Code: code,
Purpose: purpose,
Email: strings.ToLower(strings.TrimSpace(email)),
ExpiresAt: time.Now().Add(verifyCodeTTL).Unix(),
}
cacheKey := verificationCodeCacheKey(purpose, email)
s.cache.Set(cacheKey, payload, verifyCodeTTL)
s.cache.Set(rateLimitKey, "1", verifyCodeRateLimitTTL)
subject, sceneText := verificationEmailMeta(purpose)
textBody := fmt.Sprintf("【%s】验证码%s\n有效期10分钟\n请勿将验证码泄露给他人。", sceneText, code)
htmlBody := buildVerificationEmailHTML(sceneText, code)
if err := s.emailService.Send(ctx, SendEmailRequest{
To: []string{email},
Subject: subject,
TextBody: textBody,
HTMLBody: htmlBody,
}); err != nil {
s.cache.Delete(cacheKey)
return fmt.Errorf("send verification email failed: %w", err)
}
return nil
}
func (s *emailCodeServiceImpl) VerifyCode(purpose, email, code string) error {
if strings.TrimSpace(email) == "" || strings.TrimSpace(code) == "" {
return ErrVerificationCodeInvalid
}
if s.cache == nil {
return ErrVerificationCodeUnavailable
}
cacheKey := verificationCodeCacheKey(purpose, email)
raw, ok := s.cache.Get(cacheKey)
if !ok {
return ErrVerificationCodeExpired
}
var payload verificationCodePayload
switch v := raw.(type) {
case string:
if err := json.Unmarshal([]byte(v), &payload); err != nil {
return ErrVerificationCodeInvalid
}
case []byte:
if err := json.Unmarshal(v, &payload); err != nil {
return ErrVerificationCodeInvalid
}
case verificationCodePayload:
payload = v
default:
data, err := json.Marshal(v)
if err != nil {
return ErrVerificationCodeInvalid
}
if err := json.Unmarshal(data, &payload); err != nil {
return ErrVerificationCodeInvalid
}
}
if payload.Purpose != purpose || payload.Email != strings.ToLower(strings.TrimSpace(email)) {
return ErrVerificationCodeInvalid
}
if payload.ExpiresAt > 0 && time.Now().Unix() > payload.ExpiresAt {
s.cache.Delete(cacheKey)
return ErrVerificationCodeExpired
}
if payload.Code != strings.TrimSpace(code) {
return ErrVerificationCodeInvalid
}
s.cache.Delete(cacheKey)
return nil
}
func verificationEmailMeta(purpose string) (subject string, sceneText string) {
switch purpose {
case CodePurposeRegister:
return "Carrot BBS 注册验证码", "注册账号"
case CodePurposePasswordReset:
return "Carrot BBS 找回密码验证码", "找回密码"
case CodePurposeEmailVerify:
return "Carrot BBS 邮箱验证验证码", "验证邮箱"
case CodePurposeChangePassword:
return "Carrot BBS 修改密码验证码", "修改密码"
default:
return "Carrot BBS 验证码", "身份验证"
}
}
func buildVerificationEmailHTML(sceneText, code string) string {
return fmt.Sprintf(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Carrot BBS 验证码</title>
</head>
<body style="margin:0;padding:0;background:#f4f6fb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'PingFang SC','Microsoft YaHei',sans-serif;color:#1f2937;">
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="background:#f4f6fb;padding:24px 12px;">
<tr>
<td align="center">
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="max-width:560px;background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 8px 30px rgba(15,23,42,0.08);">
<tr>
<td style="background:linear-gradient(135deg,#ff6b35,#ff8f66);padding:24px 28px;color:#ffffff;">
<div style="font-size:22px;font-weight:700;line-height:1.2;">Carrot BBS</div>
<div style="margin-top:6px;font-size:14px;opacity:0.95;">%s 验证</div>
</td>
</tr>
<tr>
<td style="padding:28px;">
<p style="margin:0 0 14px;font-size:15px;line-height:1.75;">你好,</p>
<p style="margin:0 0 20px;font-size:15px;line-height:1.75;">你正在进行 <strong>%s</strong> 操作,请使用下方验证码完成验证:</p>
<div style="margin:0 auto 18px;max-width:320px;border:1px dashed #ff8f66;background:#fff8f4;border-radius:12px;padding:14px 12px;text-align:center;">
<div style="font-size:13px;color:#9a3412;letter-spacing:0.5px;">验证码10分钟内有效</div>
<div style="margin-top:8px;font-size:34px;line-height:1;font-weight:800;letter-spacing:8px;color:#ea580c;">%s</div>
</div>
<p style="margin:0 0 8px;font-size:13px;color:#6b7280;line-height:1.7;">如果不是你本人操作,请忽略此邮件,并及时检查账号安全。</p>
<p style="margin:0;font-size:13px;color:#6b7280;line-height:1.7;">请勿向任何人透露验证码,平台不会以任何理由索取验证码。</p>
</td>
</tr>
<tr>
<td style="padding:14px 28px;background:#f8fafc;border-top:1px solid #e5e7eb;color:#94a3b8;font-size:12px;line-height:1.7;">
此邮件由系统自动发送,请勿直接回复。<br/>
© Carrot BBS
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`, sceneText, sceneText, code)
}