fix error msg
This commit is contained in:
parent
35c6577ac8
commit
3afe3f9502
12
Makefile
12
Makefile
|
|
@ -16,7 +16,7 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test fmt lint lint-fix fix check run \
|
.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test fmt lint lint-fix fix check run \
|
||||||
deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index setup-dev run-local
|
deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index notify-test setup-dev run-local
|
||||||
|
|
||||||
help: ## 顯示可用指令
|
help: ## 顯示可用指令
|
||||||
@echo "Gateway Makefile"
|
@echo "Gateway Makefile"
|
||||||
|
|
@ -87,6 +87,9 @@ run-local: run-dev ## 別名:同 run-dev
|
||||||
deps-up: ## 啟動本機 Mongo + Redis(docker compose)
|
deps-up: ## 啟動本機 Mongo + Redis(docker compose)
|
||||||
docker compose up -d mongo redis
|
docker compose up -d mongo redis
|
||||||
|
|
||||||
|
deps-up-smtp: ## 啟動 Mongo + Redis + MailHog(本機 SMTP 測試)
|
||||||
|
docker compose --profile smtp up -d mongo redis mailhog
|
||||||
|
|
||||||
deps-down: ## 停止 docker compose 容器(保留 volume)
|
deps-down: ## 停止 docker compose 容器(保留 volume)
|
||||||
docker compose --profile smtp down
|
docker compose --profile smtp down
|
||||||
|
|
||||||
|
|
@ -102,5 +105,12 @@ deps-ps: ## 查看依賴服務狀態
|
||||||
mongo-index: ## 建立 notification Mongo 索引(需 Mongo 已啟動)
|
mongo-index: ## 建立 notification Mongo 索引(需 Mongo 已啟動)
|
||||||
$(GO) run ./cmd/mongo-index -f etc/gateway.dev.yaml
|
$(GO) run ./cmd/mongo-index -f etc/gateway.dev.yaml
|
||||||
|
|
||||||
|
notify-test: setup-dev ## 通知測試(METHOD 必填;例: make notify-test METHOD=email-send TO=a@b.com)
|
||||||
|
@test -n "$(METHOD)" || (echo "usage: make notify-test METHOD=email-send TO=you@example.com" && \
|
||||||
|
echo " make notify-test METHOD=sms-send PHONE=0912345678" && \
|
||||||
|
echo " make notify-test METHOD=email-send TO=t@e.com MOCK=1" && exit 1)
|
||||||
|
$(GO) run ./cmd/notify-test -f etc/gateway.dev.yaml -method "$(METHOD)" \
|
||||||
|
$(if $(TO),-to "$(TO)",) $(if $(PHONE),-phone "$(PHONE)",) $(if $(MOCK),-mock,)
|
||||||
|
|
||||||
config-check: ## 驗證 gateway.yaml / gateway.dev.yaml 可載入
|
config-check: ## 驗證 gateway.yaml / gateway.dev.yaml 可載入
|
||||||
$(GO) test ./internal/config/ -run TestLoadGatewayYAML -v
|
$(GO) test ./internal/config/ -run TestLoadGatewayYAML -v
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,450 @@
|
||||||
|
// Command notify-test runs one notification test by -method (point-and-shoot).
|
||||||
|
//
|
||||||
|
// make deps-up && make mongo-index
|
||||||
|
// make notify-test METHOD=email-send TO=you@example.com
|
||||||
|
// make notify-test METHOD=sms-send PHONE=0912345678 MOCK=1
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gateway/internal/config"
|
||||||
|
redislib "gateway/internal/library/redis"
|
||||||
|
dommember "gateway/internal/model/member/domain/usecase"
|
||||||
|
memberusecase "gateway/internal/model/member/usecase"
|
||||||
|
notifconfig "gateway/internal/model/notification/config"
|
||||||
|
"gateway/internal/model/notification/domain/enum"
|
||||||
|
domtpl "gateway/internal/model/notification/domain/template"
|
||||||
|
domusecase "gateway/internal/model/notification/domain/usecase"
|
||||||
|
notifusecase "gateway/internal/model/notification/usecase"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/zeromicro/go-zero/core/conf"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
methodEmailSend = "email-send"
|
||||||
|
methodEmailEnqueue = "email-enqueue"
|
||||||
|
methodEmailIdempotency = "email-idempotency"
|
||||||
|
methodSMSSend = "sms-send"
|
||||||
|
methodSMSEnqueue = "sms-enqueue"
|
||||||
|
methodMemberEmail = "member-email"
|
||||||
|
methodMemberPhone = "member-phone"
|
||||||
|
methodAdminDLQ = "admin-dlq"
|
||||||
|
)
|
||||||
|
|
||||||
|
var validMethods = []string{
|
||||||
|
methodEmailSend,
|
||||||
|
methodEmailEnqueue,
|
||||||
|
methodEmailIdempotency,
|
||||||
|
methodSMSSend,
|
||||||
|
methodSMSEnqueue,
|
||||||
|
methodMemberEmail,
|
||||||
|
methodMemberPhone,
|
||||||
|
methodAdminDLQ,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
configFile = flag.String("f", "etc/gateway.dev.yaml", "config file")
|
||||||
|
method = flag.String("method", "", "test method (required): "+strings.Join(validMethods, ", "))
|
||||||
|
toEmail = flag.String("to", "", "recipient email")
|
||||||
|
phone = flag.String("phone", "", "recipient phone")
|
||||||
|
tenantID = flag.String("tenant", "notify-test", "tenant_id")
|
||||||
|
uid = flag.String("uid", "notify-test-uid", "uid")
|
||||||
|
mockOnly = flag.Bool("mock", false, "force mock email/SMS providers")
|
||||||
|
pollSec = flag.Int("poll", 45, "max seconds to wait for async delivery (enqueue methods)")
|
||||||
|
)
|
||||||
|
|
||||||
|
type env struct {
|
||||||
|
ctx context.Context
|
||||||
|
tenant string
|
||||||
|
uid string
|
||||||
|
to string
|
||||||
|
phone string
|
||||||
|
locale string
|
||||||
|
notifier domusecase.NotifierUseCase
|
||||||
|
verification dommember.VerificationUseCase
|
||||||
|
admin domusecase.AdminNotifierUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage: notify-test -method <name> [options]\n\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Methods:\n")
|
||||||
|
for _, m := range validMethods {
|
||||||
|
fmt.Fprintf(os.Stderr, " %s\n", m)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " notify-test -method email-send -to you@example.com\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " notify-test -method email-enqueue -to you@example.com\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " notify-test -method sms-send -phone 0912345678\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " notify-test -method member-email -to you@example.com\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " notify-test -method admin-dlq\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " notify-test -method email-send -to t@e.com -mock\n")
|
||||||
|
flag.PrintDefaults()
|
||||||
|
}
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
m := strings.TrimSpace(*method)
|
||||||
|
if m == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "notify-test: -method is required")
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if !isValidMethod(m) {
|
||||||
|
fmt.Fprintf(os.Stderr, "notify-test: unknown method %q\n", m)
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if err := validateArgs(m); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "notify-test: %v\n", err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
var c config.Config
|
||||||
|
conf.MustLoad(*configFile, &c)
|
||||||
|
if c.Mongo.Host == "" {
|
||||||
|
fail("Mongo.Host is empty")
|
||||||
|
}
|
||||||
|
if c.Redis.Host == "" {
|
||||||
|
fail("Redis.Host is empty")
|
||||||
|
}
|
||||||
|
if c.Notification.Email.From == "" && needsEmailFrom(m) {
|
||||||
|
fail("Notification.Email.From is empty")
|
||||||
|
}
|
||||||
|
if *mockOnly {
|
||||||
|
forceMock(&c.Notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*pollSec+60)*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rds, err := redislib.NewClient(c.Redis)
|
||||||
|
if err != nil {
|
||||||
|
fail("redis: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mod, err := notifusecase.NewModuleFromParam(notifusecase.FactoryParam{
|
||||||
|
MongoConf: &c.Mongo,
|
||||||
|
Redis: rds,
|
||||||
|
Config: c.Notification,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fail("notification: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var verification dommember.VerificationUseCase
|
||||||
|
if m == methodMemberEmail || m == methodMemberPhone {
|
||||||
|
memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
|
||||||
|
Redis: rds,
|
||||||
|
Notifier: mod.Notifier,
|
||||||
|
Config: c.Member,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fail("member: %v", err)
|
||||||
|
}
|
||||||
|
verification = memberMod.Verification
|
||||||
|
}
|
||||||
|
|
||||||
|
e := &env{
|
||||||
|
ctx: ctx,
|
||||||
|
tenant: *tenantID,
|
||||||
|
uid: *uid,
|
||||||
|
to: *toEmail,
|
||||||
|
phone: *phone,
|
||||||
|
locale: c.Notification.DefaultLocale,
|
||||||
|
notifier: mod.Notifier,
|
||||||
|
verification: verification,
|
||||||
|
admin: mod.Admin,
|
||||||
|
}
|
||||||
|
|
||||||
|
if m == methodEmailEnqueue || m == methodSMSEnqueue {
|
||||||
|
if mod.RetryWorker == nil {
|
||||||
|
fail("retry worker not configured (need Redis)")
|
||||||
|
}
|
||||||
|
workerCtx, stop := context.WithCancel(context.Background())
|
||||||
|
go mod.RetryWorker.Run(workerCtx)
|
||||||
|
defer stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("method=%s email=%s sms=%s\n", m, strings.Join(emailProviders(&c.Notification), ","), strings.Join(smsProviders(&c.Notification), ","))
|
||||||
|
|
||||||
|
if err := runMethod(e, m); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "FAIL: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("OK")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMethod(e *env, m string) error {
|
||||||
|
switch m {
|
||||||
|
case methodEmailSend:
|
||||||
|
return e.emailSend()
|
||||||
|
case methodEmailEnqueue:
|
||||||
|
return e.emailEnqueue()
|
||||||
|
case methodEmailIdempotency:
|
||||||
|
return e.emailIdempotency()
|
||||||
|
case methodSMSSend:
|
||||||
|
return e.smsSend()
|
||||||
|
case methodSMSEnqueue:
|
||||||
|
return e.smsEnqueue()
|
||||||
|
case methodMemberEmail:
|
||||||
|
return e.memberEmail()
|
||||||
|
case methodMemberPhone:
|
||||||
|
return e.memberPhone()
|
||||||
|
case methodAdminDLQ:
|
||||||
|
return e.adminDLQ()
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unhandled method %q", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) emailSend() error {
|
||||||
|
dto, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{
|
||||||
|
TenantID: e.tenant,
|
||||||
|
UID: e.uid,
|
||||||
|
Channel: enum.ChannelEmail,
|
||||||
|
Kind: enum.NotifyVerifyEmail,
|
||||||
|
Target: e.to,
|
||||||
|
Locale: e.locale,
|
||||||
|
Data: map[string]any{domtpl.VarCode: "123456", domtpl.VarExpiresIn: 300},
|
||||||
|
IdempotencyKey: uuid.NewString(),
|
||||||
|
DoNotPersistBody: true,
|
||||||
|
Severity: enum.SeverityInfo,
|
||||||
|
})
|
||||||
|
return reportSent(dto, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) emailEnqueue() error {
|
||||||
|
pending, err := e.notifier.Enqueue(e.ctx, &domusecase.SendRequest{
|
||||||
|
TenantID: e.tenant,
|
||||||
|
UID: e.uid,
|
||||||
|
Channel: enum.ChannelEmail,
|
||||||
|
Kind: enum.NotifyTenantWelcome,
|
||||||
|
Target: e.to,
|
||||||
|
Locale: e.locale,
|
||||||
|
Data: map[string]any{"tenant_name": "Test Corp"},
|
||||||
|
IdempotencyKey: uuid.NewString(),
|
||||||
|
DoNotPersistBody: false,
|
||||||
|
Severity: enum.SeverityInfo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
final, err := waitSent(e.ctx, e.notifier, e.tenant, pending.ID, time.Duration(*pollSec)*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("notification_id=%s provider=%s status=%s\n", final.ID, final.Provider, final.Status)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) emailIdempotency() error {
|
||||||
|
key := uuid.NewString()
|
||||||
|
first, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{
|
||||||
|
TenantID: e.tenant,
|
||||||
|
UID: e.uid,
|
||||||
|
Channel: enum.ChannelEmail,
|
||||||
|
Kind: enum.NotifyVerifyEmail,
|
||||||
|
Target: e.to,
|
||||||
|
Locale: e.locale,
|
||||||
|
Data: map[string]any{domtpl.VarCode: "111111", domtpl.VarExpiresIn: 300},
|
||||||
|
IdempotencyKey: key,
|
||||||
|
Severity: enum.SeverityInfo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
second, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{
|
||||||
|
TenantID: e.tenant,
|
||||||
|
UID: e.uid,
|
||||||
|
Channel: enum.ChannelEmail,
|
||||||
|
Kind: enum.NotifyVerifyEmail,
|
||||||
|
Target: e.to,
|
||||||
|
Locale: e.locale,
|
||||||
|
Data: map[string]any{domtpl.VarCode: "222222", domtpl.VarExpiresIn: 300},
|
||||||
|
IdempotencyKey: key,
|
||||||
|
Severity: enum.SeverityInfo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if first.ID != second.ID {
|
||||||
|
return fmt.Errorf("idempotency: expected same id, got %s vs %s", first.ID, second.ID)
|
||||||
|
}
|
||||||
|
fmt.Printf("notification_id=%s (replay ok)\n", first.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) smsSend() error {
|
||||||
|
dto, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{
|
||||||
|
TenantID: e.tenant,
|
||||||
|
UID: e.uid,
|
||||||
|
Channel: enum.ChannelSMS,
|
||||||
|
Kind: enum.NotifyVerifyPhone,
|
||||||
|
Target: e.phone,
|
||||||
|
Locale: e.locale,
|
||||||
|
Data: map[string]any{domtpl.VarCode: "123456", domtpl.VarExpiresIn: 300},
|
||||||
|
IdempotencyKey: uuid.NewString(),
|
||||||
|
DoNotPersistBody: true,
|
||||||
|
Severity: enum.SeverityInfo,
|
||||||
|
})
|
||||||
|
return reportSent(dto, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) smsEnqueue() error {
|
||||||
|
pending, err := e.notifier.Enqueue(e.ctx, &domusecase.SendRequest{
|
||||||
|
TenantID: e.tenant,
|
||||||
|
UID: e.uid,
|
||||||
|
Channel: enum.ChannelSMS,
|
||||||
|
Kind: enum.NotifyVerifyPhone,
|
||||||
|
Target: e.phone,
|
||||||
|
Locale: e.locale,
|
||||||
|
Data: map[string]any{domtpl.VarCode: "654321", domtpl.VarExpiresIn: 300},
|
||||||
|
IdempotencyKey: uuid.NewString(),
|
||||||
|
Severity: enum.SeverityInfo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
final, err := waitSent(e.ctx, e.notifier, e.tenant, pending.ID, time.Duration(*pollSec)*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("notification_id=%s provider=%s\n", final.ID, final.Provider)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) memberEmail() error {
|
||||||
|
ch, err := e.verification.StartEmailVerify(e.ctx, e.tenant, e.uid, e.to, "zh-tw")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("challenge_id=%s expires_in=%d\n", ch.ChallengeID, ch.ExpiresIn)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) memberPhone() error {
|
||||||
|
ch, err := e.verification.StartPhoneVerify(e.ctx, e.tenant, e.uid, e.phone, "zh-tw")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("challenge_id=%s expires_in=%d\n", ch.ChallengeID, ch.ExpiresIn)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) adminDLQ() error {
|
||||||
|
if e.admin == nil {
|
||||||
|
return fmt.Errorf("admin notifier not configured")
|
||||||
|
}
|
||||||
|
entries, err := e.admin.ListDLQ(e.ctx, e.tenant, 10)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("dlq_count=%d\n", len(entries))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportSent(dto *domusecase.NotificationDTO, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dto.Status != enum.NotifyStatusSent {
|
||||||
|
return fmt.Errorf("status=%s last_error=%s", dto.Status, dto.LastError)
|
||||||
|
}
|
||||||
|
fmt.Printf("notification_id=%s provider=%s message_id=%s\n", dto.ID, dto.Provider, dto.ProviderMessageID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitSent(ctx context.Context, notifier domusecase.NotifierUseCase, tenantID, notificationID string, timeout time.Duration) (*domusecase.NotificationDTO, error) {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
dto, err := notifier.Get(ctx, tenantID, notificationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch dto.Status {
|
||||||
|
case enum.NotifyStatusSent:
|
||||||
|
return dto, nil
|
||||||
|
case enum.NotifyStatusFailed, enum.NotifyStatusDropped:
|
||||||
|
return dto, fmt.Errorf("status=%s: %s", dto.Status, dto.LastError)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("timeout after %s", timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateArgs(m string) error {
|
||||||
|
switch m {
|
||||||
|
case methodEmailSend, methodEmailEnqueue, methodEmailIdempotency, methodMemberEmail:
|
||||||
|
if *toEmail == "" {
|
||||||
|
return fmt.Errorf("%s requires -to", m)
|
||||||
|
}
|
||||||
|
case methodSMSSend, methodSMSEnqueue, methodMemberPhone:
|
||||||
|
if *phone == "" {
|
||||||
|
return fmt.Errorf("%s requires -phone", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsEmailFrom(m string) bool {
|
||||||
|
switch m {
|
||||||
|
case methodEmailSend, methodEmailEnqueue, methodEmailIdempotency, methodMemberEmail:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidMethod(m string) bool {
|
||||||
|
for _, v := range validMethods {
|
||||||
|
if v == m {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func forceMock(cfg *notifconfig.Config) {
|
||||||
|
cfg.Email.SMTP.Enable = false
|
||||||
|
cfg.Email.SES.Enable = false
|
||||||
|
cfg.Email.Provider = notifconfig.ProviderMock
|
||||||
|
cfg.SMS.Mitake.Enable = false
|
||||||
|
cfg.SMS.Provider = notifconfig.ProviderMock
|
||||||
|
}
|
||||||
|
|
||||||
|
func emailProviders(cfg *notifconfig.Config) []string {
|
||||||
|
var out []string
|
||||||
|
if cfg.Email.SMTP.Enable {
|
||||||
|
out = append(out, "smtp")
|
||||||
|
}
|
||||||
|
if cfg.Email.SES.Enable {
|
||||||
|
out = append(out, "ses")
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
out = append(out, "mock")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func smsProviders(cfg *notifconfig.Config) []string {
|
||||||
|
if cfg.SMS.Mitake.Enable {
|
||||||
|
return []string{"mitake"}
|
||||||
|
}
|
||||||
|
return []string{"mock"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(format string, args ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, "notify-test: "+format+"\n", args...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,15 @@ services:
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
|
mailhog:
|
||||||
|
profiles: ["smtp"]
|
||||||
|
image: mailhog/mailhog:v1.0.1
|
||||||
|
container_name: gateway-mailhog
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "1025:1025" # SMTP
|
||||||
|
- "8025:8025" # Web UI
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mongo_data:
|
mongo_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Notification 測試
|
||||||
|
|
||||||
|
單一入口:`make notify-test`,用 **METHOD** 指定要測哪一種,一次只跑一項。
|
||||||
|
|
||||||
|
異步(Enqueue + Worker)說明見 [internal/model/notification/README.md](../internal/model/notification/README.md#測異步enqueue)。
|
||||||
|
|
||||||
|
## 前置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make deps-up
|
||||||
|
make mongo-index
|
||||||
|
```
|
||||||
|
|
||||||
|
MailHog 本機 SMTP:`make deps-up-smtp`,yaml 設 `SMTP.Host=localhost`、`Port=1025`。
|
||||||
|
|
||||||
|
## METHOD 一覽
|
||||||
|
|
||||||
|
| METHOD | 需要參數 | 說明 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| `email-send` | `TO=` | 同步寄驗證信 |
|
||||||
|
| `email-enqueue` | `TO=` | 異步佇列 + Worker 送達 |
|
||||||
|
| `email-idempotency` | `TO=` | 同 key 不重送 |
|
||||||
|
| `sms-send` | `PHONE=` | 同步簡訊 |
|
||||||
|
| `sms-enqueue` | `PHONE=` | 異步簡訊 |
|
||||||
|
| `member-email` | `TO=` | Member 信箱 OTP 流程 |
|
||||||
|
| `member-phone` | `PHONE=` | Member 手機 OTP 流程 |
|
||||||
|
| `admin-dlq` | — | 列出 DLQ |
|
||||||
|
|
||||||
|
Provider 由 `gateway.dev.yaml` 的 `SMTP/SES/Mitake.Enable` 決定。
|
||||||
|
不連外網時加 **`MOCK=1`**(強制 mock)。
|
||||||
|
|
||||||
|
## 範例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 真實 SMTP(依 yaml)
|
||||||
|
make notify-test METHOD=email-send TO=you@example.com
|
||||||
|
|
||||||
|
# mock,不真的寄
|
||||||
|
make notify-test METHOD=email-send TO=test@example.com MOCK=1
|
||||||
|
|
||||||
|
make notify-test METHOD=email-enqueue TO=you@example.com
|
||||||
|
make notify-test METHOD=sms-send PHONE=0912345678
|
||||||
|
make notify-test METHOD=member-email TO=you@example.com
|
||||||
|
make notify-test METHOD=admin-dlq
|
||||||
|
```
|
||||||
|
|
||||||
|
成功輸出末尾為 `OK`;失敗為 `FAIL: ...` 且 exit 1。
|
||||||
|
|
||||||
|
等同:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/notify-test -method email-send -to you@example.com -f etc/gateway.dev.yaml
|
||||||
|
go run ./cmd/notify-test -method email-send -to t@e.com -mock
|
||||||
|
```
|
||||||
|
|
||||||
|
## Provider 提示
|
||||||
|
|
||||||
|
- 只測 **SES**:關 `SMTP.Enable`,開 `SES.Enable`
|
||||||
|
- 只測 **Mitake**:`SMS.Mitake.Enable: true` + `METHOD=sms-send`
|
||||||
|
- 同時開 SMTP + SES:依 **Sort** failover,通常只會打到第一個成功的
|
||||||
|
|
@ -227,6 +227,12 @@ go run gateway.go -f etc/gateway.yaml
|
||||||
|
|
||||||
# 3. dev 完整栈
|
# 3. dev 完整栈
|
||||||
make deps-up && make run-dev
|
make deps-up && make run-dev
|
||||||
|
|
||||||
|
# 4. 通知測試(單一入口,METHOD 指定測哪一種)
|
||||||
|
# 詳見 docs/notification-testing.md
|
||||||
|
make mongo-index
|
||||||
|
make notify-test METHOD=email-send TO=你的信箱@example.com
|
||||||
|
make notify-test METHOD=email-send TO=test@example.com MOCK=1
|
||||||
curl -s http://127.0.0.1:8888/api/v1/health
|
curl -s http://127.0.0.1:8888/api/v1/health
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,14 @@ Notification:
|
||||||
DefaultLocale: zh-tw
|
DefaultLocale: zh-tw
|
||||||
Email:
|
Email:
|
||||||
Provider: mock
|
Provider: mock
|
||||||
From: noreply@localhost
|
From: brad@code.30cm.net
|
||||||
SMTP:
|
SMTP:
|
||||||
Enable: false
|
Enable: true
|
||||||
Sort: 1
|
Sort: 1
|
||||||
Host: localhost
|
Host: smtp.mailgun.org
|
||||||
Port: 1025
|
Port: 587
|
||||||
|
Username: postmaster@code.30cm.net
|
||||||
|
Password: fc3827251d154c95d4dc383fa3ec0fbf-80ae0276-0941819f
|
||||||
SES:
|
SES:
|
||||||
Enable: false
|
Enable: false
|
||||||
Sort: 2
|
Sort: 2
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,175 @@
|
||||||
ji3
|
# Notification 模組
|
||||||
|
|
||||||
|
統一對外通知入口(Email / SMS),支援同步 `Send`、異步 `Enqueue` + Redis 重試 Worker、冪等、配額、DLQ。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 測試(本機)
|
||||||
|
|
||||||
|
單一 CLI:`cmd/notify-test`,Makefile 包一層:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make deps-up # Mongo + Redis(異步必備)
|
||||||
|
make mongo-index # 建索引
|
||||||
|
make notify-test METHOD=<名稱> [TO=] [PHONE=] [MOCK=1]
|
||||||
|
```
|
||||||
|
|
||||||
|
完整 METHOD 表見 [`docs/notification-testing.md`](../../../docs/notification-testing.md)。
|
||||||
|
|
||||||
|
### 同步 vs 異步
|
||||||
|
|
||||||
|
| 方式 | UseCase | 何時完成 | 需要 Redis |
|
||||||
|
|------|---------|----------|:----------:|
|
||||||
|
| **同步** | `Notifier.Send` | 呼叫當下送完(或失敗) | 建議(冪等/配額) |
|
||||||
|
| **異步** | `Notifier.Enqueue` | 先寫 Mongo `pending`,由 **RetryWorker** 從 Redis 佇列取出再送 | **必須** |
|
||||||
|
|
||||||
|
異步流程:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant App
|
||||||
|
participant Mongo
|
||||||
|
participant Redis
|
||||||
|
participant Worker
|
||||||
|
participant Provider
|
||||||
|
|
||||||
|
App->>Mongo: Insert status=pending
|
||||||
|
App->>Redis: ZADD 排程 job(立即或退避)
|
||||||
|
App-->>App: 回傳 pending DTO
|
||||||
|
Worker->>Redis: ClaimDue 到期 job
|
||||||
|
Worker->>Provider: Send 渲染後內容
|
||||||
|
Worker->>Mongo: Update status=sent / failed / retrying
|
||||||
|
Note over Worker,Mongo: 超過 MaxRetry → notification_dlq
|
||||||
|
```
|
||||||
|
|
||||||
|
### 測異步(Enqueue)
|
||||||
|
|
||||||
|
**前置:** `etc/gateway.dev.yaml` 要有 `Mongo`、`Redis`,且 `Notification.Async` 可維持預設(`Worker` ≥ 1)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Email 異步(歡迎信模板 tenant_welcome)
|
||||||
|
make notify-test METHOD=email-enqueue TO=you@example.com
|
||||||
|
|
||||||
|
# SMS 異步
|
||||||
|
make notify-test METHOD=sms-enqueue PHONE=0912345678
|
||||||
|
|
||||||
|
# mock:log 會印 [notification mock email/sms] 寄了什麼
|
||||||
|
make notify-test METHOD=email-enqueue TO=test@example.com MOCK=1
|
||||||
|
```
|
||||||
|
|
||||||
|
`notify-test` 在 `email-enqueue` / `sms-enqueue` 時會**暫時啟動** process 內 RetryWorker(與 `make run-dev` 相同邏輯),輪詢 Redis 佇列直到 Mongo 紀錄變 `sent` 或逾時(預設 45 秒,`-poll` 可改)。
|
||||||
|
|
||||||
|
**成功時輸出範例:**
|
||||||
|
|
||||||
|
```text
|
||||||
|
method=email-enqueue email=mock sms=mock
|
||||||
|
notification_id=... provider=mock status=sent
|
||||||
|
OK
|
||||||
|
```
|
||||||
|
|
||||||
|
**失敗常見原因:**
|
||||||
|
|
||||||
|
- 沒開 Redis → `async notification requires redis retry queue`
|
||||||
|
- Worker 沒跑、佇列卡住 → `timeout after 45s`
|
||||||
|
- `Email.From` 空白 → 啟動即失敗
|
||||||
|
|
||||||
|
**關於 log:** 若看到 `FindOne - fail(mongo: no documents in result)` 且 filter 含 `idempotency_key`,代表「第一次用這個 key 發送」,屬冪等查詢的正常結果,不是寄信失敗。新版 `FindByIdempotency` 已改用 `Find` 避免此誤導性 error log。
|
||||||
|
|
||||||
|
### 用 Gateway 長期跑 Worker(接近正式環境)
|
||||||
|
|
||||||
|
異步在 production 由 **同一個 Gateway process** 背景跑 Worker,不是只靠 CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run-dev # ServiceContext.StartWorkers → NotificationRetry
|
||||||
|
```
|
||||||
|
|
||||||
|
此時由 API/其他 use case 呼叫 `Enqueue` 即可;本機若尚無 Notification HTTP,仍用 `notify-test METHOD=email-enqueue` 觸發。
|
||||||
|
|
||||||
|
### 查 Mongo 確認異步結果
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mongosh gateway --eval '
|
||||||
|
db.notifications.find({ tenant_id: "notify-test" })
|
||||||
|
.sort({ occurred_at: -1 })
|
||||||
|
.limit(3)
|
||||||
|
.forEach(d => print(d.kind, d.status, d.provider, d.attempts))
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
| `status` | 意義 |
|
||||||
|
|----------|------|
|
||||||
|
| `pending` | 已 Enqueue,Worker 尚未送完 |
|
||||||
|
| `sent` | 已送達 |
|
||||||
|
| `retrying` | 失敗、等待退避重試 |
|
||||||
|
| `failed` / `dropped` | 放棄或進 DLQ |
|
||||||
|
|
||||||
|
DLQ:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make notify-test METHOD=admin-dlq
|
||||||
|
```
|
||||||
|
|
||||||
|
### Redis 佇列 key
|
||||||
|
|
||||||
|
預設 zset:`notif:retry:zset`(可由 `Notification.Async.QueueRedisKey` 覆寫,example 為 `notification:queue`)。
|
||||||
|
|
||||||
|
### 同步對照(順便測)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make notify-test METHOD=email-send TO=you@example.com MOCK=1
|
||||||
|
make notify-test METHOD=sms-send PHONE=0912345678 MOCK=1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 設定摘要(`gateway.dev.yaml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
Notification:
|
||||||
|
Async:
|
||||||
|
QueueRedisKey: notification:queue # 可省略,預設 notif:retry:zset
|
||||||
|
Worker: 2
|
||||||
|
MaxRetry: 5
|
||||||
|
BackoffSeconds: [1, 5, 30, 300, 1800]
|
||||||
|
Email:
|
||||||
|
From: noreply@example.com # 必填
|
||||||
|
SMTP:
|
||||||
|
Enable: true # 真實 SMTP;MOCK=1 時 CLI 會關閉
|
||||||
|
```
|
||||||
|
|
||||||
|
Email provider 看 `SMTP.Enable` / `SES.Enable`,不是 `Provider: smtp` 字串。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 擴充指南
|
## 擴充指南
|
||||||
|
|
||||||
### 新增 Email Provider
|
### 新增 Email Provider
|
||||||
|
|
||||||
已內建:`smtp_sender.go`、`ses_sender.go`。若要再加其他 ESP:
|
已內建:`smtp_sender.go`、`ses_sender.go`。
|
||||||
|
|
||||||
1. 在 `provider/email/` 實作 `Sender`(`Name`、`Sort`、`Send`)。
|
1. 在 `provider/email/` 實作 `Sender`(`Name`、`Sort`、`Send`)。
|
||||||
2. 在 `config/config.go` 加設定區塊,並在 `collectEmailSenders` 註冊。
|
2. 在 `config/config.go` 加設定,並在 `usecase/factory.go` 的 `collectEmailSenders` 註冊。
|
||||||
3. 補 `provider/email/*_test.go`。
|
3. 補 `provider/email/*_test.go`。
|
||||||
|
|
||||||
### 新增 NotifyKind
|
### 新增 NotifyKind
|
||||||
|
|
||||||
1. `domain/enum/kind.go` 常數。
|
1. `domain/enum/kind.go` 常數。
|
||||||
2. embed 模板 + `template/registry.go`。
|
2. embed 模板 + `template/registry.go`。
|
||||||
3. 若為 OTP 類,業務呼叫時設 `DoNotPersistBody: true`。
|
3. OTP 類業務呼叫時設 `DoNotPersistBody: true`。
|
||||||
|
|
||||||
### 新增 HTTP API
|
### 新增 HTTP API
|
||||||
|
|
||||||
1. 在 `generate/api/` 定義路由。
|
1. 在 `generate/api/` 定義路由。
|
||||||
2. `make gen-api`。
|
2. `make gen-api`。
|
||||||
3. `internal/logic` 只呼叫 `domain/usecase` 介面,做 types ↔ DTO 映射。
|
3. `internal/logic` 只呼叫 `domain/usecase`,做 types ↔ DTO 映射。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 相關文件
|
## 相關文件
|
||||||
|
|
||||||
- [docs/model.md](../../../docs/model.md) — `domain/` 分包、Redis/Mongo 連線生命週期、gomock 方案 A
|
- [docs/notification-testing.md](../../../docs/notification-testing.md) — `notify-test` METHOD 速查
|
||||||
- [docs/identity-member-design.md §11](../../../docs/identity-member-design.md#11-notification-module) — 產品級設計與決策
|
- [docs/model.md](../../../docs/model.md) — domain 分包、Redis/Mongo 生命週期
|
||||||
- [internal/library/redis/README.md](../../library/redis/README.md) — 共用 Redis 連線
|
- [docs/identity-member-design.md §11](../../../docs/identity-member-design.md#11-notification-module) — 產品設計
|
||||||
- [internal/library/mongo/README.md](../../library/mongo/README.md) — Mongo 存取層
|
- [etc/README.md](../../../etc/README.md) — Gateway 設定
|
||||||
|
- [internal/library/redis/README.md](../../library/redis/README.md)
|
||||||
|
- [internal/library/mongo/README.md](../../library/mongo/README.md)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@ package email
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockSender records calls and returns configurable results (for tests and local dev).
|
// MockSender records calls and returns configurable results (for tests and local dev).
|
||||||
|
|
@ -62,9 +65,20 @@ func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) {
|
||||||
if m.Err != nil {
|
if m.Err != nil {
|
||||||
return "", m.Err
|
return "", m.Err
|
||||||
}
|
}
|
||||||
|
if msg != nil {
|
||||||
|
logx.Infof("[notification mock email] from=%s to=%s subject=%q body=%s message_id=%s",
|
||||||
|
msg.From, strings.Join(msg.To, ","), msg.Subject, truncateForLog(msg.Body, 500), m.MessageID)
|
||||||
|
}
|
||||||
return m.MessageID, nil
|
return m.MessageID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func truncateForLog(s string, max int) string {
|
||||||
|
if max <= 0 || len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:max] + "…(truncated)"
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockSender) Calls() []*Message {
|
func (m *MockSender) Calls() []*Message {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockSender records calls and returns configurable results (for tests and local dev).
|
// MockSender records calls and returns configurable results (for tests and local dev).
|
||||||
|
|
@ -62,6 +64,10 @@ func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) {
|
||||||
if m.Err != nil {
|
if m.Err != nil {
|
||||||
return "", m.Err
|
return "", m.Err
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
return m.MessageID, nil
|
return m.MessageID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
|
mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NotificationRepositoryParam configures the Mongo notification repository.
|
// NotificationRepositoryParam configures the Mongo notification repository.
|
||||||
|
|
@ -82,19 +83,20 @@ func (r *notificationRepository) FindByIdempotency(
|
||||||
kind enum.NotifyKind,
|
kind enum.NotifyKind,
|
||||||
idempotencyKey string,
|
idempotencyKey string,
|
||||||
) (*domentity.Notification, error) {
|
) (*domentity.Notification, error) {
|
||||||
var doc domentity.Notification
|
|
||||||
filter := bson.M{
|
filter := bson.M{
|
||||||
notification.BSONFieldTenantID: tenantID,
|
notification.BSONFieldTenantID: tenantID,
|
||||||
notification.BSONFieldKind: kind,
|
notification.BSONFieldKind: kind,
|
||||||
notification.BSONFieldIdempotencyKey: idempotencyKey,
|
notification.BSONFieldIdempotencyKey: idempotencyKey,
|
||||||
}
|
}
|
||||||
if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil {
|
var docs []domentity.Notification
|
||||||
if errors.Is(err, mongodriver.ErrNoDocuments) {
|
opts := options.Find().SetLimit(1)
|
||||||
return nil, notification.ErrNotFound
|
if err := r.db.GetClient().Find(ctx, &docs, filter, opts); err != nil {
|
||||||
}
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &doc, nil
|
if len(docs) == 0 {
|
||||||
|
return nil, notification.ErrNotFound
|
||||||
|
}
|
||||||
|
return &docs[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *notificationRepository) UpdateDelivery(ctx context.Context, tenantID, id string, update *domrepo.NotificationDeliveryUpdate) error {
|
func (r *notificationRepository) UpdateDelivery(ctx context.Context, tenantID, id string, update *domrepo.NotificationDeliveryUpdate) error {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue