Files
ttstd-infinite-canvas/backend/internal/sms/service.go

385 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sms
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"regexp"
"strconv"
"strings"
"time"
sender "github.com/casdoor/go-sms-sender"
"yingce/backend/internal/kernel"
"yingce/backend/internal/model"
"yingce/backend/internal/outbound"
"yingce/backend/internal/repository"
)
const Aliyun = "aliyun"
const Tencent = "tencent"
const PluginAliyun = "official-sms-aliyun"
const PluginTencent = "official-sms-tencent"
type Host interface {
RequireAdmin(*model.User) error
EncryptSecret(string) (string, error)
DecryptSecret(string) (string, error)
SettingsEncryptionKey() ([]byte, error)
}
type Template struct {
Purpose string `json:"purpose"`
TemplateID string `json:"templateId"`
// Aliyun: named keys; Tencent: ordered keys "0", "1", ... .
Parameters []Parameter `json:"parameters"`
}
type Parameter struct {
Name string `json:"name"`
Value string `json:"value"`
}
type ChannelRequest struct {
Name string `json:"name"`
Provider string `json:"provider"`
Enabled bool `json:"enabled"`
Priority int `json:"priority"`
DailyLimit int `json:"dailyLimit"`
SignName string `json:"signName"`
AppID string `json:"appId"`
AccessID string `json:"accessId"`
AccessKey string `json:"accessKey"`
Templates []Template `json:"templates"`
Version int `json:"version"`
}
type ChannelView struct {
model.SMSChannel
Templates []Template `json:"templates"`
HasCredentials bool `json:"hasCredentials"`
PluginEnabled bool `json:"pluginEnabled"`
}
type RecordPage struct {
Items []model.SMSRecord `json:"items"`
Total int64 `json:"total"`
}
type credentials struct {
ID string `json:"id"`
Key string `json:"key"`
}
type SendFunc func(context.Context, model.SMSChannel, Template, string, string, string, string) (sender.SendResult, error)
type Service struct {
repo *repository.Repository
host Host
pluginEnabled func(string) (bool, error)
send SendFunc
}
func New(repo *repository.Repository, host Host, pluginEnabled func(string) (bool, error)) *Service {
return &Service{repo: repo, host: host, pluginEnabled: pluginEnabled, send: sendAggregate}
}
func PluginID(provider string) string {
if provider == Aliyun {
return PluginAliyun
}
if provider == Tencent {
return PluginTencent
}
return ""
}
var phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`)
var paramPattern = regexp.MustCompile(`^[a-zA-Z0-9_]{1,40}$`)
var safeCode = regexp.MustCompile(`^[a-zA-Z0-9_.:-]{1,160}$`)
func NormalizePhone(value string) (string, error) {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "+86")
if !phonePattern.MatchString(value) {
return "", kernel.BadAuthRequest("请输入中国大陆手机号(支持 +86)")
}
return "+86" + value, nil
}
func (s *Service) ready(provider string) (bool, error) {
if PluginID(provider) == "" || s.pluginEnabled == nil {
return false, nil
}
return s.pluginEnabled(PluginID(provider))
}
func templates(row model.SMSChannel) ([]Template, error) {
var result []Template
err := json.Unmarshal([]byte(row.TemplatesJSON), &result)
return result, err
}
func (s *Service) Channels(actor *model.User) ([]ChannelView, error) {
if err := s.host.RequireAdmin(actor); err != nil {
return nil, err
}
rows, err := s.repo.SMSChannels()
if err != nil {
return nil, err
}
result := make([]ChannelView, 0, len(rows))
for _, row := range rows {
t, err := templates(row)
if err != nil {
return nil, err
}
enabled, err := s.ready(row.Provider)
if err != nil {
return nil, err
}
result = append(result, ChannelView{SMSChannel: row, Templates: t, HasCredentials: row.Credentials != "", PluginEnabled: enabled})
}
return result, nil
}
func (s *Service) SaveChannel(actor *model.User, id string, req ChannelRequest) (*ChannelView, error) {
if err := s.host.RequireAdmin(actor); err != nil {
return nil, err
}
req.Name, req.SignName, req.AppID = strings.TrimSpace(req.Name), strings.TrimSpace(req.SignName), strings.TrimSpace(req.AppID)
if PluginID(req.Provider) == "" || req.Name == "" || len(req.Name) > 240 || req.SignName == "" || len(req.SignName) > 240 || len(req.AppID) > 80 || req.Priority < 0 || req.Priority > 1000 || req.DailyLimit < 1 || req.DailyLimit > 100000 {
return nil, kernel.BadAuthRequest("请填写有效的渠道名称、供应商、签名、优先级(0–1000)和日限额(1–100000)")
}
if req.Provider == Tencent && req.AppID == "" {
return nil, kernel.BadAuthRequest("腾讯云需要短信 SDK AppID")
}
if err := validateTemplates(req.Provider, req.Templates); err != nil {
return nil, err
}
row := &model.SMSChannel{ID: kernel.NewID(), CreatedAt: time.Now(), Version: 1}
if id != "" {
var err error
row, err = s.repo.SMSChannel(id)
if err != nil {
return nil, kernel.NotFound("短信渠道不存在")
}
if row.Version != req.Version {
return nil, kernel.NewAppError(409, "渠道已被修改,请刷新后重试")
}
if row.Provider != req.Provider {
return nil, kernel.BadAuthRequest("渠道供应商不能修改,请新建渠道")
}
}
if (req.AccessID == "") != (req.AccessKey == "") {
return nil, kernel.BadAuthRequest("更换凭据时需同时填写 ID 和密钥")
}
if req.AccessID != "" {
if len(req.AccessID) > 512 || len(req.AccessKey) > 512 {
return nil, kernel.BadAuthRequest("凭据长度无效")
}
encoded, _ := json.Marshal(credentials{ID: req.AccessID, Key: req.AccessKey})
var err error
row.Credentials, err = s.host.EncryptSecret(string(encoded))
if err != nil {
return nil, err
}
}
if row.Credentials == "" {
return nil, kernel.BadAuthRequest("请填写短信凭据")
}
enabled, err := s.ready(req.Provider)
if err != nil {
return nil, err
}
if req.Enabled && !enabled {
return nil, kernel.BadAuthRequest("请先在插件管理启用对应短信插件")
}
encoded, _ := json.Marshal(req.Templates)
row.Name, row.Provider, row.Enabled, row.Priority, row.DailyLimit = req.Name, req.Provider, req.Enabled, req.Priority, req.DailyLimit
row.SignName, row.AppID, row.TemplatesJSON, row.UpdatedAt = req.SignName, req.AppID, string(encoded), time.Now()
if err := s.repo.SaveSMSChannel(row, id == ""); err != nil {
if errors.Is(err, repository.ErrChannelConflict) {
return nil, kernel.NewAppError(409, "渠道已被修改,请刷新后重试")
}
return nil, err
}
return &ChannelView{SMSChannel: *row, Templates: req.Templates, HasCredentials: true, PluginEnabled: enabled}, nil
}
func validateTemplates(provider string, values []Template) error {
if len(values) == 0 || len(values) > 3 {
return kernel.BadAuthRequest("请配置 1–3 个场景模板")
}
seen := map[string]bool{}
for _, t := range values {
if (t.Purpose != "login" && t.Purpose != "register" && t.Purpose != "bind") || seen[t.Purpose] || strings.TrimSpace(t.TemplateID) == "" || len(t.TemplateID) > 100 {
return kernel.BadAuthRequest("模板场景或模板 ID 无效")
}
seen[t.Purpose] = true
keys, hasCode := map[string]bool{}, false
if len(t.Parameters) == 0 || len(t.Parameters) > 8 {
return kernel.BadAuthRequest("请配置模板参数")
}
for i, p := range t.Parameters {
if !paramPattern.MatchString(p.Name) || keys[p.Name] || (p.Value != "code" && p.Value != "minutes") || (provider == Tencent && p.Name != strconv.Itoa(i)) {
return kernel.BadAuthRequest("参数值仅支持 code、minutes;腾讯云参数名必须按 0、1 顺序填写")
}
keys[p.Name] = true
hasCode = hasCode || p.Value == "code"
}
if !hasCode {
return kernel.BadAuthRequest("模板必须包含 code 参数")
}
}
return nil
}
func (s *Service) DeleteChannel(actor *model.User, id string) error {
if err := s.host.RequireAdmin(actor); err != nil {
return err
}
row, err := s.repo.SMSChannel(id)
if err != nil {
return kernel.NotFound("短信渠道不存在")
}
if row.Enabled {
return kernel.BadAuthRequest("请先停用渠道再删除;发送记录将保留")
}
return s.repo.DeleteSMSChannel(id)
}
func (s *Service) selectChannel(purpose, id string) (*model.SMSChannel, *Template, error) {
rows, err := s.repo.SMSChannels()
if err != nil {
return nil, nil, err
}
for _, row := range rows {
if !row.Enabled || (id != "" && row.ID != id) {
continue
}
enabled, err := s.ready(row.Provider)
if err != nil {
return nil, nil, err
}
if !enabled {
continue
}
items, err := templates(row)
if err != nil {
return nil, nil, err
}
for _, t := range items {
if t.Purpose == purpose {
return &row, &t, nil
}
}
}
return nil, nil, kernel.NewAppError(503, "未配置可用的短信场景渠道")
}
func (s *Service) Available(purpose string) (bool, error) {
_, _, err := s.selectChannel(purpose, "")
var appErr *kernel.AppError
if errors.As(err, &appErr) && appErr.Status == 503 {
return false, nil
}
return err == nil, err
}
func (s *Service) SendCode(ctx context.Context, purpose, phone, code string) error {
_, err := s.sendCode(ctx, purpose, phone, code, "", false)
return err
}
func (s *Service) sendCode(ctx context.Context, purpose, phone, code, channelID string, test bool) (*model.SMSRecord, error) {
phone, err := NormalizePhone(phone)
if err != nil {
return nil, err
}
row, t, err := s.selectChannel(purpose, channelID)
if err != nil {
return nil, err
}
raw, err := s.host.DecryptSecret(row.Credentials)
if err != nil {
return nil, err
}
var creds credentials
if json.Unmarshal([]byte(raw), &creds) != nil || creds.ID == "" || creds.Key == "" {
return nil, kernel.NewAppError(503, "短信凭据无效")
}
key, err := s.host.SettingsEncryptionKey()
if err != nil {
return nil, err
}
if len(key) < 16 {
return nil, kernel.NewAppError(503, "短信加密密钥不可用")
}
hash := hmac.New(sha256.New, key)
hash.Write([]byte(phone))
target := hex.EncodeToString(hash.Sum(nil))
now := time.Now()
day := now.UTC().Format("2006-01-02")
if err := s.repo.ReserveNotificationLimits([]repository.NotificationLimit{{Key: "sms-channel:" + row.ID + ":" + day, Limit: row.DailyLimit, ExpiresAt: now.Add(48 * time.Hour)}}, now); err != nil {
if errors.Is(err, repository.ErrNotificationLimit) {
return nil, kernel.RateLimited("短信渠道当日额度已用尽")
}
return nil, err
}
// Persist unknown before the network call: a crash can never be called success.
record := &model.SMSRecord{ID: kernel.NewID(), ChannelID: row.ID, ChannelName: row.Name, Provider: row.Provider, Purpose: purpose, MaskedPhone: phone[:6] + "****" + phone[len(phone)-4:], TargetHash: target, State: "unknown", ErrorCode: "acceptance_unknown", CreatedAt: now, UpdatedAt: now}
if test {
record.Purpose = "test:" + purpose
}
if err := s.repo.Create(record); err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
result, sendErr := s.send(ctx, *row, *t, creds.ID, creds.Key, phone, code)
record.State, record.RequestID, record.MessageID, record.ErrorCode = result.State, sanitize(result.RequestID), sanitize(result.MessageID), sanitize(result.Code)
if record.State != "accepted" && record.State != "rejected" {
record.State = "unknown"
}
record.DurationMS, record.UpdatedAt = time.Since(now).Milliseconds(), time.Now()
if err := s.repo.Save(record); err != nil {
return nil, kernel.NewAppError(503, "短信发送结果未能保存,请勿立即重复发送")
}
if sendErr != nil || record.State != "accepted" {
if record.State == "unknown" {
return record, kernel.NewAppError(503, "短信受理结果未知,请稍后再试;系统不会自动重发")
}
return record, kernel.NewAppError(503, "短信服务商拒绝发送,请联系管理员检查发送记录")
}
return record, nil
}
func sanitize(value string) string {
if safeCode.MatchString(value) {
return value
}
return ""
}
func sendAggregate(ctx context.Context, channel model.SMSChannel, t Template, id, key, phone, code string) (sender.SendResult, error) {
provider := sender.Aliyun
if channel.Provider == Tencent {
provider = sender.TencentCloud
}
client, err := sender.NewSmsClient(provider, id, key, channel.SignName, t.TemplateID, channel.AppID)
if err != nil {
return sender.SendResult{State: "rejected", Code: "invalid_configuration"}, errors.New("sms configuration invalid")
}
params := map[string]string{}
for _, p := range t.Parameters {
if p.Value == "code" {
params[p.Name] = code
} else {
params[p.Name] = "10"
}
}
if channel.Provider == Aliyun {
phone = strings.TrimPrefix(phone, "+86")
}
return sender.SendMessageContext(ctx, client, outbound.OutboundHTTPClient(10*time.Second).Transport, params, phone)
}
func (s *Service) Records(actor *model.User, channel, state string, page, size int) (*RecordPage, error) {
if err := s.host.RequireAdmin(actor); err != nil {
return nil, err
}
if page < 1 || page > 100000 || size < 1 || size > 100 || (state != "" && state != "accepted" && state != "rejected" && state != "unknown") {
return nil, kernel.BadAuthRequest("分页或状态无效")
}
rows, count, err := s.repo.SMSRecords(channel, state, page, size)
return &RecordPage{Items: rows, Total: count}, err
}
func (s *Service) Test(actor *model.User, ctx context.Context, id, purpose, phone, code string) (*model.SMSRecord, error) {
if err := s.host.RequireAdmin(actor); err != nil {
return nil, err
}
return s.sendCode(ctx, purpose, phone, code, id, true)
}