Files
user-system/internal/service/sms.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

466 lines
13 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/json"
"fmt"
"log"
"regexp"
"strings"
"time"
aliyunopenapiutil "github.com/alibabacloud-go/darabonba-openapi/v2/utils"
aliyunsms "github.com/alibabacloud-go/dysmsapi-20170525/v5/client"
"github.com/alibabacloud-go/tea/dara"
tccommon "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
tcprofile "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
tcsms "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20210111"
)
var (
validPhonePattern = regexp.MustCompile(`^((\+86|86)?1[3-9]\d{9}|\+[1-9]\d{6,14})$`)
mainlandPhonePattern = regexp.MustCompile(`^1[3-9]\d{9}$`)
mainlandPhone86Pattern = regexp.MustCompile(`^86(1[3-9]\d{9})$`)
mainlandPhone0086Pattern = regexp.MustCompile(`^0086(1[3-9]\d{9})$`)
verificationCodeCharset10 = 1000000
)
// SMSProvider sends one verification code to one phone number.
type SMSProvider interface {
SendVerificationCode(ctx context.Context, phone, code string) error
}
// MockSMSProvider is a test helper and is not wired into the server runtime.
type MockSMSProvider struct{}
func (m *MockSMSProvider) SendVerificationCode(ctx context.Context, phone, code string) error {
_ = ctx
// 安全:不在日志中记录完整验证码,仅显示部分信息用于调试
maskedCode := "****"
if len(code) >= 4 {
maskedCode = strings.Repeat("*", len(code)-4) + code[len(code)-4:]
}
log.Printf("[sms-mock] phone=%s code=%s ttl=5m", phone, maskedCode)
return nil
}
type aliyunSMSClient interface {
SendSms(request *aliyunsms.SendSmsRequest) (*aliyunsms.SendSmsResponse, error)
}
type tencentSMSClient interface {
SendSmsWithContext(ctx context.Context, request *tcsms.SendSmsRequest) (*tcsms.SendSmsResponse, error)
}
type AliyunSMSConfig struct {
AccessKeyID string
AccessKeySecret string
SignName string
TemplateCode string
Endpoint string
RegionID string
CodeParamName string
}
type AliyunSMSProvider struct {
cfg AliyunSMSConfig
client aliyunSMSClient
}
func NewAliyunSMSProvider(cfg AliyunSMSConfig) (SMSProvider, error) {
cfg = normalizeAliyunSMSConfig(cfg)
if cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" || cfg.SignName == "" || cfg.TemplateCode == "" {
return nil, fmt.Errorf("aliyun SMS config is incomplete")
}
client, err := newAliyunSMSClient(cfg)
if err != nil {
return nil, fmt.Errorf("create aliyun SMS client failed: %w", err)
}
return &AliyunSMSProvider{
cfg: cfg,
client: client,
}, nil
}
func newAliyunSMSClient(cfg AliyunSMSConfig) (aliyunSMSClient, error) {
client, err := aliyunsms.NewClient(&aliyunopenapiutil.Config{
AccessKeyId: dara.String(cfg.AccessKeyID),
AccessKeySecret: dara.String(cfg.AccessKeySecret),
Endpoint: stringPointerOrNil(cfg.Endpoint),
RegionId: dara.String(cfg.RegionID),
})
if err != nil {
return nil, err
}
return client, nil
}
func (a *AliyunSMSProvider) SendVerificationCode(ctx context.Context, phone, code string) error {
_ = ctx
templateParam, err := json.Marshal(map[string]string{
a.cfg.CodeParamName: code,
})
if err != nil {
return fmt.Errorf("marshal aliyun SMS template param failed: %w", err)
}
resp, err := a.client.SendSms(
new(aliyunsms.SendSmsRequest).
SetPhoneNumbers(normalizePhoneForSMS(phone)).
SetSignName(a.cfg.SignName).
SetTemplateCode(a.cfg.TemplateCode).
SetTemplateParam(string(templateParam)),
)
if err != nil {
return fmt.Errorf("aliyun SMS request failed: %w", err)
}
if resp == nil || resp.Body == nil {
return fmt.Errorf("aliyun SMS returned empty response")
}
body := resp.Body
if !strings.EqualFold(dara.StringValue(body.Code), "OK") {
return fmt.Errorf(
"aliyun SMS rejected: code=%s message=%s request_id=%s",
valueOrDefault(dara.StringValue(body.Code), "unknown"),
valueOrDefault(dara.StringValue(body.Message), "unknown"),
valueOrDefault(dara.StringValue(body.RequestId), "unknown"),
)
}
return nil
}
type TencentSMSConfig struct {
SecretID string
SecretKey string
AppID string
SignName string
TemplateID string
Region string
Endpoint string
}
type TencentSMSProvider struct {
cfg TencentSMSConfig
client tencentSMSClient
}
func NewTencentSMSProvider(cfg TencentSMSConfig) (SMSProvider, error) {
cfg = normalizeTencentSMSConfig(cfg)
if cfg.SecretID == "" || cfg.SecretKey == "" || cfg.AppID == "" || cfg.SignName == "" || cfg.TemplateID == "" {
return nil, fmt.Errorf("tencent SMS config is incomplete")
}
client, err := newTencentSMSClient(cfg)
if err != nil {
return nil, fmt.Errorf("create tencent SMS client failed: %w", err)
}
return &TencentSMSProvider{
cfg: cfg,
client: client,
}, nil
}
func newTencentSMSClient(cfg TencentSMSConfig) (tencentSMSClient, error) {
clientProfile := tcprofile.NewClientProfile()
clientProfile.HttpProfile.ReqTimeout = 30
if cfg.Endpoint != "" {
clientProfile.HttpProfile.Endpoint = cfg.Endpoint
}
client, err := tcsms.NewClient(
tccommon.NewCredential(cfg.SecretID, cfg.SecretKey),
cfg.Region,
clientProfile,
)
if err != nil {
return nil, err
}
return client, nil
}
func (t *TencentSMSProvider) SendVerificationCode(ctx context.Context, phone, code string) error {
req := tcsms.NewSendSmsRequest()
req.PhoneNumberSet = []*string{tccommon.StringPtr(normalizePhoneForSMS(phone))}
req.SmsSdkAppId = tccommon.StringPtr(t.cfg.AppID)
req.SignName = tccommon.StringPtr(t.cfg.SignName)
req.TemplateId = tccommon.StringPtr(t.cfg.TemplateID)
req.TemplateParamSet = []*string{tccommon.StringPtr(code)}
resp, err := t.client.SendSmsWithContext(ctx, req)
if err != nil {
return fmt.Errorf("tencent SMS request failed: %w", err)
}
if resp == nil || resp.Response == nil {
return fmt.Errorf("tencent SMS returned empty response")
}
if len(resp.Response.SendStatusSet) == 0 {
return fmt.Errorf(
"tencent SMS returned empty status list: request_id=%s",
valueOrDefault(pointerString(resp.Response.RequestId), "unknown"),
)
}
status := resp.Response.SendStatusSet[0]
if !strings.EqualFold(pointerString(status.Code), "Ok") {
return fmt.Errorf(
"tencent SMS rejected: code=%s message=%s request_id=%s",
valueOrDefault(pointerString(status.Code), "unknown"),
valueOrDefault(pointerString(status.Message), "unknown"),
valueOrDefault(pointerString(resp.Response.RequestId), "unknown"),
)
}
return nil
}
type SMSCodeConfig struct {
CodeTTL time.Duration
ResendCooldown time.Duration
MaxDailyLimit int
}
func DefaultSMSCodeConfig() SMSCodeConfig {
return SMSCodeConfig{
CodeTTL: 5 * time.Minute,
ResendCooldown: time.Minute,
MaxDailyLimit: 10,
}
}
type SMSCodeService struct {
provider SMSProvider
cache cacheInterface
cfg SMSCodeConfig
}
type cacheInterface interface {
Get(ctx context.Context, key string) (interface{}, bool)
Set(ctx context.Context, key string, value interface{}, l1TTL, l2TTL time.Duration) error
Delete(ctx context.Context, key string) error
}
func NewSMSCodeService(provider SMSProvider, cacheManager cacheInterface, cfg SMSCodeConfig) *SMSCodeService {
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 &SMSCodeService{
provider: provider,
cache: cacheManager,
cfg: cfg,
}
}
type SendCodeRequest struct {
Phone string `json:"phone" binding:"required"`
Purpose string `json:"purpose"`
Scene string `json:"scene"`
}
type SendCodeResponse struct {
ExpiresIn int `json:"expires_in"`
Cooldown int `json:"cooldown"`
}
func (s *SMSCodeService) SendCode(ctx context.Context, req *SendCodeRequest) (*SendCodeResponse, error) {
if s == nil || s.provider == nil || s.cache == nil {
return nil, fmt.Errorf("sms code service is not configured")
}
if req == nil {
return nil, newValidationError("\u8bf7\u6c42\u4e0d\u80fd\u4e3a\u7a7a")
}
phone := strings.TrimSpace(req.Phone)
if !isValidPhone(phone) {
return nil, newValidationError("\u624b\u673a\u53f7\u7801\u683c\u5f0f\u4e0d\u6b63\u786e")
}
purpose := strings.TrimSpace(req.Purpose)
if purpose == "" {
purpose = strings.TrimSpace(req.Scene)
}
cooldownKey := fmt.Sprintf("sms_cooldown:%s", phone)
if _, ok := s.cache.Get(ctx, cooldownKey); ok {
return nil, newRateLimitError(fmt.Sprintf("\u64cd\u4f5c\u8fc7\u4e8e\u9891\u7e41\uff0c\u8bf7 %d \u79d2\u540e\u518d\u8bd5", int(s.cfg.ResendCooldown.Seconds())))
}
dailyKey := fmt.Sprintf("sms_daily:%s:%s", phone, time.Now().Format("2006-01-02"))
var dailyCount int
if val, ok := s.cache.Get(ctx, dailyKey); ok {
if n, ok := intValue(val); ok {
dailyCount = n
}
}
if dailyCount >= s.cfg.MaxDailyLimit {
return nil, newRateLimitError(fmt.Sprintf("\u4eca\u65e5\u53d1\u9001\u6b21\u6570\u5df2\u8fbe\u4e0a\u9650\uff08%d\u6b21\uff09\uff0c\u8bf7\u660e\u65e5\u518d\u8bd5", s.cfg.MaxDailyLimit))
}
code, err := generateSMSCode()
if err != nil {
return nil, fmt.Errorf("generate sms code failed: %w", err)
}
codeKey := fmt.Sprintf("sms_code:%s:%s", purpose, phone)
if err := s.cache.Set(ctx, codeKey, code, s.cfg.CodeTTL, s.cfg.CodeTTL); err != nil {
return nil, fmt.Errorf("store sms 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 nil, fmt.Errorf("store sms 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 nil, fmt.Errorf("store sms daily counter failed: %w", err)
}
if err := s.provider.SendVerificationCode(ctx, phone, code); err != nil {
_ = s.cache.Delete(ctx, codeKey)
_ = s.cache.Delete(ctx, cooldownKey)
return nil, fmt.Errorf("\u77ed\u4fe1\u53d1\u9001\u5931\u8d25: %w", err)
}
return &SendCodeResponse{
ExpiresIn: int(s.cfg.CodeTTL.Seconds()),
Cooldown: int(s.cfg.ResendCooldown.Seconds()),
}, nil
}
func (s *SMSCodeService) VerifyCode(ctx context.Context, phone, purpose, code string) error {
if s == nil || s.cache == nil {
return fmt.Errorf("sms code service is not configured")
}
if strings.TrimSpace(code) == "" {
return fmt.Errorf("\u9a8c\u8bc1\u7801\u4e0d\u80fd\u4e3a\u7a7a")
}
phone = strings.TrimSpace(phone)
purpose = strings.TrimSpace(purpose)
codeKey := fmt.Sprintf("sms_code:%s:%s", purpose, phone)
val, ok := s.cache.Get(ctx, codeKey)
if !ok {
return fmt.Errorf("\u9a8c\u8bc1\u7801\u5df2\u8fc7\u671f\u6216\u4e0d\u5b58\u5728")
}
stored, ok := val.(string)
if !ok || subtle.ConstantTimeCompare([]byte(stored), []byte(code)) != 1 {
return fmt.Errorf("\u9a8c\u8bc1\u7801\u4e0d\u6b63\u786e")
}
if err := s.cache.Delete(ctx, codeKey); err != nil {
return fmt.Errorf("consume sms code failed: %w", err)
}
return nil
}
func isValidPhone(phone string) bool {
return validPhonePattern.MatchString(strings.TrimSpace(phone))
}
func generateSMSCode() (string, error) {
// 使用 6 字节随机数提供足够的熵48 位)
b := make([]byte, 6)
if _, err := cryptorand.Read(b); err != nil {
return "", err
}
n := int(b[0])<<40 | int(b[1])<<32 | int(b[2])<<24 |
int(b[3])<<16 | int(b[4])<<8 | int(b[5])
if n < 0 {
n = -n
}
n = n % verificationCodeCharset10
if n < 100000 {
n += 100000
}
return fmt.Sprintf("%06d", n), nil
}
func normalizeAliyunSMSConfig(cfg AliyunSMSConfig) AliyunSMSConfig {
cfg.AccessKeyID = strings.TrimSpace(cfg.AccessKeyID)
cfg.AccessKeySecret = strings.TrimSpace(cfg.AccessKeySecret)
cfg.SignName = strings.TrimSpace(cfg.SignName)
cfg.TemplateCode = strings.TrimSpace(cfg.TemplateCode)
cfg.Endpoint = strings.TrimSpace(cfg.Endpoint)
cfg.RegionID = strings.TrimSpace(cfg.RegionID)
cfg.CodeParamName = strings.TrimSpace(cfg.CodeParamName)
if cfg.RegionID == "" {
cfg.RegionID = "cn-hangzhou"
}
if cfg.CodeParamName == "" {
cfg.CodeParamName = "code"
}
return cfg
}
func normalizeTencentSMSConfig(cfg TencentSMSConfig) TencentSMSConfig {
cfg.SecretID = strings.TrimSpace(cfg.SecretID)
cfg.SecretKey = strings.TrimSpace(cfg.SecretKey)
cfg.AppID = strings.TrimSpace(cfg.AppID)
cfg.SignName = strings.TrimSpace(cfg.SignName)
cfg.TemplateID = strings.TrimSpace(cfg.TemplateID)
cfg.Region = strings.TrimSpace(cfg.Region)
cfg.Endpoint = strings.TrimSpace(cfg.Endpoint)
if cfg.Region == "" {
cfg.Region = "ap-guangzhou"
}
return cfg
}
func normalizePhoneForSMS(phone string) string {
phone = strings.TrimSpace(phone)
switch {
case mainlandPhonePattern.MatchString(phone):
return "+86" + phone
case mainlandPhone86Pattern.MatchString(phone):
return "+" + phone
case mainlandPhone0086Pattern.MatchString(phone):
return "+86" + mainlandPhone0086Pattern.ReplaceAllString(phone, "$1")
default:
return phone
}
}
func stringPointerOrNil(value string) *string {
if value == "" {
return nil
}
return dara.String(value)
}
func pointerString(value *string) string {
if value == nil {
return ""
}
return *value
}
func valueOrDefault(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}