add notification and member modules with local dev stack

Implement outbound notification (sync/async, idempotency, quota, DLQ),
member OTP/verification, SMTP/SES/Mitake providers, shared Redis wiring,
docker-compose for Mongo/Redis, and gateway config documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王性驊 2026-05-20 15:01:08 +08:00
parent 1274c56cb5
commit 49e7099bf2
124 changed files with 7998 additions and 1325 deletions

View File

@ -15,7 +15,8 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
.DEFAULT_GOAL := help
.PHONY: help tools gen-api 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 run-local
help: ## 顯示可用指令
@echo "Gateway Makefile"
@ -30,7 +31,10 @@ help: ## 顯示可用指令
tools: ## 安裝 goctl、goimports、golangci-lint需 Go且 GOPATH/bin 在 PATH
@command -v $(GOCTL) >/dev/null 2>&1 || (echo ">> installing goctl" && $(GO) install $(GOCTL_PKG))
@command -v goimports >/dev/null 2>&1 || (echo ">> installing goimports" && $(GO) install golang.org/x/tools/cmd/goimports@latest)
@command -v golangci-lint >/dev/null 2>&1 || (echo ">> installing golangci-lint" && $(GO) install $(GOLANGCI_PKG))
@if ! command -v golangci-lint >/dev/null 2>&1 || ! golangci-lint version 2>/dev/null | grep -q 'version 2\.'; then \
echo ">> installing golangci-lint v2"; \
$(GO) install $(GOLANGCI_PKG); \
fi
@echo "tools OK"
@echo " goctl: $$(goctl --version 2>/dev/null || echo missing)"
@echo " golangci-lint: $$(golangci-lint version 2>/dev/null | head -1 || echo missing)"
@ -38,6 +42,9 @@ tools: ## 安裝 goctl、goimports、golangci-lint需 Go且 GOPATH/bin 在
gen-api: tools ## 由 .api 生成 handler / logic / types自訂 handler 模板)
$(GOCTL) api go -api $(API_ENTRY) -dir . -style $(GO_ZERO_STYLE) -home generate/goctl
gen-mock: ## 依 go:generate 產生 internal/model/*/mockgomock
$(GO) generate ./internal/model/...
build-go-doc: ## 編譯 go-docOpenAPI 文件生成器)
@echo ">> building $(GO_DOC_BIN)"
@mkdir -p $(GO_DOC_DIR)/bin
@ -55,17 +62,41 @@ fmt: ## gofmt + goimports不含 lint
$(GOFMT) -s -w $(GOFILES)
@command -v goimports >/dev/null 2>&1 && goimports -w . || (echo "goimports not found; run: make tools" && exit 1)
lint: ## golangci-lint 靜態檢查(先 make tools
@command -v golangci-lint >/dev/null 2>&1 || (echo "golangci-lint not found; run: make tools" && exit 1)
lint: tools ## golangci-lint 靜態檢查
golangci-lint run ./...
lint-fix: ## 自動修正可修的 lint / formatter 問題(見 .golangci.yml
@command -v golangci-lint >/dev/null 2>&1 || (echo "golangci-lint not found; run: make tools" && exit 1)
lint-fix: tools ## 自動修正可修的 lint / formatter 問題(見 .golangci.yml
golangci-lint run --fix ./...
fix: fmt lint-fix lint ## 格式化 + 自動修 lint + 再檢查(提交前建議)
check: fix test ## 提交 / PR 前完整檢查fmt、lint、test
run: ## 啟動 Gatewayetc/gateway.yaml
run: ## 啟動 Gatewayetc/gateway.yaml,無需 Docker
$(GO) run gateway.go -f etc/gateway.yaml
run-dev: ## 啟動 Gatewayetc/gateway.dev.yaml需 make deps-up
$(GO) run gateway.go -f etc/gateway.dev.yaml
run-local: run-dev ## 別名:同 run-dev
deps-up: ## 啟動本機 Mongo + Redisdocker compose
docker compose up -d mongo redis
deps-down: ## 停止 docker compose 容器(保留 volume
docker compose --profile smtp down
deps-down-v: ## 停止並刪除 volume清空 Mongo/Redis 資料)
docker compose --profile smtp down -v
deps-logs: ## 查看依賴服務 log
docker compose --profile smtp logs -f
deps-ps: ## 查看依賴服務狀態
docker compose --profile smtp ps
mongo-index: ## 建立 notification Mongo 索引(需 Mongo 已啟動)
$(GO) run ./cmd/mongo-index -f etc/gateway.dev.yaml
config-check: ## 驗證 gateway.yaml / gateway.dev.yaml 可載入
$(GO) test ./internal/config/ -run TestLoadGatewayYAML -v

View File

@ -42,6 +42,18 @@ make run
# 或go run gateway.go -f etc/gateway.yaml
```
### 本機 Mongo + RedisNotification / Member OTP
需要持久化通知、異步重試或 member 驗證時:
```bash
make deps-up # Mongo :27017、Redis :6379
make mongo-index # 建立 notifications / notification_dlq 索引
make run-dev # 使用 etc/gateway.dev.yaml
```
詳見 [deploy/README.md](deploy/README.md)、[etc/README.md](etc/README.md)。選用 MailHog`make deps-up-smtp`。
產生 OpenAPI會先編譯 `generate/doc-generate` 內的 go-doc
```bash
@ -79,7 +91,11 @@ curl -s http://127.0.0.1:8888/api/v1/health | jq
| `make lint-fix` | 自動修正可修的 lint / import 問題 |
| `make fix` | `fmt` + `lint-fix` + `lint`(提交前建議) |
| `make check` | `fix` + `test`PR / AI 完成前必跑) |
| `make run` | 啟動 Gateway |
| `make run` | 啟動 Gateway無 DB |
| `make deps-up` | Docker 啟動 Mongo + Redis |
| `make run-dev` | 啟動 Gateway`etc/gateway.dev.yaml`,需 Docker |
| `make config-check` | 驗證 yaml 可載入 |
| `make mongo-index` | 建立 notification Mongo 索引 |
## 專案結構

46
cmd/mongo-index/main.go Normal file
View File

@ -0,0 +1,46 @@
// Command mongo-index ensures Gateway notification MongoDB indexes exist.
// Use when docker-entrypoint-initdb.d did not run (existing mongo_data volume).
package main
import (
"context"
"flag"
"fmt"
"os"
"time"
"gateway/internal/config"
notifrepo "gateway/internal/model/notification/repository"
"github.com/zeromicro/go-zero/core/conf"
)
var configFile = flag.String("f", "etc/gateway.dev.yaml", "config file")
func main() {
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
if c.Mongo.Host == "" {
fmt.Fprintln(os.Stderr, "mongo-index: Mongo.Host is empty in config")
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
notifRepo := notifrepo.NewNotificationRepository(notifrepo.NotificationRepositoryParam{Conf: &c.Mongo})
dlqRepo := notifrepo.NewNotificationDLQRepository(notifrepo.NotificationDLQRepositoryParam{Conf: &c.Mongo})
if err := notifRepo.Index20260520001UP(ctx); err != nil {
fmt.Fprintf(os.Stderr, "mongo-index: notifications: %v\n", err)
os.Exit(1)
}
if err := dlqRepo.Index20260520001UP(ctx); err != nil {
fmt.Fprintf(os.Stderr, "mongo-index: notification_dlq: %v\n", err)
os.Exit(1)
}
fmt.Println("mongo-index: notifications + notification_dlq indexes OK")
}

56
deploy/README.md Normal file
View File

@ -0,0 +1,56 @@
# 本機依賴Docker Compose
Gateway 啟用 **Notification** / **Member OTP** 需要:
| 服務 | 用途 | 預設埠 |
|------|------|--------|
| **MongoDB** | `notifications`、`notification_dlq` collections | 27017 |
| **Redis** | 冪等、配額、異步重試佇列、member OTP challenge | 6379 |
| MailHog選用 | 本機 SMTP 測試 | 1025 / 8025 |
Mongo **不需要**事先手動建 collection應用程式寫入時會自動建立。索引由 init script 或 `make mongo-index` 建立。
## 快速開始
```bash
# 1. 啟動 Mongo + Redis
make deps-up
# 2.(選用)含 MailHog
make deps-up-smtp
# 3. 確認索引(首次 docker volume 通常已由 init 建立;可再跑一次保險)
make mongo-index
# 4. 啟動 Gateway使用 etc/gateway.dev.yaml
make run-dev
```
## Mongo collections
| Collection | 模組 | 說明 |
|------------|------|------|
| `notifications` | notification | 發送紀錄、冪等 |
| `notification_dlq` | notification | 超過 MaxRetry 的死信 |
索引定義見 [`deploy/mongo/init/01-gateway-indexes.js`](mongo/init/01-gateway-indexes.js),與 Go 的 `Index20260520001UP` 一致。
## 常用指令
```bash
make deps-up # docker compose up -d mongo redis
make deps-up-smtp # 再加上 mailhogprofile smtp
make deps-down # 停止並移除容器(保留 volume
make deps-down-v # 停止並刪除 volume會清掉 Mongo 資料)
make deps-logs # 查看 log
make mongo-index # 手動建立/補齊索引
```
## 連線設定
設定說明:[`etc/README.md`](../etc/README.md)
| 檔案 | 用途 |
|------|------|
| [`etc/gateway.yaml`](../etc/gateway.yaml) | 預設,無需 Docker |
| [`etc/gateway.dev.yaml`](../etc/gateway.dev.yaml) | 本機完整功能(`make run-dev` |

View File

@ -0,0 +1,31 @@
// Gateway MongoDB 初始化(僅在 data volume 首次建立時執行)
// 與 internal/model/notification/repository/* Index20260520001UP 對齊
// 既有 volume 請執行make mongo-index
db = db.getSiblingDB('gateway');
print('Creating indexes on notifications...');
db.notifications.createIndex(
{ tenant_id: 1, kind: 1, idempotency_key: 1 },
{ unique: true, name: 'idx_notifications_tenant_kind_idempotency' }
);
db.notifications.createIndex(
{ tenant_id: 1, uid: 1, occurred_at: -1 },
{ name: 'idx_notifications_tenant_uid_occurred' }
);
db.notifications.createIndex(
{ status: 1, attempts: 1, occurred_at: 1 },
{ name: 'idx_notifications_status_attempts_occurred' }
);
print('Creating indexes on notification_dlq...');
db.notification_dlq.createIndex(
{ tenant_id: 1, occurred_at: -1 },
{ name: 'idx_notification_dlq_tenant_occurred' }
);
print('Gateway Mongo init done.');

43
docker-compose.yml Normal file
View File

@ -0,0 +1,43 @@
# 本機開發依賴MongoDBnotification 持久化、Redis冪等配額異步重試member OTP
#
# 啟動make deps-up
# 設定etc/gateway.dev.yaml搭配 make run-dev
# 索引:首次啟動由 deploy/mongo/init 建立;既有 volume 可執行 make mongo-index
services:
mongo:
image: mongo:7
container_name: gateway-mongo
restart: unless-stopped
ports:
- "27017:27017"
environment:
MONGO_INITDB_DATABASE: gateway
volumes:
- mongo_data:/data/db
- ./deploy/mongo/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
redis:
image: redis:7-alpine
container_name: gateway-redis
restart: unless-stopped
ports:
- "6379:6379"
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
volumes:
mongo_data:
redis_data:

File diff suppressed because it is too large Load Diff

View File

@ -2,48 +2,113 @@
本文件定義 Gateway 專案中 **業務模型**`internal/model/{module}/`)的目錄結構與撰寫約定,參考 Clean Architecture 分層。
> Gateway 的 HTTP 層handler / logic / types仍由 goctl 生成;本規範適用於 **`internal/model/{module}/`**(例如 `internal/model/member/`——entity、repository、usecase 等。**不使用 `pkg/`。**
> Gateway 的 HTTP 層handler / logic / types仍由 goctl 生成;本規範適用於 **`internal/model/{module}/`**(例如 `internal/model/member/`、`internal/model/notification/`。**不使用 `pkg/`。**
專案總覽與錯誤分層見 [README.md](../README.md)。
## 目錄結構
## 目錄結構`domain/` + 外層實作)
以下以 `member` 模組為例,路徑前綴為 `internal/model/member/`
**新模組與重構中的模組**`notification`、`member`、`permission` 等)採 **domain 分包**:契約與領域型別在 `domain/`Mongo / Redis / Provider / embed 等實作在模組根下對應目錄。對齊 `app-cloudep-notification-service/pkg/``domain/` 慣例。
路徑前綴:`internal/model/{module}/`
```
internal/model/
└── member/
├── entity/ # 持久化資料模型MongoDB document
├── enum/ # 領域值物件 / 列舉Platform、Status…
├── repository/ # Repository 介面 + 實作
├── usecase/ # UseCase 介面、Request/Response DTO、實作
├── config/ # 模組用設定 struct
├── errors.go # 模組 sentinelErrNotFound 等),非第二套 8 碼
├── const.go # 模組常數
├── redis.go # Redis key 命名與 helper
└── mock/ # mockgen 產物
└── {module}/ # 例notification、member、permission
├── domain/ # 純領域介面、實體、列舉、DTO不依賴 mongo/redis/provider
│ ├── entity/ # Mongo document 結構 + CollectionName()
│ ├── enum/ # Channel、Status、Platform…
│ ├── repository/ # Repository / Cache 介面 only
│ ├── usecase/ # UseCase 介面 + Request/Response DTO
│ └── template/ # 可選:模板 Spec、Registry、Renderer 介面notification
├── repository/ # domain/repository 的 Mongo / Redis / memory 實作
├── usecase/ # domain/usecase 的實作 + factory 組裝
├── template/ # 可選go:embed、DefaultRegistry、Renderer 實作
├── provider/ # 可選:僅本模組用的 Senderemail/sms不放 library/
├── config/ # 模組設定 struct嵌入 gateway Config
├── errors.go # 模組 sentinel
├── const.go # BSON 欄位名、模組常數
├── redis.go # Redis key 命名
└── mock/ # mockgen路徑對應 domain/
├── repository/
└── usecase/
```
**參考實作:** [`internal/model/notification/`](../internal/model/notification/)N0N5 核心已完成;流程圖與設定見 [**notification README**](../internal/model/notification/README.md))。
| 層 | 路徑 | 內容 |
|----|------|------|
| 領域契約 | `domain/entity`、`domain/enum` | 實體、值物件 |
| 領域契約 | `domain/repository` | `XxxRepository`、`IdempotencyCache` 等 **interface** |
| 領域契約 | `domain/usecase` | `XxxUseCase`、`SendRequest` 等 **interface + DTO** |
| 基礎設施 | `repository/` | `NewXxxRepository`、index migration |
| 應用服務 | `usecase/` | `MustXxxUseCase`、`NewXxxUseCaseFromParam` |
| 模組專用整合 | `provider/`、`template/` | 不進 `internal/library/` |
### 依賴方向
```
usecase實作 → repository介面
→ entity、enum
→ usecase介面 + DTO
domain/entity、domain/enum → 僅標準庫 / 列舉底層型別
repository實作 → repository介面
→ entity
domain/repository、domain/usecase、domain/template
domain/entity、domain/enum彼此不 import 實作層)
entity、enum → 只依賴標準庫或第三方型別
usecase實作 → domain/usecase實作介面
→ domain/repository介面
→ repository/、provider/、template/(具體型別)
internal/logic → model/{module}/usecase 介面 only不 import repository、entity
repository實作 → domain/repository實作介面
→ domain/entity
internal/logic、其他 model → domain/usecase 介面 only
(不 import repository 實作、entity、provider
```
## 1. Entity`entity/`
**Import 範例notification**
每個 MongoDB collection 對應一個 struct放在 `internal/model/{module}/entity/`
```go
import (
domusecase "gateway/internal/model/notification/domain/usecase"
"gateway/internal/model/notification/usecase"
)
// 業務模組(如 member只依賴介面
var _ domusecase.NotifierUseCase = (*usecase.notifierUseCase)(nil)
// ServiceContext 組裝實作
notifier, err := usecase.NewNotifierUseCaseFromParam(usecase.FactoryParam{...})
```
### 基礎設施連線Mongo / Redis
**一個 Pod一個進程連線在 `internal/svc/service_context.go` 建立一次,再注入各 module。**
| 資源 | 建立位置 | 共用方式 | 模組內 |
|------|----------|----------|--------|
| Mongo | `library/mongo` + go-zero `mon` | 同一 URI → **一個 `*mongo.Client` pool**`mon` 的 `clientManager` | 每 collection 一個 `DocumentDB` / repository**不**各自 `Connect` |
| Redis | `library/redis.NewClient` + go-zero `redis.MustNewRedis` | 同一 `Addr`**一個 connection pool** | 只收 `*redis.Client`**禁止**在 factory 內 `go-redis.NewClient` |
```go
// internal/svc/service_context.go示意
rds, _ := redislib.NewClient(c.Redis) // 全進程共用
notifier, _ := notifusecase.NewNotifierUseCaseFromParam(notifusecase.FactoryParam{
MongoConf: &c.Mongo,
Redis: rds, // 注入,非 RedisConf
Config: c.Notification,
})
```
- `ServiceContext.Redis``*library/redis.Client``Host` 空則 `nil`(模組可 fallback memory
- 之後 **member / permission** 的 factory、`MustXxxUseCase` **必須**接受 `*redislib.Client`,不得在 module 內新建 Redis 連線。
- 模組 `redis.go` 只定義 **key 前綴**,不建立 client。
### 與舊版扁平目錄的關係
早期 scaffold 可能仍為 `member/entity`(無 `domain/`)。**新增 `permission`、重構 `member` 時請遷移到上表結構**,與 `notification` 一致。遷移步驟:先搬 `entity`/`enum` → `domain/`,再搬 repository/usecase **介面**`domain/`,最後保留外層實作並修正 import。
## 1. Entity`domain/entity/`
每個 MongoDB collection 對應一個 struct放在 `internal/model/{module}/domain/entity/`
**規則:**
@ -51,10 +116,10 @@ internal/logic → model/{module}/usecase 介面 only不 import repository
- struct 名稱使用 PascalCase 單數,如 `Account`、`User`。
- 必須實作 `CollectionName() string`,回傳 MongoDB collection 名稱。
- 欄位 tag`bson` 必填;對外 JSON 序列化才加 `json`
- 主鍵使用 `primitive.ObjectID`tag 為 `` `bson:"_id,omitempty" json:"id,omitempty"` ``。
- 主鍵使用 MongoDB driver **v2**`bson.ObjectID``go.mongodb.org/mongo-driver/v2/bson`tag 為 `` `bson:"_id,omitempty" json:"id,omitempty"` ``。舊模組若仍為 `primitive.ObjectID`,遷移時一併改為 v2。
- 時間戳記統一用 `*int64`,欄位名 `CreateAt` / `UpdateAt`,值為 UTC nanoseconds。
- 可選欄位用指標型別(`*string`、`*int64`)。
- 領域列舉引用 `enum/` 下的型別,不在 entity 內重複定義。
- 領域列舉引用 `domain/enum/` 下的型別,不在 entity 內重複定義。
**範例:**
@ -62,12 +127,12 @@ internal/logic → model/{module}/usecase 介面 only不 import repository
package entity
import (
"gateway/internal/model/member/enum"
"go.mongodb.org/mongo-driver/bson/primitive"
"gateway/internal/model/member/domain/enum"
"go.mongodb.org/mongo-driver/v2/bson"
)
type Account struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
ID bson.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
LoginID string `bson:"login_id"`
Token string `bson:"token"`
Platform enum.Platform `bson:"platform"`
@ -80,9 +145,9 @@ func (a *Account) CollectionName() string {
}
```
## 2. 值物件 / 列舉(`enum/`
## 2. 值物件 / 列舉(`domain/enum/`
業務列舉、狀態碼等放在 `internal/model/{module}/enum/`。
業務列舉、狀態碼等放在 `internal/model/{module}/domain/enum/`。
**規則:**
@ -111,15 +176,15 @@ func (p Platform) ToString() string { /* map lookup */ }
同一檔案或目錄下可放 `*_test.go` 驗證轉換邏輯。
## 3. Repository 介面(`repository/`
## 3. Repository 介面(`domain/repository/`
**規則:**
- 一個 entity 一個 `XxxRepository` interface。
- 一個 entity 一個 `XxxRepository` interface,檔案放在 `domain/repository/`
- 方法第一個參數固定為 `context.Context`
- 參數 / 回傳值使用 `entity` 型別,不暴露 driver 細節(除 index migration 等必要場景)。
- 參數 / 回傳值使用 `domain/entity` 型別,不暴露 driver 細節(除 index migration 等必要場景)。
- Index migration 以獨立 interface 嵌入,命名 `{Entity}IndexUP`,方法名含版本號,如 `Index20241226001UP`
- 介面檔案不含實作、不含 import 基礎設施 package`mon`、`mongo` 實作層等僅在 repository 實作出現)
- **此目錄僅介面**:不含 `NewXxxRepository`、不含 `mongo` / `redis` import
**範例:**
@ -128,31 +193,27 @@ package repository
import (
"context"
"gateway/internal/model/member/entity"
"go.mongodb.org/mongo-driver/mongo"
"gateway/internal/model/member/domain/entity"
)
type AccountRepository interface {
Insert(ctx context.Context, data *entity.Account) error
FindOne(ctx context.Context, id string) (*entity.Account, error)
Update(ctx context.Context, data *entity.Account) (*mongo.UpdateResult, error)
Update(ctx context.Context, data *entity.Account) error
Delete(ctx context.Context, id string) (int64, error)
AccountIndexUP
}
type AccountIndexUP interface {
Index20241226001UP(ctx context.Context) (*mongo.Cursor, error)
}
```
輔助介面(冪等、配額等)亦放在 `domain/repository/`,由外層 `repository/redis_store.go` 等實作。
## 4. Repository 實作(`repository/`
**規則:**
- struct 名稱 `{Entity}Repository`,建構子 `New{Entity}Repository(param {Entity}RepositoryParam)`
- Param struct 集中注入 `Conf`、`CacheConf`、`DBOpts`、`CacheOpts`。
- 建構時以 entity 的 `CollectionName()` 初始化 DocumentDB失敗時 `panic`(啟動期錯誤)。
- CRUD 透過 `mongo.DocumentDBWithCacheUseCase``gateway/internal/library/mongo`)操作,搭配模組 `redis.go` 的 key helper。
- struct 名稱 `{entity}Repository`(小寫)或 `{Entity}Repository`,建構子 `New{Entity}Repository(param {Entity}RepositoryParam)`**回傳型別為 `domain/repository` 的 interface**
- Param struct 集中注入 `*mongo.Conf` 等;實作 import `domain/entity`、`domain/repository`。
- 建構時以 `domain/entity``CollectionName()` 初始化 DocumentDB失敗時 `panic`(啟動期錯誤)。
- CRUD 透過 `gateway/internal/library/mongo` 的 DocumentDB helper,搭配模組 `redis.go` 的 key helper。
- `Insert`ID 為 zero 時自動產生 ObjectID 並寫入 `CreateAt` / `UpdateAt`
- `Update`:自動更新 `UpdateAt`
- `FindOne` / `Delete`:無效 ObjectID → `*errs.Error``ResInvalidMeasureID`)或模組 `ErrInvalidObjectID`;查無資料 → 模組 `ErrNotFound`(見第 7 節錯誤)。
@ -161,54 +222,54 @@ type AccountIndexUP interface {
**範例:**
```go
type AccountRepositoryParam struct {
Conf *mongo.Conf
CacheConf cache.CacheConf
DBOpts []mon.Option
CacheOpts []cache.Option
}
import (
domentity "gateway/internal/model/member/domain/entity"
domrepo "gateway/internal/model/member/domain/repository"
)
type accountRepository struct {
DB mongo.DocumentDBWithCacheUseCase
db mongo.DocumentDBUseCase
}
func NewAccountRepository(param AccountRepositoryParam) AccountRepository {
e := entity.Account{}
documentDB, err := mongo.MustDocumentDBWithCache(
param.Conf, e.CollectionName(), param.CacheConf, param.DBOpts, param.CacheOpts,
)
func NewAccountRepository(param AccountRepositoryParam) domrepo.AccountRepository {
e := domentity.Account{}
documentDB, err := mongo.NewDocumentDB(param.Conf, e.CollectionName())
if err != nil {
panic(err)
}
return &accountRepository{DB: documentDB}
return &accountRepository{db: documentDB}
}
```
## 5. UseCase 介面與 DTO`usecase/`
## 5. UseCase 介面與 DTO`domain/usecase/`
**規則:**
- 業務入口定義為 interface`AccountUseCase`;大介面可拆成多個小 interface 再 compose
- Request / Response struct 放在同一 package命名 `{Action}Request`、`{Action}Response`。
- DTO 只含 `json` tag 與欄位註解,不含 bson tagDTO 不直接映射 DB
- 業務入口定義為 interface`NotifierUseCase`、`AccountUseCase`,放在 `domain/usecase/`
- Request / Response struct 與 interface **同 package**,命名 `{Action}Request`、`NotificationDTO`。
- DTO 只含 `json` tag(若需序列化),不含 bson tag
- 更新類 Request 的可選欄位用指標,以便區分「未傳入」與「傳入零值」。
- 共用分頁 struct 放 `common.go`,如 `Pager`
**範例:**
```go
package usecase
import "gateway/internal/model/member/enum"
import (
"context"
"gateway/internal/model/notification/domain/enum"
)
type AccountUseCase interface {
CreateLoginUser(ctx context.Context, req *CreateLoginUserRequest) (*CreateLoginUserResponse, error)
type NotifierUseCase interface {
Send(ctx context.Context, req *SendRequest) (*NotificationDTO, error)
Enqueue(ctx context.Context, req *SendRequest) (*NotificationDTO, error)
Get(ctx context.Context, tenantID, id string) (*NotificationDTO, error)
}
type CreateLoginUserRequest struct {
LoginID string `json:"login_id"`
Platform enum.Platform `json:"platform"`
Token string `json:"token"`
type SendRequest struct {
TenantID string
Channel enum.Channel
// ...
}
```
@ -216,29 +277,34 @@ type CreateLoginUserRequest struct {
**規則:**
- struct 名稱描述業務聚合,如 `MemberUseCase`。
- 以 `{Name}UseCaseParam` 注入所有 repository 與 `config.Config`
- 建構子命名 `Must{Name}UseCase(param) AccountUseCase`,回傳 interface 型別
- 實作 struct 嵌入 Param`type MemberUseCase struct { MemberUseCaseParam }`
- 方法簽名與 interface 一致;內部組裝 `entity`,呼叫 repository。
- 實作 struct 如 `notifierUseCase`、`memberUseCase`,放在模組根 `usecase/`。
- 以 `{Name}UseCaseParam` 注入 `domain/repository` 介面、provider、renderer、`config`
- 建構子 `Must{Name}UseCase(param) domusecase.XxxUseCase`,回傳 **domain** interface
- 跨模組組裝可用 `New{Name}UseCaseFromParam` / `factory.go`(見 `notification/usecase/factory.go`
- 方法簽名與 `domain/usecase` interface 一致;內部組裝 `domain/entity`,呼叫 repository 介面
- 錯誤一律回傳 `gateway/internal/library/errors``*errs.Error`(見第 7 節)。
- 可測性:將難 mock 的純函式抽成 package 級變數(如 `var HashPasswordFunc = HashPassword`)。
**範例:**
```go
import (
domrepo "gateway/internal/model/member/domain/repository"
domusecase "gateway/internal/model/member/domain/usecase"
)
type MemberUseCaseParam struct {
Account repository.AccountRepository
User repository.UserRepository
Account domrepo.AccountRepository
User domrepo.UserRepository
Config config.Config
}
type MemberUseCase struct {
type memberUseCase struct {
MemberUseCaseParam
}
func MustMemberUseCase(param MemberUseCaseParam) AccountUseCase {
return &MemberUseCase{param}
func MustMemberUseCase(param MemberUseCaseParam) domusecase.AccountUseCase {
return &memberUseCase{param}
}
```
@ -251,14 +317,16 @@ func MustMemberUseCase(param MemberUseCaseParam) AccountUseCase {
```go
package member
import "errors"
import "fmt"
var (
ErrNotFound = errors.New("member: not found")
ErrInvalidObjectID = errors.New("member: invalid object id")
ErrNotFound = fmt.Errorf("member: not found")
ErrInvalidObjectID = fmt.Errorf("member: invalid object id")
)
```
(專案慣例:`fmt.Errorf` 定義 sentinel便於 `%w` 包裝;見 `notification/errors.go`。)
### 7.2 Repository
| 狀況 | 回傳 |
@ -304,18 +372,31 @@ func GetAccountRedisKey(id string) string {
}
```
## 9. Mock`mock/`
## 9. Mock`mock/` + gomock
Repository / UseCase 介面變更後,用 mockgen 重新生成:
**方案 A本專案採用**
| 測試對象 | 做法 |
|----------|------|
| `domain/repository` 契約行為冪等重複、FindByIdempotency | `repository/*_test.go` + **in-memory 實作**(如 `MemoryNotificationRepository` |
| `usecase` 編排、分支、配額 | **gomock** `domain/repository` 介面,`EXPECT` / `DoAndReturn` |
| `logic` 呼叫其他模組 | gomock `domain/usecase` |
| Provider Chain | 保留 `provider/*/mock_sender.go`記錄呼叫、failover |
介面變更後執行:
```bash
mockgen -source=./internal/model/member/repository/account.go \
-destination=./internal/model/member/mock/repository/account.go \
-package=mockrepository
make gen-mock
```
- 產物放在 `mock/repository/``mock/usecase/`**不要手改**。
- UseCase 單元測試注入 mock不啟動真實 DB。
`domain/repository/generate.go` 範例:
```go
//go:generate go run go.uber.org/mock/mockgen@latest -typed -destination=../../mock/repository/repository_mock.go -package=mocknotifrepo gateway/internal/model/{module}/domain/repository NotificationRepository,IdempotencyCache,QuotaCounter
```
- 產物在 `mock/repository/`、`mock/usecase/`**不要手改**。
- **勿**在 usecase test 手寫 70 行 fake repo有狀態的儲存邏輯放在 `repository/memory_*.go` 並由 **repository 層測試** 覆蓋。
## 10. 命名對照表
@ -337,17 +418,22 @@ mockgen -source=./internal/model/member/repository/account.go \
## 11. 新增模組 / Model 檢查清單
1. 建立 `internal/model/{module}/` 目錄結構。
2. 在 `entity/` 新增 struct + `CollectionName()`
3. 若有列舉 / 狀態,在 `enum/` 定義值物件。
4. 在 `repository/` 宣告 interface 並實作 CRUD + index migration + `*_test.go`
5. 在 `errors.go` 補充 sentinel若需要`redis.go` 補 cache key若需要
6. 在 `usecase/` 定義 interface、DTO 與實作 + 單元測試。
7. 執行 mockgen 更新 `mock/`
8. 在 `internal/svc/service_context.go` 組裝 repository → usecase。
9. 在 `generate/api/` 定義路由,`make gen-api`。
10. 在 `internal/logic/` 實作 types 映射,**只**呼叫 UseCase interface。
11. `make gen-doc`、`go test ./...`。
1. 建立 `internal/model/{module}/domain/{entity,enum,repository,usecase}/` 與外層 `repository/`、`usecase/`。
2. 在 `domain/entity/` 新增 struct + `CollectionName()`
3. 在 `domain/enum/` 定義值物件(若有)。
4. 在 `domain/repository/` 宣告 interface`repository/` 實作 CRUD + index + `*_test.go`
5. 在 `domain/usecase/` 宣告 interface + DTO`usecase/` 實作 + 單元測試(可 fake `domain/repository`)。
6. 模組專用整合放 `provider/`、`template/`(勿放入 `library/`)。
7. `errors.go`、`const.go`、`redis.go`、`config/` 按需補齊。
8. 執行 `make gen-mock``go:generate` 在 `domain/repository/generate.go` 等)。
9. 在 `internal/config/config.go` 嵌入模組 `config``etc/gateway.yaml` 加區塊。
10. 在 `internal/svc/service_context.go` 建立 **共用** `*redislib.Client`,再注入 `NewXxxUseCaseFromParam`Mongo / Redis 未配置時對應欄位可為 `nil`)。
11. 在 `generate/api/` 定義路由,`make gen-api``internal/logic/` **只** import `domain/usecase`
12. `make gen-doc`、`go test ./...`。
**Notification 模組進度(參考):** N0N5 核心 ✅(含 `RetryWorker`、`AdminNotifierUseCase`);文件見 [notification README](../internal/model/notification/README.md)。待做HTTP admin APIgoctl
**Member 模組進度P3.5** `OTPUseCase` + `VerificationUseCase`email/phone`Notifier.Send` 投遞;`ProfileRepository` 暫用 memoryP4 換 Mongo。`ServiceContext.MemberVerification` 在 Mongo+Redis+Notifier 就緒時注入。後續Step-up / TOTP、HTTP APIgoctl
## 12. 與 Gateway HTTP 層的關係
@ -358,13 +444,16 @@ handlergoctl 生成)→ response.Write
logicgoctl 生成框架,手寫映射)
↓ 轉換 types ↔ usecase DTO
usecaseinternal/model/{module}/usecase
usecase 介面internal/model/{module}/domain/usecase
repositoryinternal/model/{module}/repository
usecase 實作internal/model/{module}/usecase
repository 實作internal/model/{module}/repository
↓ 實作 domain/repository 介面
MongoDB / Redis
```
- `internal/types`HTTP 請求 / 回應型別,由 `.api` 生成。
- `internal/model/{module}/usecase` DTO業務層資料結構logic 負責兩者映射。
- `internal/model/{module}/domain/usecase` DTO業務層資料結構logic 負責`types` 映射。
- 錯誤自 usecase 以 `*errs.Error` 往上冒泡logic 原樣傳遞handler 經 `response.Write` 輸出 8 碼 JSON。

237
etc/README.md Normal file
View File

@ -0,0 +1,237 @@
# gateway.yaml 設定說明
Gateway 使用 [go-zero `conf`](https://go-zero.dev/docs/tutorials/go-zero/configuration/overview) 載入 YAML。結構定義在 `internal/config/config.go`,對應各模組的 `Conf` 結構。
## 用哪個檔案?
| 檔案 | 用途 | 啟動方式 |
|------|------|----------|
| **`gateway.yaml`** | 預設:不需 Docker僅 HTTP / health | `make run` |
| **`gateway.dev.yaml`** | 本機完整功能Mongo + Redis + Notification | `make deps-up``make run-dev` |
```bash
# 僅 API最快
make run
# Notification / Member OTP需 Docker
make deps-up
make mongo-index # 首次建議執行
make run-dev
```
---
## go-zero 填寫規則(重要)
1. **欄位名稱必須與 Go struct 一致**(駝峰在 yaml 裡通常照抄,如 `MaxPoolSize`)。**不要**填 struct 裡沒有的欄位(例如以前的 `MaxStaleness`),否則可能觸發奇怪錯誤。
2. **沒標 `optional` 的頂層欄位**:在該區塊出現時需型別正確;我們已在 Mongo / Notification / Member 加上 `json:",optional"`,多數子欄位可省略。
3. **空字串 `""`**:可寫,表示無帳密 / 無 AuthSource。
4. **時間**`30m`、`10s``time.Duration`)。
5. **字串陣列**(如 `Compressors`)必須是 YAML 列表,**不能**寫成單一字串:
```yaml
Compressors:
- zstd
- snappy
```
6. **Mongo 埠號**:用整數 `Port: 27017`,不要 `Port: "27017"`;或直接在 `Host``127.0.0.1:27017`
---
## 頂層HTTP 服務(`rest.RestConf`
| 欄位 | 必填 | 說明 | 範例 |
|------|:----:|------|------|
| `Name` | ✓ | 服務名稱 | `gateway` |
| `Host` | ✓ | 監聽位址 | `0.0.0.0` |
| `Port` | ✓ | HTTP 埠 | `8888` |
其餘 `RestConf` 欄位(`Timeout`、`Log`、`Prometheus` 等)可省略,使用 go-zero 預設。
---
## `Mongo``internal/library/mongo.Conf`
**`Host` 留空或整段註解** → 不連 Mongo`Notifier` / `MemberVerification` 不會注入(仍可跑 health API
| 欄位 | 必填 | 說明 | 本機 dev 建議 |
|------|:----:|------|----------------|
| `Schema` | 選 | 連線 scheme預設 `mongodb` | `mongodb` |
| `Host` | 啟用時 ✓ | 主機;可含埠 `127.0.0.1:27017` | `127.0.0.1` |
| `Port` | 選 | `Host` 無埠時附加,**整數** | `27017` |
| `Database` | 啟用時 ✓ | 資料庫名 | `gateway`(與 docker init 一致) |
| `User` | 選 | 帳號 | 本機留空 |
| `Password` | 選 | 密碼 | 本機留空 |
| `AuthSource` | 選 | 驗證用 DB帳號在 `admin` 建則填 `admin` | 本機留空 |
| `ReplicaName` | 選 | replica set 名稱 | 本機單機**留空** |
| `TLS` | 選 | 是否 `tls=true` | `false` |
| `MaxPoolSize` | 選 | 連線池上限 | `30` |
| `MinPoolSize` | 選 | 連線池下限 | `10` |
| `MaxConnIdleTime` | 選 | 閒置連線逾時 | `30m` |
| `Compressors` | 選 | 壓縮演算法陣列 | 省略(程式預設 zstd、snappy |
| `ConnectTimeoutMs` | 選 | 啟動 Ping 逾時(毫秒) | 省略(預設 10s |
**會建立的 collections**(自動,無需手動建表):`notifications`、`notification_dlq`。索引:`make mongo-index` 或 docker init。
**常見錯誤**
| 錯誤訊息 | 原因 |
|----------|------|
| `type mismatch for field "Mongo.Compressors"` | 曾用不存在的 `Port: "27017"``Compressors` 寫成單一字串 |
| `field "Mongo.xxx" is not set` | 舊版未加 `optional`;請更新程式或改用最精簡的 `gateway.dev.yaml` |
| `mongo: ping primary` 失敗 | 有填 `Mongo.Host` 但沒跑 `make deps-up` |
---
## `Redis`go-zero `redis.RedisConf`
**`Host` 留空**`gateway.yaml` 預設)→ 不連 Redis。有 Mongo 時仍可用 memory 冪等/配額,但**無異步重試 worker**、**無 Member OTP**。
`gateway.yaml` 建議保留區塊並設空 Host
```yaml
Redis:
Host: ""
Type: node
```
| 欄位 | 必填 | 說明 | 本機 dev 建議 |
|------|:----:|------|----------------|
| `Host` | 啟用時 ✓ | `host:port` | `localhost:6379` |
| `Type` | 啟用時 ✓ | 單機填 `node` | `node` |
| `Pass` | 選 | 密碼 | 省略 |
| `Tls` | 選 | TLS | 省略 |
---
## `Notification``internal/model/notification/config`
僅在 **Mongo 已連線** 時由 `ServiceContext` 組裝 Notifier。子欄位皆可省略有程式預設
### 根層
| 欄位 | 說明 | 預設 / 建議 |
|------|------|-------------|
| `DefaultLocale` | 模板語系 fallback | `zh-tw` |
| `RatePerTenant.Email` | 每租戶每日 Email 上限 | `100` |
| `RatePerTenant.SMS` | 每租戶每日 SMS 上限 | `50` |
### `Notification.Email`
| 欄位 | 說明 |
|------|------|
| `Provider` | 無真實 provider 時填 `mock` |
| `From` | 寄件者Send 時帶入) |
| `APIKey` | 保留,目前未用 |
**SMTP**`Enable: true` 時加入發信鏈,依 `Sort` failover
| 欄位 | 說明 |
|------|------|
| `Enable` / `Sort` | 是否啟用、優先順序(數字小先試) |
| `Host` / `Port` | SMTP 主機與埠MailHog`localhost:1025` |
| `Username` / `Password` | 認證 |
**SES**AWS
| 欄位 | 說明 |
|------|------|
| `Enable` / `Sort` | 同上 |
| `Region` / `AccessKey` / `SecretKey` / `SessionToken` | AWS 憑證 |
### `Notification.SMS`
| 欄位 | 說明 |
|------|------|
| `Provider` | 無真實 provider 時 `mock` |
| `Mitake.Enable` | 三竹簡訊 |
| `Mitake.User` / `Mitake.Password` | 三竹帳密 |
| `Mitake.Sort` | failover 順序 |
### `Notification.Async`
| 欄位 | 說明 |
|------|------|
| `QueueRedisKey` | Redis ZSET key空則用內建 `notif:retry:zset` |
| `Worker` | 背景重試 goroutine 數 |
| `MaxRetry` | 最大重試次數 |
| `BackoffSeconds` | 重試間隔(秒)陣列 |
**Mongo + Redis** 才會啟動 `RetryWorker`
### `Notification.Push` / `Webhook`
保留欄位,目前未接線。
---
## `Member``internal/model/member/config`
僅在 **Mongo + Redis + Notifier** 皆就緒時注入 `MemberVerification`
### `Member.OTP`
| 欄位 | 說明 | 建議 |
|------|------|------|
| `Length` | OTP 位數 | `6` |
| `TTLSeconds` | challenge 存活秒數 | `300` |
| `MaxAttempts` | 驗證嘗試上限 | `5` |
| `ResendCooldownSeconds` | 重發冷卻 | `60` |
| `DailyVerifyLimit` | 每日驗證上限 | `10` |
---
## 功能與依賴對照
```mermaid
flowchart LR
subgraph always [永遠可用]
H[health API]
end
subgraph mongo [Mongo.Host 有值]
N[Notifier Send/Get]
A[Admin DLQ]
end
subgraph redis [Redis.Host 有值]
Q[Enqueue + RetryWorker]
O[Member OTP]
end
H --> always
mongo --> N
mongo --> A
mongo --> redis
redis --> Q
redis --> O
```
| 你想用的功能 | Mongo | Redis |
|--------------|:-----:|:-----:|
| `/api/v1/health` | — | — |
| `Notifier.Send`(同步) | ✓ | 選(冪等/配額建議開) |
| `Notifier.Enqueue` + 重試 | ✓ | ✓ |
| Member 信箱/手機驗證 | ✓ | ✓ |
---
## 疑難排解
```bash
# 1. 只驗證 yaml 能否載入(不啟動服務)
go test ./internal/config/ -run TestLoadGatewayYAML -v
# 2. 看缺什麼欄位(啟動時)
go run gateway.go -f etc/gateway.yaml
# 3. dev 完整栈
make deps-up && make run-dev
curl -s http://127.0.0.1:8888/api/v1/health
```
若你改壞了 yaml可從 repo 內的 `gateway.yaml` / `gateway.dev.yaml` 重新複製,再依本文件逐段打開需要的區塊。
---
## 相關文件
- [deploy/README.md](../deploy/README.md) — Docker Compose
- [internal/model/notification/README.md](../internal/model/notification/README.md) — 通知模組
- [internal/library/mongo/README.md](../internal/library/mongo/README.md) — Mongo 存取層

59
etc/gateway.dev.yaml Normal file
View File

@ -0,0 +1,59 @@
# 本機完整開發(需先 make deps-up
# 啟動make run-dev 或 go run gateway.go -f etc/gateway.dev.yaml
# 欄位說明etc/README.md
Name: gateway
Host: 0.0.0.0
Port: 6888
Mongo:
Schema: mongodb
Host: 127.0.0.1
Port: 27017
Database: gateway
AuthSource: ""
ReplicaName: ""
TLS: false
MaxPoolSize: 30
MinPoolSize: 10
MaxConnIdleTime: 30m
Redis:
Host: localhost:6379
Type: node
Notification:
DefaultLocale: zh-tw
Email:
Provider: mock
From: noreply@localhost
SMTP:
Enable: false
Sort: 1
Host: localhost
Port: 1025
SES:
Enable: false
Sort: 2
Region: ap-northeast-1
SMS:
Provider: mock
Mitake:
Enable: false
Sort: 1
Async:
QueueRedisKey: notification:queue
Worker: 2
MaxRetry: 5
BackoffSeconds: [1, 5, 30, 300, 1800]
RatePerTenant:
Email: 100
SMS: 50
Member:
OTP:
Length: 6
TTLSeconds: 300
MaxAttempts: 5
ResendCooldownSeconds: 60
DailyVerifyLimit: 10

View File

@ -1,3 +1,42 @@
# 預設:不需 Docker 即可啟動(僅 health API
# 完整開發Mongo + Redis + Notification複製 gateway.dev.yaml 或 make run-dev
# 欄位說明etc/README.md
Name: gateway
Host: 0.0.0.0
Port: 8888
# Mongo 整段註解 = 不連線Notifier 不注入)
# Mongo:
# Host: 127.0.0.1
# Port: 27017
# Database: gateway
# Redis 留空 Host = 不連線;要 Notification 異步 / Member OTP 請用 gateway.dev.yaml
Redis:
Host: ""
Type: node
Notification:
DefaultLocale: zh-tw
Email:
Provider: mock
From: noreply@example.com
SMS:
Provider: mock
Async:
QueueRedisKey: notification:queue
Worker: 2
MaxRetry: 5
BackoffSeconds: [1, 5, 30, 300, 1800]
RatePerTenant:
Email: 100
SMS: 50
Member:
OTP:
Length: 6
TTLSeconds: 300
MaxAttempts: 5
ResendCooldownSeconds: 60
DailyVerifyLimit: 10

View File

@ -4,8 +4,12 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"gateway/internal/config"
"gateway/internal/handler"
@ -26,8 +30,13 @@ func main() {
server := rest.MustNewServer(c.RestConf)
defer server.Stop()
ctx := svc.NewServiceContext(c)
handler.RegisterHandlers(server, ctx)
sc := svc.NewServiceContext(c)
handler.RegisterHandlers(server, sc)
workerCtx, stopWorkers := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stopWorkers()
sc.StartWorkers(workerCtx)
defer sc.StopWorkers()
fmt.Printf("Starting server at %s:%d...\n", c.Host, c.Port)
server.Start()

17
go.mod
View File

@ -3,17 +3,27 @@ module gateway
go 1.26.1
require (
github.com/alicebob/miniredis/v2 v2.37.0
github.com/go-playground/locales v0.14.1
github.com/go-playground/universal-translator v0.18.1
github.com/go-playground/validator/v10 v10.30.2
github.com/google/uuid v1.6.0
github.com/shopspring/decimal v1.4.0
github.com/stretchr/testify v1.11.1
github.com/zeromicro/go-zero v1.10.1
go.mongodb.org/mongo-driver/v2 v2.5.0
go.uber.org/mock v0.6.0
golang.org/x/crypto v0.49.0
google.golang.org/grpc v1.79.3
)
require (
github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.61 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
github.com/aws/aws-sdk-go-v2/service/ses v1.30.0 // indirect
github.com/aws/smithy-go v1.22.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
@ -24,7 +34,6 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grafana/pyroscope-go v1.2.8 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
@ -32,6 +41,7 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minchao/go-mitake v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/openzipkin/zipkin-go v0.4.3 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
@ -47,6 +57,7 @@ require (
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
@ -60,9 +71,7 @@ require (
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
@ -70,6 +79,8 @@ require (
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

19
go.sum
View File

@ -1,5 +1,17 @@
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
github.com/aws/aws-sdk-go-v2/credentials v1.17.61 h1:Hd/uX6Wo2iUW1JWII+rmyCD7MMhOe7ALwQXN6sKDd1o=
github.com/aws/aws-sdk-go-v2/credentials v1.17.61/go.mod h1:L7vaLkwHY1qgW0gG1zG0z/X0sQ5tpIY5iI13+j3qI80=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
github.com/aws/aws-sdk-go-v2/service/ses v1.30.0 h1:PysTMRJ3Eq5TKQVjMKJ1JT5XLZ1YtJ9BXdzQ3RUi7XE=
github.com/aws/aws-sdk-go-v2/service/ses v1.30.0/go.mod h1:eZW5lSNTE1tQfMpl6crr/YVJYgEcnk2JQoodg6E63qM=
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@ -64,6 +76,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minchao/go-mitake v1.0.0 h1:OgfCUkSRftd6sWibpJyeKU3/gPQhq1t0ttHsnoaeVgQ=
github.com/minchao/go-mitake v1.0.0/go.mod h1:RAo0TijPUqhM2ZLMqP9x76wsomL11Ud4sDSwRYwbeGU=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg=
@ -175,6 +189,7 @@ golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
@ -194,9 +209,13 @@ google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=

View File

@ -3,8 +3,19 @@
package config
import "github.com/zeromicro/go-zero/rest"
import (
"github.com/zeromicro/go-zero/core/stores/redis"
"github.com/zeromicro/go-zero/rest"
"gateway/internal/library/mongo"
memberconfig "gateway/internal/model/member/config"
notifconfig "gateway/internal/model/notification/config"
)
type Config struct {
rest.RestConf
Mongo mongo.Conf `json:",optional"`
Redis redis.RedisConf `json:",optional"`
Notification notifconfig.Config `json:",optional"`
Member memberconfig.Config `json:",optional"`
}

View File

@ -0,0 +1,44 @@
package config_test
import (
"path/filepath"
"runtime"
"testing"
"gateway/internal/config"
"github.com/zeromicro/go-zero/core/conf"
)
func repoRoot(t *testing.T) string {
t.Helper()
_, file, _, _ := runtime.Caller(0)
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
}
func TestLoadGatewayYAML_default(t *testing.T) {
t.Parallel()
var c config.Config
path := filepath.Join(repoRoot(t), "etc", "gateway.yaml")
if err := conf.Load(path, &c); err != nil {
t.Fatal(err)
}
if c.Port != 8888 {
t.Fatalf("Port = %d", c.Port)
}
}
func TestLoadGatewayYAML_dev(t *testing.T) {
t.Parallel()
var c config.Config
path := filepath.Join(repoRoot(t), "etc", "gateway.dev.yaml")
if err := conf.Load(path, &c); err != nil {
t.Fatal(err)
}
if c.Mongo.Host == "" {
t.Fatal("Mongo.Host should be set in gateway.dev.yaml")
}
if c.Redis.Host == "" {
t.Fatal("Redis.Host should be set in gateway.dev.yaml")
}
}

View File

@ -255,14 +255,15 @@ func (r *accountRepository) FindOne(ctx context.Context, id string) (*entity.Acc
| 欄位 | 說明 |
|------|------|
| `Schema` | `mongodb``mongodb+srv`Atlas |
| `Host` | `host:port` 或 srv 的 host |
| `Host` | `host:port`;也可只寫 host 並用 `Port`(整數) |
| `Port` | 選用;`Host` 未含埠時會組成 `host:port` |
| `User` / `Password` | 會經 URL 編碼,勿手拼 URI |
| `Database` | 傳入 `mon.NewModel` 的 db 名稱 |
| `AuthSource` | 查詢參數 `authSource` |
| `ReplicaName` | 查詢參數 `replicaSet` |
| `TLS` | 查詢參數 `tls=true` |
| `MaxPoolSize` / `MinPoolSize` / `MaxConnIdleTime` | client pool |
| `Compressors` | 預設 `zstd`、`snappy` |
| `Compressors` | 選用 YAML **陣列**`["zstd","snappy"]`);勿寫單一字串。省略時程式預設 `zstd`、`snappy` |
| `ConnectTimeoutMs` | 啟動 Ping 逾時(預設 10s |
尚未接到 `etc/gateway.yaml` 時,可在 `ServiceContext` 從環境變數或本地 yaml 填入 `Conf`

View File

@ -0,0 +1,63 @@
package mongo
import (
"testing"
"github.com/zeromicro/go-zero/core/conf"
)
func loadMongoYAML(t *testing.T, snippet string) Conf {
t.Helper()
var cfg struct {
Mongo Conf
}
if err := conf.LoadFromYamlBytes([]byte(snippet), &cfg); err != nil {
t.Fatal(err)
}
return cfg.Mongo
}
func TestLoadMongoConf_hostAndPort(t *testing.T) {
t.Parallel()
c := loadMongoYAML(t, `Mongo:
Host: 127.0.0.1
Port: 27017
Database: gateway
`)
uri, err := buildConnectionURI(c)
if err != nil {
t.Fatal(err)
}
if uri != "mongodb://127.0.0.1:27017" {
t.Fatalf("uri = %q", uri)
}
}
func TestLoadMongoConf_withCompressorsArray(t *testing.T) {
t.Parallel()
c := loadMongoYAML(t, `Mongo:
Host: 127.0.0.1:27017
Database: gateway
Compressors:
- zstd
- snappy
`)
if len(c.Compressors) != 2 {
t.Fatalf("compressors = %v", c.Compressors)
}
}
func TestLoadMongoConf_compressorsStringRejected(t *testing.T) {
t.Parallel()
var cfg struct {
Mongo Conf
}
err := conf.LoadFromYamlBytes([]byte(`Mongo:
Host: 127.0.0.1:27017
Database: gateway
Compressors: zstd
`), &cfg)
if err == nil {
t.Fatal("expected type mismatch for Compressors string")
}
}

View File

@ -3,18 +3,20 @@ package mongo
import "time"
// Conf is MongoDB client configuration for DocumentDB helpers.
// Use json tags for go-zero conf (see etc/gateway.yaml).
type Conf struct {
Schema string
User string
Password string
Host string
Database string
AuthSource string
ReplicaName string
TLS bool
MaxPoolSize uint64
MinPoolSize uint64
MaxConnIdleTime time.Duration
Compressors []string
ConnectTimeoutMs int64
Schema string `json:",default=mongodb"`
User string `json:",optional"`
Password string `json:",optional"`
Host string `json:",optional"`
Port int `json:",optional"` // if Host has no ":port", appended in buildConnectionURI
Database string `json:",optional"`
AuthSource string `json:",optional"`
ReplicaName string `json:",optional"`
TLS bool `json:",optional"`
MaxPoolSize uint64 `json:",optional"`
MinPoolSize uint64 `json:",optional"`
MaxConnIdleTime time.Duration `json:",optional"`
Compressors []string `json:",optional"`
ConnectTimeoutMs int64 `json:",optional"`
}

View File

@ -2,6 +2,7 @@ package mongo
import (
"fmt"
"net"
"net/url"
)
@ -10,13 +11,17 @@ func buildConnectionURI(c Conf) (string, error) {
if scheme == "" {
scheme = "mongodb"
}
if c.Host == "" {
host := c.Host
if host == "" {
return "", fmt.Errorf("mongo: host is required")
}
if c.Port > 0 && !hostHasPort(host) {
host = fmt.Sprintf("%s:%d", host, c.Port)
}
u := &url.URL{
Scheme: scheme,
Host: c.Host,
Host: host,
}
if c.User != "" {
u.User = url.UserPassword(c.User, c.Password)
@ -39,6 +44,14 @@ func buildConnectionURI(c Conf) (string, error) {
return u.String(), nil
}
func hostHasPort(host string) bool {
// IPv6 [::1]:27017 or hostname:27017
if h, _, err := net.SplitHostPort(host); err == nil && h != "" {
return true
}
return false
}
func redactConnectionURI(uri string) string {
u, err := url.Parse(uri)
if err != nil || u.User == nil {

View File

@ -0,0 +1,23 @@
# RedisGateway 共用連線)
## 用途
- 在 **`svc.ServiceContext` 建立一次** `*redis.Client`,全進程共用 go-zero 的 connection pool同一 `Addr` 只會有一個 pool
- 各 `internal/model/{module}` 的 factory / repository **注入**此 client禁止在 module 內 `go-redis.NewClient` 或重複 `MustNewRedis`
## 使用
```go
import redislib "gateway/internal/library/redis"
rds, err := redislib.NewClient(c.Redis) // Host 空 → (nil, nil)
rds.Zero() // *github.com/zeromicro/go-zero/core/stores/redis.Redis
```
## 與 Mongo 對照
| | Mongo | Redis |
|---|--------|--------|
| 封裝 | `library/mongo` | `library/redis` |
| 共用機制 | go-zero `mon.clientManager`key = URI | go-zero `redis.clientManager`key = Addr |
| 模組內 | 每 collection 一個 repository | 共用 client + 模組 `redis.go` 定義 key |

View File

@ -0,0 +1,42 @@
// Package redis provides a process-wide Redis client (one connection pool per Addr).
// Construct once in svc.ServiceContext and inject into model repositories / use cases.
package redis
import (
"fmt"
"github.com/zeromicro/go-zero/core/stores/redis"
)
// Client wraps go-zero Redis so all modules share the same connection pool.
type Client struct {
r *redis.Redis
}
// NewClient returns a shared Redis client, or (nil, nil) when Host is empty.
func NewClient(conf redis.RedisConf) (*Client, error) {
if conf.Host == "" {
return nil, nil
}
if err := conf.Validate(); err != nil {
return nil, fmt.Errorf("redis: invalid config: %w", err)
}
return &Client{r: redis.MustNewRedis(conf)}, nil
}
// MustNewClient is like NewClient but panics on error (startup wiring).
func MustNewClient(conf redis.RedisConf) *Client {
c, err := NewClient(conf)
if err != nil {
panic(err)
}
return c
}
// Zero returns the underlying go-zero Redis handle.
func (c *Client) Zero() *redis.Redis {
if c == nil {
return nil
}
return c.r
}

View File

@ -0,0 +1,17 @@
package redis_test
import (
"testing"
redislib "gateway/internal/library/redis"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zeromicro/go-zero/core/stores/redis"
)
func TestNewClient_EmptyHost(t *testing.T) {
c, err := redislib.NewClient(redis.RedisConf{})
require.NoError(t, err)
assert.Nil(t, c)
}

View File

@ -0,0 +1,34 @@
package config
// Config is member module settings (embedded in gateway root config).
type 幫Config struct {
OTP OTPConfig `json:",optional"`
}
type OTPConfig struct {
Length int `json:",optional"`
TTLSeconds int `json:",optional"`
MaxAttempts int `json:",optional"`
ResendCooldownSeconds int `json:",optional"`
DailyVerifyLimit int `json:",optional"`
}
// Defaults returns zero-value-safe defaults.
func (c Config) Defaults() Config {
if c.OTP.Length <= 0 {
c.OTP.Length = 6
}
if c.OTP.TTLSeconds <= 0 {
c.OTP.TTLSeconds = 300
}
if c.OTP.MaxAttempts <= 0 {
c.OTP.MaxAttempts = 5
}
if c.OTP.ResendCooldownSeconds <= 0 {
c.OTP.ResendCooldownSeconds = 60
}
if c.OTP.DailyVerifyLimit <= 0 {
c.OTP.DailyVerifyLimit = 10
}
return c
}

View File

@ -0,0 +1,14 @@
package enum
// OTPPurpose scopes an OTP challenge to a business flow.
type OTPPurpose string
const (
OTPPurposeBusinessEmail OTPPurpose = "business_email"
OTPPurposeBusinessPhone OTPPurpose = "business_phone"
OTPPurposeStepUp OTPPurpose = "step_up"
)
func (p OTPPurpose) String() string {
return string(p)
}

View File

@ -0,0 +1,13 @@
package enum
// VerifyKind is used for rate-limit keys in verification flows.
type VerifyKind string
const (
VerifyKindEmail VerifyKind = "email"
VerifyKindPhone VerifyKind = "phone"
)
func (k VerifyKind) String() string {
return string(k)
}

View File

@ -0,0 +1,33 @@
package repository
import (
"context"
"time"
"gateway/internal/model/member/domain/enum"
)
// OTPChallenge is persisted state for a single OTP issuance.
type OTPChallenge struct {
TenantID string
UID string
Purpose enum.OTPPurpose
Target string
CodeHash string
Attempts int
}
// OTPChallengeStore stores OTP challenges (Redis).
type OTPChallengeStore interface {
Save(ctx context.Context, challengeID string, ch *OTPChallenge, ttl time.Duration) error
Get(ctx context.Context, challengeID string) (*OTPChallenge, error)
// IncrementAttempts atomically increments failed-verify count; returns ErrChallengeNotFound when missing.
IncrementAttempts(ctx context.Context, challengeID string) (int, error)
Delete(ctx context.Context, challengeID string) error
}
// VerifyRateStore enforces resend cooldown and daily verification caps.
type VerifyRateStore interface {
TryResendLock(ctx context.Context, key string, ttl time.Duration) (bool, error)
IncrDaily(ctx context.Context, key string, ttl time.Duration) (int64, error)
}

View File

@ -0,0 +1,9 @@
package repository
import "context"
// ProfileRepository updates member profile verification flags (Mongo in P4).
type ProfileRepository interface {
SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error
SetBusinessPhoneVerified(ctx context.Context, tenantID, uid, phone string) error
}

View File

@ -0,0 +1,7 @@
package usecase
// OTPChallengeDTO is returned when an OTP challenge is created.
type OTPChallengeDTO struct {
ChallengeID string `json:"challenge_id"`
ExpiresIn int `json:"expires_in"`
}

View File

@ -0,0 +1,33 @@
package usecase
import (
"context"
"gateway/internal/model/member/domain/enum"
)
// GenerateOTPRequest creates a new OTP challenge.
type GenerateOTPRequest struct {
TenantID string
UID string
Purpose enum.OTPPurpose
Target string // email or phone for verification flows
Identifier string // optional audit key when uid is empty
}
// VerifyOTPRequest validates a challenge.
type VerifyOTPRequest struct {
TenantID string
UID string // must match challenge UID when the challenge was issued with a UID
ChallengeID string
Code string
Purpose enum.OTPPurpose
}
// OTPUseCase is the purpose-agnostic OTP primitive.
type OTPUseCase interface {
Generate(ctx context.Context, req *GenerateOTPRequest) (*OTPChallengeDTO, string, error)
// Verify returns the challenge target (e.g. email/phone) after successful validation.
Verify(ctx context.Context, req *VerifyOTPRequest) (target string, err error)
Invalidate(ctx context.Context, challengeID string) error
}

View File

@ -0,0 +1,11 @@
package usecase
import "context"
// VerificationUseCase composes OTP + Notifier for business email/phone verification.
type VerificationUseCase interface {
StartEmailVerify(ctx context.Context, tenantID, uid, target, locale string) (*OTPChallengeDTO, error)
ConfirmEmailVerify(ctx context.Context, tenantID, uid, challengeID, code string) error
StartPhoneVerify(ctx context.Context, tenantID, uid, target, locale string) (*OTPChallengeDTO, error)
ConfirmPhoneVerify(ctx context.Context, tenantID, uid, challengeID, code string) error
}

View File

@ -0,0 +1,12 @@
package member
import "fmt"
var (
ErrNotFound = fmt.Errorf("member: not found")
ErrChallengeNotFound = fmt.Errorf("member: otp challenge not found")
ErrChallengeLocked = fmt.Errorf("member: otp challenge locked")
ErrInvalidOTP = fmt.Errorf("member: invalid otp code")
ErrResendCooldown = fmt.Errorf("member: resend cooldown active")
ErrDailyLimit = fmt.Errorf("member: daily verification limit exceeded")
)

View File

@ -0,0 +1,39 @@
package member
import "strings"
// RedisKey is the member module key prefix.
type RedisKey string
const (
OTPChallengeRedisKey RedisKey = "member:otp:challenge"
VerifyRateRedisKey RedisKey = "member:verify:rate"
VerifyDailyRedisKey RedisKey = "member:verify:daily"
)
func (key RedisKey) With(parts ...string) RedisKey {
if len(parts) == 0 {
return key
}
return RedisKey(string(key) + ":" + strings.Join(parts, ":"))
}
func (key RedisKey) String() string {
return string(key)
}
func GetOTPChallengeRedisKey(challengeID string) string {
return OTPChallengeRedisKey.With(challengeID).String()
}
func GetOTPAttemptsRedisKey(challengeID string) string {
return OTPChallengeRedisKey.With(challengeID, "attempts").String()
}
func GetVerifyRateRedisKey(tenantID, uid, kind string) string {
return VerifyRateRedisKey.With(tenantID, uid, kind).String()
}
func GetVerifyDailyRedisKey(tenantID, uid, kind string) string {
return VerifyDailyRedisKey.With(tenantID, uid, kind).String()
}

View File

@ -0,0 +1,132 @@
package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
redislib "gateway/internal/library/redis"
"gateway/internal/model/member"
domrepo "gateway/internal/model/member/domain/repository"
"github.com/zeromicro/go-zero/core/stores/redis"
)
type redisOTPChallengeStore struct {
client *redis.Redis
}
func NewRedisOTPChallengeStore(client *redislib.Client) domrepo.OTPChallengeStore {
if client == nil || client.Zero() == nil {
panic("member: redis client is required for otp store")
}
return &redisOTPChallengeStore{client: client.Zero()}
}
func (s *redisOTPChallengeStore) Save(ctx context.Context, challengeID string, ch *domrepo.OTPChallenge, ttl time.Duration) error {
payload := *ch
payload.Attempts = 0
raw, err := json.Marshal(&payload)
if err != nil {
return fmt.Errorf("member: marshal otp challenge: %w", err)
}
seconds := int(ttl.Seconds())
if seconds < 1 {
seconds = 1
}
chKey := member.GetOTPChallengeRedisKey(challengeID)
attKey := member.GetOTPAttemptsRedisKey(challengeID)
if err := s.client.SetexCtx(ctx, chKey, string(raw), seconds); err != nil {
return err
}
return s.client.SetexCtx(ctx, attKey, "0", seconds)
}
func (s *redisOTPChallengeStore) Get(ctx context.Context, challengeID string) (*domrepo.OTPChallenge, error) {
val, err := s.client.GetCtx(ctx, member.GetOTPChallengeRedisKey(challengeID))
if errors.Is(err, redis.Nil) {
return nil, member.ErrChallengeNotFound
}
if err != nil {
return nil, err
}
var ch domrepo.OTPChallenge
if err := json.Unmarshal([]byte(val), &ch); err != nil {
return nil, fmt.Errorf("member: unmarshal otp challenge: %w", err)
}
attKey := member.GetOTPAttemptsRedisKey(challengeID)
if attVal, attErr := s.client.GetCtx(ctx, attKey); attErr == nil && attVal != "" {
if n, parseErr := strconv.Atoi(attVal); parseErr == nil {
ch.Attempts = n
}
}
return &ch, nil
}
func (s *redisOTPChallengeStore) IncrementAttempts(ctx context.Context, challengeID string) (int, error) {
chKey := member.GetOTPChallengeRedisKey(challengeID)
exists, err := s.client.ExistsCtx(ctx, chKey)
if err != nil {
return 0, err
}
if !exists {
return 0, member.ErrChallengeNotFound
}
attKey := member.GetOTPAttemptsRedisKey(challengeID)
count, err := s.client.IncrCtx(ctx, attKey)
if err != nil {
return 0, err
}
if ttl, ttlErr := s.client.TtlCtx(ctx, chKey); ttlErr == nil && ttl > 0 {
if expErr := s.client.ExpireCtx(ctx, attKey, ttl); expErr != nil {
return int(count), expErr
}
}
return int(count), nil
}
func (s *redisOTPChallengeStore) Delete(ctx context.Context, challengeID string) error {
_, err := s.client.DelCtx(ctx, member.GetOTPChallengeRedisKey(challengeID), member.GetOTPAttemptsRedisKey(challengeID))
return err
}
type redisVerifyRateStore struct {
client *redis.Redis
}
func NewRedisVerifyRateStore(client *redislib.Client) domrepo.VerifyRateStore {
if client == nil || client.Zero() == nil {
panic("member: redis client is required for verify rate store")
}
return &redisVerifyRateStore{client: client.Zero()}
}
func (s *redisVerifyRateStore) TryResendLock(ctx context.Context, key string, ttl time.Duration) (bool, error) {
seconds := int(ttl.Seconds())
if seconds < 1 {
seconds = 1
}
ok, err := s.client.SetnxExCtx(ctx, key, "1", seconds)
if err != nil {
return false, err
}
return ok, nil
}
func (s *redisVerifyRateStore) IncrDaily(ctx context.Context, key string, ttl time.Duration) (int64, error) {
count, err := s.client.IncrCtx(ctx, key)
if err != nil {
return 0, err
}
seconds := int(ttl.Seconds())
if seconds < 1 {
seconds = 1
}
if err := s.client.ExpireCtx(ctx, key, seconds); err != nil {
return count, err
}
return count, nil
}

View File

@ -0,0 +1,44 @@
package repository
import (
"context"
"sync"
domrepo "gateway/internal/model/member/domain/repository"
)
// MemoryProfileRepository is an in-memory ProfileRepository for tests and local dev until P4 Mongo entity exists.
type MemoryProfileRepository struct {
mu sync.RWMutex
emails map[string]string // tenant:uid -> email
phones map[string]string
}
func NewMemoryProfileRepository() *MemoryProfileRepository {
return &MemoryProfileRepository{
emails: make(map[string]string),
phones: make(map[string]string),
}
}
func profileKey(tenantID, uid string) string {
return tenantID + ":" + uid
}
func (r *MemoryProfileRepository) SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error {
_ = ctx
r.mu.Lock()
defer r.mu.Unlock()
r.emails[profileKey(tenantID, uid)] = email
return nil
}
func (r *MemoryProfileRepository) SetBusinessPhoneVerified(ctx context.Context, tenantID, uid, phone string) error {
_ = ctx
r.mu.Lock()
defer r.mu.Unlock()
r.phones[profileKey(tenantID, uid)] = phone
return nil
}
var _ domrepo.ProfileRepository = (*MemoryProfileRepository)(nil)

View File

@ -0,0 +1,55 @@
package usecase
import (
"fmt"
redislib "gateway/internal/library/redis"
memberconfig "gateway/internal/model/member/config"
domrepo "gateway/internal/model/member/domain/repository"
domusecase "gateway/internal/model/member/domain/usecase"
"gateway/internal/model/member/repository"
domnotif "gateway/internal/model/notification/domain/usecase"
)
// Module bundles member use cases.
type Module struct {
OTP domusecase.OTPUseCase
Verification domusecase.VerificationUseCase
}
// ModuleParam wires member module dependencies.
type ModuleParam struct {
Redis *redislib.Client
Notifier domnotif.NotifierUseCase
Config memberconfig.Config
Profile domrepo.ProfileRepository // optional; defaults to memory
}
// NewModuleFromParam builds member use cases.
func NewModuleFromParam(param ModuleParam) (*Module, error) {
if param.Redis == nil || param.Redis.Zero() == nil {
return nil, fmt.Errorf("member: redis is required")
}
if param.Notifier == nil {
return nil, fmt.Errorf("member: notifier is required")
}
otpStore := repository.NewRedisOTPChallengeStore(param.Redis)
rateStore := repository.NewRedisVerifyRateStore(param.Redis)
profile := param.Profile
if profile == nil {
profile = repository.NewMemoryProfileRepository()
}
cfg := param.Config.Defaults()
otpUC := MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg})
verificationUC := MustVerificationUseCase(VerificationUseCaseParam{
OTP: otpUC,
Notifier: param.Notifier,
Profile: profile,
Rates: rateStore,
Config: cfg,
})
return &Module{OTP: otpUC, Verification: verificationUC}, nil
}

View File

@ -0,0 +1,141 @@
package usecase
import (
"context"
"crypto/rand"
"errors"
"math/big"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
errs "gateway/internal/library/errors"
"gateway/internal/library/errors/code"
"gateway/internal/model/member"
memberconfig "gateway/internal/model/member/config"
domrepo "gateway/internal/model/member/domain/repository"
domusecase "gateway/internal/model/member/domain/usecase"
)
var errb = errs.For(code.Facade)
type otpUseCase struct {
store domrepo.OTPChallengeStore
config memberconfig.Config
}
// OTPUseCaseParam wires OTPUseCase.
type OTPUseCaseParam struct {
Store domrepo.OTPChallengeStore
Config memberconfig.Config
}
// MustOTPUseCase constructs OTPUseCase.
func MustOTPUseCase(param OTPUseCaseParam) domusecase.OTPUseCase {
return &otpUseCase{
store: param.Store,
config: param.Config.Defaults(),
}
}
func (uc *otpUseCase) Generate(ctx context.Context, req *domusecase.GenerateOTPRequest) (*domusecase.OTPChallengeDTO, string, error) {
if req == nil || req.TenantID == "" || req.Purpose == "" {
return nil, "", errb.InputMissingRequired("tenant_id and purpose are required")
}
plainCode, err := generateNumericOTP(uc.config.OTP.Length)
if err != nil {
return nil, "", errb.SysInternal("otp generation failed").WithCause(err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(plainCode), bcrypt.DefaultCost)
if err != nil {
return nil, "", errb.SysInternal("otp hash failed").WithCause(err)
}
challengeID := uuid.NewString()
ch := &domrepo.OTPChallenge{
TenantID: req.TenantID,
UID: req.UID,
Purpose: req.Purpose,
Target: req.Target,
CodeHash: string(hash),
}
ttl := time.Duration(uc.config.OTP.TTLSeconds) * time.Second
if err := uc.store.Save(ctx, challengeID, ch, ttl); err != nil {
return nil, "", errb.SysInternal("otp persist failed").WithCause(err)
}
return &domusecase.OTPChallengeDTO{
ChallengeID: challengeID,
ExpiresIn: uc.config.OTP.TTLSeconds,
}, plainCode, nil
}
func (uc *otpUseCase) Verify(ctx context.Context, req *domusecase.VerifyOTPRequest) (string, error) {
if req == nil || req.ChallengeID == "" || req.Code == "" || req.Purpose == "" {
return "", errb.InputMissingRequired("challenge_id, code and purpose are required")
}
ch, err := uc.store.Get(ctx, req.ChallengeID)
if err != nil {
if errors.Is(err, member.ErrChallengeNotFound) {
return "", errb.ResNotFound("otp challenge", req.ChallengeID).WithCause(err)
}
return "", errb.SysInternal("otp read failed").WithCause(err)
}
if ch.TenantID != req.TenantID {
return "", errb.AuthForbidden("otp challenge tenant mismatch")
}
if ch.UID != "" {
if req.UID == "" {
return "", errb.InputMissingRequired("uid is required for this otp challenge")
}
if ch.UID != req.UID {
return "", errb.AuthForbidden("otp challenge uid mismatch")
}
}
if ch.Purpose != req.Purpose {
return "", errb.AuthForbidden("otp challenge purpose mismatch")
}
if ch.Attempts >= uc.config.OTP.MaxAttempts {
return "", errb.ResInvalidState("otp challenge locked").WithCause(member.ErrChallengeLocked)
}
if err := bcrypt.CompareHashAndPassword([]byte(ch.CodeHash), []byte(req.Code)); err != nil {
attempts, incErr := uc.store.IncrementAttempts(ctx, req.ChallengeID)
if incErr != nil {
if errors.Is(incErr, member.ErrChallengeNotFound) {
return "", errb.ResNotFound("otp challenge", req.ChallengeID).WithCause(incErr)
}
return "", errb.SysInternal("otp persist failed").WithCause(incErr)
}
if attempts >= uc.config.OTP.MaxAttempts {
return "", errb.ResInvalidState("otp challenge locked").WithCause(member.ErrChallengeLocked)
}
return "", errb.AuthForbidden("invalid otp code").WithCause(member.ErrInvalidOTP)
}
target := ch.Target
if delErr := uc.store.Delete(ctx, req.ChallengeID); delErr != nil {
return "", errb.SysInternal("otp delete failed").WithCause(delErr)
}
return target, nil
}
func (uc *otpUseCase) Invalidate(ctx context.Context, challengeID string) error {
if challengeID == "" {
return errb.InputMissingRequired("challenge_id is required")
}
return uc.store.Delete(ctx, challengeID)
}
func generateNumericOTP(length int) (string, error) {
if length <= 0 {
length = 6
}
out := make([]byte, length)
for i := range out {
n, err := rand.Int(rand.Reader, big.NewInt(10))
if err != nil {
return "", err
}
out[i] = byte('0' + n.Uint64()) //nolint:gosec // digit 0-9
}
return string(out), nil
}

View File

@ -0,0 +1,118 @@
package usecase_test
import (
"context"
"testing"
memberconfig "gateway/internal/model/member/config"
"gateway/internal/model/member/domain/enum"
domusecase "gateway/internal/model/member/domain/usecase"
"gateway/internal/model/member/repository"
"gateway/internal/model/member/usecase"
redislib "gateway/internal/library/redis"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/require"
"github.com/zeromicro/go-zero/core/stores/redis"
)
func TestOTPUseCase_GenerateAndVerify(t *testing.T) {
mr := miniredis.RunT(t)
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: "node"})
require.NoError(t, err)
uc := usecase.MustOTPUseCase(usecase.OTPUseCaseParam{
Store: repository.NewRedisOTPChallengeStore(rds),
Config: memberconfig.Config{}.Defaults(),
})
dto, code, err := uc.Generate(context.Background(), &domusecase.GenerateOTPRequest{
TenantID: "t1",
UID: "u1",
Purpose: enum.OTPPurposeBusinessEmail,
Target: "user@example.com",
})
require.NoError(t, err)
require.NotEmpty(t, code)
target, err := uc.Verify(context.Background(), &domusecase.VerifyOTPRequest{
TenantID: "t1",
UID: "u1",
ChallengeID: dto.ChallengeID,
Code: code,
Purpose: enum.OTPPurposeBusinessEmail,
})
require.NoError(t, err)
require.Equal(t, "user@example.com", target)
}
func TestOTPUseCase_VerifyUIDMismatch(t *testing.T) {
mr := miniredis.RunT(t)
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: "node"})
require.NoError(t, err)
uc := usecase.MustOTPUseCase(usecase.OTPUseCaseParam{
Store: repository.NewRedisOTPChallengeStore(rds),
Config: memberconfig.Config{}.Defaults(),
})
dto, code, err := uc.Generate(context.Background(), &domusecase.GenerateOTPRequest{
TenantID: "t1",
UID: "victim",
Purpose: enum.OTPPurposeBusinessEmail,
Target: "user@example.com",
})
require.NoError(t, err)
_, err = uc.Verify(context.Background(), &domusecase.VerifyOTPRequest{
TenantID: "t1",
UID: "attacker",
ChallengeID: dto.ChallengeID,
Code: code,
Purpose: enum.OTPPurposeBusinessEmail,
})
require.Error(t, err)
}
func TestOTPUseCase_MaxAttemptsLocks(t *testing.T) {
mr := miniredis.RunT(t)
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: "node"})
require.NoError(t, err)
cfg := memberconfig.Config{}.Defaults()
cfg.OTP.MaxAttempts = 2
uc := usecase.MustOTPUseCase(usecase.OTPUseCaseParam{
Store: repository.NewRedisOTPChallengeStore(rds),
Config: cfg,
})
dto, _, err := uc.Generate(context.Background(), &domusecase.GenerateOTPRequest{
TenantID: "t1",
UID: "u1",
Purpose: enum.OTPPurposeBusinessEmail,
Target: "user@example.com",
})
require.NoError(t, err)
for range cfg.OTP.MaxAttempts {
_, err = uc.Verify(context.Background(), &domusecase.VerifyOTPRequest{
TenantID: "t1",
UID: "u1",
ChallengeID: dto.ChallengeID,
Code: "000000",
Purpose: enum.OTPPurposeBusinessEmail,
})
require.Error(t, err)
}
_, err = uc.Verify(context.Background(), &domusecase.VerifyOTPRequest{
TenantID: "t1",
UID: "u1",
ChallengeID: dto.ChallengeID,
Code: "000000",
Purpose: enum.OTPPurposeBusinessEmail,
})
require.Error(t, err)
}

View File

@ -0,0 +1,150 @@
package usecase
import (
"context"
"time"
"gateway/internal/model/member"
memberconfig "gateway/internal/model/member/config"
"gateway/internal/model/member/domain/enum"
domrepo "gateway/internal/model/member/domain/repository"
domusecase "gateway/internal/model/member/domain/usecase"
notifenum "gateway/internal/model/notification/domain/enum"
domnotif "gateway/internal/model/notification/domain/usecase"
)
type verificationUseCase struct {
otp domusecase.OTPUseCase
notifier domnotif.NotifierUseCase
profile domrepo.ProfileRepository
rates domrepo.VerifyRateStore
config memberconfig.Config
}
// VerificationUseCaseParam wires VerificationUseCase.
type VerificationUseCaseParam struct {
OTP domusecase.OTPUseCase
Notifier domnotif.NotifierUseCase
Profile domrepo.ProfileRepository
Rates domrepo.VerifyRateStore
Config memberconfig.Config
}
// MustVerificationUseCase constructs VerificationUseCase.
func MustVerificationUseCase(param VerificationUseCaseParam) domusecase.VerificationUseCase {
return &verificationUseCase{
otp: param.OTP,
notifier: param.Notifier,
profile: param.Profile,
rates: param.Rates,
config: param.Config.Defaults(),
}
}
func (uc *verificationUseCase) StartEmailVerify(ctx context.Context, tenantID, uid, target, locale string) (*domusecase.OTPChallengeDTO, error) {
return uc.startVerify(ctx, tenantID, uid, target, locale, enum.VerifyKindEmail, enum.OTPPurposeBusinessEmail, notifenum.ChannelEmail, notifenum.NotifyVerifyEmail)
}
func (uc *verificationUseCase) ConfirmEmailVerify(ctx context.Context, tenantID, uid, challengeID, code string) error {
target, err := uc.otp.Verify(ctx, &domusecase.VerifyOTPRequest{
TenantID: tenantID,
UID: uid,
ChallengeID: challengeID,
Code: code,
Purpose: enum.OTPPurposeBusinessEmail,
})
if err != nil {
return err
}
return uc.profile.SetBusinessEmailVerified(ctx, tenantID, uid, target)
}
func (uc *verificationUseCase) StartPhoneVerify(ctx context.Context, tenantID, uid, target, locale string) (*domusecase.OTPChallengeDTO, error) {
return uc.startVerify(ctx, tenantID, uid, target, locale, enum.VerifyKindPhone, enum.OTPPurposeBusinessPhone, notifenum.ChannelSMS, notifenum.NotifyVerifyPhone)
}
func (uc *verificationUseCase) ConfirmPhoneVerify(ctx context.Context, tenantID, uid, challengeID, code string) error {
target, err := uc.otp.Verify(ctx, &domusecase.VerifyOTPRequest{
TenantID: tenantID,
UID: uid,
ChallengeID: challengeID,
Code: code,
Purpose: enum.OTPPurposeBusinessPhone,
})
if err != nil {
return err
}
return uc.profile.SetBusinessPhoneVerified(ctx, tenantID, uid, target)
}
func (uc *verificationUseCase) startVerify(
ctx context.Context,
tenantID, uid, target, locale string,
kind enum.VerifyKind,
purpose enum.OTPPurpose,
channel notifenum.Channel,
notifyKind notifenum.NotifyKind,
) (*domusecase.OTPChallengeDTO, error) {
if tenantID == "" || uid == "" || target == "" {
return nil, errb.InputMissingRequired("tenant_id, uid and target are required")
}
if uc.notifier == nil {
return nil, errb.SysInternal("notifier is not configured")
}
if uc.rates == nil {
return nil, errb.SysInternal("verify rate store is not configured")
}
if err := uc.checkRateLimits(ctx, tenantID, uid, kind); err != nil {
return nil, err
}
dto, code, err := uc.otp.Generate(ctx, &domusecase.GenerateOTPRequest{
TenantID: tenantID,
UID: uid,
Purpose: purpose,
Target: target,
})
if err != nil {
return nil, err
}
_, err = uc.notifier.Send(ctx, &domnotif.SendRequest{
TenantID: tenantID,
UID: uid,
Channel: channel,
Kind: notifyKind,
Target: target,
Locale: locale,
Data: map[string]any{"code": code, "expires_in": dto.ExpiresIn},
IdempotencyKey: dto.ChallengeID,
DoNotPersistBody: true,
Severity: notifenum.SeverityInfo,
})
if err != nil {
if invErr := uc.otp.Invalidate(ctx, dto.ChallengeID); invErr != nil {
return nil, errb.SysInternal("invalidate otp after send failure").WithCause(invErr)
}
return nil, err
}
return dto, nil
}
func (uc *verificationUseCase) checkRateLimits(ctx context.Context, tenantID, uid string, kind enum.VerifyKind) error {
cooldown := time.Duration(uc.config.OTP.ResendCooldownSeconds) * time.Second
ok, err := uc.rates.TryResendLock(ctx, member.GetVerifyRateRedisKey(tenantID, uid, kind.String()), cooldown)
if err != nil {
return errb.SysInternal("verify rate check failed").WithCause(err)
}
if !ok {
return errb.ResInvalidState("verification resend cooldown").WithCause(member.ErrResendCooldown)
}
count, err := uc.rates.IncrDaily(ctx, member.GetVerifyDailyRedisKey(tenantID, uid, kind.String()), 25*time.Hour)
if err != nil {
return errb.SysInternal("verify daily limit check failed").WithCause(err)
}
if count > int64(uc.config.OTP.DailyVerifyLimit) {
return errb.ResInsufficientQuota("daily verification limit exceeded").WithCause(member.ErrDailyLimit)
}
return nil
}

View File

@ -0,0 +1,31 @@
ji3
## 擴充指南
### 新增 Email Provider
已內建:`smtp_sender.go`、`ses_sender.go`。若要再加其他 ESP
1. 在 `provider/email/` 實作 `Sender``Name`、`Sort`、`Send`)。
2. 在 `config/config.go` 加設定區塊,並在 `collectEmailSenders` 註冊。
3. 補 `provider/email/*_test.go`
### 新增 NotifyKind
1. `domain/enum/kind.go` 常數。
2. embed 模板 + `template/registry.go`
3. 若為 OTP 類,業務呼叫時設 `DoNotPersistBody: true`
### 新增 HTTP API
1. 在 `generate/api/` 定義路由。
2. `make gen-api`
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 存取層

View File

@ -0,0 +1,79 @@
package config
// ProviderMock is the dev/test email/SMS provider name.
const ProviderMock = "mock"
// Config is notification module settings (embedded in gateway root config).
type Config struct {
DefaultLocale string `json:",optional"`
Async AsyncConfig `json:",optional"`
RatePerTenant RatePerTenantConfig `json:",optional"`
Email EmailConfig `json:",optional"`
SMS SMSConfig `json:",optional"`
Push PushConfig `json:",optional"`
Webhook WebhookConfig `json:",optional"`
}
type AsyncConfig struct {
QueueRedisKey string `json:",optional"`
Worker int `json:",optional"`
MaxRetry int `json:",optional"`
BackoffSeconds []int `json:",optional"`
}
type RatePerTenantConfig struct {
Email int `json:",optional"`
SMS int `json:",optional"`
}
type EmailConfig struct {
Provider string `json:",optional"`
From string `json:",optional"`
APIKey string `json:",optional"`
SMTP SMTPProviderSettings `json:",optional"`
SES SESProviderSettings `json:",optional"`
}
// SMTPProviderSettings mirrors app-cloudep-notification-service SMTPConfig.
type SMTPProviderSettings struct {
Enable bool `json:",optional"`
Sort int `json:",optional"`
Host string `json:",optional"`
Port int `json:",optional"`
Username string `json:",optional"`
Password string `json:",optional"`
}
// SESProviderSettings mirrors app-cloudep-notification-service AmazonSesSettings.
type SESProviderSettings struct {
Enable bool `json:",optional"`
Sort int `json:",optional"`
Region string `json:",optional"`
AccessKey string `json:",optional"`
SecretKey string `json:",optional"`
SessionToken string `json:",optional"`
}
type SMSConfig struct {
Provider string `json:",optional"`
AccountSID string `json:",optional"`
AuthToken string `json:",optional"`
From string `json:",optional"`
Mitake MitakeProviderSettings `json:",optional"`
}
// MitakeProviderSettings mirrors app-cloudep-notification-service MitakeSMSSender (三竹簡訊).
type MitakeProviderSettings struct {
Enable bool `json:",optional"`
Sort int `json:",optional"`
User string `json:",optional"`
Password string `json:",optional"`
}
type PushConfig struct {
Enabled bool `json:",optional"`
}
type WebhookConfig struct {
HMACSecret string `json:",optional"`
}

View File

@ -0,0 +1,13 @@
package notification
// MongoDB field names for notifications collections.
const (
BSONFieldID = "_id"
BSONFieldTenantID = "tenant_id"
BSONFieldOccurredAt = "occurred_at"
BSONFieldKind = "kind"
BSONFieldIdempotencyKey = "idempotency_key"
BSONFieldUID = "uid"
BSONFieldStatus = "status"
BSONFieldAttempts = "attempts"
)

View File

@ -0,0 +1,35 @@
package entity
import (
"gateway/internal/model/notification/domain/enum"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Notification is the outbound message audit / outbox record.
type Notification struct {
ID bson.ObjectID `bson:"_id,omitempty"`
TenantID string `bson:"tenant_id"`
UID string `bson:"uid,omitempty"`
Channel enum.Channel `bson:"channel"`
Kind enum.NotifyKind `bson:"kind"`
TargetHash string `bson:"target_hash"`
TemplateKey string `bson:"template_key"`
Locale string `bson:"locale"`
Body string `bson:"body,omitempty"`
Provider string `bson:"provider,omitempty"`
ProviderMessageID string `bson:"provider_message_id,omitempty"`
Status enum.NotifyStatus `bson:"status"`
Attempts int `bson:"attempts"`
LastError string `bson:"last_error,omitempty"`
IdempotencyKey string `bson:"idempotency_key"`
Severity enum.Severity `bson:"severity"`
OccurredAt *int64 `bson:"occurred_at,omitempty"`
DeliveredAt *int64 `bson:"delivered_at,omitempty"`
CreateAt *int64 `bson:"create_at,omitempty"`
UpdateAt *int64 `bson:"update_at,omitempty"`
}
func (n *Notification) CollectionName() string {
return "notifications"
}

View File

@ -0,0 +1,35 @@
package entity
import (
"gateway/internal/model/notification/domain/enum"
"go.mongodb.org/mongo-driver/v2/bson"
)
// DLQDeliveryPayload holds non-PII metadata for admin inspection (target is not stored; use RetryDLQ with an explicit target).
type DLQDeliveryPayload struct {
Locale string `bson:"locale"`
Data map[string]any `bson:"data,omitempty"`
IdempotencyKey string `bson:"idempotency_key"`
DoNotPersistBody bool `bson:"do_not_persist_body,omitempty"`
}
// NotificationDLQ stores notifications that exceeded max retry attempts.
type NotificationDLQ struct {
ID bson.ObjectID `bson:"_id,omitempty"`
NotificationID string `bson:"notification_id"`
TenantID string `bson:"tenant_id"`
UID string `bson:"uid,omitempty"`
Channel enum.Channel `bson:"channel"`
Kind enum.NotifyKind `bson:"kind"`
TargetHash string `bson:"target_hash"`
LastError string `bson:"last_error"`
Attempts int `bson:"attempts"`
Payload *DLQDeliveryPayload `bson:"payload,omitempty"`
OccurredAt *int64 `bson:"occurred_at,omitempty"`
CreateAt *int64 `bson:"create_at,omitempty"`
}
func (d *NotificationDLQ) CollectionName() string {
return "notification_dlq"
}

View File

@ -0,0 +1,24 @@
package enum
// Channel is the outbound delivery channel.
type Channel string
const (
ChannelEmail Channel = "email"
ChannelSMS Channel = "sms"
ChannelPush Channel = "push"
ChannelWebhook Channel = "webhook"
)
func (c Channel) String() string {
return string(c)
}
func (c Channel) IsValid() bool {
switch c {
case ChannelEmail, ChannelSMS, ChannelPush, ChannelWebhook:
return true
default:
return false
}
}

View File

@ -0,0 +1,34 @@
package enum
// NotifyKind identifies the business notification template and routing.
type NotifyKind string
const (
NotifyVerifyEmail NotifyKind = "verify_email"
NotifyVerifyPhone NotifyKind = "verify_phone"
NotifyVerifyRegistrationEmail NotifyKind = "verify_registration_email"
NotifyStepUpEmail NotifyKind = "step_up_email"
NotifyStepUpPhone NotifyKind = "step_up_phone"
NotifyAccountSuspended NotifyKind = "account_suspended"
NotifyTenantWelcome NotifyKind = "tenant_welcome"
)
func (k NotifyKind) String() string {
return string(k)
}
// IsValid reports whether the kind has a registered template (ops_alert and others are added when templates exist).
func (k NotifyKind) IsValid() bool {
switch k {
case NotifyVerifyEmail,
NotifyVerifyPhone,
NotifyVerifyRegistrationEmail,
NotifyStepUpEmail,
NotifyStepUpPhone,
NotifyAccountSuspended,
NotifyTenantWelcome:
return true
default:
return false
}
}

View File

@ -0,0 +1,14 @@
package enum
// Severity classifies notification importance for audit and routing.
type Severity string
const (
SeverityInfo Severity = "info"
SeverityWarn Severity = "warn"
SeverityCritical Severity = "critical"
)
func (s Severity) String() string {
return string(s)
}

View File

@ -0,0 +1,16 @@
package enum
// NotifyStatus is the delivery lifecycle state persisted in MongoDB.
type NotifyStatus string
const (
NotifyStatusPending NotifyStatus = "pending"
NotifyStatusSent NotifyStatus = "sent"
NotifyStatusFailed NotifyStatus = "failed"
NotifyStatusRetrying NotifyStatus = "retrying"
NotifyStatusDropped NotifyStatus = "dropped"
)
func (s NotifyStatus) String() string {
return string(s)
}

View File

@ -0,0 +1,3 @@
package repository
//go:generate go run go.uber.org/mock/mockgen@latest -typed -destination=../../mock/repository/repository_mock.go -package=mocknotifrepo gateway/internal/model/notification/domain/repository NotificationRepository,NotificationDLQRepository,IdempotencyCache,QuotaCounter,RetryQueue

View File

@ -0,0 +1,33 @@
package repository
import (
"context"
"gateway/internal/model/notification/domain/entity"
"gateway/internal/model/notification/domain/enum"
)
// NotificationRepository persists notification outbox records.
type NotificationRepository interface {
Insert(ctx context.Context, data *entity.Notification) error
FindByID(ctx context.Context, tenantID, id string) (*entity.Notification, error)
FindByIdempotency(ctx context.Context, tenantID string, kind enum.NotifyKind, idempotencyKey string) (*entity.Notification, error)
UpdateDelivery(ctx context.Context, tenantID, id string, update *NotificationDeliveryUpdate) error
NotificationIndexUP
}
// NotificationDeliveryUpdate is the partial update for delivery outcome.
type NotificationDeliveryUpdate struct {
Status enum.NotifyStatus
Provider string
ProviderMessageID string
LastError string
Attempts int
Body string
DeliveredAt *int64
}
// NotificationIndexUP migrates notification collection indexes.
type NotificationIndexUP interface {
Index20260520001UP(ctx context.Context) error
}

View File

@ -0,0 +1,20 @@
package repository
import (
"context"
"gateway/internal/model/notification/domain/entity"
)
// NotificationDLQRepository persists dead-letter notification records.
type NotificationDLQRepository interface {
Insert(ctx context.Context, data *entity.NotificationDLQ) error
FindByID(ctx context.Context, tenantID, id string) (*entity.NotificationDLQ, error)
ListByTenant(ctx context.Context, tenantID string, limit int64) ([]*entity.NotificationDLQ, error)
NotificationDLQIndexUP
}
// NotificationDLQIndexUP migrates DLQ collection indexes.
type NotificationDLQIndexUP interface {
Index20260520001UP(ctx context.Context) error
}

View File

@ -0,0 +1,13 @@
package repository
import (
"context"
domusecase "gateway/internal/model/notification/domain/usecase"
)
// RetryQueue schedules async notification delivery (Redis ZSET: score = run_at_unix_ms).
type RetryQueue interface {
Schedule(ctx context.Context, runAtMs int64, job *domusecase.RetryJob) error
ClaimDue(ctx context.Context, nowMs int64, limit int) ([]*domusecase.RetryJob, error)
}

View File

@ -0,0 +1,18 @@
package repository
import (
"context"
"time"
)
// IdempotencyCache stores a serialized notification result for deduplication.
type IdempotencyCache interface {
Get(ctx context.Context, key string) ([]byte, error)
Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
}
// QuotaCounter tracks per-tenant daily send counts.
type QuotaCounter interface {
// Incr returns the count after increment (1-based on first incr of the day).
Incr(ctx context.Context, key string, ttl time.Duration) (int64, error)
}

View File

@ -0,0 +1,13 @@
package template
// Supported notification template locales.
const (
LocaleZhTW = "zh-tw"
LocaleEnUS = "en-us"
)
// Template data keys (SendRequest.Data / RequiredVars).
const (
VarCode = "code"
VarExpiresIn = "expires_in"
)

View File

@ -0,0 +1,29 @@
package template
import (
"fmt"
"gateway/internal/model/notification/domain/enum"
)
// Registry maps NotifyKind and locale to template specs.
type Registry map[enum.NotifyKind]map[string]Spec
// Lookup returns the spec for kind and locale, with locale fallback.
func (r Registry) Lookup(kind enum.NotifyKind, locale string, fallbacks ...string) (Spec, error) {
byLocale, ok := r[kind]
if !ok {
return Spec{}, fmt.Errorf("template: unknown kind %q", kind)
}
try := append([]string{locale}, fallbacks...)
for _, loc := range try {
if loc == "" {
continue
}
if spec, ok := byLocale[loc]; ok {
return spec, nil
}
}
return Spec{}, fmt.Errorf("template: no locale for kind %q (locale=%q)", kind, locale)
}

View File

@ -0,0 +1,10 @@
package template
import (
"gateway/internal/model/notification/domain/enum"
)
// Renderer renders notification templates for a given kind and locale.
type Renderer interface {
Render(kind enum.NotifyKind, locale string, data map[string]any) (*Rendered, error)
}

View File

@ -0,0 +1,27 @@
package template
// Spec defines how a notification kind is rendered for one locale.
// Prefer EmailSubjectFile / EmailBodyFile / SMSTextFile (go:embed) over inline strings.
type Spec struct {
RequiredVars []string
// Embedded asset paths (relative to template package), e.g. "html/verify_email.zh-tw.html".
EmailSubjectFile string
EmailBodyFile string
SMSTextFile string
// Inline fallbacks (optional); used when *File fields are empty.
EmailSubject string
EmailBody string
SMSText string
// EmailProviderTemplateID is optional (e.g. SendGrid dynamic template); future use.
EmailProviderTemplateID string
}
// Rendered is the output passed to email/SMS providers.
type Rendered struct {
Subject string
Body string // HTML for email
SMSText string
}

View File

@ -0,0 +1,25 @@
package usecase
import "context"
// AdminNotifierUseCase supports operations/admin retry of failed notifications.
type AdminNotifierUseCase interface {
ListDLQ(ctx context.Context, tenantID string, limit int64) ([]*DLQEntryDTO, error)
// RetryDLQ re-queues delivery; target must be supplied by the operator (not stored in DLQ for privacy).
RetryDLQ(ctx context.Context, tenantID, dlqID, target string) (*NotificationDTO, error)
}
// DLQEntryDTO is an admin view of a dead-letter record.
type DLQEntryDTO struct {
ID string `json:"id"`
NotificationID string `json:"notification_id"`
TenantID string `json:"tenant_id"`
UID string `json:"uid,omitempty"`
Channel string `json:"channel"`
Kind string `json:"kind"`
TargetHash string `json:"target_hash"`
LastError string `json:"last_error"`
Attempts int `json:"attempts"`
OccurredAt int64 `json:"occurred_at"`
HasRetryPayload bool `json:"has_retry_payload"`
}

View File

@ -0,0 +1,40 @@
package usecase
import (
"gateway/internal/model/notification/domain/enum"
)
// SendRequest is the input for NotifierUseCase.Send / Enqueue.
type SendRequest struct {
TenantID string
UID string
Channel enum.Channel
Kind enum.NotifyKind
Target string
Locale string
Data map[string]any
Severity enum.Severity
IdempotencyKey string
DoNotPersistBody bool
}
// NotificationDTO is the API-facing notification record (no raw target).
type NotificationDTO struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
UID string `json:"uid,omitempty"`
Channel enum.Channel `json:"channel"`
Kind enum.NotifyKind `json:"kind"`
TargetHash string `json:"target_hash"`
TemplateKey string `json:"template_key"`
Locale string `json:"locale"`
Provider string `json:"provider,omitempty"`
ProviderMessageID string `json:"provider_message_id,omitempty"`
Status enum.NotifyStatus `json:"status"`
Attempts int `json:"attempts"`
LastError string `json:"last_error,omitempty"`
IdempotencyKey string `json:"idempotency_key"`
Severity enum.Severity `json:"severity"`
OccurredAt int64 `json:"occurred_at"`
DeliveredAt int64 `json:"delivered_at,omitempty"`
}

View File

@ -0,0 +1,10 @@
package usecase
import "context"
// NotifierUseCase is the unified outbound notification entry point.
type NotifierUseCase interface {
Send(ctx context.Context, req *SendRequest) (*NotificationDTO, error)
Enqueue(ctx context.Context, req *SendRequest) (*NotificationDTO, error)
Get(ctx context.Context, tenantID, notificationID string) (*NotificationDTO, error)
}

View File

@ -0,0 +1,16 @@
package usecase
import "gateway/internal/model/notification/domain/enum"
// RetryJob is the Redis ZSET member payload for async delivery (includes target; not stored in Mongo).
type RetryJob struct {
NotificationID string `json:"notification_id"`
TenantID string `json:"tenant_id"`
UID string `json:"uid,omitempty"`
Channel enum.Channel `json:"channel"`
Kind enum.NotifyKind `json:"kind"`
Target string `json:"target"`
Locale string `json:"locale"`
Data map[string]any `json:"data,omitempty"`
DoNotPersistBody bool `json:"do_not_persist_body,omitempty"`
}

View File

@ -0,0 +1,11 @@
package notification
import "fmt"
var (
ErrNotFound = fmt.Errorf("notification: not found")
ErrInvalidObjectID = fmt.Errorf("notification: invalid object id")
ErrDuplicateIdempotency = fmt.Errorf("notification: duplicate idempotency key")
ErrInvalidChannel = fmt.Errorf("notification: invalid channel")
ErrQuotaExceeded = fmt.Errorf("notification: tenant quota exceeded")
)

View File

@ -0,0 +1,681 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: gateway/internal/model/notification/domain/repository (interfaces: NotificationRepository,NotificationDLQRepository,IdempotencyCache,QuotaCounter,RetryQueue)
//
// Generated by this command:
//
// mockgen -typed -destination=../../mock/repository/repository_mock.go -package=mocknotifrepo gateway/internal/model/notification/domain/repository NotificationRepository,NotificationDLQRepository,IdempotencyCache,QuotaCounter,RetryQueue
//
// Package mocknotifrepo is a generated GoMock package.
package mocknotifrepo
import (
context "context"
entity "gateway/internal/model/notification/domain/entity"
enum "gateway/internal/model/notification/domain/enum"
repository "gateway/internal/model/notification/domain/repository"
usecase "gateway/internal/model/notification/domain/usecase"
reflect "reflect"
time "time"
gomock "go.uber.org/mock/gomock"
)
// MockNotificationRepository is a mock of NotificationRepository interface.
type MockNotificationRepository struct {
ctrl *gomock.Controller
recorder *MockNotificationRepositoryMockRecorder
isgomock struct{}
}
// MockNotificationRepositoryMockRecorder is the mock recorder for MockNotificationRepository.
type MockNotificationRepositoryMockRecorder struct {
mock *MockNotificationRepository
}
// NewMockNotificationRepository creates a new mock instance.
func NewMockNotificationRepository(ctrl *gomock.Controller) *MockNotificationRepository {
mock := &MockNotificationRepository{ctrl: ctrl}
mock.recorder = &MockNotificationRepositoryMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockNotificationRepository) EXPECT() *MockNotificationRepositoryMockRecorder {
return m.recorder
}
// FindByID mocks base method.
func (m *MockNotificationRepository) FindByID(ctx context.Context, tenantID, id string) (*entity.Notification, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FindByID", ctx, tenantID, id)
ret0, _ := ret[0].(*entity.Notification)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FindByID indicates an expected call of FindByID.
func (mr *MockNotificationRepositoryMockRecorder) FindByID(ctx, tenantID, id any) *MockNotificationRepositoryFindByIDCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByID", reflect.TypeOf((*MockNotificationRepository)(nil).FindByID), ctx, tenantID, id)
return &MockNotificationRepositoryFindByIDCall{Call: call}
}
// MockNotificationRepositoryFindByIDCall wrap *gomock.Call
type MockNotificationRepositoryFindByIDCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockNotificationRepositoryFindByIDCall) Return(arg0 *entity.Notification, arg1 error) *MockNotificationRepositoryFindByIDCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockNotificationRepositoryFindByIDCall) Do(f func(context.Context, string, string) (*entity.Notification, error)) *MockNotificationRepositoryFindByIDCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockNotificationRepositoryFindByIDCall) DoAndReturn(f func(context.Context, string, string) (*entity.Notification, error)) *MockNotificationRepositoryFindByIDCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// FindByIdempotency mocks base method.
func (m *MockNotificationRepository) FindByIdempotency(ctx context.Context, tenantID string, kind enum.NotifyKind, idempotencyKey string) (*entity.Notification, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FindByIdempotency", ctx, tenantID, kind, idempotencyKey)
ret0, _ := ret[0].(*entity.Notification)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FindByIdempotency indicates an expected call of FindByIdempotency.
func (mr *MockNotificationRepositoryMockRecorder) FindByIdempotency(ctx, tenantID, kind, idempotencyKey any) *MockNotificationRepositoryFindByIdempotencyCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByIdempotency", reflect.TypeOf((*MockNotificationRepository)(nil).FindByIdempotency), ctx, tenantID, kind, idempotencyKey)
return &MockNotificationRepositoryFindByIdempotencyCall{Call: call}
}
// MockNotificationRepositoryFindByIdempotencyCall wrap *gomock.Call
type MockNotificationRepositoryFindByIdempotencyCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockNotificationRepositoryFindByIdempotencyCall) Return(arg0 *entity.Notification, arg1 error) *MockNotificationRepositoryFindByIdempotencyCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockNotificationRepositoryFindByIdempotencyCall) Do(f func(context.Context, string, enum.NotifyKind, string) (*entity.Notification, error)) *MockNotificationRepositoryFindByIdempotencyCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockNotificationRepositoryFindByIdempotencyCall) DoAndReturn(f func(context.Context, string, enum.NotifyKind, string) (*entity.Notification, error)) *MockNotificationRepositoryFindByIdempotencyCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// Index20260520001UP mocks base method.
func (m *MockNotificationRepository) Index20260520001UP(ctx context.Context) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Index20260520001UP", ctx)
ret0, _ := ret[0].(error)
return ret0
}
// Index20260520001UP indicates an expected call of Index20260520001UP.
func (mr *MockNotificationRepositoryMockRecorder) Index20260520001UP(ctx any) *MockNotificationRepositoryIndex20260520001UPCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Index20260520001UP", reflect.TypeOf((*MockNotificationRepository)(nil).Index20260520001UP), ctx)
return &MockNotificationRepositoryIndex20260520001UPCall{Call: call}
}
// MockNotificationRepositoryIndex20260520001UPCall wrap *gomock.Call
type MockNotificationRepositoryIndex20260520001UPCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockNotificationRepositoryIndex20260520001UPCall) Return(arg0 error) *MockNotificationRepositoryIndex20260520001UPCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockNotificationRepositoryIndex20260520001UPCall) Do(f func(context.Context) error) *MockNotificationRepositoryIndex20260520001UPCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockNotificationRepositoryIndex20260520001UPCall) DoAndReturn(f func(context.Context) error) *MockNotificationRepositoryIndex20260520001UPCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// Insert mocks base method.
func (m *MockNotificationRepository) Insert(ctx context.Context, data *entity.Notification) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Insert", ctx, data)
ret0, _ := ret[0].(error)
return ret0
}
// Insert indicates an expected call of Insert.
func (mr *MockNotificationRepositoryMockRecorder) Insert(ctx, data any) *MockNotificationRepositoryInsertCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Insert", reflect.TypeOf((*MockNotificationRepository)(nil).Insert), ctx, data)
return &MockNotificationRepositoryInsertCall{Call: call}
}
// MockNotificationRepositoryInsertCall wrap *gomock.Call
type MockNotificationRepositoryInsertCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockNotificationRepositoryInsertCall) Return(arg0 error) *MockNotificationRepositoryInsertCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockNotificationRepositoryInsertCall) Do(f func(context.Context, *entity.Notification) error) *MockNotificationRepositoryInsertCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockNotificationRepositoryInsertCall) DoAndReturn(f func(context.Context, *entity.Notification) error) *MockNotificationRepositoryInsertCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// UpdateDelivery mocks base method.
func (m *MockNotificationRepository) UpdateDelivery(ctx context.Context, tenantID, id string, update *repository.NotificationDeliveryUpdate) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateDelivery", ctx, tenantID, id, update)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateDelivery indicates an expected call of UpdateDelivery.
func (mr *MockNotificationRepositoryMockRecorder) UpdateDelivery(ctx, tenantID, id, update any) *MockNotificationRepositoryUpdateDeliveryCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDelivery", reflect.TypeOf((*MockNotificationRepository)(nil).UpdateDelivery), ctx, tenantID, id, update)
return &MockNotificationRepositoryUpdateDeliveryCall{Call: call}
}
// MockNotificationRepositoryUpdateDeliveryCall wrap *gomock.Call
type MockNotificationRepositoryUpdateDeliveryCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockNotificationRepositoryUpdateDeliveryCall) Return(arg0 error) *MockNotificationRepositoryUpdateDeliveryCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockNotificationRepositoryUpdateDeliveryCall) Do(f func(context.Context, string, string, *repository.NotificationDeliveryUpdate) error) *MockNotificationRepositoryUpdateDeliveryCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockNotificationRepositoryUpdateDeliveryCall) DoAndReturn(f func(context.Context, string, string, *repository.NotificationDeliveryUpdate) error) *MockNotificationRepositoryUpdateDeliveryCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockNotificationDLQRepository is a mock of NotificationDLQRepository interface.
type MockNotificationDLQRepository struct {
ctrl *gomock.Controller
recorder *MockNotificationDLQRepositoryMockRecorder
isgomock struct{}
}
// MockNotificationDLQRepositoryMockRecorder is the mock recorder for MockNotificationDLQRepository.
type MockNotificationDLQRepositoryMockRecorder struct {
mock *MockNotificationDLQRepository
}
// NewMockNotificationDLQRepository creates a new mock instance.
func NewMockNotificationDLQRepository(ctrl *gomock.Controller) *MockNotificationDLQRepository {
mock := &MockNotificationDLQRepository{ctrl: ctrl}
mock.recorder = &MockNotificationDLQRepositoryMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockNotificationDLQRepository) EXPECT() *MockNotificationDLQRepositoryMockRecorder {
return m.recorder
}
// FindByID mocks base method.
func (m *MockNotificationDLQRepository) FindByID(ctx context.Context, tenantID, id string) (*entity.NotificationDLQ, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FindByID", ctx, tenantID, id)
ret0, _ := ret[0].(*entity.NotificationDLQ)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FindByID indicates an expected call of FindByID.
func (mr *MockNotificationDLQRepositoryMockRecorder) FindByID(ctx, tenantID, id any) *MockNotificationDLQRepositoryFindByIDCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByID", reflect.TypeOf((*MockNotificationDLQRepository)(nil).FindByID), ctx, tenantID, id)
return &MockNotificationDLQRepositoryFindByIDCall{Call: call}
}
// MockNotificationDLQRepositoryFindByIDCall wrap *gomock.Call
type MockNotificationDLQRepositoryFindByIDCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockNotificationDLQRepositoryFindByIDCall) Return(arg0 *entity.NotificationDLQ, arg1 error) *MockNotificationDLQRepositoryFindByIDCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockNotificationDLQRepositoryFindByIDCall) Do(f func(context.Context, string, string) (*entity.NotificationDLQ, error)) *MockNotificationDLQRepositoryFindByIDCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockNotificationDLQRepositoryFindByIDCall) DoAndReturn(f func(context.Context, string, string) (*entity.NotificationDLQ, error)) *MockNotificationDLQRepositoryFindByIDCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// Index20260520001UP mocks base method.
func (m *MockNotificationDLQRepository) Index20260520001UP(ctx context.Context) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Index20260520001UP", ctx)
ret0, _ := ret[0].(error)
return ret0
}
// Index20260520001UP indicates an expected call of Index20260520001UP.
func (mr *MockNotificationDLQRepositoryMockRecorder) Index20260520001UP(ctx any) *MockNotificationDLQRepositoryIndex20260520001UPCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Index20260520001UP", reflect.TypeOf((*MockNotificationDLQRepository)(nil).Index20260520001UP), ctx)
return &MockNotificationDLQRepositoryIndex20260520001UPCall{Call: call}
}
// MockNotificationDLQRepositoryIndex20260520001UPCall wrap *gomock.Call
type MockNotificationDLQRepositoryIndex20260520001UPCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockNotificationDLQRepositoryIndex20260520001UPCall) Return(arg0 error) *MockNotificationDLQRepositoryIndex20260520001UPCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockNotificationDLQRepositoryIndex20260520001UPCall) Do(f func(context.Context) error) *MockNotificationDLQRepositoryIndex20260520001UPCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockNotificationDLQRepositoryIndex20260520001UPCall) DoAndReturn(f func(context.Context) error) *MockNotificationDLQRepositoryIndex20260520001UPCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// Insert mocks base method.
func (m *MockNotificationDLQRepository) Insert(ctx context.Context, data *entity.NotificationDLQ) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Insert", ctx, data)
ret0, _ := ret[0].(error)
return ret0
}
// Insert indicates an expected call of Insert.
func (mr *MockNotificationDLQRepositoryMockRecorder) Insert(ctx, data any) *MockNotificationDLQRepositoryInsertCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Insert", reflect.TypeOf((*MockNotificationDLQRepository)(nil).Insert), ctx, data)
return &MockNotificationDLQRepositoryInsertCall{Call: call}
}
// MockNotificationDLQRepositoryInsertCall wrap *gomock.Call
type MockNotificationDLQRepositoryInsertCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockNotificationDLQRepositoryInsertCall) Return(arg0 error) *MockNotificationDLQRepositoryInsertCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockNotificationDLQRepositoryInsertCall) Do(f func(context.Context, *entity.NotificationDLQ) error) *MockNotificationDLQRepositoryInsertCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockNotificationDLQRepositoryInsertCall) DoAndReturn(f func(context.Context, *entity.NotificationDLQ) error) *MockNotificationDLQRepositoryInsertCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// ListByTenant mocks base method.
func (m *MockNotificationDLQRepository) ListByTenant(ctx context.Context, tenantID string, limit int64) ([]*entity.NotificationDLQ, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListByTenant", ctx, tenantID, limit)
ret0, _ := ret[0].([]*entity.NotificationDLQ)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListByTenant indicates an expected call of ListByTenant.
func (mr *MockNotificationDLQRepositoryMockRecorder) ListByTenant(ctx, tenantID, limit any) *MockNotificationDLQRepositoryListByTenantCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByTenant", reflect.TypeOf((*MockNotificationDLQRepository)(nil).ListByTenant), ctx, tenantID, limit)
return &MockNotificationDLQRepositoryListByTenantCall{Call: call}
}
// MockNotificationDLQRepositoryListByTenantCall wrap *gomock.Call
type MockNotificationDLQRepositoryListByTenantCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockNotificationDLQRepositoryListByTenantCall) Return(arg0 []*entity.NotificationDLQ, arg1 error) *MockNotificationDLQRepositoryListByTenantCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockNotificationDLQRepositoryListByTenantCall) Do(f func(context.Context, string, int64) ([]*entity.NotificationDLQ, error)) *MockNotificationDLQRepositoryListByTenantCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockNotificationDLQRepositoryListByTenantCall) DoAndReturn(f func(context.Context, string, int64) ([]*entity.NotificationDLQ, error)) *MockNotificationDLQRepositoryListByTenantCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockIdempotencyCache is a mock of IdempotencyCache interface.
type MockIdempotencyCache struct {
ctrl *gomock.Controller
recorder *MockIdempotencyCacheMockRecorder
isgomock struct{}
}
// MockIdempotencyCacheMockRecorder is the mock recorder for MockIdempotencyCache.
type MockIdempotencyCacheMockRecorder struct {
mock *MockIdempotencyCache
}
// NewMockIdempotencyCache creates a new mock instance.
func NewMockIdempotencyCache(ctrl *gomock.Controller) *MockIdempotencyCache {
mock := &MockIdempotencyCache{ctrl: ctrl}
mock.recorder = &MockIdempotencyCacheMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockIdempotencyCache) EXPECT() *MockIdempotencyCacheMockRecorder {
return m.recorder
}
// Get mocks base method.
func (m *MockIdempotencyCache) Get(ctx context.Context, key string) ([]byte, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Get", ctx, key)
ret0, _ := ret[0].([]byte)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Get indicates an expected call of Get.
func (mr *MockIdempotencyCacheMockRecorder) Get(ctx, key any) *MockIdempotencyCacheGetCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockIdempotencyCache)(nil).Get), ctx, key)
return &MockIdempotencyCacheGetCall{Call: call}
}
// MockIdempotencyCacheGetCall wrap *gomock.Call
type MockIdempotencyCacheGetCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockIdempotencyCacheGetCall) Return(arg0 []byte, arg1 error) *MockIdempotencyCacheGetCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockIdempotencyCacheGetCall) Do(f func(context.Context, string) ([]byte, error)) *MockIdempotencyCacheGetCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockIdempotencyCacheGetCall) DoAndReturn(f func(context.Context, string) ([]byte, error)) *MockIdempotencyCacheGetCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// Set mocks base method.
func (m *MockIdempotencyCache) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Set", ctx, key, value, ttl)
ret0, _ := ret[0].(error)
return ret0
}
// Set indicates an expected call of Set.
func (mr *MockIdempotencyCacheMockRecorder) Set(ctx, key, value, ttl any) *MockIdempotencyCacheSetCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockIdempotencyCache)(nil).Set), ctx, key, value, ttl)
return &MockIdempotencyCacheSetCall{Call: call}
}
// MockIdempotencyCacheSetCall wrap *gomock.Call
type MockIdempotencyCacheSetCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockIdempotencyCacheSetCall) Return(arg0 error) *MockIdempotencyCacheSetCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockIdempotencyCacheSetCall) Do(f func(context.Context, string, []byte, time.Duration) error) *MockIdempotencyCacheSetCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockIdempotencyCacheSetCall) DoAndReturn(f func(context.Context, string, []byte, time.Duration) error) *MockIdempotencyCacheSetCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockQuotaCounter is a mock of QuotaCounter interface.
type MockQuotaCounter struct {
ctrl *gomock.Controller
recorder *MockQuotaCounterMockRecorder
isgomock struct{}
}
// MockQuotaCounterMockRecorder is the mock recorder for MockQuotaCounter.
type MockQuotaCounterMockRecorder struct {
mock *MockQuotaCounter
}
// NewMockQuotaCounter creates a new mock instance.
func NewMockQuotaCounter(ctrl *gomock.Controller) *MockQuotaCounter {
mock := &MockQuotaCounter{ctrl: ctrl}
mock.recorder = &MockQuotaCounterMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockQuotaCounter) EXPECT() *MockQuotaCounterMockRecorder {
return m.recorder
}
// Incr mocks base method.
func (m *MockQuotaCounter) Incr(ctx context.Context, key string, ttl time.Duration) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Incr", ctx, key, ttl)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Incr indicates an expected call of Incr.
func (mr *MockQuotaCounterMockRecorder) Incr(ctx, key, ttl any) *MockQuotaCounterIncrCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Incr", reflect.TypeOf((*MockQuotaCounter)(nil).Incr), ctx, key, ttl)
return &MockQuotaCounterIncrCall{Call: call}
}
// MockQuotaCounterIncrCall wrap *gomock.Call
type MockQuotaCounterIncrCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockQuotaCounterIncrCall) Return(arg0 int64, arg1 error) *MockQuotaCounterIncrCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockQuotaCounterIncrCall) Do(f func(context.Context, string, time.Duration) (int64, error)) *MockQuotaCounterIncrCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockQuotaCounterIncrCall) DoAndReturn(f func(context.Context, string, time.Duration) (int64, error)) *MockQuotaCounterIncrCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockRetryQueue is a mock of RetryQueue interface.
type MockRetryQueue struct {
ctrl *gomock.Controller
recorder *MockRetryQueueMockRecorder
isgomock struct{}
}
// MockRetryQueueMockRecorder is the mock recorder for MockRetryQueue.
type MockRetryQueueMockRecorder struct {
mock *MockRetryQueue
}
// NewMockRetryQueue creates a new mock instance.
func NewMockRetryQueue(ctrl *gomock.Controller) *MockRetryQueue {
mock := &MockRetryQueue{ctrl: ctrl}
mock.recorder = &MockRetryQueueMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockRetryQueue) EXPECT() *MockRetryQueueMockRecorder {
return m.recorder
}
// ClaimDue mocks base method.
func (m *MockRetryQueue) ClaimDue(ctx context.Context, nowMs int64, limit int) ([]*usecase.RetryJob, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ClaimDue", ctx, nowMs, limit)
ret0, _ := ret[0].([]*usecase.RetryJob)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ClaimDue indicates an expected call of ClaimDue.
func (mr *MockRetryQueueMockRecorder) ClaimDue(ctx, nowMs, limit any) *MockRetryQueueClaimDueCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClaimDue", reflect.TypeOf((*MockRetryQueue)(nil).ClaimDue), ctx, nowMs, limit)
return &MockRetryQueueClaimDueCall{Call: call}
}
// MockRetryQueueClaimDueCall wrap *gomock.Call
type MockRetryQueueClaimDueCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockRetryQueueClaimDueCall) Return(arg0 []*usecase.RetryJob, arg1 error) *MockRetryQueueClaimDueCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRetryQueueClaimDueCall) Do(f func(context.Context, int64, int) ([]*usecase.RetryJob, error)) *MockRetryQueueClaimDueCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRetryQueueClaimDueCall) DoAndReturn(f func(context.Context, int64, int) ([]*usecase.RetryJob, error)) *MockRetryQueueClaimDueCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// Schedule mocks base method.
func (m *MockRetryQueue) Schedule(ctx context.Context, runAtMs int64, job *usecase.RetryJob) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Schedule", ctx, runAtMs, job)
ret0, _ := ret[0].(error)
return ret0
}
// Schedule indicates an expected call of Schedule.
func (mr *MockRetryQueueMockRecorder) Schedule(ctx, runAtMs, job any) *MockRetryQueueScheduleCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Schedule", reflect.TypeOf((*MockRetryQueue)(nil).Schedule), ctx, runAtMs, job)
return &MockRetryQueueScheduleCall{Call: call}
}
// MockRetryQueueScheduleCall wrap *gomock.Call
type MockRetryQueueScheduleCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockRetryQueueScheduleCall) Return(arg0 error) *MockRetryQueueScheduleCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRetryQueueScheduleCall) Do(f func(context.Context, int64, *usecase.RetryJob) error) *MockRetryQueueScheduleCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRetryQueueScheduleCall) DoAndReturn(f func(context.Context, int64, *usecase.RetryJob) error) *MockRetryQueueScheduleCall {
c.Call = c.Call.DoAndReturn(f)
return c
}

View File

@ -0,0 +1,78 @@
package email
import (
"context"
"fmt"
"sort"
"time"
)
const defaultSendTimeout = 10 * time.Second
// Chain tries senders in ascending Sort order until one succeeds.
type Chain struct {
senders []Sender
timeout time.Duration
}
type ChainOption func(*Chain)
func WithTimeout(d time.Duration) ChainOption {
return func(c *Chain) {
c.timeout = d
}
}
func NewChain(senders ...Sender) *Chain {
c := &Chain{
senders: append([]Sender(nil), senders...),
timeout: defaultSendTimeout,
}
c.sortSenders()
return c
}
func NewChainWithOptions(senders []Sender, opts ...ChainOption) *Chain {
c := &Chain{
senders: append([]Sender(nil), senders...),
timeout: defaultSendTimeout,
}
for _, opt := range opts {
opt(c)
}
c.sortSenders()
return c
}
func (c *Chain) sortSenders() {
sort.Slice(c.senders, func(i, j int) bool {
return c.senders[i].Sort() < c.senders[j].Sort()
})
}
// Send returns the winning provider name and provider-assigned message id.
func (c *Chain) Send(ctx context.Context, msg *Message) (providerName, messageID string, err error) {
if len(c.senders) == 0 {
return "", "", fmt.Errorf("email: no senders configured")
}
if msg == nil {
return "", "", fmt.Errorf("email: message is nil")
}
sendCtx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
var lastErr error
for _, sender := range c.senders {
id, sendErr := sender.Send(sendCtx, msg)
if sendErr == nil {
return sender.Name(), id, nil
}
lastErr = sendErr
}
if lastErr == nil {
lastErr = fmt.Errorf("email: all providers failed")
}
return "", "", fmt.Errorf("email: all providers failed: %w", lastErr)
}

View File

@ -0,0 +1,80 @@
package email_test
import (
"context"
"fmt"
"testing"
"gateway/internal/model/notification/provider/email"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testRecipientEmail = "x@test.com"
func TestChain_Send_FailoverToSecondProvider(t *testing.T) {
primary := email.NewMockSender(
email.WithMockName("primary"),
email.WithMockSort(1),
email.WithMockError(fmt.Errorf("primary down")),
)
backup := email.NewMockSender(
email.WithMockName("backup"),
email.WithMockSort(2),
email.WithMockMessageID("backup-123"),
)
chain := email.NewChain(primary, backup)
provider, id, err := chain.Send(context.Background(), &email.Message{
From: "noreply@test.com",
To: []string{"user@test.com"},
Subject: "verify",
Body: "<p>code</p>",
})
require.NoError(t, err)
assert.Equal(t, "backup", provider)
assert.Equal(t, "backup-123", id)
assert.Len(t, primary.Calls(), 1)
assert.Len(t, backup.Calls(), 1)
}
func TestChain_Send_TriesLowerSortFirst(t *testing.T) {
var order []string
mk := func(name string, sort int, fail bool) *email.MockSender {
m := email.NewMockSender(email.WithMockName(name), email.WithMockSort(sort))
m.SendHook = func(context.Context, *email.Message) (string, error) {
order = append(order, name)
if fail {
return "", fmt.Errorf("%s failed", name)
}
return name + "-id", nil
}
return m
}
// Unsorted registration; chain must try a(1) → b(2) succeed, never c(3).
chain := email.NewChain(mk("c", 3, true), mk("a", 1, true), mk("b", 2, false))
provider, _, err := chain.Send(context.Background(), &email.Message{To: []string{testRecipientEmail}})
require.NoError(t, err)
assert.Equal(t, "b", provider)
assert.Equal(t, []string{"a", "b"}, order)
}
func TestChain_Send_AllProvidersFail(t *testing.T) {
chain := email.NewChain(
email.ErrSender("a", 1, fmt.Errorf("a fail")),
email.ErrSender("b", 2, fmt.Errorf("b fail")),
)
_, _, err := chain.Send(context.Background(), &email.Message{To: []string{testRecipientEmail}})
require.Error(t, err)
assert.Contains(t, err.Error(), "all providers failed")
}
func TestChain_Send_NoSenders(t *testing.T) {
chain := email.NewChain()
_, _, err := chain.Send(context.Background(), &email.Message{To: []string{testRecipientEmail}})
require.Error(t, err)
assert.Contains(t, err.Error(), "no senders")
}

View File

@ -0,0 +1,9 @@
package email
// Message is the payload passed to email providers.
type Message struct {
From string
To []string
Subject string
Body string // HTML body
}

View File

@ -0,0 +1,100 @@
package email
import (
"context"
"fmt"
"sync"
)
// MockSender records calls and returns configurable results (for tests and local dev).
type MockSender struct {
name string
sort int
mu sync.Mutex
calls []*Message
Err error
MessageID string
SendHook func(ctx context.Context, msg *Message) (string, error)
}
type MockSenderOption func(*MockSender)
func WithMockName(name string) MockSenderOption {
return func(m *MockSender) { m.name = name }
}
func WithMockSort(sort int) MockSenderOption {
return func(m *MockSender) { m.sort = sort }
}
func WithMockError(err error) MockSenderOption {
return func(m *MockSender) { m.Err = err }
}
func WithMockMessageID(id string) MockSenderOption {
return func(m *MockSender) { m.MessageID = id }
}
func NewMockSender(opts ...MockSenderOption) *MockSender {
m := &MockSender{
name: "mock",
sort: 0,
MessageID: "mock-email-id",
}
for _, opt := range opts {
opt(m)
}
return m
}
func (m *MockSender) Name() string { return m.name }
func (m *MockSender) Sort() int { return m.sort }
func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) {
m.mu.Lock()
m.calls = append(m.calls, msg)
m.mu.Unlock()
if m.SendHook != nil {
return m.SendHook(ctx, msg)
}
if m.Err != nil {
return "", m.Err
}
return m.MessageID, nil
}
func (m *MockSender) Calls() []*Message {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]*Message, len(m.calls))
copy(out, m.calls)
return out
}
func (m *MockSender) Reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.calls = nil
}
// ErrSender is a Sender that always fails (helper for tests).
func ErrSender(name string, sort int, err error) Sender {
return &staticErrSender{name: name, sort: sort, err: err}
}
type staticErrSender struct {
name string
sort int
err error
}
func (s *staticErrSender) Name() string { return s.name }
func (s *staticErrSender) Sort() int { return s.sort }
func (s *staticErrSender) Send(context.Context, *Message) (string, error) {
if s.err != nil {
return "", s.err
}
return "", fmt.Errorf("%s: send failed", s.name)
}

View File

@ -0,0 +1,10 @@
package email
import "context"
// Sender delivers a single email via one provider implementation.
type Sender interface {
Name() string
Sort() int
Send(ctx context.Context, msg *Message) (providerMessageID string, err error)
}

View File

@ -0,0 +1,102 @@
package email
import (
"context"
"fmt"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/ses"
"github.com/aws/aws-sdk-go-v2/service/ses/types"
)
// SESSettings configures AWS SES (ported from app-cloudep-notification-service).
type SESSettings struct {
Sort int
Region string
AccessKey string
SecretKey string
SessionToken string
SendTimeout time.Duration
}
// SESSender delivers email via AWS SES.
type SESSender struct {
name string
sort int
client *ses.Client
timeout time.Duration
}
// NewSESSender builds an SES provider.
func NewSESSender(cfg SESSettings) (*SESSender, error) {
if cfg.Region == "" {
return nil, fmt.Errorf("email ses: region is required")
}
if cfg.AccessKey == "" || cfg.SecretKey == "" {
return nil, fmt.Errorf("email ses: access key and secret key are required")
}
timeout := cfg.SendTimeout
if timeout <= 0 {
timeout = 90 * time.Second
}
awsCfg := aws.Config{
Region: cfg.Region,
Credentials: credentials.NewStaticCredentialsProvider(
cfg.AccessKey,
cfg.SecretKey,
cfg.SessionToken,
),
}
return &SESSender{
name: "ses",
sort: cfg.Sort,
client: ses.NewFromConfig(awsCfg),
timeout: timeout,
}, nil
}
func (s *SESSender) Name() string { return s.name }
func (s *SESSender) Sort() int { return s.sort }
func (s *SESSender) Send(ctx context.Context, msg *Message) (string, error) {
if msg == nil || len(msg.To) == 0 {
return "", fmt.Errorf("email ses: message or recipients missing")
}
from := msg.From
if from == "" {
return "", fmt.Errorf("email ses: from address is required")
}
sendCtx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel()
out, err := s.client.SendEmail(sendCtx, &ses.SendEmailInput{
Source: aws.String(from),
Destination: &types.Destination{
ToAddresses: append([]string(nil), msg.To...),
},
Message: &types.Message{
Subject: &types.Content{
Charset: aws.String("UTF-8"),
Data: aws.String(msg.Subject),
},
Body: &types.Body{
Html: &types.Content{
Charset: aws.String("UTF-8"),
Data: aws.String(msg.Body),
},
},
},
})
if err != nil {
return "", fmt.Errorf("email ses: %w", err)
}
if out.MessageId == nil || *out.MessageId == "" {
return "", fmt.Errorf("email ses: empty message id")
}
return *out.MessageId, nil
}

View File

@ -0,0 +1,71 @@
package email
import (
"context"
"fmt"
"github.com/google/uuid"
"gopkg.in/gomail.v2"
)
// SMTPSettings configures an SMTP Sender (ported from app-cloudep-notification-service).
type SMTPSettings struct {
Sort int
Host string
Port int
Username string
Password string
}
// SMTPSender delivers email via SMTP (gomail).
type SMTPSender struct {
name string
sort int
dialer *gomail.Dialer
}
// NewSMTPSender builds an SMTP provider. Host and Port are required.
func NewSMTPSender(cfg SMTPSettings) (*SMTPSender, error) {
if cfg.Host == "" {
return nil, fmt.Errorf("email smtp: host is required")
}
if cfg.Port <= 0 {
return nil, fmt.Errorf("email smtp: port must be positive")
}
return &SMTPSender{
name: "smtp",
sort: cfg.Sort,
dialer: gomail.NewDialer(cfg.Host, cfg.Port, cfg.Username, cfg.Password),
}, nil
}
func (s *SMTPSender) Name() string { return s.name }
func (s *SMTPSender) Sort() int { return s.sort }
func (s *SMTPSender) Send(ctx context.Context, msg *Message) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
if msg == nil || len(msg.To) == 0 {
return "", fmt.Errorf("email smtp: message or recipients missing")
}
from := msg.From
if from == "" {
return "", fmt.Errorf("email smtp: from address is required")
}
m := gomail.NewMessage()
m.SetHeader("From", from)
m.SetHeader("To", msg.To...)
m.SetHeader("Subject", msg.Subject)
m.SetBody("text/html", msg.Body)
// gomail does not accept context; honor cancellation before blocking I/O.
if err := ctx.Err(); err != nil {
return "", err
}
if err := s.dialer.DialAndSend(m); err != nil {
return "", fmt.Errorf("email smtp: %w", err)
}
return uuid.NewString(), nil
}

View File

@ -0,0 +1,78 @@
package sms
import (
"context"
"fmt"
"sort"
"time"
)
const defaultSendTimeout = 10 * time.Second
// Chain tries senders in ascending Sort order until one succeeds.
type Chain struct {
senders []Sender
timeout time.Duration
}
type ChainOption func(*Chain)
func WithTimeout(d time.Duration) ChainOption {
return func(c *Chain) {
c.timeout = d
}
}
func NewChain(senders ...Sender) *Chain {
c := &Chain{
senders: append([]Sender(nil), senders...),
timeout: defaultSendTimeout,
}
c.sortSenders()
return c
}
func NewChainWithOptions(senders []Sender, opts ...ChainOption) *Chain {
c := &Chain{
senders: append([]Sender(nil), senders...),
timeout: defaultSendTimeout,
}
for _, opt := range opts {
opt(c)
}
c.sortSenders()
return c
}
func (c *Chain) sortSenders() {
sort.Slice(c.senders, func(i, j int) bool {
return c.senders[i].Sort() < c.senders[j].Sort()
})
}
// Send returns the winning provider name and provider-assigned message id.
func (c *Chain) Send(ctx context.Context, msg *Message) (providerName, messageID string, err error) {
if len(c.senders) == 0 {
return "", "", fmt.Errorf("sms: no senders configured")
}
if msg == nil {
return "", "", fmt.Errorf("sms: message is nil")
}
sendCtx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
var lastErr error
for _, sender := range c.senders {
id, sendErr := sender.Send(sendCtx, msg)
if sendErr == nil {
return sender.Name(), id, nil
}
lastErr = sendErr
}
if lastErr == nil {
lastErr = fmt.Errorf("sms: all providers failed")
}
return "", "", fmt.Errorf("sms: all providers failed: %w", lastErr)
}

View File

@ -0,0 +1,48 @@
package sms_test
import (
"context"
"fmt"
"testing"
"gateway/internal/model/notification/provider/sms"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestChain_Send_FailoverToSecondProvider(t *testing.T) {
primary := sms.NewMockSender(
sms.WithMockName("mitake"),
sms.WithMockSort(1),
sms.WithMockError(fmt.Errorf("mitake down")),
)
backup := sms.NewMockSender(
sms.WithMockName("twilio"),
sms.WithMockSort(2),
sms.WithMockMessageID("twilio-sid"),
)
chain := sms.NewChain(primary, backup)
provider, id, err := chain.Send(context.Background(), &sms.Message{
PhoneNumber: "+886912345678",
RecipientName: "User",
Body: "code 123456",
})
require.NoError(t, err)
assert.Equal(t, "twilio", provider)
assert.Equal(t, "twilio-sid", id)
assert.Len(t, primary.Calls(), 1)
assert.Len(t, backup.Calls(), 1)
}
func TestChain_Send_AllProvidersFail(t *testing.T) {
chain := sms.NewChain(
sms.ErrSender("a", 1, fmt.Errorf("a fail")),
sms.ErrSender("b", 2, fmt.Errorf("b fail")),
)
_, _, err := chain.Send(context.Background(), &sms.Message{PhoneNumber: "+886900000000"})
require.Error(t, err)
assert.Contains(t, err.Error(), "all providers failed")
}

View File

@ -0,0 +1,8 @@
package sms
// Message is the payload passed to SMS providers.
type Message struct {
PhoneNumber string // E.164 recommended
RecipientName string
Body string
}

View File

@ -0,0 +1,88 @@
package sms
import (
"context"
"fmt"
"net/http"
"github.com/minchao/go-mitake"
)
// MitakeSettings configures 三竹 Mitake SMS (ported from app-cloudep-notification-service).
type MitakeSettings struct {
Sort int
User string
Password string
}
// MitakeSender delivers SMS via Mitake SmExpress API.
type MitakeSender struct {
name string
sort int
client *mitake.Client
}
// NewMitakeSender builds a Mitake provider.
func NewMitakeSender(cfg MitakeSettings, httpClient *http.Client) (*MitakeSender, error) {
if cfg.User == "" || cfg.Password == "" {
return nil, fmt.Errorf("sms mitake: user and password are required")
}
return &MitakeSender{
name: "mitake",
sort: cfg.Sort,
client: mitake.NewClient(cfg.User, cfg.Password, httpClient),
}, nil
}
func (m *MitakeSender) Name() string { return m.name }
func (m *MitakeSender) Sort() int { return m.sort }
func (m *MitakeSender) Send(ctx context.Context, msg *Message) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
if msg == nil || msg.PhoneNumber == "" {
return "", fmt.Errorf("sms mitake: phone number is required")
}
if msg.Body == "" {
return "", fmt.Errorf("sms mitake: body is required")
}
// SDK has no context support; check before the HTTP call.
if err := ctx.Err(); err != nil {
return "", err
}
resp, err := m.client.Send(mitake.Message{
Dstaddr: msg.PhoneNumber,
Destname: msg.RecipientName,
Smbody: msg.Body,
})
if err != nil {
return "", fmt.Errorf("sms mitake: %w", err)
}
if resp == nil || len(resp.Results) == 0 {
return "", fmt.Errorf("sms mitake: empty response")
}
r := resp.Results[0]
if err := mitakeStatusError(r.Statuscode); err != nil {
return "", err
}
if r.Msgid == "" {
return "", fmt.Errorf("sms mitake: empty msgid")
}
return r.Msgid, nil
}
func mitakeStatusError(code string) error {
switch mitake.StatusCode(code) {
case mitake.StatusReservationForDelivery,
mitake.StatusCarrierAccepted,
mitake.StatusCarrierAccepted2,
mitake.StatusCarrierAccepted3,
mitake.StatusDelivered:
return nil
default:
return fmt.Errorf("sms mitake: status %s (%s)", code, mitake.StatusCode(code).String())
}
}

View File

@ -0,0 +1,100 @@
package sms
import (
"context"
"fmt"
"sync"
)
// MockSender records calls and returns configurable results (for tests and local dev).
type MockSender struct {
name string
sort int
mu sync.Mutex
calls []*Message
Err error
MessageID string
SendHook func(ctx context.Context, msg *Message) (string, error)
}
type MockSenderOption func(*MockSender)
func WithMockName(name string) MockSenderOption {
return func(m *MockSender) { m.name = name }
}
func WithMockSort(sort int) MockSenderOption {
return func(m *MockSender) { m.sort = sort }
}
func WithMockError(err error) MockSenderOption {
return func(m *MockSender) { m.Err = err }
}
func WithMockMessageID(id string) MockSenderOption {
return func(m *MockSender) { m.MessageID = id }
}
func NewMockSender(opts ...MockSenderOption) *MockSender {
m := &MockSender{
name: "mock",
sort: 0,
MessageID: "mock-sms-id",
}
for _, opt := range opts {
opt(m)
}
return m
}
func (m *MockSender) Name() string { return m.name }
func (m *MockSender) Sort() int { return m.sort }
func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) {
m.mu.Lock()
m.calls = append(m.calls, msg)
m.mu.Unlock()
if m.SendHook != nil {
return m.SendHook(ctx, msg)
}
if m.Err != nil {
return "", m.Err
}
return m.MessageID, nil
}
func (m *MockSender) Calls() []*Message {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]*Message, len(m.calls))
copy(out, m.calls)
return out
}
func (m *MockSender) Reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.calls = nil
}
// ErrSender is a Sender that always fails (helper for tests).
func ErrSender(name string, sort int, err error) Sender {
return &staticErrSender{name: name, sort: sort, err: err}
}
type staticErrSender struct {
name string
sort int
err error
}
func (s *staticErrSender) Name() string { return s.name }
func (s *staticErrSender) Sort() int { return s.sort }
func (s *staticErrSender) Send(context.Context, *Message) (string, error) {
if s.err != nil {
return "", s.err
}
return "", fmt.Errorf("%s: send failed", s.name)
}

View File

@ -0,0 +1,10 @@
package sms
import "context"
// Sender delivers a single SMS via one provider implementation.
type Sender interface {
Name() string
Sort() int
Send(ctx context.Context, msg *Message) (providerMessageID string, err error)
}

View File

@ -0,0 +1,38 @@
package notification
import "strings"
// RedisKey is the notification module key prefix.
type RedisKey string
const (
IdempotencyRedisKey RedisKey = "notif:idem"
QuotaRedisKey RedisKey = "notif:quota"
RetryZSetRedisKey RedisKey = "notif:retry:zset"
)
func (key RedisKey) With(parts ...string) RedisKey {
if len(parts) == 0 {
return key
}
return RedisKey(string(key) + ":" + strings.Join(parts, ":"))
}
func (key RedisKey) String() string {
return string(key)
}
// GetIdempotencyRedisKey caches a prior send result: notif:idem:{tenant}:{kind}:{idempotencyKey}.
func GetIdempotencyRedisKey(tenantID, kind, idempotencyKey string) string {
return IdempotencyRedisKey.With(tenantID, kind, idempotencyKey).String()
}
// GetQuotaRedisKey is the daily counter: notif:quota:{tenant}:{channel}:{yyyyMMdd}.
func GetQuotaRedisKey(tenantID, channel, day string) string {
return QuotaRedisKey.With(tenantID, channel, day).String()
}
// GetRetryZSetRedisKey is the async retry schedule zset.
func GetRetryZSetRedisKey() string {
return RetryZSetRedisKey.String()
}

View File

@ -0,0 +1,94 @@
package repository
import (
"context"
"time"
"gateway/internal/model/notification"
domentity "gateway/internal/model/notification/domain/entity"
"gateway/internal/model/notification/domain/enum"
domrepo "gateway/internal/model/notification/domain/repository"
"go.mongodb.org/mongo-driver/v2/bson"
)
// MemoryNotificationRepository is an in-memory NotificationRepository for tests and local tooling.
type MemoryNotificationRepository struct {
byID map[string]*domentity.Notification
byIdem map[string]*domentity.Notification
}
// NewMemoryNotificationRepository creates an empty in-memory store.
func NewMemoryNotificationRepository() *MemoryNotificationRepository {
return &MemoryNotificationRepository{
byID: make(map[string]*domentity.Notification),
byIdem: make(map[string]*domentity.Notification),
}
}
func idemKey(tenantID string, kind enum.NotifyKind, key string) string {
return tenantID + ":" + string(kind) + ":" + key
}
func (r *MemoryNotificationRepository) Insert(_ context.Context, data *domentity.Notification) error {
if data.ID.IsZero() {
data.ID = bson.NewObjectID()
}
now := time.Now().UTC().UnixNano()
if data.CreateAt == nil {
data.CreateAt = &now
}
if data.UpdateAt == nil {
data.UpdateAt = &now
}
if data.OccurredAt == nil {
data.OccurredAt = &now
}
k := idemKey(data.TenantID, data.Kind, data.IdempotencyKey)
if _, exists := r.byIdem[k]; exists {
return notification.ErrDuplicateIdempotency
}
r.byIdem[k] = data
r.byID[data.ID.Hex()] = data
return nil
}
func (r *MemoryNotificationRepository) FindByID(_ context.Context, tenantID, id string) (*domentity.Notification, error) {
doc, ok := r.byID[id]
if !ok || doc.TenantID != tenantID {
return nil, notification.ErrNotFound
}
return doc, nil
}
func (r *MemoryNotificationRepository) FindByIdempotency(
_ context.Context,
tenantID string,
kind enum.NotifyKind,
idempotencyKey string,
) (*domentity.Notification, error) {
doc, ok := r.byIdem[idemKey(tenantID, kind, idempotencyKey)]
if !ok {
return nil, notification.ErrNotFound
}
return doc, nil
}
func (r *MemoryNotificationRepository) UpdateDelivery(_ context.Context, tenantID, id string, update *domrepo.NotificationDeliveryUpdate) error {
doc, ok := r.byID[id]
if !ok || doc.TenantID != tenantID {
return notification.ErrNotFound
}
doc.Status = update.Status
doc.Attempts = update.Attempts
doc.Provider = update.Provider
doc.ProviderMessageID = update.ProviderMessageID
doc.LastError = update.LastError
doc.Body = update.Body
doc.DeliveredAt = update.DeliveredAt
return nil
}
func (r *MemoryNotificationRepository) Index20260520001UP(context.Context) error {
return nil
}

View File

@ -0,0 +1,57 @@
package repository
import (
"context"
"sync"
"time"
domrepo "gateway/internal/model/notification/domain/repository"
)
// MemoryIdempotencyCache is an in-process IdempotencyCache for tests.
type MemoryIdempotencyCache struct {
mu sync.RWMutex
data map[string][]byte
}
func NewMemoryIdempotencyCache() domrepo.IdempotencyCache {
return &MemoryIdempotencyCache{data: make(map[string][]byte)}
}
func (c *MemoryIdempotencyCache) Get(_ context.Context, key string) ([]byte, error) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[key]
if !ok {
return nil, nil
}
out := make([]byte, len(v))
copy(out, v)
return out, nil
}
func (c *MemoryIdempotencyCache) Set(_ context.Context, key string, value []byte, _ time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
dup := make([]byte, len(value))
copy(dup, value)
c.data[key] = dup
return nil
}
// MemoryQuotaCounter is an in-process QuotaCounter for tests.
type MemoryQuotaCounter struct {
mu sync.Mutex
data map[string]int64
}
func NewMemoryQuotaCounter() domrepo.QuotaCounter {
return &MemoryQuotaCounter{data: make(map[string]int64)}
}
func (c *MemoryQuotaCounter) Incr(_ context.Context, key string, _ time.Duration) (int64, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key]++
return c.data[key], nil
}

View File

@ -0,0 +1,153 @@
package repository
import (
"context"
"errors"
"time"
"gateway/internal/library/mongo"
"gateway/internal/model/notification"
domentity "gateway/internal/model/notification/domain/entity"
"gateway/internal/model/notification/domain/enum"
domrepo "gateway/internal/model/notification/domain/repository"
"go.mongodb.org/mongo-driver/v2/bson"
mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
)
// NotificationRepositoryParam configures the Mongo notification repository.
type NotificationRepositoryParam struct {
Conf *mongo.Conf
}
type notificationRepository struct {
db mongo.DocumentDBUseCase
}
// NewNotificationRepository creates a Mongo-backed NotificationRepository.
func NewNotificationRepository(param NotificationRepositoryParam) domrepo.NotificationRepository {
e := domentity.Notification{}
documentDB, err := mongo.NewDocumentDB(param.Conf, e.CollectionName())
if err != nil {
panic(err)
}
return &notificationRepository{db: documentDB}
}
func (r *notificationRepository) Insert(ctx context.Context, data *domentity.Notification) error {
now := time.Now().UTC().UnixNano()
if data.ID.IsZero() {
data.ID = bson.NewObjectID()
}
if data.CreateAt == nil {
data.CreateAt = &now
}
if data.UpdateAt == nil {
data.UpdateAt = &now
}
if data.OccurredAt == nil {
data.OccurredAt = &now
}
_, err := r.db.GetClient().InsertOne(ctx, data)
if err != nil {
if mongodriver.IsDuplicateKeyError(err) {
return notification.ErrDuplicateIdempotency
}
return err
}
return nil
}
func (r *notificationRepository) FindByID(ctx context.Context, tenantID, id string) (*domentity.Notification, error) {
oid, err := bson.ObjectIDFromHex(id)
if err != nil {
return nil, notification.ErrInvalidObjectID
}
var doc domentity.Notification
filter := bson.M{notification.BSONFieldID: oid, notification.BSONFieldTenantID: tenantID}
if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil {
if errors.Is(err, mongodriver.ErrNoDocuments) {
return nil, notification.ErrNotFound
}
return nil, err
}
return &doc, nil
}
func (r *notificationRepository) FindByIdempotency(
ctx context.Context,
tenantID string,
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
}
return nil, err
}
return &doc, nil
}
func (r *notificationRepository) UpdateDelivery(ctx context.Context, tenantID, id string, update *domrepo.NotificationDeliveryUpdate) error {
oid, err := bson.ObjectIDFromHex(id)
if err != nil {
return notification.ErrInvalidObjectID
}
now := time.Now().UTC().UnixNano()
set := bson.M{
"status": update.Status,
"attempts": update.Attempts,
"update_at": now,
}
if update.Provider != "" {
set["provider"] = update.Provider
}
if update.ProviderMessageID != "" {
set["provider_message_id"] = update.ProviderMessageID
}
if update.LastError != "" {
set["last_error"] = update.LastError
}
if update.Body != "" {
set["body"] = update.Body
}
if update.DeliveredAt != nil {
set["delivered_at"] = *update.DeliveredAt
}
filter := bson.M{
notification.BSONFieldID: oid,
notification.BSONFieldTenantID: tenantID,
}
_, err = r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set})
return err
}
func (r *notificationRepository) Index20260520001UP(ctx context.Context) error {
if err := r.db.PopulateMultiIndex(ctx, []string{
notification.BSONFieldTenantID, notification.BSONFieldKind, notification.BSONFieldIdempotencyKey,
}, []int32{1, 1, 1}, true); err != nil {
return err
}
if err := r.db.PopulateMultiIndex(ctx, []string{
notification.BSONFieldTenantID, notification.BSONFieldUID, notification.BSONFieldOccurredAt,
}, []int32{1, 1, -1}, false); err != nil {
return err
}
if err := r.db.PopulateMultiIndex(ctx, []string{
notification.BSONFieldStatus, notification.BSONFieldAttempts, notification.BSONFieldOccurredAt,
}, []int32{1, 1, 1}, false); err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,89 @@
package repository
import (
"context"
"errors"
"time"
"gateway/internal/library/mongo"
"gateway/internal/model/notification"
domentity "gateway/internal/model/notification/domain/entity"
domrepo "gateway/internal/model/notification/domain/repository"
"go.mongodb.org/mongo-driver/v2/bson"
mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// NotificationDLQRepositoryParam configures the Mongo DLQ repository.
type NotificationDLQRepositoryParam struct {
Conf *mongo.Conf
}
type notificationDLQRepository struct {
db mongo.DocumentDBUseCase
}
// NewNotificationDLQRepository creates a Mongo-backed NotificationDLQRepository.
func NewNotificationDLQRepository(param NotificationDLQRepositoryParam) domrepo.NotificationDLQRepository {
e := domentity.NotificationDLQ{}
documentDB, err := mongo.NewDocumentDB(param.Conf, e.CollectionName())
if err != nil {
panic(err)
}
return &notificationDLQRepository{db: documentDB}
}
func (r *notificationDLQRepository) Insert(ctx context.Context, data *domentity.NotificationDLQ) error {
now := time.Now().UTC().UnixNano()
if data.ID.IsZero() {
data.ID = bson.NewObjectID()
}
if data.CreateAt == nil {
data.CreateAt = &now
}
if data.OccurredAt == nil {
data.OccurredAt = &now
}
_, err := r.db.GetClient().InsertOne(ctx, data)
return err
}
func (r *notificationDLQRepository) FindByID(ctx context.Context, tenantID, id string) (*domentity.NotificationDLQ, error) {
oid, err := bson.ObjectIDFromHex(id)
if err != nil {
return nil, notification.ErrInvalidObjectID
}
var doc domentity.NotificationDLQ
filter := bson.M{notification.BSONFieldID: oid, notification.BSONFieldTenantID: tenantID}
if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil {
if errors.Is(err, mongodriver.ErrNoDocuments) {
return nil, notification.ErrNotFound
}
return nil, err
}
return &doc, nil
}
func (r *notificationDLQRepository) ListByTenant(ctx context.Context, tenantID string, limit int64) ([]*domentity.NotificationDLQ, error) {
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
filter := bson.M{notification.BSONFieldTenantID: tenantID}
opts := options.Find().SetSort(bson.M{notification.BSONFieldOccurredAt: -1}).SetLimit(limit)
var docs []*domentity.NotificationDLQ
if err := r.db.GetClient().Find(ctx, &docs, filter, opts); err != nil {
return nil, err
}
return docs, nil
}
func (r *notificationDLQRepository) Index20260520001UP(ctx context.Context) error {
return r.db.PopulateMultiIndex(ctx, []string{
notification.BSONFieldTenantID, notification.BSONFieldOccurredAt,
}, []int32{1, -1}, false)
}

View File

@ -0,0 +1,79 @@
package repository_test
import (
"context"
"testing"
"gateway/internal/model/notification"
domentity "gateway/internal/model/notification/domain/entity"
"gateway/internal/model/notification/domain/enum"
domrepo "gateway/internal/model/notification/domain/repository"
"gateway/internal/model/notification/repository"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const memTestTenant = "tenant-mem"
func TestMemoryNotificationRepository_InsertDuplicateIdempotency(t *testing.T) {
repo := repository.NewMemoryNotificationRepository()
ctx := context.Background()
doc := &domentity.Notification{
TenantID: memTestTenant,
Kind: enum.NotifyVerifyEmail,
IdempotencyKey: "dup-1",
Channel: enum.ChannelEmail,
}
require.NoError(t, repo.Insert(ctx, doc))
require.NotEmpty(t, doc.ID.Hex())
err := repo.Insert(ctx, &domentity.Notification{
TenantID: memTestTenant,
Kind: enum.NotifyVerifyEmail,
IdempotencyKey: "dup-1",
Channel: enum.ChannelEmail,
})
require.ErrorIs(t, err, notification.ErrDuplicateIdempotency)
}
func TestMemoryNotificationRepository_FindByIdempotency(t *testing.T) {
repo := repository.NewMemoryNotificationRepository()
ctx := context.Background()
doc := &domentity.Notification{
TenantID: memTestTenant,
Kind: enum.NotifyVerifyPhone,
IdempotencyKey: "find-1",
}
require.NoError(t, repo.Insert(ctx, doc))
found, err := repo.FindByIdempotency(ctx, memTestTenant, enum.NotifyVerifyPhone, "find-1")
require.NoError(t, err)
assert.Equal(t, doc.ID, found.ID)
_, err = repo.FindByIdempotency(ctx, memTestTenant, enum.NotifyVerifyPhone, "missing")
require.ErrorIs(t, err, notification.ErrNotFound)
}
func TestMemoryNotificationRepository_UpdateDeliveryBody(t *testing.T) {
repo := repository.NewMemoryNotificationRepository()
ctx := context.Background()
doc := &domentity.Notification{
TenantID: memTestTenant,
Kind: enum.NotifyStepUpEmail,
IdempotencyKey: "body-1",
}
require.NoError(t, repo.Insert(ctx, doc))
require.NoError(t, repo.UpdateDelivery(ctx, memTestTenant, doc.ID.Hex(), &domrepo.NotificationDeliveryUpdate{
Status: enum.NotifyStatusSent,
Body: "",
}))
stored, err := repo.FindByID(ctx, memTestTenant, doc.ID.Hex())
require.NoError(t, err)
assert.Empty(t, stored.Body)
}

View File

@ -0,0 +1,70 @@
package repository
import (
"context"
"errors"
"time"
redislib "gateway/internal/library/redis"
domrepo "gateway/internal/model/notification/domain/repository"
"github.com/zeromicro/go-zero/core/stores/redis"
)
// RedisIdempotencyCache implements IdempotencyCache with the shared go-zero Redis client.
type RedisIdempotencyCache struct {
client *redis.Redis
}
func NewRedisIdempotencyCache(client *redislib.Client) domrepo.IdempotencyCache {
if client == nil || client.Zero() == nil {
panic("notification: redis client is required")
}
return &RedisIdempotencyCache{client: client.Zero()}
}
func (c *RedisIdempotencyCache) Get(ctx context.Context, key string) ([]byte, error) {
val, err := c.client.GetCtx(ctx, key)
if errors.Is(err, redis.Nil) {
return nil, nil
}
if err != nil {
return nil, err
}
return []byte(val), nil
}
func (c *RedisIdempotencyCache) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
seconds := int(ttl.Seconds())
if seconds < 1 {
seconds = 1
}
return c.client.SetexCtx(ctx, key, string(value), seconds)
}
// RedisQuotaCounter implements QuotaCounter with the shared go-zero Redis client.
type RedisQuotaCounter struct {
client *redis.Redis
}
func NewRedisQuotaCounter(client *redislib.Client) domrepo.QuotaCounter {
if client == nil || client.Zero() == nil {
panic("notification: redis client is required")
}
return &RedisQuotaCounter{client: client.Zero()}
}
func (c *RedisQuotaCounter) Incr(ctx context.Context, key string, ttl time.Duration) (int64, error) {
count, err := c.client.IncrCtx(ctx, key)
if err != nil {
return 0, err
}
seconds := int(ttl.Seconds())
if seconds < 1 {
seconds = 1
}
if err := c.client.ExpireCtx(ctx, key, seconds); err != nil {
return count, err
}
return count, nil
}

View File

@ -0,0 +1,67 @@
package repository
import (
"context"
"encoding/json"
"fmt"
redislib "gateway/internal/library/redis"
domrepo "gateway/internal/model/notification/domain/repository"
domusecase "gateway/internal/model/notification/domain/usecase"
"github.com/zeromicro/go-zero/core/stores/redis"
)
// RedisRetryQueue implements RetryQueue on a Redis sorted set.
type RedisRetryQueue struct {
client *redis.Redis
key string
}
// NewRedisRetryQueue creates a retry queue backed by the shared Redis client.
func NewRedisRetryQueue(client *redislib.Client, key string) domrepo.RetryQueue {
if client == nil || client.Zero() == nil {
panic("notification: redis client is required for retry queue")
}
if key == "" {
panic("notification: retry queue redis key is required")
}
return &RedisRetryQueue{client: client.Zero(), key: key}
}
func (q *RedisRetryQueue) Schedule(ctx context.Context, runAtMs int64, job *domusecase.RetryJob) error {
if job == nil {
return fmt.Errorf("notification: retry job is nil")
}
raw, err := json.Marshal(job)
if err != nil {
return fmt.Errorf("notification: marshal retry job: %w", err)
}
_, err = q.client.ZaddCtx(ctx, q.key, runAtMs, string(raw))
return err
}
func (q *RedisRetryQueue) ClaimDue(ctx context.Context, nowMs int64, limit int) ([]*domusecase.RetryJob, error) {
if limit <= 0 {
limit = 32
}
pairs, err := q.client.ZrangebyscoreWithScoresAndLimitCtx(ctx, q.key, 0, nowMs, 0, limit)
if err != nil {
return nil, err
}
out := make([]*domusecase.RetryJob, 0, len(pairs))
for _, p := range pairs {
member := p.Key
if n, err := q.client.ZremCtx(ctx, q.key, member); err != nil {
return nil, err
} else if n == 0 {
continue
}
var job domusecase.RetryJob
if err := json.Unmarshal([]byte(member), &job); err != nil {
return nil, fmt.Errorf("notification: unmarshal retry job: %w", err)
}
out = append(out, &job)
}
return out, nil
}

View File

@ -0,0 +1,56 @@
package template
import (
"embed"
"fmt"
"strings"
domtpl "gateway/internal/model/notification/domain/template"
)
//go:embed subjects/*.txt html/*.html sms/*.txt
var embeddedFS embed.FS
func readEmbeddedTemplate(path string) (string, error) {
path = strings.TrimPrefix(path, "/")
b, err := embeddedFS.ReadFile(path)
if err != nil {
return "", fmt.Errorf("template: read %q: %w", path, err)
}
return string(b), nil
}
// resolveSpec loads embedded files into executable template strings.
func resolveSpec(spec domtpl.Spec) (subject, body, sms string, err error) {
switch {
case spec.EmailSubjectFile != "":
subject, err = readEmbeddedTemplate(spec.EmailSubjectFile)
if err != nil {
return "", "", "", err
}
default:
subject = spec.EmailSubject
}
switch {
case spec.EmailBodyFile != "":
body, err = readEmbeddedTemplate(spec.EmailBodyFile)
if err != nil {
return "", "", "", err
}
default:
body = spec.EmailBody
}
switch {
case spec.SMSTextFile != "":
sms, err = readEmbeddedTemplate(spec.SMSTextFile)
if err != nil {
return "", "", "", err
}
default:
sms = spec.SMSText
}
return subject, body, sms, nil
}

View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>帳號狀態通知</title>
</head>
<body style="margin:0;padding:0;background-color:#f4f6f8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6f8;padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(15,23,42,0.08);">
<tr>
<td style="background:#64748b;padding:28px 32px;">
<h1 style="margin:0;font-size:20px;font-weight:600;color:#ffffff;">帳號已停權</h1>
</td>
</tr>
<tr>
<td style="padding:32px;">
<p style="margin:0;font-size:15px;line-height:1.6;color:#334155;">您的 CloudEP 帳號已被停權,暫時無法登入或使用服務。如有疑問,請聯絡租戶管理員。</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Security verification</title>
</head>
<body style="margin:0;padding:0;background-color:#f4f6f8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6f8;padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(15,23,42,0.08);">
<tr>
<td style="background:linear-gradient(135deg,#d97706 0%,#b45309 100%);padding:28px 32px;">
<p style="margin:0;font-size:13px;letter-spacing:0.06em;text-transform:uppercase;color:rgba(255,255,255,0.85);">CloudEP Security</p>
<h1 style="margin:8px 0 0;font-size:22px;font-weight:600;color:#ffffff;">Security check</h1>
</td>
</tr>
<tr>
<td style="padding:32px;">
<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#334155;">You are performing a <strong>sensitive action</strong>. Enter this code within <strong>{{.expires_in}}</strong> seconds. Never share it with anyone.</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="background:#fffbeb;border:1px solid #fcd34d;border-radius:8px;padding:20px;">
<span style="font-size:32px;font-weight:700;letter-spacing:0.35em;color:#92400e;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;">{{.code}}</span>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:20px 32px;background:#f8fafc;border-top:1px solid #e2e8f0;">
<p style="margin:0;font-size:12px;color:#94a3b8;text-align:center;">© CloudEP</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>安全驗證碼</title>
</head>
<body style="margin:0;padding:0;background-color:#f4f6f8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6f8;padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(15,23,42,0.08);">
<tr>
<td style="background:linear-gradient(135deg,#d97706 0%,#b45309 100%);padding:28px 32px;">
<p style="margin:0;font-size:13px;letter-spacing:0.06em;text-transform:uppercase;color:rgba(255,255,255,0.85);">CloudEP Security</p>
<h1 style="margin:8px 0 0;font-size:22px;font-weight:600;color:#ffffff;">安全驗證</h1>
</td>
</tr>
<tr>
<td style="padding:32px;">
<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#334155;">您正在進行<strong>高風險操作</strong>,請輸入以下驗證碼以繼續。</p>
<p style="margin:0 0 24px;font-size:14px;line-height:1.5;color:#b45309;">驗證碼 {{.expires_in}} 秒內有效,請勿將驗證碼提供給他人。</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="background:#fffbeb;border:1px solid #fcd34d;border-radius:8px;padding:20px;">
<span style="font-size:32px;font-weight:700;letter-spacing:0.35em;color:#92400e;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;">{{.code}}</span>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:20px 32px;background:#f8fafc;border-top:1px solid #e2e8f0;">
<p style="margin:0;font-size:12px;color:#94a3b8;text-align:center;">© CloudEP · 若非本人操作請立即聯絡管理員</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>歡迎使用 CloudEP</title>
</head>
<body style="margin:0;padding:0;background-color:#f4f6f8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6f8;padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(15,23,42,0.08);">
<tr>
<td style="background:linear-gradient(135deg,#2563eb 0%,#1d4ed8 100%);padding:28px 32px;">
<h1 style="margin:0;font-size:22px;font-weight:600;color:#ffffff;">租戶已就緒</h1>
</td>
</tr>
<tr>
<td style="padding:32px;">
<p style="margin:0 0 12px;font-size:15px;line-height:1.6;color:#334155;">您好,</p>
<p style="margin:0;font-size:15px;line-height:1.6;color:#334155;">租戶 <strong style="color:#1d4ed8;">{{.tenant_name}}</strong> 已建立完成,您可以開始設定成員與權限。</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Email verification code</title>
</head>
<body style="margin:0;padding:0;background-color:#f4f6f8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6f8;padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(15,23,42,0.08);">
<tr>
<td style="background:linear-gradient(135deg,#2563eb 0%,#1d4ed8 100%);padding:28px 32px;">
<p style="margin:0;font-size:13px;letter-spacing:0.06em;text-transform:uppercase;color:rgba(255,255,255,0.85);">CloudEP</p>
<h1 style="margin:8px 0 0;font-size:22px;font-weight:600;color:#ffffff;">Email verification</h1>
</td>
</tr>
<tr>
<td style="padding:32px;">
<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#334155;">Hello,</p>
<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#334155;">Use the code below to verify your email. It expires in <strong>{{.expires_in}}</strong> seconds.</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="background:#f8fafc;border:1px dashed #cbd5e1;border-radius:8px;padding:20px;">
<span style="font-size:32px;font-weight:700;letter-spacing:0.35em;color:#0f172a;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;">{{.code}}</span>
</td>
</tr>
</table>
<p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#64748b;">If you did not request this, you can safely ignore this email.</p>
</td>
</tr>
<tr>
<td style="padding:20px 32px;background:#f8fafc;border-top:1px solid #e2e8f0;">
<p style="margin:0;font-size:12px;color:#94a3b8;text-align:center;">© CloudEP · Automated message, please do not reply</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>電子郵件驗證碼</title>
</head>
<body style="margin:0;padding:0;background-color:#f4f6f8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6f8;padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(15,23,42,0.08);">
<tr>
<td style="background:linear-gradient(135deg,#2563eb 0%,#1d4ed8 100%);padding:28px 32px;">
<p style="margin:0;font-size:13px;letter-spacing:0.06em;text-transform:uppercase;color:rgba(255,255,255,0.85);">CloudEP</p>
<h1 style="margin:8px 0 0;font-size:22px;font-weight:600;color:#ffffff;">電子郵件驗證</h1>
</td>
</tr>
<tr>
<td style="padding:32px;">
<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#334155;">您好,</p>
<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#334155;">請使用以下驗證碼完成綁定。此驗證碼將於 <strong>{{.expires_in}}</strong> 秒後失效。</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="background:#f8fafc;border:1px dashed #cbd5e1;border-radius:8px;padding:20px;">
<span style="font-size:32px;font-weight:700;letter-spacing:0.35em;color:#0f172a;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;">{{.code}}</span>
</td>
</tr>
</table>
<p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#64748b;">若您未提出此請求,請忽略本信,無需進一步操作。</p>
</td>
</tr>
<tr>
<td style="padding:20px 32px;background:#f8fafc;border-top:1px solid #e2e8f0;">
<p style="margin:0;font-size:12px;color:#94a3b8;text-align:center;">© CloudEP · 本信由系統自動發送,請勿直接回覆</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Registration verification</title>
</head>
<body style="margin:0;padding:0;background-color:#f4f6f8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6f8;padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(15,23,42,0.08);">
<tr>
<td style="background:linear-gradient(135deg,#059669 0%,#047857 100%);padding:28px 32px;">
<p style="margin:0;font-size:13px;letter-spacing:0.06em;text-transform:uppercase;color:rgba(255,255,255,0.85);">CloudEP</p>
<h1 style="margin:8px 0 0;font-size:22px;font-weight:600;color:#ffffff;">Welcome</h1>
</td>
</tr>
<tr>
<td style="padding:32px;">
<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#334155;">Thanks for signing up. Your verification code expires in <strong>{{.expires_in}}</strong> seconds.</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="background:#f0fdf4;border:1px dashed #86efac;border-radius:8px;padding:20px;">
<span style="font-size:32px;font-weight:700;letter-spacing:0.35em;color:#14532d;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;">{{.code}}</span>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:20px 32px;background:#f8fafc;border-top:1px solid #e2e8f0;">
<p style="margin:0;font-size:12px;color:#94a3b8;text-align:center;">© CloudEP</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>註冊驗證碼</title>
</head>
<body style="margin:0;padding:0;background-color:#f4f6f8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#f4f6f8;padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(15,23,42,0.08);">
<tr>
<td style="background:linear-gradient(135deg,#059669 0%,#047857 100%);padding:28px 32px;">
<p style="margin:0;font-size:13px;letter-spacing:0.06em;text-transform:uppercase;color:rgba(255,255,255,0.85);">CloudEP</p>
<h1 style="margin:8px 0 0;font-size:22px;font-weight:600;color:#ffffff;">歡迎註冊</h1>
</td>
</tr>
<tr>
<td style="padding:32px;">
<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#334155;">感謝您註冊 CloudEP。</p>
<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#334155;">您的註冊驗證碼如下,請於 <strong>{{.expires_in}}</strong> 秒內完成驗證。</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="background:#f0fdf4;border:1px dashed #86efac;border-radius:8px;padding:20px;">
<span style="font-size:32px;font-weight:700;letter-spacing:0.35em;color:#14532d;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;">{{.code}}</span>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:20px 32px;background:#f8fafc;border-top:1px solid #e2e8f0;">
<p style="margin:0;font-size:12px;color:#94a3b8;text-align:center;">© CloudEP · 本信由系統自動發送</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,92 @@
package template
import (
"fmt"
"gateway/internal/model/notification/domain/enum"
domtpl "gateway/internal/model/notification/domain/template"
)
// DefaultRegistry returns built-in templates (content loaded from go:embed files).
func DefaultRegistry() domtpl.Registry {
return domtpl.Registry{
enum.NotifyVerifyEmail: localeSpecs(otpEmailVars(),
emailFiles("verify_email"),
nil,
),
enum.NotifyVerifyRegistrationEmail: localeSpecs(otpEmailVars(),
emailFiles("verify_registration_email"),
nil,
),
enum.NotifyVerifyPhone: localeSpecs(otpSMSVars(),
nil,
smsFiles("verify_phone"),
),
enum.NotifyStepUpEmail: localeSpecs(otpEmailVars(),
emailFiles("step_up_email"),
nil,
),
enum.NotifyStepUpPhone: localeSpecs(otpSMSVars(),
nil,
smsFiles("step_up_phone"),
),
enum.NotifyAccountSuspended: {
domtpl.LocaleZhTW: {
RequiredVars: []string{},
EmailSubjectFile: fmt.Sprintf("subjects/account_suspended.%s.txt", domtpl.LocaleZhTW),
EmailBodyFile: fmt.Sprintf("html/account_suspended.%s.html", domtpl.LocaleZhTW),
},
},
enum.NotifyTenantWelcome: {
domtpl.LocaleZhTW: {
RequiredVars: []string{"tenant_name"},
EmailSubjectFile: fmt.Sprintf("subjects/tenant_welcome.%s.txt", domtpl.LocaleZhTW),
EmailBodyFile: fmt.Sprintf("html/tenant_welcome.%s.html", domtpl.LocaleZhTW),
},
},
}
}
func otpEmailVars() []string { return []string{domtpl.VarCode, domtpl.VarExpiresIn} }
func otpSMSVars() []string { return []string{domtpl.VarCode, domtpl.VarExpiresIn} }
func emailFiles(base string) map[string]domtpl.Spec {
return map[string]domtpl.Spec{
domtpl.LocaleZhTW: {
EmailSubjectFile: fmt.Sprintf("subjects/%s.%s.txt", base, domtpl.LocaleZhTW),
EmailBodyFile: fmt.Sprintf("html/%s.%s.html", base, domtpl.LocaleZhTW),
},
domtpl.LocaleEnUS: {
EmailSubjectFile: fmt.Sprintf("subjects/%s.%s.txt", base, domtpl.LocaleEnUS),
EmailBodyFile: fmt.Sprintf("html/%s.%s.html", base, domtpl.LocaleEnUS),
},
}
}
func smsFiles(base string) map[string]domtpl.Spec {
return map[string]domtpl.Spec{
domtpl.LocaleZhTW: {SMSTextFile: fmt.Sprintf("sms/%s.%s.txt", base, domtpl.LocaleZhTW)},
domtpl.LocaleEnUS: {SMSTextFile: fmt.Sprintf("sms/%s.%s.txt", base, domtpl.LocaleEnUS)},
}
}
func localeSpecs(required []string, email, sms map[string]domtpl.Spec) map[string]domtpl.Spec {
locales := []string{domtpl.LocaleZhTW, domtpl.LocaleEnUS}
out := make(map[string]domtpl.Spec, len(locales))
for _, loc := range locales {
spec := domtpl.Spec{RequiredVars: required}
if email != nil {
if e, ok := email[loc]; ok {
spec.EmailSubjectFile = e.EmailSubjectFile
spec.EmailBodyFile = e.EmailBodyFile
}
}
if sms != nil {
if s, ok := sms[loc]; ok {
spec.SMSTextFile = s.SMSTextFile
}
}
out[loc] = spec
}
return out
}

View File

@ -0,0 +1,108 @@
package template
import (
"bytes"
"fmt"
htmltemplate "html/template"
"strings"
texttemplate "text/template"
"gateway/internal/model/notification/domain/enum"
domtpl "gateway/internal/model/notification/domain/template"
)
// Renderer renders notification templates from a registry.
type Renderer struct {
registry domtpl.Registry
fallbacks []string
}
// NewRenderer builds a Renderer. Implements domain/template.Renderer.
func NewRenderer(registry domtpl.Registry, fallbacks ...string) *Renderer {
if registry == nil {
registry = DefaultRegistry()
}
return &Renderer{
registry: registry,
fallbacks: fallbacks,
}
}
// Render produces subject/body/sms text for the given kind and locale.
func (r *Renderer) Render(kind enum.NotifyKind, locale string, data map[string]any) (*domtpl.Rendered, error) {
spec, err := r.registry.Lookup(kind, locale, r.fallbacks...)
if err != nil {
return nil, err
}
if err := validateRequiredVars(spec.RequiredVars, data); err != nil {
return nil, err
}
subjectTpl, bodyTpl, smsTpl, err := resolveSpec(spec)
if err != nil {
return nil, err
}
out := &domtpl.Rendered{}
if subjectTpl != "" {
subject, err := executeTextTemplate("subject", subjectTpl, data)
if err != nil {
return nil, fmt.Errorf("template: render email subject: %w", err)
}
out.Subject = subject
}
if bodyTpl != "" {
body, err := executeHTMLTemplate("body", bodyTpl, data)
if err != nil {
return nil, fmt.Errorf("template: render email body: %w", err)
}
out.Body = body
}
if smsTpl != "" {
sms, err := executeTextTemplate("sms", smsTpl, data)
if err != nil {
return nil, fmt.Errorf("template: render sms: %w", err)
}
out.SMSText = sms
}
if out.Subject == "" && out.Body == "" && out.SMSText == "" {
return nil, fmt.Errorf("template: kind %q has no renderable fields for locale %q", kind, locale)
}
return out, nil
}
func validateRequiredVars(required []string, data map[string]any) error {
for _, key := range required {
if _, ok := data[key]; !ok {
return fmt.Errorf("template: missing required variable %q", key)
}
}
return nil
}
func executeTextTemplate(name, tpl string, data map[string]any) (string, error) {
t, err := texttemplate.New(name).Parse(tpl)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()), nil
}
func executeHTMLTemplate(name, tpl string, data map[string]any) (string, error) {
t, err := htmltemplate.New(name).Parse(tpl)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()), nil
}

View File

@ -0,0 +1,69 @@
package template_test
import (
"testing"
"gateway/internal/model/notification/domain/enum"
domtpl "gateway/internal/model/notification/domain/template"
"gateway/internal/model/notification/template"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRenderer_Render_VerifyEmailZhTW(t *testing.T) {
r := template.NewRenderer(template.DefaultRegistry(), domtpl.LocaleZhTW, domtpl.LocaleEnUS)
out, err := r.Render(enum.NotifyVerifyEmail, domtpl.LocaleZhTW, map[string]any{
domtpl.VarCode: "123456",
domtpl.VarExpiresIn: 300,
})
require.NoError(t, err)
assert.Contains(t, out.Subject, "驗證碼")
assert.Contains(t, out.Body, "123456")
assert.Contains(t, out.Body, "300")
}
func TestRenderer_Render_LocaleFallback(t *testing.T) {
r := template.NewRenderer(template.DefaultRegistry(), domtpl.LocaleZhTW, domtpl.LocaleEnUS)
out, err := r.Render(enum.NotifyVerifyEmail, "ja-jp", map[string]any{
domtpl.VarCode: "999999",
domtpl.VarExpiresIn: 60,
})
require.NoError(t, err)
assert.Contains(t, out.Subject, "驗證")
}
func TestRenderer_Render_MissingVar(t *testing.T) {
r := template.NewRenderer(template.DefaultRegistry())
_, err := r.Render(enum.NotifyVerifyPhone, domtpl.LocaleZhTW, map[string]any{domtpl.VarCode: "1"})
require.Error(t, err)
assert.Contains(t, err.Error(), domtpl.VarExpiresIn)
}
func TestRenderer_Render_VerifyPhoneSMS(t *testing.T) {
r := template.NewRenderer(template.DefaultRegistry())
out, err := r.Render(enum.NotifyVerifyPhone, domtpl.LocaleEnUS, map[string]any{
domtpl.VarCode: "654321",
domtpl.VarExpiresIn: 300,
})
require.NoError(t, err)
assert.Contains(t, out.SMSText, "654321")
assert.Empty(t, out.Body)
}
func TestRegistry_Lookup_UnknownKind(t *testing.T) {
_, err := template.DefaultRegistry().Lookup(enum.NotifyKind("unknown"), domtpl.LocaleZhTW)
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown kind")
}
func TestRenderer_Render_EscapesHTMLInBody(t *testing.T) {
r := template.NewRenderer(template.DefaultRegistry(), domtpl.LocaleZhTW, domtpl.LocaleEnUS)
out, err := r.Render(enum.NotifyVerifyEmail, domtpl.LocaleZhTW, map[string]any{
domtpl.VarCode: "<script>alert(1)</script>",
domtpl.VarExpiresIn: 300,
})
require.NoError(t, err)
assert.NotContains(t, out.Body, "<script>")
assert.Contains(t, out.Body, "&lt;script&gt;")
}

View File

@ -0,0 +1 @@
CloudEP security code {{.code}}, valid for {{.expires_in}} seconds.

View File

@ -0,0 +1 @@
CloudEP 安全驗證碼 {{.code}}{{.expires_in}} 秒內有效。

View File

@ -0,0 +1 @@
CloudEP code {{.code}}, valid for {{.expires_in}} seconds.

View File

@ -0,0 +1 @@
CloudEP 驗證碼 {{.code}}{{.expires_in}} 秒內有效。

Some files were not shown because too many files have changed in this diff Show More