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:
parent
1274c56cb5
commit
49e7099bf2
45
Makefile
45
Makefile
|
|
@ -15,7 +15,8 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
.PHONY: help tools gen-api 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: ## 顯示可用指令
|
help: ## 顯示可用指令
|
||||||
@echo "Gateway Makefile"
|
@echo "Gateway Makefile"
|
||||||
|
|
@ -30,7 +31,10 @@ help: ## 顯示可用指令
|
||||||
tools: ## 安裝 goctl、goimports、golangci-lint(需 Go,且 GOPATH/bin 在 PATH)
|
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 $(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 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 "tools OK"
|
||||||
@echo " goctl: $$(goctl --version 2>/dev/null || echo missing)"
|
@echo " goctl: $$(goctl --version 2>/dev/null || echo missing)"
|
||||||
@echo " golangci-lint: $$(golangci-lint version 2>/dev/null | head -1 || 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 模板)
|
gen-api: tools ## 由 .api 生成 handler / logic / types(自訂 handler 模板)
|
||||||
$(GOCTL) api go -api $(API_ENTRY) -dir . -style $(GO_ZERO_STYLE) -home generate/goctl
|
$(GOCTL) api go -api $(API_ENTRY) -dir . -style $(GO_ZERO_STYLE) -home generate/goctl
|
||||||
|
|
||||||
|
gen-mock: ## 依 go:generate 產生 internal/model/*/mock(gomock)
|
||||||
|
$(GO) generate ./internal/model/...
|
||||||
|
|
||||||
build-go-doc: ## 編譯 go-doc(OpenAPI 文件生成器)
|
build-go-doc: ## 編譯 go-doc(OpenAPI 文件生成器)
|
||||||
@echo ">> building $(GO_DOC_BIN)"
|
@echo ">> building $(GO_DOC_BIN)"
|
||||||
@mkdir -p $(GO_DOC_DIR)/bin
|
@mkdir -p $(GO_DOC_DIR)/bin
|
||||||
|
|
@ -55,17 +62,41 @@ fmt: ## gofmt + goimports(不含 lint)
|
||||||
$(GOFMT) -s -w $(GOFILES)
|
$(GOFMT) -s -w $(GOFILES)
|
||||||
@command -v goimports >/dev/null 2>&1 && goimports -w . || (echo "goimports not found; run: make tools" && exit 1)
|
@command -v goimports >/dev/null 2>&1 && goimports -w . || (echo "goimports not found; run: make tools" && exit 1)
|
||||||
|
|
||||||
lint: ## golangci-lint 靜態檢查(先 make tools)
|
lint: tools ## golangci-lint 靜態檢查
|
||||||
@command -v golangci-lint >/dev/null 2>&1 || (echo "golangci-lint not found; run: make tools" && exit 1)
|
|
||||||
golangci-lint run ./...
|
golangci-lint run ./...
|
||||||
|
|
||||||
lint-fix: ## 自動修正可修的 lint / formatter 問題(見 .golangci.yml)
|
lint-fix: tools ## 自動修正可修的 lint / formatter 問題(見 .golangci.yml)
|
||||||
@command -v golangci-lint >/dev/null 2>&1 || (echo "golangci-lint not found; run: make tools" && exit 1)
|
|
||||||
golangci-lint run --fix ./...
|
golangci-lint run --fix ./...
|
||||||
|
|
||||||
fix: fmt lint-fix lint ## 格式化 + 自動修 lint + 再檢查(提交前建議)
|
fix: fmt lint-fix lint ## 格式化 + 自動修 lint + 再檢查(提交前建議)
|
||||||
|
|
||||||
check: fix test ## 提交 / PR 前完整檢查(fmt、lint、test)
|
check: fix test ## 提交 / PR 前完整檢查(fmt、lint、test)
|
||||||
|
|
||||||
run: ## 啟動 Gateway(etc/gateway.yaml)
|
run: ## 啟動 Gateway(etc/gateway.yaml,無需 Docker)
|
||||||
$(GO) run gateway.go -f etc/gateway.yaml
|
$(GO) run gateway.go -f etc/gateway.yaml
|
||||||
|
|
||||||
|
run-dev: ## 啟動 Gateway(etc/gateway.dev.yaml,需 make deps-up)
|
||||||
|
$(GO) run gateway.go -f etc/gateway.dev.yaml
|
||||||
|
|
||||||
|
run-local: run-dev ## 別名:同 run-dev
|
||||||
|
|
||||||
|
deps-up: ## 啟動本機 Mongo + Redis(docker 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
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -42,6 +42,18 @@ make run
|
||||||
# 或:go run gateway.go -f etc/gateway.yaml
|
# 或:go run gateway.go -f etc/gateway.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 本機 Mongo + Redis(Notification / 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):
|
產生 OpenAPI(會先編譯 `generate/doc-generate` 內的 go-doc):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -79,7 +91,11 @@ curl -s http://127.0.0.1:8888/api/v1/health | jq
|
||||||
| `make lint-fix` | 自動修正可修的 lint / import 問題 |
|
| `make lint-fix` | 自動修正可修的 lint / import 問題 |
|
||||||
| `make fix` | `fmt` + `lint-fix` + `lint`(提交前建議) |
|
| `make fix` | `fmt` + `lint-fix` + `lint`(提交前建議) |
|
||||||
| `make check` | `fix` + `test`(PR / AI 完成前必跑) |
|
| `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 索引 |
|
||||||
|
|
||||||
## 專案結構
|
## 專案結構
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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 # 再加上 mailhog(profile 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`) |
|
||||||
|
|
@ -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.');
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
# 本機開發依賴:MongoDB(notification 持久化)、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
297
docs/model.md
297
docs/model.md
|
|
@ -2,48 +2,113 @@
|
||||||
|
|
||||||
本文件定義 Gateway 專案中 **業務模型**(`internal/model/{module}/`)的目錄結構與撰寫約定,參考 Clean Architecture 分層。
|
本文件定義 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)。
|
專案總覽與錯誤分層見 [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/
|
internal/model/
|
||||||
└── member/
|
└── {module}/ # 例:notification、member、permission
|
||||||
├── entity/ # 持久化資料模型(MongoDB document)
|
├── domain/ # 純領域:介面、實體、列舉、DTO(不依賴 mongo/redis/provider)
|
||||||
├── enum/ # 領域值物件 / 列舉(Platform、Status…)
|
│ ├── entity/ # Mongo document 結構 + CollectionName()
|
||||||
├── repository/ # Repository 介面 + 實作
|
│ ├── enum/ # Channel、Status、Platform…
|
||||||
├── usecase/ # UseCase 介面、Request/Response DTO、實作
|
│ ├── repository/ # Repository / Cache 介面 only
|
||||||
├── config/ # 模組用設定 struct
|
│ ├── usecase/ # UseCase 介面 + Request/Response DTO
|
||||||
├── errors.go # 模組 sentinel(ErrNotFound 等),非第二套 8 碼
|
│ └── template/ # 可選:模板 Spec、Registry、Renderer 介面(notification)
|
||||||
├── const.go # 模組常數
|
├── repository/ # domain/repository 的 Mongo / Redis / memory 實作
|
||||||
├── redis.go # Redis key 命名與 helper
|
├── usecase/ # domain/usecase 的實作 + factory 組裝
|
||||||
└── mock/ # mockgen 產物
|
├── template/ # 可選:go:embed、DefaultRegistry、Renderer 實作
|
||||||
|
├── provider/ # 可選:僅本模組用的 Sender(email/sms),不放 library/
|
||||||
|
├── config/ # 模組設定 struct(嵌入 gateway Config)
|
||||||
|
├── errors.go # 模組 sentinel
|
||||||
|
├── const.go # BSON 欄位名、模組常數
|
||||||
|
├── redis.go # Redis key 命名
|
||||||
|
└── mock/ # mockgen(路徑對應 domain/)
|
||||||
├── repository/
|
├── repository/
|
||||||
└── usecase/
|
└── usecase/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**參考實作:** [`internal/model/notification/`](../internal/model/notification/)(N0–N5 核心已完成;流程圖與設定見 [**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(介面)
|
domain/entity、domain/enum → 僅標準庫 / 列舉底層型別
|
||||||
→ entity、enum
|
|
||||||
→ usecase(介面 + DTO)
|
|
||||||
|
|
||||||
repository(實作) → repository(介面)
|
domain/repository、domain/usecase、domain/template
|
||||||
→ entity
|
→ 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`。
|
- struct 名稱使用 PascalCase 單數,如 `Account`、`User`。
|
||||||
- 必須實作 `CollectionName() string`,回傳 MongoDB collection 名稱。
|
- 必須實作 `CollectionName() string`,回傳 MongoDB collection 名稱。
|
||||||
- 欄位 tag:`bson` 必填;對外 JSON 序列化才加 `json`。
|
- 欄位 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。
|
- 時間戳記統一用 `*int64`,欄位名 `CreateAt` / `UpdateAt`,值為 UTC nanoseconds。
|
||||||
- 可選欄位用指標型別(`*string`、`*int64`)。
|
- 可選欄位用指標型別(`*string`、`*int64`)。
|
||||||
- 領域列舉引用 `enum/` 下的型別,不在 entity 內重複定義。
|
- 領域列舉引用 `domain/enum/` 下的型別,不在 entity 內重複定義。
|
||||||
|
|
||||||
**範例:**
|
**範例:**
|
||||||
|
|
||||||
|
|
@ -62,12 +127,12 @@ internal/logic → model/{module}/usecase 介面 only(不 import repository
|
||||||
package entity
|
package entity
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gateway/internal/model/member/enum"
|
"gateway/internal/model/member/domain/enum"
|
||||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Account struct {
|
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"`
|
LoginID string `bson:"login_id"`
|
||||||
Token string `bson:"token"`
|
Token string `bson:"token"`
|
||||||
Platform enum.Platform `bson:"platform"`
|
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` 驗證轉換邏輯。
|
同一檔案或目錄下可放 `*_test.go` 驗證轉換邏輯。
|
||||||
|
|
||||||
## 3. Repository 介面(`repository/`)
|
## 3. Repository 介面(`domain/repository/`)
|
||||||
|
|
||||||
**規則:**
|
**規則:**
|
||||||
|
|
||||||
- 一個 entity 一個 `XxxRepository` interface。
|
- 一個 entity 一個 `XxxRepository` interface,檔案放在 `domain/repository/`。
|
||||||
- 方法第一個參數固定為 `context.Context`。
|
- 方法第一個參數固定為 `context.Context`。
|
||||||
- 參數 / 回傳值使用 `entity` 型別,不暴露 driver 細節(除 index migration 等必要場景)。
|
- 參數 / 回傳值使用 `domain/entity` 型別,不暴露 driver 細節(除 index migration 等必要場景)。
|
||||||
- Index migration 以獨立 interface 嵌入,命名 `{Entity}IndexUP`,方法名含版本號,如 `Index20241226001UP`。
|
- Index migration 以獨立 interface 嵌入,命名 `{Entity}IndexUP`,方法名含版本號,如 `Index20241226001UP`。
|
||||||
- 介面檔案不含實作、不含 import 基礎設施 package(`mon`、`mongo` 實作層等僅在 repository 實作出現)。
|
- **此目錄僅介面**:不含 `NewXxxRepository`、不含 `mongo` / `redis` import。
|
||||||
|
|
||||||
**範例:**
|
**範例:**
|
||||||
|
|
||||||
|
|
@ -128,31 +193,27 @@ package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"gateway/internal/model/member/entity"
|
"gateway/internal/model/member/domain/entity"
|
||||||
"go.mongodb.org/mongo-driver/mongo"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type AccountRepository interface {
|
type AccountRepository interface {
|
||||||
Insert(ctx context.Context, data *entity.Account) error
|
Insert(ctx context.Context, data *entity.Account) error
|
||||||
FindOne(ctx context.Context, id string) (*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)
|
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/`)
|
## 4. Repository 實作(`repository/`)
|
||||||
|
|
||||||
**規則:**
|
**規則:**
|
||||||
|
|
||||||
- struct 名稱 `{Entity}Repository`,建構子 `New{Entity}Repository(param {Entity}RepositoryParam)`。
|
- struct 名稱 `{entity}Repository`(小寫)或 `{Entity}Repository`,建構子 `New{Entity}Repository(param {Entity}RepositoryParam)`,**回傳型別為 `domain/repository` 的 interface**。
|
||||||
- Param struct 集中注入 `Conf`、`CacheConf`、`DBOpts`、`CacheOpts`。
|
- Param struct 集中注入 `*mongo.Conf` 等;實作 import `domain/entity`、`domain/repository`。
|
||||||
- 建構時以 entity 的 `CollectionName()` 初始化 DocumentDB;失敗時 `panic`(啟動期錯誤)。
|
- 建構時以 `domain/entity` 的 `CollectionName()` 初始化 DocumentDB;失敗時 `panic`(啟動期錯誤)。
|
||||||
- CRUD 透過 `mongo.DocumentDBWithCacheUseCase`(`gateway/internal/library/mongo`)操作,搭配模組 `redis.go` 的 key helper。
|
- CRUD 透過 `gateway/internal/library/mongo` 的 DocumentDB helper,搭配模組 `redis.go` 的 key helper。
|
||||||
- `Insert`:ID 為 zero 時自動產生 ObjectID 並寫入 `CreateAt` / `UpdateAt`。
|
- `Insert`:ID 為 zero 時自動產生 ObjectID 並寫入 `CreateAt` / `UpdateAt`。
|
||||||
- `Update`:自動更新 `UpdateAt`。
|
- `Update`:自動更新 `UpdateAt`。
|
||||||
- `FindOne` / `Delete`:無效 ObjectID → `*errs.Error`(`ResInvalidMeasureID`)或模組 `ErrInvalidObjectID`;查無資料 → 模組 `ErrNotFound`(見第 7 節錯誤)。
|
- `FindOne` / `Delete`:無效 ObjectID → `*errs.Error`(`ResInvalidMeasureID`)或模組 `ErrInvalidObjectID`;查無資料 → 模組 `ErrNotFound`(見第 7 節錯誤)。
|
||||||
|
|
@ -161,54 +222,54 @@ type AccountIndexUP interface {
|
||||||
**範例:**
|
**範例:**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type AccountRepositoryParam struct {
|
import (
|
||||||
Conf *mongo.Conf
|
domentity "gateway/internal/model/member/domain/entity"
|
||||||
CacheConf cache.CacheConf
|
domrepo "gateway/internal/model/member/domain/repository"
|
||||||
DBOpts []mon.Option
|
)
|
||||||
CacheOpts []cache.Option
|
|
||||||
}
|
|
||||||
|
|
||||||
type accountRepository struct {
|
type accountRepository struct {
|
||||||
DB mongo.DocumentDBWithCacheUseCase
|
db mongo.DocumentDBUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAccountRepository(param AccountRepositoryParam) AccountRepository {
|
func NewAccountRepository(param AccountRepositoryParam) domrepo.AccountRepository {
|
||||||
e := entity.Account{}
|
e := domentity.Account{}
|
||||||
documentDB, err := mongo.MustDocumentDBWithCache(
|
documentDB, err := mongo.NewDocumentDB(param.Conf, e.CollectionName())
|
||||||
param.Conf, e.CollectionName(), param.CacheConf, param.DBOpts, param.CacheOpts,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
return &accountRepository{DB: documentDB}
|
return &accountRepository{db: documentDB}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5. UseCase 介面與 DTO(`usecase/`)
|
## 5. UseCase 介面與 DTO(`domain/usecase/`)
|
||||||
|
|
||||||
**規則:**
|
**規則:**
|
||||||
|
|
||||||
- 業務入口定義為 interface,如 `AccountUseCase`;大介面可拆成多個小 interface 再 compose。
|
- 業務入口定義為 interface,如 `NotifierUseCase`、`AccountUseCase`,放在 `domain/usecase/`。
|
||||||
- Request / Response struct 放在同一 package,命名 `{Action}Request`、`{Action}Response`。
|
- Request / Response struct 與 interface **同 package**,命名 `{Action}Request`、`NotificationDTO`。
|
||||||
- DTO 只含 `json` tag 與欄位註解,不含 bson tag(DTO 不直接映射 DB)。
|
- DTO 只含 `json` tag(若需序列化),不含 bson tag。
|
||||||
- 更新類 Request 的可選欄位用指標,以便區分「未傳入」與「傳入零值」。
|
- 更新類 Request 的可選欄位用指標,以便區分「未傳入」與「傳入零值」。
|
||||||
- 共用分頁 struct 放 `common.go`,如 `Pager`。
|
|
||||||
|
|
||||||
**範例:**
|
**範例:**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package usecase
|
package usecase
|
||||||
|
|
||||||
import "gateway/internal/model/member/enum"
|
import (
|
||||||
|
"context"
|
||||||
|
"gateway/internal/model/notification/domain/enum"
|
||||||
|
)
|
||||||
|
|
||||||
type AccountUseCase interface {
|
type NotifierUseCase interface {
|
||||||
CreateLoginUser(ctx context.Context, req *CreateLoginUserRequest) (*CreateLoginUserResponse, error)
|
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 {
|
type SendRequest struct {
|
||||||
LoginID string `json:"login_id"`
|
TenantID string
|
||||||
Platform enum.Platform `json:"platform"`
|
Channel enum.Channel
|
||||||
Token string `json:"token"`
|
// ...
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -216,29 +277,34 @@ type CreateLoginUserRequest struct {
|
||||||
|
|
||||||
**規則:**
|
**規則:**
|
||||||
|
|
||||||
- struct 名稱描述業務聚合,如 `MemberUseCase`。
|
- 實作 struct 如 `notifierUseCase`、`memberUseCase`,放在模組根 `usecase/`。
|
||||||
- 以 `{Name}UseCaseParam` 注入所有 repository 與 `config.Config`。
|
- 以 `{Name}UseCaseParam` 注入 `domain/repository` 介面、provider、renderer、`config`。
|
||||||
- 建構子命名 `Must{Name}UseCase(param) AccountUseCase`,回傳 interface 型別。
|
- 建構子 `Must{Name}UseCase(param) domusecase.XxxUseCase`,回傳 **domain** interface。
|
||||||
- 實作 struct 嵌入 Param:`type MemberUseCase struct { MemberUseCaseParam }`。
|
- 跨模組組裝可用 `New{Name}UseCaseFromParam` / `factory.go`(見 `notification/usecase/factory.go`)。
|
||||||
- 方法簽名與 interface 一致;內部組裝 `entity`,呼叫 repository。
|
- 方法簽名與 `domain/usecase` interface 一致;內部組裝 `domain/entity`,呼叫 repository 介面。
|
||||||
- 錯誤一律回傳 `gateway/internal/library/errors` 的 `*errs.Error`(見第 7 節)。
|
- 錯誤一律回傳 `gateway/internal/library/errors` 的 `*errs.Error`(見第 7 節)。
|
||||||
- 可測性:將難 mock 的純函式抽成 package 級變數(如 `var HashPasswordFunc = HashPassword`)。
|
- 可測性:將難 mock 的純函式抽成 package 級變數(如 `var HashPasswordFunc = HashPassword`)。
|
||||||
|
|
||||||
**範例:**
|
**範例:**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
import (
|
||||||
|
domrepo "gateway/internal/model/member/domain/repository"
|
||||||
|
domusecase "gateway/internal/model/member/domain/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
type MemberUseCaseParam struct {
|
type MemberUseCaseParam struct {
|
||||||
Account repository.AccountRepository
|
Account domrepo.AccountRepository
|
||||||
User repository.UserRepository
|
User domrepo.UserRepository
|
||||||
Config config.Config
|
Config config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
type MemberUseCase struct {
|
type memberUseCase struct {
|
||||||
MemberUseCaseParam
|
MemberUseCaseParam
|
||||||
}
|
}
|
||||||
|
|
||||||
func MustMemberUseCase(param MemberUseCaseParam) AccountUseCase {
|
func MustMemberUseCase(param MemberUseCaseParam) domusecase.AccountUseCase {
|
||||||
return &MemberUseCase{param}
|
return &memberUseCase{param}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -251,14 +317,16 @@ func MustMemberUseCase(param MemberUseCaseParam) AccountUseCase {
|
||||||
```go
|
```go
|
||||||
package member
|
package member
|
||||||
|
|
||||||
import "errors"
|
import "fmt"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrNotFound = errors.New("member: not found")
|
ErrNotFound = fmt.Errorf("member: not found")
|
||||||
ErrInvalidObjectID = errors.New("member: invalid object id")
|
ErrInvalidObjectID = fmt.Errorf("member: invalid object id")
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(專案慣例:`fmt.Errorf` 定義 sentinel,便於 `%w` 包裝;見 `notification/errors.go`。)
|
||||||
|
|
||||||
### 7.2 Repository
|
### 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
|
```bash
|
||||||
mockgen -source=./internal/model/member/repository/account.go \
|
make gen-mock
|
||||||
-destination=./internal/model/member/mock/repository/account.go \
|
|
||||||
-package=mockrepository
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- 產物放在 `mock/repository/` 或 `mock/usecase/`,**不要手改**。
|
`domain/repository/generate.go` 範例:
|
||||||
- UseCase 單元測試注入 mock,不啟動真實 DB。
|
|
||||||
|
```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. 命名對照表
|
## 10. 命名對照表
|
||||||
|
|
||||||
|
|
@ -337,17 +418,22 @@ mockgen -source=./internal/model/member/repository/account.go \
|
||||||
|
|
||||||
## 11. 新增模組 / Model 檢查清單
|
## 11. 新增模組 / Model 檢查清單
|
||||||
|
|
||||||
1. 建立 `internal/model/{module}/` 目錄結構。
|
1. 建立 `internal/model/{module}/domain/{entity,enum,repository,usecase}/` 與外層 `repository/`、`usecase/`。
|
||||||
2. 在 `entity/` 新增 struct + `CollectionName()`。
|
2. 在 `domain/entity/` 新增 struct + `CollectionName()`。
|
||||||
3. 若有列舉 / 狀態,在 `enum/` 定義值物件。
|
3. 在 `domain/enum/` 定義值物件(若有)。
|
||||||
4. 在 `repository/` 宣告 interface 並實作 CRUD + index migration + `*_test.go`。
|
4. 在 `domain/repository/` 宣告 interface;於 `repository/` 實作 CRUD + index + `*_test.go`。
|
||||||
5. 在 `errors.go` 補充 sentinel(若需要);在 `redis.go` 補 cache key(若需要)。
|
5. 在 `domain/usecase/` 宣告 interface + DTO;於 `usecase/` 實作 + 單元測試(可 fake `domain/repository`)。
|
||||||
6. 在 `usecase/` 定義 interface、DTO 與實作 + 單元測試。
|
6. 模組專用整合放 `provider/`、`template/`(勿放入 `library/`)。
|
||||||
7. 執行 mockgen 更新 `mock/`。
|
7. `errors.go`、`const.go`、`redis.go`、`config/` 按需補齊。
|
||||||
8. 在 `internal/svc/service_context.go` 組裝 repository → usecase。
|
8. 執行 `make gen-mock`(`go:generate` 在 `domain/repository/generate.go` 等)。
|
||||||
9. 在 `generate/api/` 定義路由,`make gen-api`。
|
9. 在 `internal/config/config.go` 嵌入模組 `config`;`etc/gateway.yaml` 加區塊。
|
||||||
10. 在 `internal/logic/` 實作 types 映射,**只**呼叫 UseCase interface。
|
10. 在 `internal/svc/service_context.go` 建立 **共用** `*redislib.Client`,再注入 `NewXxxUseCaseFromParam`(Mongo / Redis 未配置時對應欄位可為 `nil`)。
|
||||||
11. `make gen-doc`、`go test ./...`。
|
11. 在 `generate/api/` 定義路由,`make gen-api`;`internal/logic/` **只** import `domain/usecase`。
|
||||||
|
12. `make gen-doc`、`go test ./...`。
|
||||||
|
|
||||||
|
**Notification 模組進度(參考):** N0–N5 核心 ✅(含 `RetryWorker`、`AdminNotifierUseCase`);文件見 [notification README](../internal/model/notification/README.md)。待做:HTTP admin API(goctl)。
|
||||||
|
|
||||||
|
**Member 模組進度(P3.5):** `OTPUseCase` + `VerificationUseCase`(email/phone)✅,經 `Notifier.Send` 投遞;`ProfileRepository` 暫用 memory(P4 換 Mongo)。`ServiceContext.MemberVerification` 在 Mongo+Redis+Notifier 就緒時注入。後續:Step-up / TOTP、HTTP API(goctl)。
|
||||||
|
|
||||||
## 12. 與 Gateway HTTP 層的關係
|
## 12. 與 Gateway HTTP 層的關係
|
||||||
|
|
||||||
|
|
@ -358,13 +444,16 @@ handler(goctl 生成)→ response.Write
|
||||||
↓
|
↓
|
||||||
logic(goctl 生成框架,手寫映射)
|
logic(goctl 生成框架,手寫映射)
|
||||||
↓ 轉換 types ↔ usecase DTO
|
↓ 轉換 types ↔ usecase DTO
|
||||||
usecase(internal/model/{module}/usecase)
|
usecase 介面(internal/model/{module}/domain/usecase)
|
||||||
↓
|
↓
|
||||||
repository(internal/model/{module}/repository)
|
usecase 實作(internal/model/{module}/usecase)
|
||||||
|
↓
|
||||||
|
repository 實作(internal/model/{module}/repository)
|
||||||
|
↓ 實作 domain/repository 介面
|
||||||
↓
|
↓
|
||||||
MongoDB / Redis
|
MongoDB / Redis
|
||||||
```
|
```
|
||||||
|
|
||||||
- `internal/types`:HTTP 請求 / 回應型別,由 `.api` 生成。
|
- `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。
|
- 錯誤自 usecase 以 `*errs.Error` 往上冒泡;logic 原樣傳遞,handler 經 `response.Write` 輸出 8 碼 JSON。
|
||||||
|
|
|
||||||
|
|
@ -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 存取層
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1,3 +1,42 @@
|
||||||
|
# 預設:不需 Docker 即可啟動(僅 health API)
|
||||||
|
# 完整開發(Mongo + Redis + Notification):複製 gateway.dev.yaml 或 make run-dev
|
||||||
|
# 欄位說明:etc/README.md
|
||||||
|
|
||||||
Name: gateway
|
Name: gateway
|
||||||
Host: 0.0.0.0
|
Host: 0.0.0.0
|
||||||
Port: 8888
|
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
|
||||||
|
|
|
||||||
13
gateway.go
13
gateway.go
|
|
@ -4,8 +4,12 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"gateway/internal/config"
|
"gateway/internal/config"
|
||||||
"gateway/internal/handler"
|
"gateway/internal/handler"
|
||||||
|
|
@ -26,8 +30,13 @@ func main() {
|
||||||
server := rest.MustNewServer(c.RestConf)
|
server := rest.MustNewServer(c.RestConf)
|
||||||
defer server.Stop()
|
defer server.Stop()
|
||||||
|
|
||||||
ctx := svc.NewServiceContext(c)
|
sc := svc.NewServiceContext(c)
|
||||||
handler.RegisterHandlers(server, ctx)
|
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)
|
fmt.Printf("Starting server at %s:%d...\n", c.Host, c.Port)
|
||||||
server.Start()
|
server.Start()
|
||||||
|
|
|
||||||
17
go.mod
17
go.mod
|
|
@ -3,17 +3,27 @@ module gateway
|
||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/alicebob/miniredis/v2 v2.37.0
|
||||||
github.com/go-playground/locales v0.14.1
|
github.com/go-playground/locales v0.14.1
|
||||||
github.com/go-playground/universal-translator v0.18.1
|
github.com/go-playground/universal-translator v0.18.1
|
||||||
github.com/go-playground/validator/v10 v10.30.2
|
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/shopspring/decimal v1.4.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/zeromicro/go-zero v1.10.1
|
github.com/zeromicro/go-zero v1.10.1
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
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
|
google.golang.org/grpc v1.79.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // 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/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.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 v1.2.8 // indirect
|
||||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
|
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // 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/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/openzipkin/zipkin-go v0.4.3 // indirect
|
github.com/openzipkin/zipkin-go v0.4.3 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.3.0 // 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/scram v1.2.0 // indirect
|
||||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // 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/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace 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.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.uber.org/automaxprocs v1.6.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
|
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/net v0.51.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.42.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/api v0.0.0-20260128011058-8636f8732409 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc 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
|
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.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
19
go.sum
19
go.sum
|
|
@ -1,5 +1,17 @@
|
||||||
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
|
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/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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
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=
|
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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
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.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.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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
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/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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
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 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
|
||||||
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
|
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
|
||||||
gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=
|
gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,19 @@
|
||||||
|
|
||||||
package config
|
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 {
|
type Config struct {
|
||||||
rest.RestConf
|
rest.RestConf
|
||||||
|
Mongo mongo.Conf `json:",optional"`
|
||||||
|
Redis redis.RedisConf `json:",optional"`
|
||||||
|
Notification notifconfig.Config `json:",optional"`
|
||||||
|
Member memberconfig.Config `json:",optional"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -255,14 +255,15 @@ func (r *accountRepository) FindOne(ctx context.Context, id string) (*entity.Acc
|
||||||
| 欄位 | 說明 |
|
| 欄位 | 說明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `Schema` | `mongodb` 或 `mongodb+srv`(Atlas) |
|
| `Schema` | `mongodb` 或 `mongodb+srv`(Atlas) |
|
||||||
| `Host` | `host:port` 或 srv 的 host |
|
| `Host` | `host:port`;也可只寫 host 並用 `Port`(整數) |
|
||||||
|
| `Port` | 選用;`Host` 未含埠時會組成 `host:port` |
|
||||||
| `User` / `Password` | 會經 URL 編碼,勿手拼 URI |
|
| `User` / `Password` | 會經 URL 編碼,勿手拼 URI |
|
||||||
| `Database` | 傳入 `mon.NewModel` 的 db 名稱 |
|
| `Database` | 傳入 `mon.NewModel` 的 db 名稱 |
|
||||||
| `AuthSource` | 查詢參數 `authSource` |
|
| `AuthSource` | 查詢參數 `authSource` |
|
||||||
| `ReplicaName` | 查詢參數 `replicaSet` |
|
| `ReplicaName` | 查詢參數 `replicaSet` |
|
||||||
| `TLS` | 查詢參數 `tls=true` |
|
| `TLS` | 查詢參數 `tls=true` |
|
||||||
| `MaxPoolSize` / `MinPoolSize` / `MaxConnIdleTime` | client pool |
|
| `MaxPoolSize` / `MinPoolSize` / `MaxConnIdleTime` | client pool |
|
||||||
| `Compressors` | 預設 `zstd`、`snappy` |
|
| `Compressors` | 選用 YAML **陣列**(`["zstd","snappy"]`);勿寫單一字串。省略時程式預設 `zstd`、`snappy` |
|
||||||
| `ConnectTimeoutMs` | 啟動 Ping 逾時(預設 10s) |
|
| `ConnectTimeoutMs` | 啟動 Ping 逾時(預設 10s) |
|
||||||
|
|
||||||
尚未接到 `etc/gateway.yaml` 時,可在 `ServiceContext` 從環境變數或本地 yaml 填入 `Conf`。
|
尚未接到 `etc/gateway.yaml` 時,可在 `ServiceContext` 從環境變數或本地 yaml 填入 `Conf`。
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,18 +3,20 @@ package mongo
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// Conf is MongoDB client configuration for DocumentDB helpers.
|
// Conf is MongoDB client configuration for DocumentDB helpers.
|
||||||
|
// Use json tags for go-zero conf (see etc/gateway.yaml).
|
||||||
type Conf struct {
|
type Conf struct {
|
||||||
Schema string
|
Schema string `json:",default=mongodb"`
|
||||||
User string
|
User string `json:",optional"`
|
||||||
Password string
|
Password string `json:",optional"`
|
||||||
Host string
|
Host string `json:",optional"`
|
||||||
Database string
|
Port int `json:",optional"` // if Host has no ":port", appended in buildConnectionURI
|
||||||
AuthSource string
|
Database string `json:",optional"`
|
||||||
ReplicaName string
|
AuthSource string `json:",optional"`
|
||||||
TLS bool
|
ReplicaName string `json:",optional"`
|
||||||
MaxPoolSize uint64
|
TLS bool `json:",optional"`
|
||||||
MinPoolSize uint64
|
MaxPoolSize uint64 `json:",optional"`
|
||||||
MaxConnIdleTime time.Duration
|
MinPoolSize uint64 `json:",optional"`
|
||||||
Compressors []string
|
MaxConnIdleTime time.Duration `json:",optional"`
|
||||||
ConnectTimeoutMs int64
|
Compressors []string `json:",optional"`
|
||||||
|
ConnectTimeoutMs int64 `json:",optional"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package mongo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -10,13 +11,17 @@ func buildConnectionURI(c Conf) (string, error) {
|
||||||
if scheme == "" {
|
if scheme == "" {
|
||||||
scheme = "mongodb"
|
scheme = "mongodb"
|
||||||
}
|
}
|
||||||
if c.Host == "" {
|
host := c.Host
|
||||||
|
if host == "" {
|
||||||
return "", fmt.Errorf("mongo: host is required")
|
return "", fmt.Errorf("mongo: host is required")
|
||||||
}
|
}
|
||||||
|
if c.Port > 0 && !hostHasPort(host) {
|
||||||
|
host = fmt.Sprintf("%s:%d", host, c.Port)
|
||||||
|
}
|
||||||
|
|
||||||
u := &url.URL{
|
u := &url.URL{
|
||||||
Scheme: scheme,
|
Scheme: scheme,
|
||||||
Host: c.Host,
|
Host: host,
|
||||||
}
|
}
|
||||||
if c.User != "" {
|
if c.User != "" {
|
||||||
u.User = url.UserPassword(c.User, c.Password)
|
u.User = url.UserPassword(c.User, c.Password)
|
||||||
|
|
@ -39,6 +44,14 @@ func buildConnectionURI(c Conf) (string, error) {
|
||||||
return u.String(), nil
|
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 {
|
func redactConnectionURI(uri string) string {
|
||||||
u, err := url.Parse(uri)
|
u, err := url.Parse(uri)
|
||||||
if err != nil || u.User == nil {
|
if err != nil || u.User == nil {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Redis(Gateway 共用連線)
|
||||||
|
|
||||||
|
## 用途
|
||||||
|
|
||||||
|
- 在 **`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 |
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 存取層
|
||||||
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 ¬ificationRepository{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
|
||||||
|
}
|
||||||
|
|
@ -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 ¬ificationDLQRepository{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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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, "<script>")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
CloudEP security code {{.code}}, valid for {{.expires_in}} seconds.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
CloudEP 安全驗證碼 {{.code}},{{.expires_in}} 秒內有效。
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
CloudEP code {{.code}}, valid for {{.expires_in}} seconds.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
CloudEP 驗證碼 {{.code}},{{.expires_in}} 秒內有效。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue