164 lines
5.1 KiB
Go
164 lines
5.1 KiB
Go
package service
|
||
|
||
import (
|
||
"crypto/hmac"
|
||
"crypto/sha1"
|
||
"encoding/base64"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/QuantumNous/new-api/common"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
const aliyunSMSAPIEndpoint = "https://dysmsapi.aliyuncs.com/"
|
||
|
||
// AliyunSMSConfig 阿里云短信发送配置。
|
||
type AliyunSMSConfig struct {
|
||
AccessKeyID string
|
||
AccessKeySecret string
|
||
SignName string
|
||
TemplateCode string
|
||
}
|
||
|
||
// LoadAliyunSMSConfig 读取阿里云短信配置(优先系统设置,环境变量兜底)。
|
||
func LoadAliyunSMSConfig() (*AliyunSMSConfig, error) {
|
||
accessKeyID := strings.TrimSpace(common.SMSAccessKeyID)
|
||
if accessKeyID == "" {
|
||
accessKeyID = strings.TrimSpace(os.Getenv("ALIYUN_SMS_ACCESS_KEY_ID"))
|
||
}
|
||
accessKeySecret := strings.TrimSpace(common.SMSAccessKeySecret)
|
||
if accessKeySecret == "" {
|
||
accessKeySecret = strings.TrimSpace(os.Getenv("ALIYUN_SMS_ACCESS_KEY_SECRET"))
|
||
}
|
||
signName := strings.TrimSpace(common.SMSCodeSignName)
|
||
if signName == "" {
|
||
signName = strings.TrimSpace(os.Getenv("ALIYUN_SMS_SIGN_NAME"))
|
||
}
|
||
templateCode := strings.TrimSpace(common.SMSCodeTemplateCode)
|
||
if templateCode == "" {
|
||
templateCode = strings.TrimSpace(os.Getenv("ALIYUN_SMS_TEMPLATE_CODE"))
|
||
}
|
||
cfg := &AliyunSMSConfig{
|
||
AccessKeyID: accessKeyID,
|
||
AccessKeySecret: accessKeySecret,
|
||
SignName: signName,
|
||
TemplateCode: templateCode,
|
||
}
|
||
if cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" {
|
||
return nil, fmt.Errorf("短信服务未配置 AccessKey,请在系统设置填写“短信API账号/短信API密钥”或设置 ALIYUN_SMS_ACCESS_KEY_ID / ALIYUN_SMS_ACCESS_KEY_SECRET")
|
||
}
|
||
if cfg.SignName == "" {
|
||
return nil, fmt.Errorf("短信服务未配置签名,请在系统设置填写“短信签名”或设置 ALIYUN_SMS_SIGN_NAME")
|
||
}
|
||
if cfg.TemplateCode == "" {
|
||
return nil, fmt.Errorf("短信服务未配置模板,请在系统设置填写“短信模板Code”(SMSCodeTemplateCode)或设置 ALIYUN_SMS_TEMPLATE_CODE")
|
||
}
|
||
return cfg, nil
|
||
}
|
||
|
||
// SendAliyunSMSCode 通过阿里云短信服务发送验证码短信。
|
||
func SendAliyunSMSCode(phone, code string) error {
|
||
cfg, err := LoadAliyunSMSConfig()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
templateParamBytes, err := common.Marshal(map[string]string{
|
||
"code": code,
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("构造短信模板参数失败: %w", err)
|
||
}
|
||
params := map[string]string{
|
||
"Action": "SendSms",
|
||
"Format": "JSON",
|
||
"Version": "2017-05-25",
|
||
"AccessKeyId": cfg.AccessKeyID,
|
||
"SignatureMethod": "HMAC-SHA1",
|
||
"SignatureVersion": "1.0",
|
||
"SignatureNonce": uuid.NewString(),
|
||
"Timestamp": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||
"RegionId": "cn-hangzhou",
|
||
"PhoneNumbers": phone,
|
||
"SignName": cfg.SignName,
|
||
"TemplateCode": cfg.TemplateCode,
|
||
"TemplateParam": string(templateParamBytes),
|
||
}
|
||
|
||
signature, err := aliyunSignRPCRequest(params, cfg.AccessKeySecret)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
values := url.Values{}
|
||
values.Set("Signature", signature)
|
||
keys := make([]string, 0, len(params))
|
||
for k := range params {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
for _, k := range keys {
|
||
values.Set(k, params[k])
|
||
}
|
||
|
||
reqURL := aliyunSMSAPIEndpoint + "?" + values.Encode()
|
||
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("构建短信请求失败: %w", err)
|
||
}
|
||
resp, err := GetHttpClient().Do(req)
|
||
if err != nil {
|
||
return fmt.Errorf("短信请求失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||
if resp.StatusCode != http.StatusOK {
|
||
return fmt.Errorf("短信发送失败: HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||
}
|
||
var result struct {
|
||
Code string `json:"Code"`
|
||
Message string `json:"Message"`
|
||
}
|
||
if err := common.Unmarshal(body, &result); err != nil {
|
||
return fmt.Errorf("解析短信服务响应失败: %w", err)
|
||
}
|
||
if strings.ToUpper(result.Code) != "OK" {
|
||
return fmt.Errorf("短信发送失败: %s", strings.TrimSpace(result.Message))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// aliyunSignRPCRequest 按阿里云 RPC 协议计算 Signature。
|
||
func aliyunSignRPCRequest(params map[string]string, accessKeySecret string) (string, error) {
|
||
keys := make([]string, 0, len(params))
|
||
for k := range params {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
|
||
pairs := make([]string, 0, len(keys))
|
||
for _, k := range keys {
|
||
pairs = append(pairs, aliyunPercentEncode(k)+"="+aliyunPercentEncode(params[k]))
|
||
}
|
||
canonicalizedQuery := strings.Join(pairs, "&")
|
||
stringToSign := "GET&%2F&" + aliyunPercentEncode(canonicalizedQuery)
|
||
mac := hmac.New(sha1.New, []byte(accessKeySecret+"&"))
|
||
_, _ = mac.Write([]byte(stringToSign))
|
||
return base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil
|
||
}
|
||
|
||
// aliyunPercentEncode 采用阿里云要求的 RFC3986 百分号编码规则。
|
||
func aliyunPercentEncode(s string) string {
|
||
escaped := url.QueryEscape(s)
|
||
escaped = strings.ReplaceAll(escaped, "+", "%20")
|
||
escaped = strings.ReplaceAll(escaped, "*", "%2A")
|
||
escaped = strings.ReplaceAll(escaped, "%7E", "~")
|
||
return escaped
|
||
}
|