spark-server/internal/module/sms/logic/send.go
2026-09-22 17:07:26 +08:00

179 lines
4.9 KiB
Go

package logic
import (
"context"
"strings"
smsentity "spark-server/internal/module/sms/entity"
userentity "spark-server/internal/module/user/entity"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/i18n"
"spark-server/internal/pkg/reqctx"
)
// SendCodeInput is the HTTP body for POST /v1/sms/codes.
type SendCodeInput struct {
Purpose smsentity.Purpose `json:"purpose" validate:"required,oneof=1 2" label:"field.verification_purpose" errmsg:"required:validate.sms.purpose_required;oneof:validate.sms.purpose_invalid"`
Target string `json:"target" validate:"required" label:"field.verification_target" errmsg:"required:validate.sms.target_required"`
}
type SentCode struct {
ExpiresIn int64 `json:"expires_in"`
}
func (s *Service) Send(ctx context.Context, in SendCodeInput) (*SentCode, error) {
if s == nil || s.store == nil {
return nil, errs.ErrAvailable
}
ch, target, ok := smsentity.ParseTarget(in.Target)
if !ok {
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySMSTargetInvalid, nil))
}
switch ch {
case smsentity.ChannelEmail:
return s.sendEmail(ctx, in.Purpose, target, reqctxUserID(ctx))
case smsentity.ChannelPhone:
return s.sendPhone(ctx, in.Purpose, target, reqctxUserID(ctx))
default:
return nil, errs.ErrInvalidArgument
}
}
func (s *Service) sendEmail(ctx context.Context, purpose smsentity.Purpose, email string, userID int64) (*SentCode, error) {
if s == nil || s.store == nil {
return nil, errs.ErrAvailable
}
email = userentity.NormalizeEmail(email)
if email == "" {
return nil, errs.ErrInvalidArgument
}
if err := s.guardPurpose(ctx, purpose, smsentity.ChannelEmail, email, userID); err != nil {
return nil, err
}
if skipSendEmail(email) {
return &SentCode{ExpiresIn: CodeTTLSeconds}, nil
}
key := storeKey(smsentity.ChannelEmail, purpose, email)
ok, err := s.store.SetCooldown(ctx, key, defaultCodeCooldownSeconds)
if err != nil {
return nil, err
}
if !ok {
return nil, errs.ErrRateLimited
}
code, err := randomDigits(6)
if err != nil {
return nil, errs.ErrInner.WithError("sms-code", err)
}
if s.email != nil {
lang := reqctx.Language(ctx)
if err := s.email.SendOTP(ctx, email, purpose, code, lang); err != nil {
_ = s.store.Del(ctx, key)
return nil, err
}
}
if err := s.store.Put(ctx, key, code, defaultCodeTTLSeconds); err != nil {
return nil, err
}
return &SentCode{ExpiresIn: CodeTTLSeconds}, nil
}
func (s *Service) sendPhone(ctx context.Context, purpose smsentity.Purpose, phone string, userID int64) (*SentCode, error) {
if s == nil || s.store == nil {
return nil, errs.ErrAvailable
}
phone = smsentity.NormalizePhone(phone)
if phone == "" {
return nil, errs.ErrInvalidArgument
}
if err := s.guardPurpose(ctx, purpose, smsentity.ChannelPhone, phone, userID); err != nil {
return nil, err
}
if skipSendPhone(phone) {
return &SentCode{ExpiresIn: CodeTTLSeconds}, nil
}
key := storeKey(smsentity.ChannelPhone, purpose, phone)
ok, err := s.store.SetCooldown(ctx, key, defaultCodeCooldownSeconds)
if err != nil {
return nil, err
}
if !ok {
return nil, errs.ErrRateLimited
}
code, err := randomDigits(6)
if err != nil {
return nil, errs.ErrInner.WithError("sms-code", err)
}
if err := s.dispatchPhone(ctx, phone, purpose, code); err != nil {
_ = s.store.Del(ctx, key)
return nil, err
}
if err := s.store.Put(ctx, key, code, defaultCodeTTLSeconds); err != nil {
return nil, err
}
return &SentCode{ExpiresIn: CodeTTLSeconds}, nil
}
func (s *Service) dispatchPhone(ctx context.Context, phone string, purpose smsentity.Purpose, code string) error {
if len(s.phone) == 0 {
return errs.ErrAvailable
}
carrier := smsentity.DetectCarrier(phone)
for _, p := range s.phone {
if p == nil {
continue
}
if p.Carrier() == carrier {
return p.SendOTP(ctx, phone, purpose, code)
}
}
for _, p := range s.phone {
if p == nil {
continue
}
if p.Carrier() == smsentity.CarrierMobile {
return p.SendOTP(ctx, phone, purpose, code)
}
}
return errs.ErrAvailable
}
func (s *Service) guardPurpose(ctx context.Context, purpose smsentity.Purpose, ch smsentity.Channel, target string, userID int64) error {
if !purpose.Valid() {
return errs.ErrInvalidArgument
}
if purpose != smsentity.PurposeAccountDelete {
return nil
}
if userID <= 0 {
return errs.ErrUnauthorized
}
if s.accounts == nil {
return errs.ErrAvailable
}
bound, err := s.accounts.EmailByUserID(ctx, userID)
if err != nil {
return err
}
bound = userentity.NormalizeEmail(bound)
if bound == "" {
return errs.ErrForbidden
}
if ch == smsentity.ChannelEmail {
if userentity.NormalizeEmail(target) != bound {
return errs.ErrForbidden
}
return nil
}
_ = strings.TrimSpace(target)
return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySMSDeletePhoneUnsupported, nil))
}
func reqctxUserID(ctx context.Context) int64 {
rc := reqctx.From(ctx)
if rc == nil {
return 0
}
return rc.UserID
}