package sms import ( "context" "crypto/rand" "fmt" "math/big" "strings" "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" sms "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20210111" "go.uber.org/zap" "hyapi-server/internal/config" ) // TencentSMSService 腾讯云短信(与 bdqr-server 接入方式一致) type TencentSMSService struct { client *sms.Client cfg config.SMSConfig logger *zap.Logger } // NewTencentSMSService 创建腾讯云短信客户端 func NewTencentSMSService(cfg config.SMSConfig, logger *zap.Logger) (*TencentSMSService, error) { tc := cfg.TencentCloud if tc.SecretId == "" || tc.SecretKey == "" { return nil, fmt.Errorf("腾讯云短信未配置 secret_id / secret_key") } credential := common.NewCredential(tc.SecretId, tc.SecretKey) cpf := profile.NewClientProfile() cpf.HttpProfile.ReqMethod = "POST" cpf.HttpProfile.ReqTimeout = 10 cpf.HttpProfile.Endpoint = "sms.tencentcloudapi.com" if tc.Endpoint != "" { cpf.HttpProfile.Endpoint = tc.Endpoint } region := tc.Region if region == "" { region = "ap-guangzhou" } client, err := sms.NewClient(credential, region, cpf) if err != nil { return nil, fmt.Errorf("创建腾讯云短信客户端失败: %w", err) } return &TencentSMSService{ client: client, cfg: cfg, logger: logger, }, nil } func normalizeTencentPhone(phone string) string { if strings.HasPrefix(phone, "+86") { return phone } return "+86" + phone } // SendVerificationCode 发送验证码(模板参数为单个验证码,与 bdqr 一致) func (s *TencentSMSService) SendVerificationCode(ctx context.Context, phone string, code string) error { tc := s.cfg.TencentCloud request := &sms.SendSmsRequest{} request.SmsSdkAppId = common.StringPtr(tc.SmsSdkAppId) request.SignName = common.StringPtr(tc.SignName) request.TemplateId = common.StringPtr(tc.TemplateID) request.TemplateParamSet = common.StringPtrs([]string{code}) request.PhoneNumberSet = common.StringPtrs([]string{normalizeTencentPhone(phone)}) response, err := s.client.SendSms(request) if err != nil { s.logger.Error("腾讯云短信发送失败", zap.String("phone", phone), zap.Error(err)) return fmt.Errorf("短信发送失败: %w", err) } if response.Response == nil || len(response.Response.SendStatusSet) == 0 { return fmt.Errorf("腾讯云短信返回空响应") } st := response.Response.SendStatusSet[0] if st.Code == nil || *st.Code != "Ok" { msg := "" if st.Message != nil { msg = *st.Message } s.logger.Error("腾讯云短信业务失败", zap.String("phone", phone), zap.String("message", msg)) return fmt.Errorf("短信发送失败: %s", msg) } s.logger.Info("腾讯云短信发送成功", zap.String("phone", phone), zap.String("serial_no", safeStrPtr(st.SerialNo))) return nil } // SendBalanceAlert 发送余额类预警。低余额与欠费使用不同模板(见 low_balance_template_id / arrears_template_id), // 若未分别配置则回退 balance_alert_template_id。除验证码外,腾讯云短信按无变量模板发送。 func (s *TencentSMSService) SendBalanceAlert(ctx context.Context, phone string, balance float64, threshold float64, alertType string, enterpriseName ...string) error { tc := s.cfg.TencentCloud tplID := resolveTencentBalanceTemplateID(tc, alertType) if tplID == "" { return fmt.Errorf("腾讯云余额类短信模板未配置(请设置 sms.tencent_cloud.low_balance_template_id 与 arrears_template_id,或回退 balance_alert_template_id)") } request := &sms.SendSmsRequest{} request.SmsSdkAppId = common.StringPtr(tc.SmsSdkAppId) request.SignName = common.StringPtr(tc.SignName) request.TemplateId = common.StringPtr(tplID) request.PhoneNumberSet = common.StringPtrs([]string{normalizeTencentPhone(phone)}) response, err := s.client.SendSms(request) if err != nil { s.logger.Error("腾讯云余额预警短信失败", zap.String("phone", phone), zap.String("alert_type", alertType), zap.Error(err)) return fmt.Errorf("短信发送失败: %w", err) } if response.Response == nil || len(response.Response.SendStatusSet) == 0 { return fmt.Errorf("腾讯云短信返回空响应") } st := response.Response.SendStatusSet[0] if st.Code == nil || *st.Code != "Ok" { msg := "" if st.Message != nil { msg = *st.Message } return fmt.Errorf("短信发送失败: %s", msg) } s.logger.Info("腾讯云余额预警短信发送成功", zap.String("phone", phone), zap.String("alert_type", alertType)) return nil } // GenerateCode 生成数字验证码 func (s *TencentSMSService) GenerateCode(length int) string { if length <= 0 { length = 6 } max := big.NewInt(int64(pow10Tencent(length))) n, _ := rand.Int(rand.Reader, max) format := fmt.Sprintf("%%0%dd", length) return fmt.Sprintf(format, n.Int64()) } func safeStrPtr(p *string) string { if p == nil { return "" } return *p } func pow10Tencent(n int) int { result := 1 for i := 0; i < n; i++ { result *= 10 } return result } func resolveTencentBalanceTemplateID(tc config.TencentSMSConfig, alertType string) string { switch alertType { case "low_balance": if tc.LowBalanceTemplateID != "" { return tc.LowBalanceTemplateID } case "arrears": if tc.ArrearsTemplateID != "" { return tc.ArrearsTemplateID } } return tc.BalanceAlertTemplateID }