fix error msg

This commit is contained in:
王性驊 2026-05-20 17:32:22 +08:00
parent 35c6577ac8
commit 3afe3f9502
10 changed files with 723 additions and 20 deletions

View File

@ -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 + Redisdocker compose deps-up: ## 啟動本機 Mongo + Redisdocker 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

450
cmd/notify-test/main.go Normal file
View File

@ -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)
}

View File

@ -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:

View File

@ -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通常只會打到第一個成功的

View File

@ -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
``` ```

View File

@ -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

View File

@ -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
# mocklog 會印 [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` | 已 EnqueueWorker 尚未送完 |
| `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 # 真實 SMTPMOCK=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)

View File

@ -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()

View File

@ -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
} }

View File

@ -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 {