Files
hyapi-server/internal/infrastructure/external/sms/tencent_sms.go
2026-04-23 21:39:15 +08:00

236 lines
7.1 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 sms
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"strings"
"time"
"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
}
// 兼容模板变量从单参数升级为双参数(验证码 + 有效分钟数)的场景。
// 命中“内容与模板不符”时,自动使用双参数重试一次,避免生产发布被模板变更阻断。
if isTemplateContentMismatch(msg) {
expireMinutes := getExpireMinutes(s.cfg.ExpireTime)
retryReq := &sms.SendSmsRequest{}
retryReq.SmsSdkAppId = common.StringPtr(tc.SmsSdkAppId)
retryReq.SignName = common.StringPtr(tc.SignName)
retryReq.TemplateId = common.StringPtr(tc.TemplateID)
retryReq.TemplateParamSet = common.StringPtrs([]string{code, fmt.Sprintf("%d", expireMinutes)})
retryReq.PhoneNumberSet = common.StringPtrs([]string{normalizeTencentPhone(phone)})
retryResp, retryErr := s.client.SendSms(retryReq)
if retryErr == nil && retryResp != nil && len(retryResp.Response.SendStatusSet) > 0 {
retryStatus := retryResp.Response.SendStatusSet[0]
if retryStatus.Code != nil && *retryStatus.Code == "Ok" {
s.logger.Info("腾讯云短信发送成功(模板双参数重试)",
zap.String("phone", phone),
zap.Int64("expire_minutes", expireMinutes),
zap.String("serial_no", safeStrPtr(retryStatus.SerialNo)))
return nil
}
}
s.logger.Error("腾讯云短信双参数重试失败",
zap.String("phone", phone),
zap.Int64("expire_minutes", expireMinutes),
zap.Error(retryErr))
}
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
}
func isTemplateContentMismatch(msg string) bool {
m := strings.ToLower(strings.TrimSpace(msg))
return strings.Contains(m, "request content does not match template content") ||
strings.Contains(m, "内容与模板不符")
}
func getExpireMinutes(expire time.Duration) int64 {
if expire <= 0 {
return 5
}
minutes := int64(expire / time.Minute)
if minutes <= 0 {
return 1
}
return minutes
}