diff --git a/Makefile b/Makefile index a317159..54ddb76 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 .DEFAULT_GOAL := help .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: ## 顯示可用指令 @echo "Gateway Makefile" @@ -87,6 +87,9 @@ run-local: run-dev ## 別名:同 run-dev deps-up: ## 啟動本機 Mongo + Redis(docker compose) 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) docker compose --profile smtp down @@ -102,5 +105,12 @@ deps-ps: ## 查看依賴服務狀態 mongo-index: ## 建立 notification Mongo 索引(需 Mongo 已啟動) $(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 可載入 $(GO) test ./internal/config/ -run TestLoadGatewayYAML -v diff --git a/cmd/notify-test/main.go b/cmd/notify-test/main.go new file mode 100644 index 0000000..b8de4ea --- /dev/null +++ b/cmd/notify-test/main.go @@ -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 [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) +} diff --git a/docker-compose.yml b/docker-compose.yml index 22d05ce..3bb4db5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,15 @@ services: timeout: 3s 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: mongo_data: redis_data: diff --git a/docs/notification-testing.md b/docs/notification-testing.md new file mode 100644 index 0000000..01764f2 --- /dev/null +++ b/docs/notification-testing.md @@ -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,通常只會打到第一個成功的 diff --git a/etc/README.md b/etc/README.md index cbf1d65..72ac90a 100644 --- a/etc/README.md +++ b/etc/README.md @@ -227,6 +227,12 @@ go run gateway.go -f etc/gateway.yaml # 3. 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 ``` diff --git a/etc/gateway.dev.yaml b/etc/gateway.dev.yaml index 342c7de..21deafb 100644 --- a/etc/gateway.dev.yaml +++ b/etc/gateway.dev.yaml @@ -26,12 +26,14 @@ Notification: DefaultLocale: zh-tw Email: Provider: mock - From: noreply@localhost + From: brad@code.30cm.net SMTP: - Enable: false + Enable: true Sort: 1 - Host: localhost - Port: 1025 + Host: smtp.mailgun.org + Port: 587 + Username: postmaster@code.30cm.net + Password: fc3827251d154c95d4dc383fa3ec0fbf-80ae0276-0941819f SES: Enable: false Sort: 2 diff --git a/internal/model/notification/README.md b/internal/model/notification/README.md index 41f9e26..3e0691d 100644 --- a/internal/model/notification/README.md +++ b/internal/model/notification/README.md @@ -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 -已內建:`smtp_sender.go`、`ses_sender.go`。若要再加其他 ESP: +已內建:`smtp_sender.go`、`ses_sender.go`。 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`。 ### 新增 NotifyKind 1. `domain/enum/kind.go` 常數。 2. embed 模板 + `template/registry.go`。 -3. 若為 OTP 類,業務呼叫時設 `DoNotPersistBody: true`。 +3. OTP 類業務呼叫時設 `DoNotPersistBody: true`。 ### 新增 HTTP API 1. 在 `generate/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/identity-member-design.md §11](../../../docs/identity-member-design.md#11-notification-module) — 產品級設計與決策 -- [internal/library/redis/README.md](../../library/redis/README.md) — 共用 Redis 連線 -- [internal/library/mongo/README.md](../../library/mongo/README.md) — Mongo 存取層 +- [docs/notification-testing.md](../../../docs/notification-testing.md) — `notify-test` METHOD 速查 +- [docs/model.md](../../../docs/model.md) — domain 分包、Redis/Mongo 生命週期 +- [docs/identity-member-design.md §11](../../../docs/identity-member-design.md#11-notification-module) — 產品設計 +- [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) diff --git a/internal/model/notification/provider/email/mock_sender.go b/internal/model/notification/provider/email/mock_sender.go index 866d6e0..8260647 100644 --- a/internal/model/notification/provider/email/mock_sender.go +++ b/internal/model/notification/provider/email/mock_sender.go @@ -3,7 +3,10 @@ package email import ( "context" "fmt" + "strings" "sync" + + "github.com/zeromicro/go-zero/core/logx" ) // 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 { 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 } +func truncateForLog(s string, max int) string { + if max <= 0 || len(s) <= max { + return s + } + return s[:max] + "…(truncated)" +} + func (m *MockSender) Calls() []*Message { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/model/notification/provider/sms/mock_sender.go b/internal/model/notification/provider/sms/mock_sender.go index 2710d0e..3b078df 100644 --- a/internal/model/notification/provider/sms/mock_sender.go +++ b/internal/model/notification/provider/sms/mock_sender.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "sync" + + "github.com/zeromicro/go-zero/core/logx" ) // 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 { 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 } diff --git a/internal/model/notification/repository/notification.go b/internal/model/notification/repository/notification.go index 4319ff7..2ea54f7 100644 --- a/internal/model/notification/repository/notification.go +++ b/internal/model/notification/repository/notification.go @@ -13,6 +13,7 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" mongodriver "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" ) // NotificationRepositoryParam configures the Mongo notification repository. @@ -82,19 +83,20 @@ func (r *notificationRepository) FindByIdempotency( kind enum.NotifyKind, idempotencyKey string, ) (*domentity.Notification, error) { - var doc domentity.Notification filter := bson.M{ notification.BSONFieldTenantID: tenantID, notification.BSONFieldKind: kind, notification.BSONFieldIdempotencyKey: idempotencyKey, } - if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil { - if errors.Is(err, mongodriver.ErrNoDocuments) { - return nil, notification.ErrNotFound - } + var docs []domentity.Notification + opts := options.Find().SetLimit(1) + if err := r.db.GetClient().Find(ctx, &docs, filter, opts); err != nil { 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 {