chore(init): 初始化解忧商行项目并完成 Phase 0 基座
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/utils"
|
||||
dysms "github.com/alibabacloud-go/dysmsapi-20170525/v5/client"
|
||||
"github.com/alibabacloud-go/tea/dara"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AliyunConfig struct {
|
||||
AccessKeyID string
|
||||
AccessKeySecret string
|
||||
Endpoint string
|
||||
SignName string
|
||||
LoginTemplateCode string
|
||||
}
|
||||
|
||||
type AliyunProvider struct {
|
||||
client *dysms.Client
|
||||
signName string
|
||||
loginTemplateCode string
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewAliyunProvider(cfg AliyunConfig, log *zap.Logger) (*AliyunProvider, error) {
|
||||
cfg.Endpoint = strings.TrimSpace(cfg.Endpoint)
|
||||
if cfg.Endpoint == "" {
|
||||
cfg.Endpoint = "dysmsapi.aliyuncs.com"
|
||||
}
|
||||
if strings.TrimSpace(cfg.AccessKeyID) == "" ||
|
||||
strings.TrimSpace(cfg.AccessKeySecret) == "" ||
|
||||
strings.TrimSpace(cfg.SignName) == "" ||
|
||||
strings.TrimSpace(cfg.LoginTemplateCode) == "" {
|
||||
return nil, ErrProviderConfigInvalid
|
||||
}
|
||||
|
||||
client, err := dysms.NewClient(&openapi.Config{
|
||||
AccessKeyId: dara.String(strings.TrimSpace(cfg.AccessKeyID)),
|
||||
AccessKeySecret: dara.String(strings.TrimSpace(cfg.AccessKeySecret)),
|
||||
Endpoint: dara.String(cfg.Endpoint),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AliyunProvider{
|
||||
client: client,
|
||||
signName: strings.TrimSpace(cfg.SignName),
|
||||
loginTemplateCode: strings.TrimSpace(cfg.LoginTemplateCode),
|
||||
log: log,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *AliyunProvider) SendLoginCode(ctx context.Context, phone string, code string) error {
|
||||
return p.SendTemplate(ctx, TemplateRequest{
|
||||
Phone: phone,
|
||||
TemplateCode: p.loginTemplateCode,
|
||||
Params: map[string]string{"code": code},
|
||||
})
|
||||
}
|
||||
|
||||
// SendTemplate 发送已报备的模板短信。req.SignName 为空时回退到 Provider 默认签名。
|
||||
// 模板无变量时按阿里云要求传空 JSON,避免拼接文案导致模板不匹配。
|
||||
func (p *AliyunProvider) SendTemplate(ctx context.Context, req TemplateRequest) error {
|
||||
phone := strings.TrimSpace(req.Phone)
|
||||
if phone == "" {
|
||||
return ErrProviderConfigInvalid
|
||||
}
|
||||
signName := strings.TrimSpace(req.SignName)
|
||||
if signName == "" {
|
||||
signName = p.signName
|
||||
}
|
||||
templateCode := strings.TrimSpace(req.TemplateCode)
|
||||
if templateCode == "" {
|
||||
return ErrProviderConfigInvalid
|
||||
}
|
||||
|
||||
params := "{}"
|
||||
if len(req.Params) > 0 {
|
||||
raw, err := json.Marshal(req.Params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params = string(raw)
|
||||
}
|
||||
|
||||
apiReq := &dysms.SendSmsRequest{}
|
||||
apiReq.SetPhoneNumbers(phone)
|
||||
apiReq.SetSignName(signName)
|
||||
apiReq.SetTemplateCode(templateCode)
|
||||
apiReq.SetTemplateParam(params)
|
||||
|
||||
resp, err := p.client.SendSmsWithContext(ctx, apiReq, &dara.RuntimeOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("aliyun sms send failed: %w", err)
|
||||
}
|
||||
if resp == nil || resp.Body == nil {
|
||||
return fmt.Errorf("aliyun sms empty response")
|
||||
}
|
||||
|
||||
resultCode := dara.StringValue(resp.Body.Code)
|
||||
if resultCode != "OK" {
|
||||
resultMessage := dara.StringValue(resp.Body.Message)
|
||||
providerErr := &ProviderError{Code: resultCode, Message: resultMessage}
|
||||
if resultCode == "isv.BUSINESS_LIMIT_CONTROL" {
|
||||
providerErr.Err = ErrProviderRateLimited
|
||||
}
|
||||
return providerErr
|
||||
}
|
||||
|
||||
if p.log != nil {
|
||||
p.log.Debug(
|
||||
"短信发送成功",
|
||||
zap.String("phone", maskPhone(phone)),
|
||||
zap.String("template_code", templateCode),
|
||||
zap.String("provider_request_id", dara.StringValue(resp.Body.RequestId)),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func maskPhone(phone string) string {
|
||||
if len(phone) < 7 {
|
||||
return phone
|
||||
}
|
||||
return phone[:3] + "****" + phone[len(phone)-4:]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type MockProvider struct {
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewMockProvider(log *zap.Logger) *MockProvider {
|
||||
return &MockProvider{log: log}
|
||||
}
|
||||
|
||||
func (p *MockProvider) SendLoginCode(ctx context.Context, phone string, code string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.log != nil {
|
||||
p.log.Info("本地短信验证码", zap.String("phone", maskPhone(phone)), zap.String("code", code))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MockProvider) SendTemplate(ctx context.Context, req TemplateRequest) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.log != nil {
|
||||
p.log.Info("本地模板短信(未真实发送)",
|
||||
zap.String("phone", maskPhone(req.Phone)),
|
||||
zap.String("template_code", req.TemplateCode),
|
||||
zap.Any("params", req.Params),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProviderConfigInvalid = errors.New("sms provider config invalid")
|
||||
ErrProviderRateLimited = errors.New("sms provider rate limited")
|
||||
)
|
||||
|
||||
// TemplateRequest 描述一条模板短信。SignName 留空时使用 Provider 自身配置的默认签名。
|
||||
type TemplateRequest struct {
|
||||
Phone string
|
||||
SignName string
|
||||
TemplateCode string
|
||||
Params map[string]string
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
SendLoginCode(ctx context.Context, phone string, code string) error
|
||||
// SendTemplate 发送模板短信,用于登录验证码以外的业务节点触达。
|
||||
// 阿里云要求模板必须提前报备审核,因此调用方只传模板 CODE 与变量,不拼接文案。
|
||||
SendTemplate(ctx context.Context, req TemplateRequest) error
|
||||
}
|
||||
|
||||
type ProviderError struct {
|
||||
Code string
|
||||
Message string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ProviderError) Error() string {
|
||||
if e.Message != "" {
|
||||
return fmt.Sprintf("sms provider rejected: %s: %s", e.Code, e.Message)
|
||||
}
|
||||
if e.Code != "" {
|
||||
return fmt.Sprintf("sms provider rejected: %s", e.Code)
|
||||
}
|
||||
return "sms provider rejected"
|
||||
}
|
||||
|
||||
func (e *ProviderError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func IsProviderRateLimited(err error) bool {
|
||||
return errors.Is(err, ErrProviderRateLimited)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsProviderRateLimited(t *testing.T) {
|
||||
err := &ProviderError{
|
||||
Code: "isv.BUSINESS_LIMIT_CONTROL",
|
||||
Message: "触发小时级流控Permits:5",
|
||||
Err: ErrProviderRateLimited,
|
||||
}
|
||||
|
||||
if !IsProviderRateLimited(err) {
|
||||
t.Fatalf("expected provider rate limit error")
|
||||
}
|
||||
if !errors.Is(err, ErrProviderRateLimited) {
|
||||
t.Fatalf("expected errors.Is to match provider rate limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderErrorMessage(t *testing.T) {
|
||||
err := &ProviderError{Code: "isv.INVALID_PARAMETERS", Message: "参数错误"}
|
||||
|
||||
if got := err.Error(); got != "sms provider rejected: isv.INVALID_PARAMETERS: 参数错误" {
|
||||
t.Fatalf("unexpected error message: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package sms
|
||||
|
||||
import "context"
|
||||
|
||||
type UnavailableProvider struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func NewUnavailableProvider(err error) *UnavailableProvider {
|
||||
return &UnavailableProvider{err: err}
|
||||
}
|
||||
|
||||
func (p *UnavailableProvider) SendLoginCode(ctx context.Context, phone string, code string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.err != nil {
|
||||
return p.err
|
||||
}
|
||||
return ErrProviderConfigInvalid
|
||||
}
|
||||
|
||||
func (p *UnavailableProvider) SendTemplate(ctx context.Context, req TemplateRequest) error {
|
||||
return p.SendLoginCode(ctx, req.Phone, "")
|
||||
}
|
||||
Reference in New Issue
Block a user