spark-server/internal/infrastructure/sms/aliyun.go
2026-09-22 17:07:26 +08:00

184 lines
4.9 KiB
Go

// Package sms implements Aliyun SMS OTP delivery per carrier segment.
package sms
import (
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
smsentity "spark-server/internal/module/sms/entity"
smslogic "spark-server/internal/module/sms/logic"
"spark-server/internal/pkg/errs"
)
const dysmsEndpoint = "https://dysmsapi.aliyuncs.com/"
// TemplateSet holds Aliyun template codes per purpose for one carrier.
type TemplateSet struct {
Login string
AccountDelete string
}
// AliyunOptions configures RPC access to dysmsapi.
type AliyunOptions struct {
AccessKeyID string
AccessKeySecret string
SignName string
}
// CarrierGateway sends SMS for one operator using distinct templates.
type CarrierGateway struct {
carrier smsentity.Carrier
client *AliyunClient
signName string
templates TemplateSet
}
func NewCarrierGateway(carrier smsentity.Carrier, opts AliyunOptions, templates TemplateSet) *CarrierGateway {
return &CarrierGateway{
carrier: carrier,
client: NewAliyunClient(opts),
signName: strings.TrimSpace(opts.SignName),
templates: templates,
}
}
func (g *CarrierGateway) Carrier() smsentity.Carrier {
if g == nil {
return smsentity.CarrierUnknown
}
return g.carrier
}
func (g *CarrierGateway) SendOTP(ctx context.Context, phone string, purpose smsentity.Purpose, code string) error {
if g == nil || g.client == nil || !g.client.Ready() {
return errs.ErrAvailable
}
tpl := g.templateFor(purpose)
if tpl == "" || g.signName == "" {
return errs.ErrAvailable
}
params := map[string]string{
"PhoneNumbers": strings.TrimPrefix(phone, "+86"),
"SignName": g.signName,
"TemplateCode": tpl,
"TemplateParam": fmt.Sprintf(`{"code":"%s"}`, code),
}
return g.client.SendSMS(ctx, params)
}
func (g *CarrierGateway) templateFor(purpose smsentity.Purpose) string {
switch purpose {
case smsentity.PurposeAccountDelete:
return strings.TrimSpace(g.templates.AccountDelete)
default:
return strings.TrimSpace(g.templates.Login)
}
}
// AliyunClient calls SendSms on dysmsapi.aliyuncs.com.
type AliyunClient struct {
accessKeyID string
accessKeySecret string
http *http.Client
}
func NewAliyunClient(opts AliyunOptions) *AliyunClient {
return &AliyunClient{
accessKeyID: strings.TrimSpace(opts.AccessKeyID),
accessKeySecret: strings.TrimSpace(opts.AccessKeySecret),
http: &http.Client{Timeout: 15 * time.Second},
}
}
func (c *AliyunClient) Ready() bool {
return c != nil && c.accessKeyID != "" && c.accessKeySecret != ""
}
func (c *AliyunClient) SendSMS(ctx context.Context, extra map[string]string) error {
if c == nil || !c.Ready() {
return errs.ErrAvailable
}
params := map[string]string{
"Action": "SendSms",
"Version": "2017-05-25",
"RegionId": "cn-hangzhou",
"Format": "JSON",
"SignatureMethod": "HMAC-SHA1",
"SignatureVersion": "1.0",
"SignatureNonce": fmt.Sprintf("%d", time.Now().UnixNano()),
"Timestamp": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
"AccessKeyId": c.accessKeyID,
}
for k, v := range extra {
params[k] = v
}
params["Signature"] = signParams(params, c.accessKeySecret)
form := url.Values{}
for k, v := range params {
form.Set(k, v)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, dysmsEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return errs.ErrOperatFail.WithError("dysms-req", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http.Do(req)
if err != nil {
return errs.ErrOperatFail.WithError("dysms-http", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
var out struct {
Code string `json:"Code"`
Message string `json:"Message"`
}
if err := json.Unmarshal(body, &out); err != nil {
return errs.ErrOperatFail.WithError("dysms-decode", err)
}
if out.Code != "OK" {
return errs.ErrOperatFail.WithMsg(out.Message)
}
return nil
}
func signParams(params map[string]string, secret string) string {
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
var canonical strings.Builder
for i, k := range keys {
if i > 0 {
canonical.WriteByte('&')
}
canonical.WriteString(pctEncode(k))
canonical.WriteByte('=')
canonical.WriteString(pctEncode(params[k]))
}
stringToSign := "POST&" + pctEncode("/") + "&" + pctEncode(canonical.String())
mac := hmac.New(sha1.New, []byte(secret+"&"))
mac.Write([]byte(stringToSign))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
func pctEncode(s string) string {
enc := url.QueryEscape(s)
enc = strings.ReplaceAll(enc, "+", "%20")
enc = strings.ReplaceAll(enc, "*", "%2A")
enc = strings.ReplaceAll(enc, "%7E", "~")
return enc
}
var _ smslogic.PhoneCarrierSender = (*CarrierGateway)(nil)