2026-05-20 07:01:08 +00:00
|
|
|
package sms
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"sync"
|
2026-05-20 09:32:22 +00:00
|
|
|
|
|
|
|
|
"github.com/zeromicro/go-zero/core/logx"
|
2026-05-20 07:01:08 +00:00
|
|
|
)
|
|
|
|
|
|
2026-05-26 06:05:33 +00:00
|
|
|
// MockRedisHook is the minimal Redis surface needed to persist the last mock
|
|
|
|
|
// SMS body for dev/k6 inspection. *github.com/zeromicro/go-zero/core/stores/redis.Redis
|
|
|
|
|
// already satisfies it (SetexCtx).
|
|
|
|
|
//
|
|
|
|
|
// Production SMS senders never receive this hook; it is only wired when
|
|
|
|
|
// SMSConfig.Provider=="mock" and a Redis client is available.
|
|
|
|
|
type MockRedisHook interface {
|
|
|
|
|
SetexCtx(ctx context.Context, key, value string, seconds int) error
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 07:01:08 +00:00
|
|
|
// MockSender records calls and returns configurable results (for tests and local dev).
|
|
|
|
|
type MockSender struct {
|
|
|
|
|
name string
|
|
|
|
|
sort int
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
calls []*Message
|
|
|
|
|
|
|
|
|
|
Err error
|
|
|
|
|
MessageID string
|
|
|
|
|
SendHook func(ctx context.Context, msg *Message) (string, error)
|
2026-05-26 06:05:33 +00:00
|
|
|
|
|
|
|
|
// Optional Redis hook for dev/k6: on every successful Send, the message
|
|
|
|
|
// body is written to "dev:notification:last:sms:<phone>" with the given TTL.
|
|
|
|
|
// nil hook → behaviour identical to original implementation.
|
|
|
|
|
redis MockRedisHook
|
|
|
|
|
redisKeyTTL int // seconds, defaults to 600 when redis hook set
|
2026-05-20 07:01:08 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type MockSenderOption func(*MockSender)
|
|
|
|
|
|
|
|
|
|
func WithMockName(name string) MockSenderOption {
|
|
|
|
|
return func(m *MockSender) { m.name = name }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func WithMockSort(sort int) MockSenderOption {
|
|
|
|
|
return func(m *MockSender) { m.sort = sort }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func WithMockError(err error) MockSenderOption {
|
|
|
|
|
return func(m *MockSender) { m.Err = err }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func WithMockMessageID(id string) MockSenderOption {
|
|
|
|
|
return func(m *MockSender) { m.MessageID = id }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 06:05:33 +00:00
|
|
|
// WithMockRedis enables dev/k6 OTP inspection by mirroring every outbound
|
|
|
|
|
// mock SMS body into Redis at key "dev:notification:last:sms:<phone>".
|
|
|
|
|
// ttlSeconds <= 0 → defaults to 600 (10m).
|
|
|
|
|
func WithMockRedis(r MockRedisHook, ttlSeconds int) MockSenderOption {
|
|
|
|
|
return func(m *MockSender) {
|
|
|
|
|
m.redis = r
|
|
|
|
|
if ttlSeconds <= 0 {
|
|
|
|
|
ttlSeconds = 600
|
|
|
|
|
}
|
|
|
|
|
m.redisKeyTTL = ttlSeconds
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 07:01:08 +00:00
|
|
|
func NewMockSender(opts ...MockSenderOption) *MockSender {
|
|
|
|
|
m := &MockSender{
|
2026-05-26 06:05:33 +00:00
|
|
|
name: "mock",
|
|
|
|
|
sort: 0,
|
|
|
|
|
MessageID: "mock-sms-id",
|
|
|
|
|
redisKeyTTL: 600,
|
2026-05-20 07:01:08 +00:00
|
|
|
}
|
|
|
|
|
for _, opt := range opts {
|
|
|
|
|
opt(m)
|
|
|
|
|
}
|
|
|
|
|
return m
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *MockSender) Name() string { return m.name }
|
|
|
|
|
func (m *MockSender) Sort() int { return m.sort }
|
|
|
|
|
|
2026-05-26 06:05:33 +00:00
|
|
|
// MockSMSRedisKeyPrefix is the key prefix written by the Redis hook.
|
|
|
|
|
// Exposed so k6 / dev tooling can resolve the key for a given phone.
|
|
|
|
|
const MockSMSRedisKeyPrefix = "dev:notification:last:sms:"
|
|
|
|
|
|
2026-05-20 07:01:08 +00:00
|
|
|
func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
m.calls = append(m.calls, msg)
|
|
|
|
|
m.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
if m.SendHook != nil {
|
|
|
|
|
return m.SendHook(ctx, msg)
|
|
|
|
|
}
|
|
|
|
|
if m.Err != nil {
|
|
|
|
|
return "", m.Err
|
|
|
|
|
}
|
2026-05-20 09:32:22 +00:00
|
|
|
if msg != nil {
|
|
|
|
|
logx.Infof("[notification mock sms] to=%s recipient=%s body=%q message_id=%s",
|
|
|
|
|
msg.PhoneNumber, msg.RecipientName, msg.Body, m.MessageID)
|
2026-05-26 06:05:33 +00:00
|
|
|
if m.redis != nil && msg.PhoneNumber != "" {
|
|
|
|
|
key := MockSMSRedisKeyPrefix + msg.PhoneNumber
|
|
|
|
|
if err := m.redis.SetexCtx(ctx, key, msg.Body, m.redisKeyTTL); err != nil {
|
|
|
|
|
logx.Errorf("[notification mock sms] redis hook setex %s: %v", key, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-20 09:32:22 +00:00
|
|
|
}
|
2026-05-20 07:01:08 +00:00
|
|
|
return m.MessageID, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *MockSender) Calls() []*Message {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
defer m.mu.Unlock()
|
|
|
|
|
out := make([]*Message, len(m.calls))
|
|
|
|
|
copy(out, m.calls)
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *MockSender) Reset() {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
defer m.mu.Unlock()
|
|
|
|
|
m.calls = nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ErrSender is a Sender that always fails (helper for tests).
|
|
|
|
|
func ErrSender(name string, sort int, err error) Sender {
|
|
|
|
|
return &staticErrSender{name: name, sort: sort, err: err}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type staticErrSender struct {
|
|
|
|
|
name string
|
|
|
|
|
sort int
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *staticErrSender) Name() string { return s.name }
|
|
|
|
|
func (s *staticErrSender) Sort() int { return s.sort }
|
|
|
|
|
func (s *staticErrSender) Send(context.Context, *Message) (string, error) {
|
|
|
|
|
if s.err != nil {
|
|
|
|
|
return "", s.err
|
|
|
|
|
}
|
|
|
|
|
return "", fmt.Errorf("%s: send failed", s.name)
|
|
|
|
|
}
|