chore(init): 初始化解忧商行项目并完成 Phase 0 基座

This commit is contained in:
yml2213
2026-10-30 11:46:10 +08:00
commit 8d9481c251
689 changed files with 138703 additions and 0 deletions
+132
View File
@@ -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:]
}
+39
View File
@@ -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, "")
}