Files
license-manager/backend/pkg/utils/sms.go
T

355 lines
11 KiB
Go

package utils
import (
"context"
"errors"
"fmt"
"math/rand"
"time"
"license-manager/internal/config"
"license-manager/pkg/cache"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20170525/v5/client"
util "github.com/alibabacloud-go/tea-utils/v2/service"
"github.com/alibabacloud-go/tea/tea"
)
// 短信相关错误定义
var (
ErrSMSCodeNotExist = errors.New("sms_code_not_exist")
ErrSMSCodeExpired = errors.New("sms_code_expired")
ErrSMSRateLimited = errors.New("sms_rate_limited")
ErrSMSSendFailed = errors.New("sms_send_failed")
ErrSMSCodeIncorrect = errors.New("sms_code_incorrect")
)
// 默认短信模板常量
const (
DefaultTemplateRegister = "SMS_330275014"
DefaultTemplateResetPwd = "SMS_330275015"
DefaultTemplateLogin = "SMS_330275016"
DefaultTemplateCurrentPhone = "SMS_330275017" // 当前手机号验证码
DefaultTemplateNewPhone = "SMS_330275018" // 新手机号验证码
)
// 缓存键常量
const (
SMSCodeKeyPrefix = "sms:code"
SMSLimitKeyPrefix = "sms:limit"
SMSCodeExpireTime = 5 * time.Minute
SMSLimitTimeWindow = time.Hour
SMSLimitMaxCount = 25
)
// SMSService 短信服务接口
type SMSService interface {
SendVerificationCode(ctx context.Context, phone, phoneCountryCode, templateCode string) error
VerifyCode(ctx context.Context, phone, phoneCountryCode, code string) (bool, error)
IsRateLimited(ctx context.Context, phone, phoneCountryCode string) bool
SendRegisterCode(ctx context.Context, phone, phoneCountryCode string) error
SendResetPwdCode(ctx context.Context, phone, phoneCountryCode string) error
SendLoginCode(ctx context.Context, phone, phoneCountryCode string) error
SendCurrentPhoneCode(ctx context.Context, phone, phoneCountryCode string) error
SendNewPhoneCode(ctx context.Context, phone, phoneCountryCode string) error
}
// smsService 短信服务实现
type smsService struct {
config *config.SMSConfig
templates *config.SMSTemplates
cache cache.Cache
logger interface{} // 支持不同类型的logger
}
// NewSMSService 创建SMS服务实例
func NewSMSService(cfg *config.SMSConfig, cache cache.Cache, logger interface{}) (SMSService, error) {
if !cfg.Enabled {
return &disabledSMSService{}, nil
}
// 验证配置有效性
if cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" {
return nil, fmt.Errorf("SMS service configuration is invalid: AccessKeyID and AccessKeySecret are required")
}
if cfg.SignName == "" {
return nil, fmt.Errorf("SMS service configuration is invalid: SignName is required")
}
return &smsService{
config: cfg,
templates: &cfg.Templates,
cache: cache,
logger: logger,
}, nil
}
// disabledSMSService 禁用的SMS服务
type disabledSMSService struct{}
func (s *disabledSMSService) SendVerificationCode(ctx context.Context, phone, phoneCountryCode, templateCode string) error {
return fmt.Errorf("SMS service is disabled")
}
func (s *disabledSMSService) VerifyCode(ctx context.Context, phone, phoneCountryCode, code string) (bool, error) {
return false, fmt.Errorf("SMS service is disabled")
}
func (s *disabledSMSService) IsRateLimited(ctx context.Context, phone, phoneCountryCode string) bool {
return true
}
func (s *disabledSMSService) SendRegisterCode(ctx context.Context, phone, phoneCountryCode string) error {
return fmt.Errorf("SMS service is disabled")
}
func (s *disabledSMSService) SendResetPwdCode(ctx context.Context, phone, phoneCountryCode string) error {
return fmt.Errorf("SMS service is disabled")
}
func (s *disabledSMSService) SendLoginCode(ctx context.Context, phone, phoneCountryCode string) error {
return fmt.Errorf("SMS service is disabled")
}
func (s *disabledSMSService) SendCurrentPhoneCode(ctx context.Context, phone, phoneCountryCode string) error {
return fmt.Errorf("SMS service is disabled")
}
func (s *disabledSMSService) SendNewPhoneCode(ctx context.Context, phone, phoneCountryCode string) error {
return fmt.Errorf("SMS service is disabled")
}
// createSMSClient 创建阿里云短信客户端
// TODO: 实现阿里云SMS客户端创建,需要添加依赖:
// github.com/alibabacloud-go/dysmsapi-20170525/v5/client
// github.com/alibabacloud-go/darabonba-openapi/v2/client
// github.com/alibabacloud-go/tea-utils/v2/service
// github.com/alibabacloud-go/tea/tea
func createSMSClient(accessKeyID, accessKeySecret, regionID, endpoint string) (interface{}, error) {
// 暂时返回nil,等待依赖添加
return nil, fmt.Errorf("阿里云SMS客户端未实现,请添加相关依赖")
}
// SendVerificationCode 发送验证码
func (s *smsService) SendVerificationCode(ctx context.Context, phone, phoneCountryCode, templateCode string) error {
// 检查频率限制
if s.IsRateLimited(ctx, phone, phoneCountryCode) {
return ErrSMSRateLimited
}
// 生成6位随机验证码
code := generateVerificationCode()
// 构造完整手机号
fullPhone := phoneCountryCode + phone
if phoneCountryCode == "" {
fullPhone = "+86" + phone
}
// 送短信 - 需要阿里云SMS SDK
err := s.sendSMS(fullPhone, templateCode, code)
if err != nil {
fmt.Printf("Failed to send SMS to %s: %v", fullPhone, err)
// 返回包含详细错误信息的错误,这样上层可以获取具体错误原因
return fmt.Errorf("SMS send failed: %w", err)
}
// 缓存验证码
cacheKey := fmt.Sprintf("%s:%s:%s", SMSCodeKeyPrefix, phoneCountryCode, phone)
err = s.cache.Set(ctx, cacheKey, code, SMSCodeExpireTime)
if err != nil {
fmt.Printf("Failed to cache verification code: %v", err)
}
// 记录发送日志
fmt.Printf("SMS sent successfully to %s with template %s, code: %s", fullPhone, templateCode, code)
return nil
}
// sendSMS 调用阿里云API发送短信
func (s *smsService) sendSMS(phone, templateCode, code string) error {
// 创建阿里云SMS客户端
client, err := s.createSMSClient()
if err != nil {
return fmt.Errorf("failed to create SMS client: %w", err)
}
// 构建发送短信请求
sendSmsRequest := &dysmsapi20170525.SendSmsRequest{
SignName: tea.String(s.config.SignName),
TemplateCode: tea.String(templateCode),
PhoneNumbers: tea.String(phone),
TemplateParam: tea.String(fmt.Sprintf(`{"code":"%s"}`, code)),
}
// 创建运行时选项
runtime := &util.RuntimeOptions{}
// 发送短信
resp, err := client.SendSmsWithOptions(sendSmsRequest, runtime)
if err != nil {
return fmt.Errorf("failed to send SMS: %w", err)
}
// 检查响应结果
if resp.Body == nil {
return fmt.Errorf("SMS response body is nil")
}
// 检查返回码
if tea.StringValue(resp.Body.Code) != "OK" {
return fmt.Errorf("SMS send failed, code: %s, message: %s",
tea.StringValue(resp.Body.Code),
tea.StringValue(resp.Body.Message))
}
// 记录成功发送的日志
fmt.Printf("SMS sent successfully, BizId: %s, Code: %s\n",
tea.StringValue(resp.Body.BizId),
tea.StringValue(resp.Body.Code))
return nil
}
// createSMSClient 创建阿里云SMS客户端
func (s *smsService) createSMSClient() (*dysmsapi20170525.Client, error) {
config := &openapi.Config{
AccessKeyId: tea.String(s.config.AccessKeyID),
AccessKeySecret: tea.String(s.config.AccessKeySecret),
}
// 设置地域和端点
if s.config.RegionID != "" {
config.RegionId = tea.String(s.config.RegionID)
} else {
config.RegionId = tea.String("cn-hangzhou")
}
if s.config.Endpoint != "" {
config.Endpoint = tea.String(s.config.Endpoint)
} else {
config.Endpoint = tea.String("dysmsapi.aliyuncs.com")
}
client, err := dysmsapi20170525.NewClient(config)
if err != nil {
return nil, err
}
return client, nil
}
// VerifyCode 验证验证码
func (s *smsService) VerifyCode(ctx context.Context, phone, phoneCountryCode, code string) (bool, error) {
cacheKey := fmt.Sprintf("%s:%s:%s", SMSCodeKeyPrefix, phoneCountryCode, phone)
storedCode, err := s.cache.Get(ctx, cacheKey)
if err != nil {
if err == cache.ErrCacheMiss {
return false, ErrSMSCodeNotExist
}
return false, err
}
// 验证成功后删除验证码(一次性使用)
if storedCode == code {
s.cache.Del(ctx, cacheKey)
return true, nil
}
return false, ErrSMSCodeIncorrect
}
// IsRateLimited 检查是否被频率限制
func (s *smsService) IsRateLimited(ctx context.Context, phone, phoneCountryCode string) bool {
limitKey := fmt.Sprintf("%s:%s:%s", SMSLimitKeyPrefix, phoneCountryCode, phone)
// 使用Incr实现简单的频率限制
count, err := s.cache.Incr(ctx, limitKey)
if err != nil {
// 缓存错误时允许请求通过(降级策略)
return false
}
// 首次发送,设置过期时间
if count == 1 {
s.cache.Expire(ctx, limitKey, SMSLimitTimeWindow)
}
// 每小时最多5次
return count > SMSLimitMaxCount
}
// generateVerificationCode 生成6位随机验证码
func generateVerificationCode() string {
rand.Seed(time.Now().UnixNano())
code := fmt.Sprintf("%06d", rand.Intn(1000000))
return code
}
// GetTemplateCode 获取模板代码:优先使用配置,否则使用默认值
func (s *smsService) GetTemplateCode(templateType string) string {
switch templateType {
case "register":
if s.templates.Register != "" {
return s.templates.Register
}
return DefaultTemplateRegister
case "reset_pwd":
if s.templates.ResetPwd != "" {
return s.templates.ResetPwd
}
return DefaultTemplateResetPwd
case "login":
if s.templates.Login != "" {
return s.templates.Login
}
return DefaultTemplateLogin
case "current_phone":
if s.templates.CurrentPhone != "" {
return s.templates.CurrentPhone
}
return DefaultTemplateCurrentPhone
case "new_phone":
if s.templates.NewPhone != "" {
return s.templates.NewPhone
}
return DefaultTemplateNewPhone
default:
return ""
}
}
// SendRegisterCode 发送注册验证码
func (s *smsService) SendRegisterCode(ctx context.Context, phone, phoneCountryCode string) error {
templateCode := s.GetTemplateCode("register")
return s.SendVerificationCode(ctx, phone, phoneCountryCode, templateCode)
}
// SendResetPwdCode 发送重置密码验证码
func (s *smsService) SendResetPwdCode(ctx context.Context, phone, phoneCountryCode string) error {
templateCode := s.GetTemplateCode("reset_pwd")
return s.SendVerificationCode(ctx, phone, phoneCountryCode, templateCode)
}
// SendLoginCode 发送登录验证码
func (s *smsService) SendLoginCode(ctx context.Context, phone, phoneCountryCode string) error {
templateCode := s.GetTemplateCode("login")
return s.SendVerificationCode(ctx, phone, phoneCountryCode, templateCode)
}
// SendCurrentPhoneCode 发送当前手机号验证码
func (s *smsService) SendCurrentPhoneCode(ctx context.Context, phone, phoneCountryCode string) error {
templateCode := s.GetTemplateCode("current_phone")
return s.SendVerificationCode(ctx, phone, phoneCountryCode, templateCode)
}
// SendNewPhoneCode 发送新手机号验证码
func (s *smsService) SendNewPhoneCode(ctx context.Context, phone, phoneCountryCode string) error {
templateCode := s.GetTemplateCode("new_phone")
return s.SendVerificationCode(ctx, phone, phoneCountryCode, templateCode)
}