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 }