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
|
||||
|
||||
.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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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 完整栈
|
||||
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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue