Files
user-system/internal/service/email.go
long-agent 5ca3633be4 feat: 系统全面优化 - 设备管理/登录日志导出/性能监控/设置页面
后端:
- 新增全局设备管理 API(DeviceHandler.GetAllDevices)
- 新增登录日志导出功能(LogHandler.ExportLoginLogs, CSV/XLSX)
- 新增设置服务(SettingsService)和设置页面 API
- 设备管理支持多条件筛选(状态/信任状态/关键词)
- 登录日志支持流式导出防 OOM
- 操作日志支持按方法/时间范围搜索
- 主题配置服务(ThemeService)
- 增强监控健康检查(Prometheus metrics + SLO)
- 移除旧 ratelimit.go(已迁移至 robustness)
- 修复 SocialAccount NULL 扫描问题
- 新增 API 契约测试、Handler 测试、Settings 测试

前端:
- 新增管理员设备管理页面(DevicesPage)
- 新增管理员登录日志导出功能
- 新增系统设置页面(SettingsPage)
- 设备管理支持筛选和分页
- 增强 HTTP 响应类型

测试:
- 业务逻辑测试 68 个(含并发 CONC_001~003)
- 规模测试 16 个(P99 百分位统计)
- E2E 测试、集成测试、契约测试
- 性能基准测试、鲁棒性测试

全面测试通过(38 个测试包)
2026-04-07 12:08:16 +08:00

312 lines
9.3 KiB
Go
Raw 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"
cryptorand "crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"log"
"net/url"
"net/smtp"
"strings"
"time"
)
type EmailProvider interface {
SendMail(ctx context.Context, to, subject, htmlBody string) error
}
type SMTPEmailConfig struct {
Host string
Port int
Username string
Password string
FromEmail string
FromName string
TLS bool
}
type SMTPEmailProvider struct {
cfg SMTPEmailConfig
}
func NewSMTPEmailProvider(cfg SMTPEmailConfig) EmailProvider {
return &SMTPEmailProvider{cfg: cfg}
}
func (p *SMTPEmailProvider) SendMail(ctx context.Context, to, subject, htmlBody string) error {
_ = ctx
var authInfo smtp.Auth
if p.cfg.Username != "" || p.cfg.Password != "" {
authInfo = smtp.PlainAuth("", p.cfg.Username, p.cfg.Password, p.cfg.Host)
}
from := p.cfg.FromEmail
if p.cfg.FromName != "" {
from = fmt.Sprintf("%s <%s>", p.cfg.FromName, p.cfg.FromEmail)
}
headers := []string{
fmt.Sprintf("From: %s", from),
fmt.Sprintf("To: %s", to),
fmt.Sprintf("Subject: %s", subject),
"MIME-Version: 1.0",
"Content-Type: text/html; charset=UTF-8",
"",
}
message := strings.Join(headers, "\r\n") + htmlBody
addr := fmt.Sprintf("%s:%d", p.cfg.Host, p.cfg.Port)
return smtp.SendMail(addr, authInfo, p.cfg.FromEmail, []string{to}, []byte(message))
}
type MockEmailProvider struct{}
func (m *MockEmailProvider) SendMail(ctx context.Context, to, subject, htmlBody string) error {
_ = ctx
log.Printf("[email-mock] to=%s subject=%s body_bytes=%d", to, subject, len(htmlBody))
return nil
}
type EmailCodeConfig struct {
CodeTTL time.Duration
ResendCooldown time.Duration
MaxDailyLimit int
SiteURL string
SiteName string
}
func DefaultEmailCodeConfig() EmailCodeConfig {
return EmailCodeConfig{
CodeTTL: 5 * time.Minute,
ResendCooldown: time.Minute,
MaxDailyLimit: 10,
SiteURL: "http://localhost:8080",
SiteName: "User Management System",
}
}
type EmailCodeService struct {
provider EmailProvider
cache cacheInterface
cfg EmailCodeConfig
}
func NewEmailCodeService(provider EmailProvider, cache cacheInterface, cfg EmailCodeConfig) *EmailCodeService {
if cfg.CodeTTL <= 0 {
cfg.CodeTTL = 5 * time.Minute
}
if cfg.ResendCooldown <= 0 {
cfg.ResendCooldown = time.Minute
}
if cfg.MaxDailyLimit <= 0 {
cfg.MaxDailyLimit = 10
}
return &EmailCodeService{
provider: provider,
cache: cache,
cfg: cfg,
}
}
func (s *EmailCodeService) SendEmailCode(ctx context.Context, email, purpose string) error {
cooldownKey := fmt.Sprintf("email_cooldown:%s:%s", purpose, email)
if _, ok := s.cache.Get(ctx, cooldownKey); ok {
return newRateLimitError(fmt.Sprintf("\u64cd\u4f5c\u8fc7\u4e8e\u9891\u7e41\uff0c\u8bf7 %d \u79d2\u540e\u518d\u8bd5", int(s.cfg.ResendCooldown.Seconds())))
}
dailyKey := fmt.Sprintf("email_daily:%s:%s", email, time.Now().Format("2006-01-02"))
var dailyCount int
if value, ok := s.cache.Get(ctx, dailyKey); ok {
if count, ok := intValue(value); ok {
dailyCount = count
}
}
if dailyCount >= s.cfg.MaxDailyLimit {
return newRateLimitError("\u4eca\u65e5\u53d1\u9001\u6b21\u6570\u5df2\u8fbe\u4e0a\u9650\uff0c\u8bf7\u660e\u5929\u518d\u8bd5")
}
code, err := generateEmailCode()
if err != nil {
return err
}
codeKey := fmt.Sprintf("email_code:%s:%s", purpose, email)
if err := s.cache.Set(ctx, codeKey, code, s.cfg.CodeTTL, s.cfg.CodeTTL); err != nil {
return fmt.Errorf("store email code failed: %w", err)
}
if err := s.cache.Set(ctx, cooldownKey, true, s.cfg.ResendCooldown, s.cfg.ResendCooldown); err != nil {
_ = s.cache.Delete(ctx, codeKey)
return fmt.Errorf("store email cooldown failed: %w", err)
}
if err := s.cache.Set(ctx, dailyKey, dailyCount+1, 24*time.Hour, 24*time.Hour); err != nil {
_ = s.cache.Delete(ctx, codeKey)
_ = s.cache.Delete(ctx, cooldownKey)
return fmt.Errorf("store email daily counter failed: %w", err)
}
subject, body := buildEmailCodeContent(purpose, code, s.cfg.SiteName, s.cfg.CodeTTL)
if err := s.provider.SendMail(ctx, email, subject, body); err != nil {
_ = s.cache.Delete(ctx, codeKey)
_ = s.cache.Delete(ctx, cooldownKey)
return fmt.Errorf("email delivery failed: %w", err)
}
return nil
}
func (s *EmailCodeService) VerifyEmailCode(ctx context.Context, email, purpose, code string) error {
if strings.TrimSpace(code) == "" {
return fmt.Errorf("verification code is required")
}
codeKey := fmt.Sprintf("email_code:%s:%s", purpose, email)
value, ok := s.cache.Get(ctx, codeKey)
if !ok {
return fmt.Errorf("verification code expired or missing")
}
storedCode, ok := value.(string)
if !ok || subtle.ConstantTimeCompare([]byte(storedCode), []byte(code)) != 1 {
return fmt.Errorf("verification code is invalid")
}
if err := s.cache.Delete(ctx, codeKey); err != nil {
return fmt.Errorf("consume email code failed: %w", err)
}
return nil
}
type EmailActivationService struct {
provider EmailProvider
cache cacheInterface
tokenTTL time.Duration
siteURL string
siteName string
}
func NewEmailActivationService(provider EmailProvider, cache cacheInterface, siteURL, siteName string) *EmailActivationService {
return &EmailActivationService{
provider: provider,
cache: cache,
tokenTTL: 24 * time.Hour,
siteURL: siteURL,
siteName: siteName,
}
}
func (s *EmailActivationService) SendActivationEmail(ctx context.Context, userID int64, email, username string) error {
tokenBytes := make([]byte, 32)
if _, err := cryptorand.Read(tokenBytes); err != nil {
return fmt.Errorf("generate activation token failed: %w", err)
}
token := hex.EncodeToString(tokenBytes)
cacheKey := fmt.Sprintf("email_activation:%s", token)
if err := s.cache.Set(ctx, cacheKey, userID, s.tokenTTL, s.tokenTTL); err != nil {
return fmt.Errorf("store activation token failed: %w", err)
}
activationURL := buildFrontendActivationURL(s.siteURL, token)
subject := fmt.Sprintf("[%s] Activate Your Account", s.siteName)
body := buildActivationEmailBody(username, activationURL, s.siteName, s.tokenTTL)
return s.provider.SendMail(ctx, email, subject, body)
}
func buildFrontendActivationURL(siteURL, token string) string {
base := strings.TrimRight(strings.TrimSpace(siteURL), "/")
if base == "" {
base = DefaultEmailCodeConfig().SiteURL
}
return fmt.Sprintf("%s/activate-account?token=%s", base, url.QueryEscape(token))
}
func (s *EmailActivationService) ValidateActivationToken(ctx context.Context, token string) (int64, error) {
token = strings.TrimSpace(token)
if token == "" {
return 0, fmt.Errorf("activation token is required")
}
cacheKey := fmt.Sprintf("email_activation:%s", token)
value, ok := s.cache.Get(ctx, cacheKey)
if !ok {
return 0, fmt.Errorf("activation token expired or missing")
}
userID, ok := int64Value(value)
if !ok {
return 0, fmt.Errorf("activation token payload is invalid")
}
if err := s.cache.Delete(ctx, cacheKey); err != nil {
return 0, fmt.Errorf("consume activation token failed: %w", err)
}
return userID, nil
}
func buildEmailCodeContent(purpose, code, siteName string, ttl time.Duration) (subject, body string) {
purposeText := map[string]string{
"login": "login verification",
"register": "registration verification",
"reset": "password reset",
"bind": "binding verification",
}
label := purposeText[purpose]
if label == "" {
label = "identity verification"
}
subject = fmt.Sprintf("[%s] Your %s code: %s", siteName, label, code)
body = fmt.Sprintf(`<!DOCTYPE html>
<html>
<body style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
<h2 style="color:#333;">%s</h2>
<p>Your %s code is:</p>
<div style="background:#f5f5f5;padding:20px;text-align:center;margin:20px 0;border-radius:8px;">
<span style="font-size:36px;font-weight:bold;color:#2563eb;letter-spacing:8px;">%s</span>
</div>
<p>This code expires in <strong>%d minutes</strong>.</p>
<p style="color:#999;font-size:12px;">If you did not request this code, you can ignore this email.</p>
</body>
</html>`, siteName, label, code, int(ttl.Minutes()))
return subject, body
}
func buildActivationEmailBody(username, activationURL, siteName string, ttl time.Duration) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<body style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
<h2 style="color:#333;">Welcome to %s</h2>
<p>Hello <strong>%s</strong>,</p>
<p>Please click the button below to activate your account.</p>
<div style="text-align:center;margin:30px 0;">
<a href="%s"
style="background:#2563eb;color:#fff;padding:14px 32px;text-decoration:none;border-radius:8px;font-size:16px;font-weight:bold;">
Activate Account
</a>
</div>
<p>If the button does not work, copy this link into your browser:</p>
<p style="word-break:break-all;color:#2563eb;">%s</p>
<p>This link expires in <strong>%d hours</strong>.</p>
</body>
</html>`, siteName, username, activationURL, activationURL, int(ttl.Hours()))
}
func generateEmailCode() (string, error) {
// 使用 6 字节随机数提供足够的熵48 位)
buffer := make([]byte, 6)
if _, err := cryptorand.Read(buffer); err != nil {
return "", fmt.Errorf("generate email code failed: %w", err)
}
value := int(buffer[0])<<40 | int(buffer[1])<<32 | int(buffer[2])<<24 |
int(buffer[3])<<16 | int(buffer[4])<<8 | int(buffer[5])
value = value % 1000000
if value < 100000 {
value += 100000
}
return fmt.Sprintf("%06d", value), nil
}