// 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)