diff --git a/Makefile b/Makefile index 77989ee..40026a2 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,8 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 .DEFAULT_GOAL := help -.PHONY: help tools gen-api build-go-doc gen-doc test fmt lint lint-fix fix check run +.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test fmt lint lint-fix fix check run \ + deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index run-local help: ## 顯示可用指令 @echo "Gateway Makefile" @@ -30,7 +31,10 @@ help: ## 顯示可用指令 tools: ## 安裝 goctl、goimports、golangci-lint(需 Go,且 GOPATH/bin 在 PATH) @command -v $(GOCTL) >/dev/null 2>&1 || (echo ">> installing goctl" && $(GO) install $(GOCTL_PKG)) @command -v goimports >/dev/null 2>&1 || (echo ">> installing goimports" && $(GO) install golang.org/x/tools/cmd/goimports@latest) - @command -v golangci-lint >/dev/null 2>&1 || (echo ">> installing golangci-lint" && $(GO) install $(GOLANGCI_PKG)) + @if ! command -v golangci-lint >/dev/null 2>&1 || ! golangci-lint version 2>/dev/null | grep -q 'version 2\.'; then \ + echo ">> installing golangci-lint v2"; \ + $(GO) install $(GOLANGCI_PKG); \ + fi @echo "tools OK" @echo " goctl: $$(goctl --version 2>/dev/null || echo missing)" @echo " golangci-lint: $$(golangci-lint version 2>/dev/null | head -1 || echo missing)" @@ -38,6 +42,9 @@ tools: ## 安裝 goctl、goimports、golangci-lint(需 Go,且 GOPATH/bin 在 gen-api: tools ## 由 .api 生成 handler / logic / types(自訂 handler 模板) $(GOCTL) api go -api $(API_ENTRY) -dir . -style $(GO_ZERO_STYLE) -home generate/goctl +gen-mock: ## 依 go:generate 產生 internal/model/*/mock(gomock) + $(GO) generate ./internal/model/... + build-go-doc: ## 編譯 go-doc(OpenAPI 文件生成器) @echo ">> building $(GO_DOC_BIN)" @mkdir -p $(GO_DOC_DIR)/bin @@ -55,17 +62,41 @@ fmt: ## gofmt + goimports(不含 lint) $(GOFMT) -s -w $(GOFILES) @command -v goimports >/dev/null 2>&1 && goimports -w . || (echo "goimports not found; run: make tools" && exit 1) -lint: ## golangci-lint 靜態檢查(先 make tools) - @command -v golangci-lint >/dev/null 2>&1 || (echo "golangci-lint not found; run: make tools" && exit 1) +lint: tools ## golangci-lint 靜態檢查 golangci-lint run ./... -lint-fix: ## 自動修正可修的 lint / formatter 問題(見 .golangci.yml) - @command -v golangci-lint >/dev/null 2>&1 || (echo "golangci-lint not found; run: make tools" && exit 1) +lint-fix: tools ## 自動修正可修的 lint / formatter 問題(見 .golangci.yml) golangci-lint run --fix ./... fix: fmt lint-fix lint ## 格式化 + 自動修 lint + 再檢查(提交前建議) check: fix test ## 提交 / PR 前完整檢查(fmt、lint、test) -run: ## 啟動 Gateway(etc/gateway.yaml) +run: ## 啟動 Gateway(etc/gateway.yaml,無需 Docker) $(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 diff --git a/README.md b/README.md index b20e435..071e087 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,18 @@ make run # 或: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): ```bash @@ -79,7 +91,11 @@ curl -s http://127.0.0.1:8888/api/v1/health | jq | `make lint-fix` | 自動修正可修的 lint / import 問題 | | `make fix` | `fmt` + `lint-fix` + `lint`(提交前建議) | | `make check` | `fix` + `test`(PR / AI 完成前必跑) | -| `make run` | 啟動 Gateway | +| `make run` | 啟動 Gateway(無 DB) | +| `make deps-up` | Docker 啟動 Mongo + Redis | +| `make run-dev` | 啟動 Gateway(`etc/gateway.dev.yaml`,需 Docker) | +| `make config-check` | 驗證 yaml 可載入 | +| `make mongo-index` | 建立 notification Mongo 索引 | ## 專案結構 diff --git a/cmd/mongo-index/main.go b/cmd/mongo-index/main.go new file mode 100644 index 0000000..3e8d83a --- /dev/null +++ b/cmd/mongo-index/main.go @@ -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") +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..acccd16 --- /dev/null +++ b/deploy/README.md @@ -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`) | diff --git a/deploy/mongo/init/01-gateway-indexes.js b/deploy/mongo/init/01-gateway-indexes.js new file mode 100644 index 0000000..82ff7b3 --- /dev/null +++ b/deploy/mongo/init/01-gateway-indexes.js @@ -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.'); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..22d05ce --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/identity-member-design.md b/docs/identity-member-design.md index b5fafdc..d235802 100644 --- a/docs/identity-member-design.md +++ b/docs/identity-member-design.md @@ -1,244 +1,244 @@ -# Identity / Member / Permission 模組設計草稿 +# Identity / Member / Permission Ҳճ]pZ -> **狀態**:Draft(待 Review) -> **適用專案**:Portal API Gateway(PGW) -> **參考實作**:[app-cloudep-permission-server](https://code.30cm.net/digimon/app-cloudep-permission-server)(Casbin RBAC、Permission Tree、Role/RolePermission) -> **最後更新**:2026-05-19 -> **前提**:全新 Gateway module,不考慮舊版 member-server 遷移。 +> **A**GDraft] Review^ +> **AαM**GPortal API Gateway]PGW^ +> **Ѧҹ@**G[app-cloudep-permission-server](https://code.30cm.net/digimon/app-cloudep-permission-server)]Casbin RBACBPermission TreeBRole/RolePermission^ +> **̫s**G2026-05-19 +> **e**Gs Gateway moduleAҼ{ª member-server EC -本文件描述 Gateway 內 **auth**、**member**、**permission** 三個業務模組的目標架構,整合 **ZITADEL**(身份)、**LDAP**(企業目錄)、**SCIM 2.0**(企業 provisioning),支援 **多租戶** 與 **百萬級會員**(含單租戶 50 萬)。 +yz Gateway **auth**B**member**B**permission** Tӷ~ȼҲժؼЬ[cAX **ZITADEL**]^B**LDAP**]~ؿ^B**SCIM 2.0**]~ provisioning^A䴩 **h** P **ʸUŷ|**]t毲 50 U^C -模組分層與程式碼撰寫規範見 [model.md](./model.md)。 +ҲդhP{XgWd [model.md](./model.md)C --- -## 目錄 +## ؿ -1. [設計目標與原則](#1-設計目標與原則) -2. [模組全景](#2-模組全景) -3. [外部系統分工](#3-外部系統分工) -4. [auth 模組](#4-auth-模組) -5. [member 模組](#5-member-模組) -6. [permission 模組(B2B 自定義)](#6-permission-模組b2b-自定義) -7. [API 規劃](#7-api-規劃) -8. [Middleware 鏈](#8-middleware-鏈) -9. [核心流程](#9-核心流程) -10. [LDAP 與 SCIM](#10-ldap-與-scim) +1. []pؼлPh](#1-]pؼлPh) +2. [Ҳե](#2-Ҳե) +3. [~tΤu](#3-~tΤu) +4. [auth Ҳ](#4-auth-Ҳ) +5. [member Ҳ](#5-member-Ҳ) +6. [permission Ҳա]B2B ۩wq^](#6-permission-Ҳb2b-۩wq) +7. [API W](#7-api-W) +8. [Middleware ](#8-middleware-) +9. [֤߬y{](#9-֤߬y{) +10. [LDAP P SCIM](#10-ldap-P-scim) 11. [Notification Module](#11-notification-module) -12. [可讀 UID 設計](#12-可讀-uid-設計已決策) -13. [資料模型與索引](#13-資料模型與索引) -14. [Redis Key 命名](#14-redis-key-命名) -15. [規模與性能(100 萬+ / 單租戶 50 萬)](#15-規模與性能100-萬--單租戶-50-萬) -16. [目錄結構](#16-目錄結構) -17. [設定檔](#17-設定檔) -18. [實施順序](#18-實施順序) -19. [已決策事項](#19-已決策事項) -20. [Audit Log 與 Rate Limit](#20-audit-log-與-rate-limit) +12. [iŪ UID ]p](#12-iŪ-uid-]pwM) +13. [ƼҫP](#13-ƼҫP) +14. [Redis Key RW](#14-redis-key-RW) +15. [WһPʯ]100 U+ / 毲 50 U^](#15-WһPʯ100-U--毲-50-U) +16. [ؿc](#16-ؿc) +17. []w](#17-]w) +18. [I](#18-I) +19. [wMƶ](#19-wMƶ) +20. [Audit Log P Rate Limit](#20-audit-log-P-rate-limit) --- -## 1. 設計目標與原則 +## 1. ]pؼлPh -### 1.1 目標 +### 1.1 ؼ -| 目標 | 說明 | +| ؼ | | |------|------| -| 統一身份 | ZITADEL 作為 IdP(含 LDAP IdP、Social Login) | -| 業務會員 | Gateway `member` 模組管理 tenant-scoped profile | -| 細粒度授權 | Gateway `permission` 模組(**Casbin RBAC + Permission Tree**);**每個 B2B 租戶可自定義 Role 並勾選 Permission** | -| Token | go-zero JWT 驗證 + Redis 黑名單(只黑名單 JWT) | -| 企業整合 | SCIM 2.0 + LDAP Directory Sync(AD + OpenLDAP) | -| 規模 | 全平台 100 萬+ 會員;單租戶可達 50 萬 | -| UID | 人類可讀、帶租戶前綴,如 `AMEX-10000000`;唯一性以 `tenant_id + uid` 為準 | +| Τ@ | ZITADEL @ IdP]t LDAP IdPBSocial Login^ | +| ~ȷ| | Gateway `member` Ҳպ޲z tenant-scoped profile | +| Ӳɫױv | Gateway `permission` Ҳա]**Casbin RBAC + Permission Tree**^F**C B2B i۩wq Role äĿ Permission** | +| Token | go-zero JWT + Redis ¦W]u¦W JWT^ | +| ~X | SCIM 2.0 + LDAP Directory Sync]AD + OpenLDAP^ | +| W | x 100 U+ |F毲iF 50 U | +| UID | HiŪBaeAp `AMEX-10000000`Fߤ@ʥH `tenant_id + uid` | -### 1.2 核心原則 +### 1.2 ֤߭h -1. **職責分離** - - `auth`:你是誰(Authentication) - - `member`:你的業務資料是什麼(Profile) - - `permission`:你能做什麼(Authorization) +1. **¾d** + - `auth`GAO֡]Authentication^ + - `member`GA~ȸƬO]Profile^ + - `permission`GAవ]Authorization^ -2. **LDAP 不做登入 bind** - - 登入驗證由 ZITADEL LDAP IdP 處理 - - Gateway 的 LDAP client 僅供 Directory Sync(read-only) +2. **LDAP nJ bind** + - nJҥ ZITADEL LDAP IdP Bz + - Gateway LDAP client Ȩ Directory Sync]read-only^ 3. **Token Exchange** - - 對外 API 只接受 Gateway 簽發的 CloudEP JWT - - ZITADEL OIDC token 僅在 `/auth/token/exchange` 使用一次 + - ~ API u Gateway ño CloudEP JWT + - ZITADEL OIDC token Ȧb `/auth/token/exchange` ϥΤ@ -4. **租戶隔離** - - 所有持久化資料以 `tenant_id` 為邊界 - - JWT `tenant_id` 與請求資源必須一致 +4. **j** + - Ҧ[ƸƥH `tenant_id` + - JWT `tenant_id` PШD귽@P -5. **B2B 權限自定義**(參考 [app-cloudep-permission-server](https://code.30cm.net/digimon/app-cloudep-permission-server)) - - 平台 seed 全局 Permission Tree(含 `http_path` / `http_method`) - - 租戶建立自訂 Role,從 Tree **勾選** Permission(`RolePermission` + 自動補 parent) - - API 授權由 **Casbin** 比對 `(tenant_id, role_key, path, method)`,避免不同租戶同名角色互相污染 - - B2C 租戶**唯讀** seed 模板,**不可**自定義 Role(已決策) +5. **B2B v۩wq**]Ѧ [app-cloudep-permission-server](https://code.30cm.net/digimon/app-cloudep-permission-server)^ + - x seed Permission Tree]t `http_path` / `http_method`^ + - إߦۭq RoleAq Tree **Ŀ** Permission]`RolePermission` + ۰ʸ parent^ + - API v **Casbin** `(tenant_id, role_key, path, method)`AקKPPW⤬ۦìV + - B2C **Ū** seed ҪOA**i**۩wq Role]wM^ -6. **身份驗證 vs 業務驗證分層**(已決策) - - **ZITADEL = 身份級驗證**:登入 MFA(TOTP / WebAuthn / SMS)、註冊 email 驗證、忘記密碼、帳號鎖定 - - **Gateway member = 業務級驗證**:業務 email / phone 綁定 OTP、Step-up MFA - - Gateway **不**依賴 `ZITADEL email_verified` 當業務守門條件;Logic 層改讀 `BusinessEmailVerified` 等 member 旗標 - - **Email / SMS OTP 由 Gateway 自送**(不轉 ZITADEL Notification) - - **MFA 強制策略**:admin 級 role 由 ZITADEL Org Policy 強制 TOTP;一般 user 預設不強制,但高風險操作走 Gateway Step-up +6. ** vs ~Ҥh**]wM^ + - **ZITADEL = **GnJ MFA]TOTP / WebAuthn / SMS^BU email ҡBѰOKXBbw + - **Gateway member = ~ȯ**G~ email / phone jw OTPBStep-up MFA + - Gateway ****̿ `ZITADEL email_verified` ~ȦuFLogic hŪ `BusinessEmailVerified` member X + - **Email / SMS OTP Gateway ۰e**] ZITADEL Notification^ + - **MFA j**Gadmin role ZITADEL Org Policy j TOTPF@ user w]jAIާ@ Gateway Step-up --- -## 2. 模組全景 +## 2. Ҳե ``` -┌─────────────────────────────────────────────────────────────────┐ -│ Portal API Gateway (go-zero) │ -├─────────────────────────────────────────────────────────────────┤ -│ generate/api/ │ -│ auth.api · member.api · permission.api · tenant.api · scim.api│ -├─────────────────────────────────────────────────────────────────┤ -│ internal/middleware/ │ -│ jwt_revoke · casbin_rbac · scim_auth · tenant_context │ -├─────────────────────────────────────────────────────────────────┤ -│ internal/model/ │ -│ auth/ → Token 簽發、換票、登出、黑名單、auth_gen、step-up│ -│ member/ → Profile、Identity、Tenant、UID、Sync、TOTP、驗證│ -│ permission/ → Casbin RBAC、Permission Tree、Role(B2B 自定義)│ -│ notification/ → Email/SMS/Push 統一發送、模板、重試、audit │ -├─────────────────────────────────────────────────────────────────┤ -│ internal/library/ │ -│ zitadel/ · ldap/ · uid/ · casbin/ │ -│ notification/email · notification/sms · notification/push │ -├─────────────────────────────────────────────────────────────────┤ -│ internal/worker/ │ -│ directory_sync/ · notification_retry/ · member_anonymize/ │ -└─────────────────────────────────────────────────────────────────┘ - │ │ │ - ▼ ▼ ▼ +zwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww{ +x Portal API Gateway (go-zero) x +uwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwt +x generate/api/ x +x auth.api P member.api P permission.api P tenant.api P scim.apix +uwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwt +x internal/middleware/ x +x jwt_revoke P casbin_rbac P scim_auth P tenant_context x +uwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwt +x internal/model/ x +x auth/ Token ñoBBnXB¦WBauth_genBstep-upx +x member/ ProfileBIdentityBTenantBUIDBSyncBTOTPBҢx +x permission/ Casbin RBACBPermission TreeBRole]B2B ۩wq^x +x notification/ Email/SMS/Push Τ@oeBҪOBաBaudit x +uwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwt +x internal/library/ x +x zitadel/ P ldap/ P uid/ P casbin/ x +x notification/email P notification/sms P notification/push x +uwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwt +x internal/worker/ x +x directory_sync/ P notification_retry/ P member_anonymize/ x +|wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww} + x x x + MongoDB Redis ZITADEL (profile/role) (cache/blacklist) (identity/LDAP IdP) + Email / SMS Provider ``` -### 2.1 模組依賴方向 +### 2.1 Ҳը̿V ``` -handler → logic → model/{auth|member|permission|notification}/usecase(interface) - ↓ - repository → MongoDB / Redis +handler logic model/{auth|member|permission|notification}/usecase]interface^ + + repository MongoDB / Redis -logic 不 import entity / repository(見 model.md) +logic import entity / repository] model.md^ -auth → member(EnsureFromOIDC / EnsureFromLDAP / EnsureFromSCIM) -auth → permission(SyncRolesFromClaims) -auth → member.TOTPUseCase(step-up TOTP 驗證) -member → auth(停權時 RevokeAllForUser) -member → notification(業務驗證 / step-up OTP 寄送) -permission → member(可選:驗證 uid 存在) -notification → library/notification/{email,sms,push}(provider 實作) +auth member]EnsureFromOIDC / EnsureFromLDAP / EnsureFromSCIM^ +auth permission]SyncRolesFromClaims^ +auth member.TOTPUseCase]step-up TOTP ҡ^ +member auth]v RevokeAllForUser^ +member notification]~ / step-up OTP He^ +permission member]iG uid sb^ +notification library/notification/{email,sms,push}]provider @^ ``` --- -## 3. 外部系統分工 +## 3. ~tΤu -| 能力 | ZITADEL | Gateway auth | Gateway member | Gateway permission | Gateway notification | +| O | ZITADEL | Gateway auth | Gateway member | Gateway permission | Gateway notification | |------|---------|--------------|----------------|-------------------|----------------------| -| 註冊 / 登入(OIDC / LDAP / SCIM) | ✅ | 換票 | EnsureFromOIDC/LDAP/SCIM | SyncRoles | — | -| 平台原生註冊(未來,含 email OTP) | (local user)| — | LifecycleUseCase + OTPUseCase | — | 寄 OTP | -| 密碼 / 身份 MFA / 忘記密碼 | ✅ | — | — | — | — | -| 身份 MFA 強制策略 | ✅ Org Policy | — | — | — | — | -| Google / LINE / Apple | ✅ IdP | — | — | — | — | -| LDAP 登入 | ✅ LDAP IdP | — | — | Group→Role 映射 | — | -| Access / Refresh Token(對外) | — | ✅ CloudEP JWT | — | — | — | -| Step-up Token(高風險操作) | — | ✅ 簽 step_up_token | OTP / TOTP 驗證 | Logic 守門 | OTP 寄送 | -| 業務 TOTP(Authenticator) | — | — | ✅ secret 加密儲存 + 驗證 | — | — | -| JWT 黑名單 | — | ✅ Redis | — | — | — | -| 業務 UID | — | — | ✅ | — | — | -| Profile | — | — | ✅ | — | — | -| 業務 Email / Phone 驗證 | — | — | ✅ Verification 流程 | — | ✅ OTP 寄送 | -| Email / SMS / Push 發送 | — | — | — | — | ✅ 統一入口 + 模板 + 重試 | -| 會員列表 / 狀態 | — | — | ✅ | 需授權 | 變更通知(異步) | -| API 細粒度權限 | 粗粒度 Role | — | — | **Casbin RBAC**(path + method) | — | -| SCIM Users/Groups | 可同步 | — | ✅ 業務寫入 | ✅ Group→Role | — | -| LDAP Directory Sync | — | — | ✅ Worker | ✅ Group→Role | 同步異常告警 | +| U / nJ]OIDC / LDAP / SCIM^ | ? | | EnsureFromOIDC/LDAP/SCIM | SyncRoles | X | +| x͵U]ӡAt email OTP^ | ]local user^| X | LifecycleUseCase + OTPUseCase | X | H OTP | +| KX / MFA / ѰOKX | ? | X | X | X | X | +| MFA j | ? Org Policy | X | X | X | X | +| Google / LINE / Apple | ? IdP | X | X | X | X | +| LDAP nJ | ? LDAP IdP | X | X | GroupRole Mg | X | +| Access / Refresh Token]~^ | X | ? CloudEP JWT | X | X | X | +| Step-up Token]Iާ@^ | X | ? ñ step_up_token | OTP / TOTP | Logic u | OTP He | +| ~ TOTP]Authenticator^ | X | X | ? secret [Kxs + | X | X | +| JWT ¦W | X | ? Redis | X | X | X | +| ~ UID | X | X | ? | X | X | +| Profile | X | X | ? | X | X | +| ~ Email / Phone | X | X | ? Verification y{ | X | ? OTP He | +| Email / SMS / Push oe | X | X | X | X | ? Τ@Jf + ҪO + | +| |C / A | X | X | ? | ݱv | ܧq]B^ | +| API Ӳɫv | ʲɫ Role | X | X | **Casbin RBAC**]path + method^ | X | +| SCIM Users/Groups | iPB | X | ? ~ȼgJ | ? GroupRole | X | +| LDAP Directory Sync | X | X | ? Worker | ? GroupRole | PB`iĵ | -### 3.1 多租戶對應 +### 3.1 h ``` -1 CloudEP Tenant = 1 ZITADEL Organization = 1 資料隔離邊界 +1 CloudEP Tenant = 1 ZITADEL Organization = 1 ƹj ``` -| 欄位 | 來源 | 用途 | +| | ӷ | γ~ | |------|------|------| -| `tenant_id` | ZITADEL `org_id` | 分片鍵、授權邊界 | -| `identity_id` | ZITADEL `sub` | 身份映射 | -| `uid` | Member 模組產生 | 業務會員 ID(如 `AMEX-10000000`) | +| `tenant_id` | ZITADEL `org_id` | Bv | +| `identity_id` | ZITADEL `sub` | Mg | +| `uid` | Member Ҳղ | ~ȷ| ID]p `AMEX-10000000`^ | -#### Tenant 建立順序(已決策:Gateway 先建草稿) +#### Tenant إ߶ǡ]wMGGateway دZ^ ``` 1. POST /api/v1/admin/tenants { slug, uid_prefix, type, ... } - → Mongo upsert tenants {status: "provisioning", org_id: ""} + Mongo upsert tenants {status: "provisioning", org_id: ""} 2. ZITADEL Mgmt.CreateOrganization(name=slug) - → 拿到 org_id + org_id 3. UPDATE tenants {org_id, status: "active"} -4. seed 預設 Role + Casbin policy reload -5. 回傳 tenant payload -失敗補償: -- 步驟 2 失敗 → status = "failed",cron 重試(指數退避,3 次後人工介入) -- 步驟 3 失敗 → status = "orphan_zitadel_org",cron 偵測並補綁 +4. seed w] Role + Casbin policy reload +5. ^ tenant payload +ѸvG +- BJ 2 status = "failed"Acron ա]ưhסA3 HuJ^ +- BJ 3 status = "orphan_zitadel_org"Acron øɸj ``` -> Saga 風格:Gateway 為主、ZITADEL 為從;補償 cron 每 5 分鐘掃 `status in ("failed", "orphan_zitadel_org")` 重試或告警。 +> Saga GGateway DBZITADEL qFv cron C 5 `status in ("failed", "orphan_zitadel_org")` թΧiĵC -### 3.2 租戶類型 +### 3.2 -| 類型 | 登入 | LDAP | 權限 | +| | nJ | LDAP | v | |------|------|------|------| -| **B2C** | Email / Social | 無 | 系統預設 Role(不可或不常自定義) | -| **B2B** | ZITADEL → LDAP IdP | 有 | **完全自定義 Role + Permission** | -| **Hybrid** | Social + LDAP | 有 | B2B 自定義;外部客戶用 B2C 唯讀模板 | +| **B2C** | Email / Social | L | tιw] Role]iΤ`۩wq^ | +| **B2B** | ZITADEL LDAP IdP | | **۩wq Role + Permission** | +| **Hybrid** | Social + LDAP | | B2B ۩wqF~Ȥ B2C ŪҪO | -### 3.3 ZITADEL 部署(已決策:Self-hosted) +### 3.3 ZITADEL p]wMGSelf-hosted^ -- **部署方式**:Self-hosted(自建),與 Gateway / Mongo / Redis 同環境或同 VPC -- **LDAP 網路**:ZITADEL 實例需能直連企業 AD / OpenLDAP(常見:VPN、專線、或 DMZ 轉發) -- **Management API / JWKS**:Gateway 透過內網 URL 存取,不經公網 -- **設定**:`etc/gateway.yaml` 的 `Zitadel.Issuer` / `MgmtURL` 指向 self-hosted 端點 +- **p覡**GSelf-hosted]۫ء^AP Gateway / Mongo / Redis PҩΦP VPC +- **LDAP **GZITADEL һݯઽs~ AD / OpenLDAP]`GVPNBMuB DMZ o^ +- **Management API / JWKS**GGateway zL URL sAg +- **]w**G`etc/gateway.yaml` `Zitadel.Issuer` / `MgmtURL` V self-hosted I -### 3.4 註冊路徑(已決策:不提供 Gateway 註冊 API) +### 3.4 U|]wMG Gateway U API^ -Gateway **不暴露** `/auth/register`。註冊由下列路徑完成: +Gateway **S** `/auth/register`CUѤUC|G -| 租戶類型 | 註冊路徑 | 首次登入副作用 | +| | U| | nJƧ@ | |---------|----------|----------------| -| **B2C** | ZITADEL Hosted Register UI(或前端走 ZITADEL OIDC PKCE) | token exchange 觸發 `EnsureFromOIDC` JIT | -| **B2B(LDAP)** | 由 IT 在 AD / OpenLDAP 建帳;可選 Directory Sync 預 provision 到 ZITADEL | LDAP IdP 登入觸發 `EnsureFromLDAP` JIT | -| **B2B(SCIM)** | HR / Okta / Entra 推 SCIM Create User | SCIM endpoint 寫 ZITADEL + Gateway(不需 JIT) | +| **B2C** | ZITADEL Hosted Register UI]Ϋeݨ ZITADEL OIDC PKCE^ | token exchange IJo `EnsureFromOIDC` JIT | +| **B2B]LDAP^** | IT b AD / OpenLDAP رbFi Directory Sync w provision ZITADEL | LDAP IdP nJIJo `EnsureFromLDAP` JIT | +| **B2B]SCIM^** | HR / Okta / Entra SCIM Create User | SCIM endpoint g ZITADEL + Gateway] JIT^ | -> ZITADEL 內建 email 驗證已完成「**可登入**」門檻;業務上「**可使用功能**」門檻見 §5.4 業務驗證。 +> ZITADEL email Ҥwu**inJ**veF~ȤWu**iϥΥ\**ve 5.4 ~ҡC -### 3.5 平台 MFA 強制(已決策) +### 3.5 x MFA j]wM^ -- ZITADEL Org Policy 設定:**任何 admin 級 role**(`tenant_owner` / `tenant_admin` / `platform_super_admin`)登入時強制 TOTP / WebAuthn -- 一般 user 預設不強制(避免 B2C 流失) -- 高風險業務操作 → 走 Gateway Step-up MFA(§5.6),與 ZITADEL 身份 MFA **互不取代** +- ZITADEL Org Policy ]wG** admin role**]`tenant_owner` / `tenant_admin` / `platform_super_admin`^nJɱj TOTP / WebAuthn +- @ user w]j]קK B2C y^ +- I~Ⱦާ@ Gateway Step-up MFA]5.6^AP ZITADEL MFA **N** --- -## 4. auth 模組 +## 4. auth Ҳ -路徑:`internal/model/auth/` +|G`internal/model/auth/` -### 4.1 職責 +### 4.1 ¾d -- 驗證 ZITADEL OIDC token(id_token / authorization_code + PKCE) -- 編排 `member.EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM` 與 `permission.SyncRolesFromClaims` -- 簽發 CloudEP JWT(access + refresh) -- **簽發 Step-up Token**(高風險操作用,短壽命 5min;見 §5.6) -- 登出:jti 黑名單 -- 批量失效:`auth_gen`(停權 / 改密碼 / 權限強制刷新) +- ZITADEL OIDC token]id_token / authorization_code + PKCE^ +- s `member.EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM` P `permission.SyncRolesFromClaims` +- ño CloudEP JWT]access + refresh^ +- **ño Step-up Token**]Iާ@ΡAuةR 5minF 5.6^ +- nXGjti ¦W +- qġG`auth_gen`]v / KX / vjs^ -### 4.2 UseCase 介面 +### 4.2 UseCase ```go type TokenUseCase interface { @@ -251,7 +251,7 @@ type TokenUseCase interface { type StepUpTokenUseCase interface { Issue(ctx context.Context, tenantID, uid, action string) (stepUpToken string, err error) Verify(ctx context.Context, token, expectedAction, tenantID, uid string) (jti string, err error) - MarkUsed(ctx context.Context, jti string) error // 單次性 + MarkUsed(ctx context.Context, jti string) error // 榸 } ``` @@ -259,32 +259,32 @@ type StepUpTokenUseCase interface { ```go type Claims struct { - jwt.RegisteredClaims // 含 jti, exp, iat + jwt.RegisteredClaims // t jti, exp, iat TenantID string `json:"tenant_id"` UID string `json:"uid"` Typ string `json:"typ"` // access | refresh | step_up - AuthGen int64 `json:"auth_gen"` // 批量失效代號(簽發時 = redis.GET 當前值;不存在視為 0) - Action string `json:"action,omitempty"` // typ=step_up 時必填,鎖定允許執行的高風險 action + AuthGen int64 `json:"auth_gen"` // qĥN]ño = redis.GET eȡFsb 0^ + Action string `json:"action,omitempty"` // typ=step_up ɥAw\檺I action } ``` -> **JWT 內不放 role / permission 快照**。Middleware 每次從 `perm:user_roles:{tenant_id}:{uid}` cache 讀取當前 role keys 再 enforce;避免「改名 / 撤角 / 變更權限」後舊 token 還能用。 -> 角色變更立即生效靠 `auth_gen` + cache invalidate;不依賴 token 內容。 +> **JWT role / permission ַ**CMiddleware Cq `perm:user_roles:{tenant_id}:{uid}` cache Ūe role keys A enforceFקKuW / M / ܧvv token ٯΡC +> ܧߧYͮľa `auth_gen` + cache invalidateF̿ token eC -### 4.4 JWT 設定(go-zero)+ Secret Rotation(已決策) +### 4.4 JWT ]w]go-zero^+ Secret Rotation]wM^ ```yaml Auth: - AccessExpire: 900 # 15 分鐘 - ActiveKID: v2 # 當前簽發用 kid - Keys: # 驗證可接受的 kid 名單(含正在退役的) + AccessExpire: 900 # 15 + ActiveKID: v2 # eño kid + Keys: # ҥi kid W]tbhЪ^ - kid: v1 Secret: ${JWT_ACCESS_SECRET_V1} - kid: v2 Secret: ${JWT_ACCESS_SECRET_V2} RefreshAuth: - AccessExpire: 604800 # 7 天 + AccessExpire: 604800 # 7 ActiveKID: v2 Keys: - kid: v1 @@ -300,212 +300,212 @@ StepUp: Secret: ${JWT_STEPUP_SECRET_V1} ``` -**Rotation 流程:** +**Rotation y{G** ``` -1. 新增 v(N+1) key 到 Keys(不改 ActiveKID)→ rolling deploy -2. 切 ActiveKID = v(N+1) → 新 token 用新 kid 簽;舊 kid token 仍可驗 -3. 等舊 token 全部過期(access 15min / refresh 7d) -4. 從 Keys 移除舊 kid → rolling deploy +1. sW v(N+1) key Keys] ActiveKID^ rolling deploy +2. ActiveKID = v(N+1) s token ηs kid ñF kid token i +3. token L]access 15min / refresh 7d^ +4. q Keys kid rolling deploy ``` -- JWT header 必帶 `kid`,驗證時依 `kid` 找 secret;找不到 → `401 invalid_kid` -- go-zero 內建 JWT middleware 僅吃單 secret,**自寫 `JwtMultiKeyMiddleware`** 取代或前置(在 `JwtRevokeMiddleware` 之前) -- ZITADEL Token Exchange、Step-up 共用此架構 +- JWT header a `kid`AҮɨ `kid` secretF䤣 `401 invalid_kid` +- go-zero JWT middleware ȦY secretA**ۼg `JwtMultiKeyMiddleware`** NΫem]b `JwtRevokeMiddleware` e^ +- ZITADEL Token ExchangeBStep-up @Φ[c -`.api` 受保護路由: +`.api` O@ѡG ```api @server(jwt: Auth, middleware: JwtMultiKeyMiddleware,JwtRevokeMiddleware) ``` -### 4.5 黑名單策略(只黑名單 JWT) +### 4.5 ¦W浦]u¦W JWT^ -#### Issue Token Pair 時記對應(讓 logout 不必帶 refresh) +#### Issue Token Pair ɰO] logout a refresh^ ``` SET auth:jwt:pair:{access_jti} = refresh_jti TTL = access TTL SET auth:jwt:pair:{refresh_jti} = access_jti TTL = refresh TTL ``` -#### 單 Token 撤銷(登出) +#### Token MP]nX^ ``` Key: auth:jwt:bl:{jti} Value: 1 -TTL: token 剩餘有效時間(exp - now) +TTL: token ѾlĮɶ]exp - now^ ``` ``` POST /auth/logout (Bearer access_jwt) - 1. 解 access_jti → SET auth:jwt:bl:{access_jti} - 2. GET auth:jwt:pair:{access_jti} → refresh_jti(若存在) + 1. access_jti SET auth:jwt:bl:{access_jti} + 2. GET auth:jwt:pair:{access_jti} refresh_jti]Ysb^ 3. SET auth:jwt:bl:{refresh_jti} 4. DEL auth:jwt:pair:{access_jti} / auth:jwt:pair:{refresh_jti} ``` -#### Refresh Token 輪換(已決策)+ Reuse Detection +#### Refresh Token ]wM^+ Reuse Detection ``` POST /auth/token/refresh - 1. 驗證 refresh_jwt(typ=refresh、未過期、auth_gen 有效) - 2. 若 refresh_jti 已在黑名單: - 視為被竊或重放 → INCR auth:gen:{tenant_id}:{uid}(撤銷整條 chain) - 回 401,並寫 audit log - 3. 簽發新 access_jwt + 新 refresh_jwt(新 jti) - 4. 黑舊 refresh_jti;若舊 access 對應 jti 仍未過期,一併黑名單 - 5. 寫入新的 auth:jwt:pair + 1. refresh_jwt]typ=refreshBLBauth_gen ġ^ + 2. Y refresh_jti wb¦WG + Qѩέ INCR auth:gen:{tenant_id}:{uid}]MP chain^ + ^ 401Aüg audit log + 3. ños access_jwt + s refresh_jwt]s jti^ + 4. refresh_jtiFY access jti LA@ֶ¦W + 5. gJs auth:jwt:pair ``` -- 每次 refresh 都輪換(Refresh Token Rotation) -- **Reuse detection**:舊 refresh 被第二次使用 → 視同盜用,立即批量撤銷該 user +- C refresh ]Refresh Token Rotation^ +- **Reuse detection**G refresh QĤGϥ PsΡAߧYqMP user -#### Token Exchange 防重放 +#### Token Exchange ``` POST /auth/token/exchange { tenant_slug, id_token } - 1. zitadel.VerifyIDToken(檢 aud、iss、exp、signature) - 2. 強制檢查 id_token.iat 在最近 5 分鐘內 - 3. SETNX auth:exchange:nonce:{id_token.jti}=1 TTL 10min;失敗 → 409 已使用 - 4. 校驗 tenant_slug → tenant.org_id == id_token.org_id + 1. zitadel.VerifyIDToken] audBissBexpBsignature^ + 2. jˬd id_token.iat b̪ 5 + 3. SETNX auth:exchange:nonce:{id_token.jti}=1 TTL 10minF 409 wϥ + 4. tenant_slug tenant.org_id == id_token.org_id 5. EnsureFromOIDC / SyncRoles / IssueTokenPair ``` -#### Step-up Token(單次性、鎖 action) +#### Step-up Token]榸ʡB action^ ``` Key: auth:stepup:used:{jti} SETNX TTL = step_up_token TTL Value: 1 ``` -- TTL:5 分鐘 -- Claims:`typ=step_up` + `action`(如 `change_business_email`) -- Logic 層守門: - 1. 解 step_up JWT → 檢 `typ == "step_up"`、`tenant_id`、`uid`、`action == expected` - 2. `SETNX auth:stepup:used:{jti}=1`,已存在 → 視為重放,拒絕 - 3. 通過後執行高風險操作;token 即作廢 -- Step-up token **不**進 jti 黑名單系統;單次性靠 `auth:stepup:used` 即可 +- TTLG5 +- ClaimsG`typ=step_up` + `action`]p `change_business_email`^ +- Logic huG + 1. step_up JWT `typ == "step_up"`B`tenant_id`B`uid`B`action == expected` + 2. `SETNX auth:stepup:used:{jti}=1`Awsb Aڵ + 3. qL氪Iާ@Ftoken Y@o +- Step-up token ****i jti ¦WtΡF榸ʾa `auth:stepup:used` Yi -#### 批量失效(停權 / 改密碼 / SCIM deactivate / **權限變更**) +#### qġ]v / KX / SCIM deactivate / **vܧ**^ ``` Key: auth:gen:{tenant_id}:{uid} -Value: 整數,預設 1;事件發生時 INCR +Value: ơAw] 1Fƥoͮ INCR ``` -Middleware 檢查:`token.auth_gen >= redis.auth_gen`,否則 401。 +Middleware ˬdG`token.auth_gen >= redis.auth_gen`A_h 401C -> **已決策**:UserRole 指派/撤銷、外部 Group 映射導致的 user role 變更 → **`INCR auth_gen`**(等效強制刷新,使用者需重新 exchange/refresh 取得新 auth_gen)。 +> **wM**GUserRole /MPB~ Group MgɭP user role ܧ **`INCR auth_gen`**]ıjsAϥΪ̻ݭs exchange/refresh os auth_gen^C > -> RolePermission 變更不改變「使用者有哪些角色」,只需 `LoadPolicy(tenant_id)` + 權限快取失效;若未來改成完全信任 JWT 內角色/權限快照,才需要同步 `INCR auth_gen`。 +> RolePermission ܧ󤣧ܡuϥΪ̦ǨvAu `LoadPolicy(tenant_id)` + v֨ġFYӧ令H JWT /vַӡA~ݭnPB `INCR auth_gen`C -> JWT 內不放全部 permission(避免 token 過大);批量失效用 `auth_gen`,單次登出用 jti 黑名單。 +> JWT permission]קK token Lj^Fqĥ `auth_gen`A榸nX jti ¦WC -### 4.6 Middleware 檢查順序 +### 4.6 Middleware ˬd ``` -0. Platform Admin allowlist 命中(platform tenant + platform_super_admin role 或 break-glass UID) - → audit.LogPlatformBypass → 直接放行 -1. go-zero JWT 驗簽 + exp -2. typ == "access"(受保護 API) +0. Platform Admin allowlist R]platform tenant + platform_super_admin role break-glass UID^ + audit.LogPlatformBypass +1. go-zero JWT ñ + exp +2. typ == "access"]O@ API^ 3. NOT EXISTS auth:jwt:bl:{jti} 4. claims.auth_gen >= redis auth:gen:{tenant}:{uid} - - redis key 不存在 → 視為 0 - - 簽發 token 時 claims.auth_gen = redis.GET 或 0 -5. 注入 context:tenant_id, uid(role keys 由下一層 CasbinRBACMiddleware 從 cache 載入) + - redis key sb 0 + - ño token claims.auth_gen = redis.GET 0 +5. `J contextGtenant_id, uid]role keys ѤU@h CasbinRBACMiddleware q cache J^ ``` --- -## 5. member 模組 +## 5. member Ҳ -路徑:`internal/model/member/` +|G`internal/model/member/` -### 5.1 職責 +### 5.1 ¾d -- 會員 Profile CRUD(tenant-scoped) -- Identity 映射(`zitadel_sub` ↔ `uid`) -- Tenant metadata 與 LDAP 同步設定 -- UID 產生(可讀格式) -- SCIM 業務寫入(SCIM `id` / Gateway UID + 客戶端 `externalId`) -- Directory Sync Worker(AD + OpenLDAP) -- 會員狀態(active / suspended / deleted)→ 通知 auth 撤銷 token -- **業務級驗證**:business email / phone 綁定 + OTP 自送 -- **Step-up MFA OTP 驗證**(搭配 auth 模組簽 step_up_token) +- | Profile CRUD]tenant-scoped^ +- Identity Mg]`zitadel_sub` ? `uid`^ +- Tenant metadata P LDAP PB]w +- UID ͡]iŪ榡^ +- SCIM ~ȼgJ]SCIM `id` / Gateway UID + Ȥ `externalId`^ +- Directory Sync Worker]AD + OpenLDAP^ +- |A]active / suspended / deleted^ q auth MP token +- **~ȯ**Gbusiness email / phone jw + OTP ۰e +- **Step-up MFA OTP **]ft auth Ҳñ step_up_token^ -### 5.2 UseCase 介面 +### 5.2 UseCase -> **設計原則(呼應 model.md)**:每個 UseCase 是**原子業務操作**,**不假設前後步驟存在**。流程編排(如「註冊 → 寄驗證信 → 啟用」)由 **logic 層**用多個 UseCase 拼裝;本層只負責單一動作 + 副作用。 +> **]ph]I model.md^**GC UseCase O**l~Ⱦާ@**A**]eBJsb**Cy{sơ]puU HҫH ҥΡv^ **logic h**Φh UseCase ˡFhutd@ʧ@ + Ƨ@ΡC > -> 介面分兩層: -> 1. **Atomic primitives**:純粹的單一動作(建 member、產 OTP、驗 OTP、寄 notification)。Logic 可任意組合,跨流程共用。 -> 2. **Composite**:把幾個常用 atomic 預先組好的「快捷組合」(如 `VerificationUseCase` = `OTP.Generate` + `Notifier.Send` + `Member.SetVerified`)。Composite 是**可選**,logic 也可以繞過直接組 atomic。 +> hG +> 1. **Atomic primitives**Gº骺@ʧ@] memberB OTPB OTPBH notification^CLogic iNզXAy{@ΡC +> 2. **Composite**GXӱ` atomic wզnuֱզXv]p `VerificationUseCase` = `OTP.Generate` + `Notifier.Send` + `Member.SetVerified`^CComposite O**i**Alogic ]iH¶L atomicC > -> 業務邏輯(API、handler、流程編排)目前**不實作**;先固化介面契約。 +> ~޿]APIBhandlerBy{sơ^ثe**@**FTƤC #### 5.2.1 Atomic primitives ```go -// ────────────────────────────────────────────────────────── -// Profile:讀寫 member 欄位(不含啟用 / 停權等狀態變遷) -// ────────────────────────────────────────────────────────── +// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww +// ProfileGŪg member ]tҥ / vAܾE^ +// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww type ProfileUseCase interface { GetByUID(ctx context.Context, req *GetMemberRequest) (*MemberDTO, error) Update(ctx context.Context, req *UpdateMemberRequest) (*MemberDTO, error) List(ctx context.Context, req *ListMembersRequest) (*ListMembersResponse, error) - // 業務 email / phone 旗標切換(被 Verification 或外部流程使用) + // ~ email / phone XФ]Q Verification Υ~y{ϥΡ^ SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error SetBusinessPhoneVerified(ctx context.Context, tenantID, uid, phone string) error } -// ────────────────────────────────────────────────────────── -// Lifecycle:狀態變遷的單一動作;不寄信、不簽 token -// ────────────────────────────────────────────────────────── +// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww +// LifecycleGAܾE@ʧ@FHHBñ token +// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww type LifecycleUseCase interface { - // 平台原生註冊:建立 unverified member(不寄 OTP,不發 token) + // x͵UGإ unverified member]H OTPAo token^ CreateUnverified(ctx context.Context, req *CreatePlatformMemberRequest) (*MemberDTO, error) - // 啟用:unverified → active;caller 須先確保所有前置驗證已通過 + // ҥΡGunverified activeFcaller TOҦemҤwqL Activate(ctx context.Context, tenantID, uid string) error - // 停權:active → suspended;不撤 token(撤 token 由 auth 模組做) + // vGactive suspendedFM token]M token auth Ҳհ^ Suspend(ctx context.Context, tenantID, uid, reason string) error - // 復權:suspended → active + // _vGsuspended active Reactivate(ctx context.Context, tenantID, uid string) error - // 軟刪:active|suspended → deleted(不會立刻匿名化;30 天後由 worker 處理 §5.7) + // nRGactive|suspended deleted]|ߨΦWơF30 ѫ worker Bz 5.7^ SoftDelete(ctx context.Context, tenantID, uid string) error - // 中止未啟用註冊(逾時清理;只能對 unverified 用) + // ҥεU]OɲMzFu unverified Ρ^ AbortPending(ctx context.Context, tenantID, uid string) error } -// ────────────────────────────────────────────────────────── -// Provisioning:外部來源 → Gateway member 的 JIT / sync upsert -// 每個來源獨立一個動作;email 視為來源 IdP 已驗證,不再走 OTP -// ────────────────────────────────────────────────────────── +// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww +// ProvisioningG~ӷ Gateway member JIT / sync upsert +// CӨӷWߤ@Ӱʧ@Femail ӷ IdP wҡAA OTP +// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww type ProvisioningUseCase interface { - // ZITADEL OIDC token exchange:用 id_token claims 上 upsert(B2C / Social IdP) + // ZITADEL OIDC token exchangeG id_token claims W upsert]B2C / Social IdP^ EnsureFromOIDC(ctx context.Context, req *EnsureFromOIDCRequest) (*MemberDTO, error) - // ZITADEL LDAP IdP 登入後 JIT;或 Directory Sync worker 推送 + // ZITADEL LDAP IdP nJ JITF Directory Sync worker e EnsureFromLDAP(ctx context.Context, req *EnsureFromLDAPRequest) (*MemberDTO, error) // SCIM Create / Update User EnsureFromSCIM(ctx context.Context, req *EnsureFromSCIMRequest) (*MemberDTO, error) } -// ────────────────────────────────────────────────────────── -// OTP:atomic、purpose-agnostic 一次性密碼 -// 不寄信、不更新 member;caller 拿 code 後自行透過 NotifierUseCase 投遞 -// ────────────────────────────────────────────────────────── +// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww +// OTPGatomicBpurpose-agnostic @ʱKX +// HHBs memberFcaller code ۦzL NotifierUseCase 뻼 +// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww type OTPUseCase interface { - // 生成:bcrypt 存 redis,回 challenge_id + 明碼 code(一次性回傳) + // ͦGbcrypt s redisA^ challenge_id + X code]@ʦ^ǡ^ Generate(ctx context.Context, req *GenerateOTPRequest) (*OTPChallengeDTO, error) - // 驗證:成功則 invalidate;purpose 必須與 challenge 建立時一致 + // ҡG\h invalidateFpurpose P challenge إ߮ɤ@P Verify(ctx context.Context, req *VerifyOTPRequest) error - // 主動失效(換 challenge / 取消註冊) + // Dʥġ] challenge / U^ Invalidate(ctx context.Context, tenantID, challengeID string) error } -// ────────────────────────────────────────────────────────── -// TOTP(Authenticator App):見 §5.8 -// ────────────────────────────────────────────────────────── +// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww +// TOTP]Authenticator App^G 5.8 +// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww type TOTPUseCase interface { StartEnroll(ctx context.Context, tenantID, uid string) (*EnrollStartDTO, error) ConfirmEnroll(ctx context.Context, tenantID, uid, code string) (backupCodes []string, err error) @@ -514,18 +514,18 @@ type TOTPUseCase interface { RegenerateBackupCodes(ctx context.Context, tenantID, uid string) ([]string, error) } -// ────────────────────────────────────────────────────────── +// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww // Tenant -// ────────────────────────────────────────────────────────── +// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww type TenantUseCase interface { Create(ctx context.Context, req *CreateTenantRequest) (*TenantDTO, error) ResolveBySlug(ctx context.Context, slug string) (*TenantDTO, error) ConfigureLDAP(ctx context.Context, req *ConfigureLDAPRequest) error } -// ────────────────────────────────────────────────────────── +// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww // SCIM Resource handlers -// ────────────────────────────────────────────────────────── +// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww type ScimUseCase interface { CreateUser(ctx context.Context, req *ScimCreateUserRequest) (*ScimUserDTO, error) GetUser(ctx context.Context, req *ScimGetUserRequest) (*ScimUserDTO, error) @@ -539,13 +539,13 @@ type DirectorySyncUseCase interface { } ``` -#### 5.2.2 Composite(可選;常用組合的便利包) +#### 5.2.2 Composite]iF`βզXKQ]^ -> Composite 內部只呼叫 Atomic primitives + library / notifier,**不持有任何不可由 atomic 推出的副作用**。 -> Logic 可選擇用 composite(簡單情況)或直接組 atomic(特殊需求)。 +> Composite uIs Atomic primitives + library / notifierA**󤣥i atomic XƧ@**C +> Logic iܥ composite]²污p^Ϊ atomic]SݨD^C ```go -// 業務 email / phone 驗證 = OTP.Generate + Notifier.Send + Profile.SetXxxVerified +// ~ email / phone = OTP.Generate + Notifier.Send + Profile.SetXxxVerified type VerificationUseCase interface { StartEmailVerify(ctx context.Context, tenantID, uid, target string) (*OTPChallengeDTO, error) ConfirmEmailVerify(ctx context.Context, tenantID, uid, challengeID, code string) error @@ -553,14 +553,14 @@ type VerificationUseCase interface { ConfirmPhoneVerify(ctx context.Context, tenantID, uid, challengeID, code string) error } -// Step-up = (TOTP.VerifyCode 或 OTP.Generate+Notifier.Send/OTP.Verify) + auth.StepUpToken.Issue +// Step-up = (TOTP.VerifyCode OTP.Generate+Notifier.Send/OTP.Verify) + auth.StepUpToken.Issue type StepUpUseCase interface { Start(ctx context.Context, tenantID, uid string, req *StepUpStartRequest) (*StepUpChallengeDTO, error) Confirm(ctx context.Context, tenantID, uid string, req *StepUpConfirmRequest) (stepUpToken string, err error) } ``` -#### 5.2.3 Request / DTO 草案 +#### 5.2.3 Request / DTO ```go // Provisioning @@ -568,7 +568,7 @@ type EnsureFromOIDCRequest struct { TenantID string ZitadelSub string Email string - EmailVerified bool // 來自 id_token claim;OIDC 通常 true + EmailVerified bool // Ӧ id_token claimFOIDC q` true DisplayName string Locale string RawClaims map[string]any @@ -587,7 +587,7 @@ type EnsureFromLDAPRequest struct { type EnsureFromSCIMRequest struct { TenantID string - ExternalID string // SCIM externalId(不等於 UID) + ExternalID string // SCIM externalId] UID^ UserName string Email string DisplayName string @@ -599,24 +599,24 @@ type EnsureFromSCIMRequest struct { type CreatePlatformMemberRequest struct { TenantID string Email string - PasswordHash string // 若使用 ZITADEL local user,留空(由 ZITADEL 管) + PasswordHash string // Yϥ ZITADEL local userAdš] ZITADEL ޡ^ DisplayName string Language string - // 不會立即 active;新建 member.status = unverified + // |ߧY activeFs member.status = unverified } // OTP type GenerateOTPRequest struct { TenantID string Purpose enum.OTPPurpose // registration_email | business_email | business_phone | step_up | password_reset | ... - Identifier string // 通常是 uid;註冊期 uid 尚未存在時可用 hash(email) - Length int // 0 = 用 config 預設(6) - TTLSeconds int // 0 = 用 config 預設(300) + Identifier string // q`O uidFU uid |sbɥi hash(email) + Length int // 0 = config w]]6^ + TTLSeconds int // 0 = config w]]300^ } type OTPChallengeDTO struct { ChallengeID string - Code string // 僅 Generate 時回傳一次(明碼);caller 自負投遞 + Code string // Generate ɦ^Ǥ@]X^Fcaller ۭt뻼 ExpiresIn int } @@ -624,7 +624,7 @@ type VerifyOTPRequest struct { TenantID string ChallengeID string Code string - Purpose enum.OTPPurpose // 必填,防 challenge 被借用到其他用途 + Purpose enum.OTPPurpose // A challenge QɥΨLγ~ } // Step-up @@ -632,11 +632,11 @@ type StepUpStartRequest struct { TenantID string UID string Action enum.StepUpAction - PreferChannel enum.Channel // 可選:totp | sms | email;不指定則依 §5.6 優先序 + PreferChannel enum.Channel // iGtotp | sms | emailFwh 5.6 u } type StepUpChallengeDTO struct { - ChallengeID string // TOTP 無 challenge_id 也可回固定值;Confirm 時不會去比對 + ChallengeID string // TOTP L challenge_id ]i^TwȡFConfirm ɤ|h Channel enum.Channel ExpiresIn int } @@ -650,7 +650,7 @@ type StepUpConfirmRequest struct { } ``` -#### 5.2.4 Enum 草案 +#### 5.2.4 Enum ```go // member/enum/otp_purpose.go @@ -660,10 +660,10 @@ const ( OTPPurposeBusinessEmail OTPPurpose = "business_email" OTPPurposeBusinessPhone OTPPurpose = "business_phone" OTPPurposeStepUp OTPPurpose = "step_up" - OTPPurposePasswordReset OTPPurpose = "password_reset" // 預留 + OTPPurposePasswordReset OTPPurpose = "password_reset" // wd ) -// auth/enum/step_up_action.go(已存在於 §5.6,集中宣告於此) +// auth/enum/step_up_action.go]wsb 5.6Aŧi󦹡^ type StepUpAction string const ( StepUpChangeBusinessEmail StepUpAction = "change_business_email" @@ -675,37 +675,37 @@ const ( ) ``` -### 5.3 會員生命週期狀態 +### 5.3 |ͩRgA -| 狀態 | 語意 | 副作用 | +| A | yN | Ƨ@ | |------|------|--------| -| `unverified` | **僅平台原生註冊**會出現:member 已建立,但註冊 email 尚未通過 OTP 驗證 | 不簽 token、不可登入;逾期由 cron `AbortPending` 清理 | -| `active` | 正常使用 | — | -| `suspended` | 停權(管理員操作 / 風控) | `auth.RevokeAllForUser`(`INCR auth_gen`) | -| `deleted` | 軟刪除 | 清 cache、撤銷 token、ZITADEL disable;30 天後匿名化(§5.7) | +| `unverified` | **ȥx͵U**|X{Gmember wإߡAU email |qL OTP | ñ tokenBinJFO cron `AbortPending` Mz | +| `active` | `ϥ | X | +| `suspended` | v]޲zާ@ / ^ | `auth.RevokeAllForUser`]`INCR auth_gen`^ | +| `deleted` | nR | M cacheBMP tokenBZITADEL disableF30 ѫΦWơ]5.7^ | -> 來自 OIDC / LDAP / SCIM 的 member **直接建為 `active`**(email 由來源 IdP 已驗證);只有 platform-native 註冊會經過 `unverified`。 -> 業務 email / phone 驗證以獨立旗標(`BusinessEmailVerified` / `BusinessPhoneVerified`)表示,與生命週期狀態解耦。 +> Ӧ OIDC / LDAP / SCIM member **ج `active`**]email Ѩӷ IdP wҡ^Fu platform-native U|gL `unverified`C +> ~ email / phone ҥHWߺXС]`BusinessEmailVerified` / `BusinessPhoneVerified`^ܡAPͩRgAѽC -#### Member 欄位 Source of Truth(已決策) +#### Member Source of Truth]wM^ -| 欄位類別 | 範例 | SoT | 行為 | +| O | d | SoT | 欰 | |---------|------|-----|------| -| 身份識別 | `zitadel_sub`、`ZitadelEmail`、`DisplayName`(IdP)、ZITADEL `status` | **ZITADEL** | 每次 token exchange / webhook 同步;Gateway 不可改寫 | -| 業務資料 | `BusinessEmail/Phone(+Verified)`、`Language`、`Currency`、`Avatar`、`Preferences` | **Gateway** | 業務 API 寫;不回推 ZITADEL | -| Provisioning 來源 | `external_id`、`ldap_dn`、SCIM 群組成員資格 | **來源系統**(LDAP/SCIM) | sync replace;Gateway 不直接編輯 | +| ѧO | `zitadel_sub`B`ZitadelEmail`B`DisplayName`]IdP^BZITADEL `status` | **ZITADEL** | C token exchange / webhook PBFGateway ig | +| ~ȸ | `BusinessEmail/Phone(+Verified)`B`Language`B`Currency`B`Avatar`B`Preferences` | **Gateway** | ~ API gF^ ZITADEL | +| Provisioning ӷ | `external_id`B`ldap_dn`BSCIM sզ | **ӷt**]LDAP/SCIM^ | sync replaceFGateway s | -> 推論:`Member.Origin` 標主來源;對應「Provisioning」欄位類別的可寫範圍。Gateway UI 改業務欄位永遠可行;改身份/Provisioning 欄位需走來源系統。 +> סG`Member.Origin` ХDӷFuProvisioningvOigdCGateway UI ~ûiF鶴/Provisioning ݨӷtΡC -### 5.4 業務級驗證模型(已決策) +### 5.4 ~ȯҼҫ]wM^ ```go -// Member 既有 + 本節新增欄位 +// Member J + `sW type Member struct { TenantID string UID string - ZitadelUserID string // ZITADEL sub(OIDC / LDAP IdP / platform local user 都會有) - ZitadelEmail string // 來源 IdP 提供的登入 email + ZitadelUserID string // ZITADEL sub]OIDC / LDAP IdP / platform local user |^ + ZitadelEmail string // ӷ IdP ѪnJ email DisplayName string Avatar string Phone string @@ -714,9 +714,9 @@ type Member struct { Status enum.MemberStatus // unverified | active | suspended | deleted Origin enum.MemberOrigin // platform_native | oidc | ldap | scim - PasswordHash string // 平台原生且不用 ZITADEL local user 時才填;其餘留空 + PasswordHash string // xͥB ZITADEL local user ɤ~Fld - BusinessEmail string // 業務 email(可與 ZitadelEmail 不同) + BusinessEmail string // ~ email]iP ZitadelEmail P^ BusinessEmailVerified bool BusinessEmailVerifiedAt int64 BusinessPhone string @@ -730,51 +730,51 @@ type Member struct { CreateAt int64 UpdateAt int64 - DeletedAt int64 // soft delete 時間 - AnonymizedAt int64 // 匿名化時間 + DeletedAt int64 // soft delete ɶ + AnonymizedAt int64 // ΦWƮɶ } ``` -> **Origin** 取值: -> - `platform_native`:Gateway 平台原生註冊(搭配 ZITADEL local user 或 Gateway 自管密碼) -> - `oidc`:Social / ZITADEL Hosted UI 等 IdP 來的 -> - `ldap`:透過 ZITADEL LDAP IdP 或 Directory Sync -> - `scim`:HR / Entra / Okta 推送 +> **Origin** ȡG +> - `platform_native`GGateway x͵U]ft ZITADEL local user Gateway ۺޱKX^ +> - `oidc`GSocial / ZITADEL Hosted UI IdP Ӫ +> - `ldap`GzL ZITADEL LDAP IdP Directory Sync +> - `scim`GHR / Entra / Okta e -> `Member.Origin` 決定 Profile 欄位 UI 可寫範圍: -> - `zitadel_local`:身份欄位(IdP email/name)唯讀,需走 ZITADEL UI 改;業務欄位可寫 -> - `ldap`:身份 + provisioning 欄位皆唯讀(由 Directory Sync 維護);業務欄位可寫 -> - `scim`:身份 + provisioning 欄位由 SCIM Provider 推送,唯讀;業務欄位可寫 +> `Member.Origin` Mw Profile UI igdG +> - `zitadel_local`G]IdP email/name^ŪAݨ ZITADEL UI F~ig +> - `ldap`G + provisioning ҰŪ] Directory Sync @^F~ig +> - `scim`G + provisioning SCIM Provider eAŪF~ig > -> `UserRole.Source` 仍維持 `manual / zitadel / ldap / scim`,影響 sync replace 範圍(見 §6.10)。兩者各司其職。 +> `UserRole.Source` `manual / zitadel / ldap / scim`AvT sync replace d] 6.10^C̦Uq¾C -| 欄位 | 來源 | 用途 | +| | ӷ | γ~ | |------|------|------| -| `ZitadelEmail`(既有) | OIDC claim | 登入帳號識別,不做業務守門 | -| `BusinessEmail` | 業務 API 綁定 + OTP | 業務通知、業務守門條件 | -| `BusinessPhone` | 業務 API 綁定 + OTP | SMS 通知、Step-up MFA 通道 | +| `ZitadelEmail`]J^ | OIDC claim | nJbѧOA~Ȧu | +| `BusinessEmail` | ~ API jw + OTP | ~ȳqB~Ȧu | +| `BusinessPhone` | ~ API jw + OTP | SMS qBStep-up MFA qD | -**Verification Challenge(不入 Mongo,僅存 Redis,TTL 5min):** +**Verification Challenge]J MongoAȦs RedisATTL 5min^G** ```go type VerificationChallenge struct { TenantID string UID string Kind enum.VerifyKind // email | phone | step_up - Target string // email/phone 目的地;step_up 為 action + Target string // email/phone تaFstep_up action CodeHash string // bcrypt(otp) - AttemptCnt int // 失敗次數,超過 MaxAttempts → 鎖 + AttemptCnt int // ѦơAWL MaxAttempts ExpireAt int64 // epoch ms CreateAt int64 } ``` -### 5.5 OTP 投遞(已決策:透過 Notification Module) +### 5.5 OTP 뻼]wMGzL Notification Module^ -業務 / step-up OTP **一律走** `notification.NotifierUseCase`,**不**在 member 模組直接接 provider SDK。Notification module 統一處理 provider 切換、模板、idempotency、重試、audit(見 §11)。 +~ / step-up OTP **@ߨ** `notification.NotifierUseCase`A****b member Ҳժ provider SDKCNotification module Τ@Bz provider BҪOBidempotencyBաBaudit] 11^C ```go -// member.VerificationUseCase 內呼叫 +// member.VerificationUseCase Is nu.Notifier.Send(ctx, ¬ification.SendRequest{ TenantID: tenantID, UID: uid, @@ -783,219 +783,219 @@ nu.Notifier.Send(ctx, ¬ification.SendRequest{ Target: targetEmail, Locale: member.Language, Data: map[string]any{"code": otp, "expires_in": 300}, - IdempotencyKey: challengeID, // 同 challenge 不會重發 - DoNotPersistBody: true, // OTP 不入 notification.body + IdempotencyKey: challengeID, // P challenge |o + DoNotPersistBody: true, // OTP J notification.body Severity: enum.SeverityInfo, }) ``` -- **OTP 規格**:6 位數、TTL 5min、bcrypt 儲存(不存明碼)、重發冷卻 60s、單一 challenge 失敗 5 次直接鎖 -- **Rate Limit**: - - `verify:rate:{tenant}:{uid}:{kind}` SETNX TTL=60s(重發保護) - - `verify:daily:{tenant}:{uid}:{kind}` INCR TTL=24h(單日上限,預設 10 次) -- **Audit**:Start / Confirm 進 audit log(Notification 自己也會記送達狀態,兩者互補) -- **Provider 切換不影響 member 模組**:換 SendGrid → SES、Twilio → SNS 只動 `etc/gateway.yaml` 與 library 實作 +- **OTP W**G6 ơBTTL 5minBbcrypt xs]sX^BoNo 60sB@ challenge 5 +- **Rate Limit**G + - `verify:rate:{tenant}:{uid}:{kind}` SETNX TTL=60s]oO@^ + - `verify:daily:{tenant}:{uid}:{kind}` INCR TTL=24h]WAw] 10 ^ +- **Audit**GStart / Confirm i audit log]Notification ۤv]|OeFAA̤ɡ^ +- **Provider vT member Ҳ**G SendGrid SESBTwilio SNS u `etc/gateway.yaml` P library @ -### 5.6 Step-up MFA(已決策:啟用) +### 5.6 Step-up MFA]wMGҥΡ^ -**用途**:高風險業務操作前的二次驗證,與 ZITADEL 身份 MFA **互不取代**。 +**γ~**GI~Ⱦާ@eGҡAP ZITADEL MFA **N**C -#### 高風險 Action 清單(enum) +#### I Action M]enum^ -| Action | 目標 API | +| Action | ؼ API | |--------|---------| | `change_business_email` | `PATCH /members/me/business-email` | | `change_business_phone` | `PATCH /members/me/business-phone` | | `delete_member` | `DELETE /members/me` | -| `tenant_admin_force_status` | `PATCH /members/:uid/status`(管理員停權他人)| +| `tenant_admin_force_status` | `PATCH /members/:uid/status`]޲zvLH^| | `revoke_all_sessions` | `POST /auth/revoke-all` | | `disable_totp` | `DELETE /members/me/totp` | -> 後續可由 tenant 透過設定加白名單;初版 platform-wide enum,禁止任意字串。 +> i tenant zL]w[զWF쪩 platform-wide enumATNrC -#### Step-up 通道(已決策) +#### Step-up qD]wM^ -優先序:**TOTP > SMS > Email** +uǡG**TOTP > SMS > Email** -| 通道 | 條件 | 為何優先 | +| qD | | u | |------|------|---------| -| **TOTP**(Google Authenticator) | 使用者已 `enroll_totp` 完成(§5.8) | 不依賴外部 provider、不會被 SIM swap、無頻寬限制、零成本 | -| **SMS** | `BusinessPhoneVerified = true` | 比 email 即時、不易被攔截 | -| **Email** | `BusinessEmailVerified = true` | 後備通道 | +| **TOTP**]Google Authenticator^ | ϥΪ̤w `enroll_totp` ]5.8^ | ̿~ providerB|Q SIM swapBLWeBs | +| **SMS** | `BusinessPhoneVerified = true` | email YɡBQdI | +| **Email** | `BusinessEmailVerified = true` | ƳqD | -Start 時由 `StepUpUseCase` 依使用者狀態挑通道;若使用者要求其他通道(如不想用 TOTP)可在 request 帶 `prefer_channel` 覆寫,但仍需該通道已驗證。 +Start ɥ `StepUpUseCase` ̨ϥΪ̪ADqDFYϥΪ̭nDLqD]pQ TOTP^ib request a `prefer_channel` мgAݸӳqDwҡC -#### 流程 +#### y{ ``` -1. Client → POST /auth/step-up/start { action, prefer_channel?: "totp" } - - 解析使用者已可用通道;挑選優先通道 - - 若選 totp:不寄 OTP,直接回 challenge_id;code 由使用者從 app 取 - - 若選 sms/email:生成 6 碼 OTP、bcrypt 儲存、透過 NotifierUseCase.Send 寄出 - ← { challenge_id, channel: "totp"|"sms"|"email", expires_in: 300 } +1. Client POST /auth/step-up/start { action, prefer_channel?: "totp" } + - ѪRϥΪ̤wiγqDFDuqD + - Y totpGH OTPA^ challenge_idFcode ѨϥΪ̱q app + - Y sms/emailGͦ 6 X OTPBbcrypt xsBzL NotifierUseCase.Send HX + { challenge_id, channel: "totp"|"sms"|"email", expires_in: 300 } -2. Client → POST /auth/step-up/confirm { challenge_id, code, action } - - totp:member.TOTPUseCase.VerifyCode(uid, code, window=±1) - - sms/email:bcrypt 比對 challenge code;失敗 INCR AttemptCnt - - 成功 → auth.StepUpTokenUseCase.Issue(tenant, uid, action) → 短壽 JWT - ← { step_up_token, token_type: "step_up", expires_in: 300 } +2. Client POST /auth/step-up/confirm { challenge_id, code, action } + - totpGmember.TOTPUseCase.VerifyCode(uid, code, window=1) + - sms/emailGbcrypt challenge codeF INCR AttemptCnt + - \ auth.StepUpTokenUseCase.Issue(tenant, uid, action) u JWT + { step_up_token, token_type: "step_up", expires_in: 300 } -3. Client → PATCH /members/me/business-email { ... } +3. Client PATCH /members/me/business-email { ... } Header: X-Step-Up-Token: - - Logic 層: - a. Casbin enforce 通過(基本權限) + - Logic hG + a. Casbin enforce qL]v^ b. StepUpTokenUseCase.Verify(token, expectedAction="change_business_email", tenant, uid) - c. SETNX auth:stepup:used:{jti}=1,已用過 → 拒絕 - d. 執行業務邏輯 + c. SETNX auth:stepup:used:{jti}=1AwιL ڵ + d. ~޿ ``` -#### 守門點 +#### uI -- Logic 層守門:**Casbin allow 後**再驗 step-up;雙閘門 -- Header 名稱:`X-Step-Up-Token` -- 失敗回傳:`403 step_up_required` + `{ required_action: "change_business_email", available_channels: ["totp","sms"] }`,前端依此跳 step-up 流程 +- Logic huG**Casbin allow **A step-upFh +- Header W١G`X-Step-Up-Token` +- Ѧ^ǡG`403 step_up_required` + `{ required_action: "change_business_email", available_channels: ["totp","sms"] }`Aeݨ̦ step-up y{ -### 5.7 帳號刪除與匿名化(已決策) +### 5.7 bRPΦWơ]wM^ ``` T0: DELETE /api/v1/members/me (Step-up: delete_member) 1. status = deleted, deleted_at = now - 2. auth.RevokeAllForUser(INCR auth_gen + 拉 jti pair 黑名單) + 2. auth.RevokeAllForUser]INCR auth_gen + jti pair ¦W^ 3. ZITADEL Mgmt.DeactivateUser - 4. 清 member:profile / member:sub cache + 4. M member:profile / member:sub cache 5. audit log (actor, ip, ua, step_up_jti) -T+30 天: cron `member_anonymize_worker` - 匿名化欄位(覆寫為 hash 或固定 placeholder): - ZitadelEmail → "deleted:{uid}@anonymized.local" - DisplayName → "Deleted User" - Avatar → "" - Phone → "" - BusinessEmail → "" - BusinessPhone → "" - BusinessEmail/PhoneVerified → false - TOTPSecretCipher → "" - TOTPBackupCodesHash → nil - external_id, ldap_dn → "" - zitadel_sub → "deleted:{uid}" # 維持 identities 唯一索引 - 保留欄位(不可改 / 審計用): +T+30 : cron `member_anonymize_worker` + ΦW]мg hash ΩTw placeholder^: + ZitadelEmail "deleted:{uid}@anonymized.local" + DisplayName "Deleted User" + Avatar "" + Phone "" + BusinessEmail "" + BusinessPhone "" + BusinessEmail/PhoneVerified false + TOTPSecretCipher "" + TOTPBackupCodesHash nil + external_id, ldap_dn "" + zitadel_sub "deleted:{uid}" # identities ߤ@ + Od]i / fpΡ^: tenant_id, uid, status=deleted, deleted_at, anonymized_at, created_at - 寫 audit log: action=member.anonymized + g audit log: action=member.anonymized ``` -- **不可逆**;30 天內可由租戶 admin 還原(`status=deleted → active`,恢復 cache,但 ZITADEL 帳號需另行啟用) -- audit log 不受匿名化影響(actor uid 仍保留,便於追溯) -- 匿名化後 SCIM `Users.{id}` 仍可查到(回傳 `active=false` + 匿名 payload),不回 404,以維持 client 的 reconciliation +- **if**F30 Ѥiѯ admin ٭]`status=deleted active`A_ cacheA ZITADEL bݥtҥΡ^ +- audit log ΦWƼvT]actor uid OdAKl^ +- ΦWƫ SCIM `Users.{id}` id]^ `active=false` + ΦW payload^A^ 404AH client reconciliation -### 5.8 TOTP(Authenticator App,已決策:啟用) +### 5.8 TOTP]Authenticator AppAwMGҥΡ^ -業務級 TOTP,Gateway **自己存 secret**,與 ZITADEL 身份級 TOTP **獨立**(兩個獨立綁定,使用者首次 setup 需各掃一次 QR)。 +~ȯ TOTPAGateway **ۤvs secret**AP ZITADEL TOTP **W**]ӿW߸jwAϥΪ̭ setup ݦU@ QR^C -> 為什麼分開?ZITADEL TOTP 是登入用、secret 在 ZITADEL;Gateway step-up TOTP 用於業務操作、secret 在 Gateway,避免 Gateway 對 ZITADEL 私有資料的依賴與耦合。 +> }HZITADEL TOTP OnJΡBsecret b ZITADELFGateway step-up TOTP Ω~Ⱦާ@Bsecret b GatewayAקK Gateway ZITADEL pƪ̿PXC -#### Member 欄位(補充 §5.4) +#### Member ]ɥR 5.4^ ```go type Member struct { - // ... 既有欄位 + // ... J TOTPEnrolled bool - TOTPSecretCipher string // AES-GCM(secret, KEK),AES-256;KEK 走 KMS / secret manager + TOTPSecretCipher string // AES-GCM(secret, KEK)AAES-256FKEK KMS / secret manager TOTPEnrolledAt int64 - TOTPBackupCodesHash []string // bcrypt(code),10 組一次性備援碼,用過即抹除 + TOTPBackupCodesHash []string // bcrypt(code)A10 դ@ʳƴXAιLYٰ } ``` -> Secret 必須對稱加密儲存,**禁止**明碼或單純 base32。KEK 走 KMS / Vault;rotation 時逐筆 re-encrypt(背景 worker)。 +> Secret ٥[KxsA**T**Xγ base32CKEK KMS / VaultFrotation ɳv re-encrypt]I worker^C -#### UseCase 介面(補充 §5.2) +#### UseCase ]ɥR 5.2^ ```go type TOTPUseCase interface { - // 產生 secret + otpauth URL + 10 組 backup codes(首次 enroll;尚未啟用) + // secret + otpauth URL + 10 backup codes] enrollF|ҥΡ^ StartEnroll(ctx context.Context, tenantID, uid string) (*EnrollStartDTO, error) - // 使用者掃 QR、輸入第一組 code → 確認 → 標 TOTPEnrolled = true - ConfirmEnroll(ctx context.Context, tenantID, uid, code string) ([]string, error) // 回 backup_codes(明碼,只回一次) - // step-up 用:驗一個 code(含 backup code) + // ϥΪ̱ QRBJĤ@ code T{ TOTPEnrolled = true + ConfirmEnroll(ctx context.Context, tenantID, uid, code string) ([]string, error) // ^ backup_codes]XAu^@^ + // step-up ΡG@ code]t backup code^ VerifyCode(ctx context.Context, tenantID, uid, code string) error - // 解除綁定(需 step-up = disable_totp) + // Ѱjw] step-up = disable_totp^ Disable(ctx context.Context, tenantID, uid string) error - // 重新產生 backup codes(需 step-up) + // s backup codes] step-up^ RegenerateBackupCodes(ctx context.Context, tenantID, uid string) ([]string, error) } ``` -#### 流程 +#### y{ ``` A. Enroll - Client → POST /api/v1/members/me/totp/enroll-start - 1. 若已 TOTPEnrolled = true → 409 already_enrolled - 2. 生成 32-byte random secret → base32 + Client POST /api/v1/members/me/totp/enroll-start + 1. Yw TOTPEnrolled = true 409 already_enrolled + 2. ͦ 32-byte random secret base32 3. otpauth_url = "otpauth://totp/{Issuer}:{tenant_slug}:{uid}?secret={base32}&issuer={Issuer}&algorithm=SHA1&digits=6&period=30" - 4. 暫存於 Redis(不入 Mongo,避免半完成的 secret 散落): + 4. Ȧs Redis]J MongoAקKb secret ^: totp:enroll:{tenant}:{uid} = {secret_cipher} TTL 10min - ← { otpauth_url, qr_png_base64 } + { otpauth_url, qr_png_base64 } - Client → POST /api/v1/members/me/totp/enroll-confirm { code } - 1. 從 Redis 取暫存 secret - 2. VerifyTOTP(secret, code, window=±1) → 失敗則 400 invalid_code + Client POST /api/v1/members/me/totp/enroll-confirm { code } + 1. q Redis Ȧs secret + 2. VerifyTOTP(secret, code, window=1) ѫh 400 invalid_code 3. member.TOTPSecretCipher = secret_cipher member.TOTPEnrolled = true member.TOTPEnrolledAt = now - 4. 生成 10 組 backup code (random hex)、bcrypt 後存 TOTPBackupCodesHash + 4. ͦ 10 backup code (random hex)Bbcrypt s TOTPBackupCodesHash 5. DEL totp:enroll:* 6. audit log - ← { backup_codes: [...10 組明碼,僅此一次回傳] } + { backup_codes: [...10 թXAȦ@^] } -B. Verify(step-up 共用) - StepUpUseCase.Confirm 內: - VerifyTOTP(decryptedSecret, code, window=±1) OR matchBackupCode(code) - 若用 backup code 命中 → 從 TOTPBackupCodesHash 移除該筆(單次性) +B. Verify]step-up @Ρ^ + StepUpUseCase.Confirm G + VerifyTOTP(decryptedSecret, code, window=1) OR matchBackupCode(code) + Y backup code R q TOTPBackupCodesHash ӵ]榸ʡ^ C. Disable - Client → DELETE /api/v1/members/me/totp + Client DELETE /api/v1/members/me/totp Header: X-Step-Up-Token: - 1. 清 TOTPSecretCipher、TOTPEnrolled=false、TOTPBackupCodesHash=nil + 1. M TOTPSecretCipherBTOTPEnrolled=falseBTOTPBackupCodesHash=nil 2. audit log ``` -#### TOTP 演算法與參數 +#### TOTP tkPѼ -- **RFC 6238**(SHA1 / 30s period / 6 digits),相容 Google Authenticator、Authy、1Password、Microsoft Authenticator -- `window = ±1`:允許前後一個 30s 區間,容忍時鐘漂移 -- Replay 保護:成功使用的 `(uid, code, timestep)` 寫入 `totp:used:{tenant}:{uid}:{timestep}` SETNX TTL=90s;同一 code 二次出現直接拒 -- Backup code:10 組、12 字 hex(48-bit entropy)、bcrypt cost 10、明碼僅 enroll 時回傳一次 +- **RFC 6238**]SHA1 / 30s period / 6 digits^Aۮe Google AuthenticatorBAuthyB1PasswordBMicrosoft Authenticator +- `window = 1`G\e@ 30s ϶AeԮ} +- Replay O@G\ϥΪ `(uid, code, timestep)` gJ `totp:used:{tenant}:{uid}:{timestep}` SETNX TTL=90sFP@ code GX{ +- Backup codeG10 աB12 r hex]48-bit entropy^Bbcrypt cost 10BX enroll ɦ^Ǥ@ -#### API(補充 §7.2) +#### API]ɥR 7.2^ -| Method | Path | 說明 | Step-up | +| Method | Path | | Step-up | |--------|------|------|---------| -| POST | `/api/v1/members/me/totp/enroll-start` | 取 otpauth URL + QR | — | -| POST | `/api/v1/members/me/totp/enroll-confirm` | 驗第一組 code,啟用 + 回 backup codes | — | -| GET | `/api/v1/members/me/totp` | 取 TOTP 狀態(enrolled? backup 剩餘數) | — | -| POST | `/api/v1/members/me/totp/backup-codes` | 重產 backup codes | ✅ `disable_totp` | -| DELETE | `/api/v1/members/me/totp` | 解除綁定 | ✅ `disable_totp` | +| POST | `/api/v1/members/me/totp/enroll-start` | otpauth URL + QR | X | +| POST | `/api/v1/members/me/totp/enroll-confirm` | Ĥ@ codeAҥ + ^ backup codes | X | +| GET | `/api/v1/members/me/totp` | TOTP A]enrolled? backup Ѿlơ^ | X | +| POST | `/api/v1/members/me/totp/backup-codes` | backup codes | ? `disable_totp` | +| DELETE | `/api/v1/members/me/totp` | Ѱjw | ? `disable_totp` | -### 5.9 UseCase 編排示例(純概念;handler / API 暫不實作) +### 5.9 UseCase sƥܨҡ]·Fhandler / API Ȥ@^ -> 展示 atomic primitives 可任意組合的邏輯流。**logic 層尚未實作**;本節僅證明介面契約可支撐預期業務。 +> i atomic primitives iNզX޿yC**logic h|@**F`ҩi伵w~ȡC -#### Case A:平台原生註冊 + Email OTP 驗證(未來路徑) +#### Case AGx͵U + Email OTP ҡ]Ӹ|^ ```go -// 1) 建立 unverified member(不寄信、不發 token) +// 1) إ unverified member]HHBo token^ m, _ := mLifecycle.CreateUnverified(ctx, &CreatePlatformMemberRequest{ TenantID: tenantID, Email: email, DisplayName: name, }) -// 2) 產生 OTP(atomic、purpose-agnostic) +// 2) OTP]atomicBpurpose-agnostic^ chal, _ := mOTP.Generate(ctx, &GenerateOTPRequest{ TenantID: tenantID, Purpose: OTPPurposeRegistrationEmail, Identifier: m.UID, }) -// 3) 投遞 OTP(atomic;caller 控制 channel / template) +// 3) 뻼 OTP]atomicFcaller channel / template^ notifier.Send(ctx, &SendRequest{ TenantID: tenantID, UID: m.UID, @@ -1007,19 +1007,19 @@ notifier.Send(ctx, &SendRequest{ DoNotPersistBody: true, }) -// (使用者收信、輸入 code → 後端走以下兩步) +// ]ϥΪ̦HBJ code ݨHUB^ -// 4) 驗證 OTP(atomic) +// 4) OTP]atomic^ _ = mOTP.Verify(ctx, &VerifyOTPRequest{ TenantID: tenantID, ChallengeID: chal.ChallengeID, Code: userCode, Purpose: OTPPurposeRegistrationEmail, }) -// 5) 啟用(atomic):unverified → active +// 5) ҥΡ]atomic^Gunverified active _ = mLifecycle.Activate(ctx, tenantID, m.UID) ``` -#### Case B:OIDC(Social / ZITADEL Hosted UI)登入 — 不需 OTP +#### Case BGOIDC]Social / ZITADEL Hosted UI^nJ X OTP ```go m, _ := mProv.EnsureFromOIDC(ctx, &EnsureFromOIDCRequest{ @@ -1029,10 +1029,10 @@ m, _ := mProv.EnsureFromOIDC(ctx, &EnsureFromOIDCRequest{ EmailVerified: claims.EmailVerified, DisplayName: claims.Name, }) -// 直接 active;之後 auth.IssueTokenPair +// activeF auth.IssueTokenPair ``` -#### Case C:LDAP IdP 首次登入 JIT — 不需 OTP +#### Case CGLDAP IdP nJ JIT X OTP ```go m, _ := mProv.EnsureFromLDAP(ctx, &EnsureFromLDAPRequest{ @@ -1042,7 +1042,7 @@ m, _ := mProv.EnsureFromLDAP(ctx, &EnsureFromLDAPRequest{ }) ``` -#### Case D:SCIM Create User — 不需 OTP +#### Case DGSCIM Create User X OTP ```go m, _ := mProv.EnsureFromSCIM(ctx, &EnsureFromSCIMRequest{ @@ -1051,10 +1051,10 @@ m, _ := mProv.EnsureFromSCIM(ctx, &EnsureFromSCIMRequest{ }) ``` -#### Case E:已登入 user 改綁業務 email(atomic 直組 vs composite) +#### Case EGwnJ user j~ email]atomic vs composite^ ```go -// 路徑 1:直接組 atomic(精細控制時用) +// | 1G atomic]ӱɥΡ^ chal, _ := mOTP.Generate(ctx, &GenerateOTPRequest{ TenantID: tenantID, Purpose: OTPPurposeBusinessEmail, Identifier: uid, }) @@ -1069,182 +1069,182 @@ _ = mOTP.Verify(ctx, &VerifyOTPRequest{ }) _ = mProfile.SetBusinessEmailVerified(ctx, tenantID, uid, newEmail) -// 路徑 2:用 composite(簡單情況走這個就好) +// | 2G composite]²污poӴNn^ chal, _ := mVerification.StartEmailVerify(ctx, tenantID, uid, newEmail) // ... _ = mVerification.ConfirmEmailVerify(ctx, tenantID, uid, chal.ChallengeID, userCode) ``` -> 每個 atomic 動作獨立可呼叫、獨立 audit、獨立失敗重試。Logic 自行決定組合與順序。 +> C atomic ʧ@WߥiIsBW auditBWߥѭաCLogic ۦMwզXPǡC --- -## 6. permission 模組(B2B 自定義,參考 permission-server) +## 6. permission Ҳա]B2B ۩wqAѦ permission-server^ -路徑:`internal/model/permission/` +|G`internal/model/permission/` -> 本節吸收 [app-cloudep-permission-server](https://code.30cm.net/digimon/app-cloudep-permission-server) 已驗證的設計:**Casbin + Redis RBAC**、**Permission Tree(父子繼承)**、**HTTP Path/Method 綁定**。 -> **與舊 permission-server 的差異**:Token 簽發/驗證/黑名單移至 Gateway `auth` 模組;`ClientID` 改為 `tenant_id`;支援多租戶 B2B 各自定義 Role。 +> `l [app-cloudep-permission-server](https://code.30cm.net/digimon/app-cloudep-permission-server) wҪ]pG**Casbin + Redis RBAC**B**Permission Tree]l~ӡ^**B**HTTP Path/Method jw**C +> **P permission-server t**GToken ño//¦W沾 Gateway `auth` ҲաF`ClientID` אּ `tenant_id`F䴩h B2B U۩wq RoleC -### 6.1 設計目標 +### 6.1 ]pؼ -| 能力 | 說明 | +| O | | |------|------| -| **Permission Tree** | 全局權限樹(平台 seed),父子節點繼承;父節點關閉則子節點不可用 | -| **Casbin RBAC** | 以 `(tenant_id, role_key, http_path, http_method)` 做 API 授權;path 支援 `keyMatch2` 萬用字元 | -| **B2B 自定義 Role** | 每個租戶建立自訂 Role,從全局 Catalog **勾選** Permission(不可自創 Permission 字串) | -| **UserRole** | 租戶 + uid + role;支援多角色(以 immutable role key 做 Casbin subject) | -| **RolePermission** | 勾選子權限時自動補齊父權限 ID(沿用 permission-server 的 `getFullParentPermissionIDs`) | -| **Policy 同步** | MongoDB → Casbin Policy → Redis;定時 `LoadPolicy` + 變更時觸發 reload | -| **外部映射** | ZITADEL Role / LDAP Group / SCIM Group → 租戶內部 Role.Key | -| **細粒度擴展** | 同一 API 可掛 `.plain_code` 子權限(如明碼查詢),沿用舊設計 | +| **Permission Tree** | v]x seed^Al`I~ӡF`Ihl`Ii | +| **Casbin RBAC** | H `(tenant_id, role_key, http_path, http_method)` API vFpath 䴩 `keyMatch2` UΦr | +| **B2B ۩wq Role** | Cӯإߦۭq RoleAq Catalog **Ŀ** Permission]i۳ Permission r^ | +| **UserRole** | + uid + roleF䴩h]H immutable role key Casbin subject^ | +| **RolePermission** | Ŀlvɦ۰ʸɻv ID]u permission-server `getFullParentPermissionIDs`^ | +| **Policy PB** | MongoDB Casbin Policy RedisFw `LoadPolicy` + ܧIJo reload | +| **~Mg** | ZITADEL Role / LDAP Group / SCIM Group ᤺ Role.Key | +| **ӲɫXi** | P@ API i `.plain_code` lv]pXdߡ^Au³]p | -### 6.2 與 app-cloudep-permission-server 對照 +### 6.2 P app-cloudep-permission-server -| permission-server | Gateway permission 模組 | 備註 | +| permission-server | Gateway permission Ҳ | Ƶ | |-------------------|---------------------------|------| -| `TokenService` | **`auth` 模組** | JWT 不再放 permission-server | -| `PermissionService`(空) | 完整 HTTP API | 直接在 Gateway 暴露 | -| `entity.Permission` | 沿用 + `tenant_id` 不適用(全局 Catalog) | Permission 為平台級 | -| `entity.Role.ClientID` | `Role.TenantID` | 租戶隔離 | -| `entity.Role.UID` | `Role.CreatorUID` | 建立者,可選 | -| `entity.Role.Name` | `Role.DisplayName` | 顯示名稱,可改名 | -| — | `Role.Key` | **Casbin policy 的 role 欄位**,租戶內唯一且不可改 | -| `Casbin Enforcer` | `RBACUseCase` + Redis Adapter | 沿用 | -| `PermissionTree` | `usecase/permission_tree.go` | 沿用 | -| `AdminRoleUID` / `GodDog` | `PlatformAdminRoleKey` + allowlist | 平台超級管理員 bypass,需 audit | +| `TokenService` | **`auth` Ҳ** | JWT A permission-server | +| `PermissionService`]š^ | HTTP API | b Gateway S | +| `entity.Permission` | u + `tenant_id` AΡ] Catalog^ | Permission x | +| `entity.Role.ClientID` | `Role.TenantID` | j | +| `entity.Role.UID` | `Role.CreatorUID` | إߪ̡Ai | +| `entity.Role.Name` | `Role.DisplayName` | ܦW١AiW | +| X | `Role.Key` | **Casbin policy role **A᤺ߤ@Bi | +| `Casbin Enforcer` | `RBACUseCase` + Redis Adapter | u | +| `PermissionTree` | `usecase/permission_tree.go` | u | +| `AdminRoleUID` / `GodDog` | `PlatformAdminRoleKey` + allowlist | xWź޲z bypassA audit | | `permission.Type` | `enum.PermissionType` | `BackendUser` / `FrontendUser` | -### 6.3 核心概念 +### 6.3 ֤߷ ``` -Permission(全局樹) 平台定義,含 name / http_path / http_method / parent / status / type -Role(租戶自定義) 租戶建立的角色;display_name 可改,key 不可改,如 sales_supervisor、tenant_admin -RolePermission Role ↔ Permission ID 多對多;勾選時自動補父節點 -UserRole uid ↔ Role;一 user 可多 role -RoleMapping 外部 Group/Role → 內部 RoleID / Role.Key +Permission]^ xwqAt name / http_path / http_method / parent / status / type +Role]۩wq^ إߪFdisplay_name iAkey iAp sales_supervisorBtenant_admin +RolePermission Role ? Permission ID hhFĿɦ۰ʸɤ`I +UserRole uid ? RoleF@ user ih role +RoleMapping ~ Group/Role RoleID / Role.Key Casbin Policy p, {tenant_id}, {role_key}, {http_path}, {http_method}, {permission.Name} ``` -### 6.4 Permission Entity(全局 Catalog) +### 6.4 Permission Entity] Catalog^ -沿用 permission-server 的 `entity.Permission` 結構,MongoDB collection:`permission`。 +u permission-server `entity.Permission` cAMongoDB collectionG`permission`C ```go type Permission struct { ID primitive.ObjectID - Parent string // 父權限 ID(ObjectID hex);空 = 掛 root - Name string // 唯一語意名,dot notation,如 member.info.select - HTTPMethods string // 單值如 "GET",或 regex 如 "GET|POST|PATCH";分類節點為空 - HTTPPath string // 如 /api/v1/members/*(keyMatch2 pattern);分類節點為空 + Parent string // v ID]ObjectID hex^F = root + Name string // ߤ@yNWAdot notationAp member.info.select + HTTPMethods string // Ȧp "GET"A regex p "GET|POST|PATCH"F`I + HTTPPath string // p /api/v1/members/*]keyMatch2 pattern^F`I Status enum.Status // open | close - Type enum.PermissionType // backend_user | frontend_user(後台 / 前台菜單) + Type enum.PermissionType // backend_user | frontend_user]x / ex^ CreateAt int64 UpdateAt int64 } ``` -> **`Permission.Name` 一旦建立不可改名**(被 RolePermission、UI i18n 鍵、Casbin policy.name 欄位引用)。 -> 廢棄走 `status=close`;新名稱另建新 leaf。重命名要走資料遷移腳本。 -> **`HTTPPath` 限制**:避免裸 `*`;萬用路徑要明確標出資源根,例如 `/api/v1/members/*`,禁止 `/api/v1/*` 之類的廣域 pattern(防 keyMatch2 貪婪命中)。 +> **`Permission.Name` @إߤiW**]Q RolePermissionBUI i18n BCasbin policy.name ޥΡ^C +> o `status=close`FsW٥tطs leafCRWnƾE}C +> **`HTTPPath` **GקKr `*`FUθ|nTХX귽ڡAҦp `/api/v1/members/*`AT `/api/v1/*` s pattern] keyMatch2 gR^C -#### 命名規則(dot notation,與 permission-server 一致) +#### RWWh]dot notationAP permission-server @P^ ``` {domain}.{module}.{action} -{domain}.{module}.{action}.{variant} # 如 .plain_code +{domain}.{module}.{action}.{variant} # p .plain_code ``` -#### Permission Tree 範例(seed 草案) +#### Permission Tree dҡ]seed ס^ ``` -member.info.management # 一級:會員資訊管理(分類,無 HTTP) -├── member.basic.info # 二級:基礎資訊 -│ ├── member.info.select # GET /api/v1/members/me -│ ├── member.info.update # PATCH /api/v1/members/me -│ └── member.info.select.plain_code # GET /api/v1/members(明碼欄位) -├── member.admin.list # GET /api/v1/members -├── member.admin.read # GET /api/v1/members/:uid -├── member.admin.update # PATCH /api/v1/members/:uid -└── member.admin.status # PATCH /api/v1/members/:uid/status +member.info.management # @šG|T޲z]AL HTTP^ +uww member.basic.info # GšG¦T +x uww member.info.select # GET /api/v1/members/me +x uww member.info.update # PATCH /api/v1/members/me +x |ww member.info.select.plain_code # GET /api/v1/members]X^ +uww member.admin.list # GET /api/v1/members +uww member.admin.read # GET /api/v1/members/:uid +uww member.admin.update # PATCH /api/v1/members/:uid +|ww member.admin.status # PATCH /api/v1/members/:uid/status -permission.role.management # 一級:角色權限管理 -├── permission.role.read # GET /api/v1/permissions/roles -├── permission.role.write # POST/PUT/DELETE roles -├── permission.assign.write # POST/DELETE user roles -└── permission.catalog.read # GET /api/v1/permissions/catalog +permission.role.management # @šGv޲z +uww permission.role.read # GET /api/v1/permissions/roles +uww permission.role.write # POST/PUT/DELETE roles +uww permission.assign.write # POST/DELETE user roles +|ww permission.catalog.read # GET /api/v1/permissions/catalog tenant.management -├── tenant.read -├── tenant.ldap.write -└── tenant.sync.trigger +uww tenant.read +uww tenant.ldap.write +|ww tenant.sync.trigger scim.management -├── scim.users.write -└── scim.groups.write +uww scim.users.write +|ww scim.groups.write -system.management # 平台級 -└── system.tenant.create +system.management # x +|ww system.tenant.create ``` -> **分類節點**(無 `http_path`)供 UI 樹狀勾選;**葉節點**才寫入 Casbin Policy。 -> 新增 Permission 走平台 seed migration;租戶**不可**自行新增 Permission 名稱。 +> **`I**]L `http_path`^ UI 𪬤ĿF**`I**~gJ Casbin PolicyC +> sW Permission x seed migrationF**i**ۦsW Permission W١C -#### Permission Tree 行為(沿用 permission-server) +#### Permission Tree 欰]u permission-server^ -1. **`filterOpenNodes`**:父節點 `status=close` → 整棵子樹不可用 -2. **`getFullParentPermissionIDs`**:勾選子權限 → 自動加入所有父節點 ID -3. **`getFullParentPermission`**:查 Role 權限 → 回傳含父節點的完整 permission name → status map(供前端 UI) +1. **`filterOpenNodes`**G`I `status=close` ʤl𤣥i +2. **`getFullParentPermissionIDs`**GĿlv ۰ʥ[JҦ`I ID +3. **`getFullParentPermission`**Gd Role v ^ǧt`I permission name status map]ѫe UI^ -### 6.5 Role Entity(B2B 租戶自定義) +### 6.5 Role Entity]B2B ۩wq^ ```go type Role struct { ID primitive.ObjectID - TenantID string // 租戶 ID(= 舊 ClientID) - Key string // immutable role key,租戶內唯一;Casbin enforce 用此值 - DisplayName string // 顯示名稱,可改名 - CreatorUID string // 建立者 uid(= 舊 Role.UID,可選) + TenantID string // ID]= ClientID^ + Key string // immutable role keyA᤺ߤ@FCasbin enforce Φ + DisplayName string // ܦW١AiW + CreatorUID string // إߪ uid]= Role.UIDAi^ Status enum.Status // open | close - IsSystem bool // 系統 seed 的預設角色,B2B 可改 Permission 但不可刪除 Owner + IsSystem bool // t seed w]AB2B i Permission iR Owner CreateAt int64 UpdateAt int64 } // Index: { tenant_id, key } unique ``` -> **`Role.Key` 規範**: -> - 格式:`^[a-z][a-z0-9_]{1,63}$` -> - 租戶內唯一;建立後**不可修改** -> - 禁止 `system.` / `platform_` 字首(保留給平台級 role) -> - rename 改 `DisplayName`,不影響 UserRole、RoleMapping、Casbin policy 與既有 token +> **`Role.Key` Wd**G +> - 榡G`^[a-z][a-z0-9_]{1,63}$` +> - ᤺ߤ@Fإ߫**iק** +> - T `system.` / `platform_` r]Odx role^ +> - rename `DisplayName`AvT UserRoleBRoleMappingBCasbin policy PJ token -#### B2B 自定義規則 +#### B2B ۩wqWh -1. 租戶可 **CRUD** 自訂 Role(`is_system=false`) -2. 系統 seed 的預設 Role(`is_system=true`)可修改 Permission 集合,**tenant_owner 不可刪** -3. Role 綁定的 Permission 必須是全局 Catalog 中 `status=open` 的節點 -4. 租戶**不可**勾選 `system.*` 權限(除非平台另行開啟) -5. 至少保留一個 Role 含 `permission.role.write`,避免租戶自鎖 +1. i **CRUD** ۭq Role]`is_system=false`^ +2. t seed w] Role]`is_system=true`^iק Permission XA**tenant_owner iR** +3. Role jw Permission O Catalog `status=open` `I +4. **i**Ŀ `system.*` v]Dxt}ҡ^ +5. ܤ֫Od@ Role t `permission.role.write`AקK -#### 預設 Role 模板(建立 B2B tenant 時 seed) +#### w] Role ҪO]إ B2B tenant seed^ -| Key | DisplayName | 預設勾選(Permission Name) | +| Key | DisplayName | w]Ŀ]Permission Name^ | |------|------|----------------------------| -| `tenant_owner` | 租戶擁有者 | 除 `system.*` 外全部 open 節點 | -| `tenant_admin` | 租戶管理員 | member.*, permission.*, tenant.*, scim.* | -| `member_manager` | 會員管理 | member.admin.list, member.admin.read, member.admin.status | -| `member` | 一般會員 | member.info.select, member.info.update | -| `viewer` | 唯讀 | member.info.select | +| `tenant_owner` | ֦ | `system.*` ~ open `I | +| `tenant_admin` | ޲z | member.*, permission.*, tenant.*, scim.* | +| `member_manager` | |޲z | member.admin.list, member.admin.read, member.admin.status | +| `member` | @| | member.info.select, member.info.update | +| `viewer` | Ū | member.info.select | -B2B 管理員範例: +B2B ޲zdҡG ``` -建立 Role:sales_supervisor -勾選:member.admin.list, member.admin.read -指派:POST /permissions/users/{uid}/roles { "role_id": "..." } -→ RolePermission.Create → getFullParentPermissionIDs 自動補 parent -→ LoadPolicy 刷新 Casbin +إ RoleGsales_supervisor +ĿGmember.admin.list, member.admin.read +GPOST /permissions/users/{uid}/roles { "role_id": "..." } + RolePermission.Create getFullParentPermissionIDs ۰ʸ parent + LoadPolicy s Casbin ``` ### 6.6 UserRole / RolePermission @@ -1271,11 +1271,11 @@ type RolePermission struct { // Index: { tenant_id, role_id, permission_id } unique ``` -> 舊 permission-server 的 UserRole 為一 user 一 role(Update 覆蓋);新設計**支援多角色**,Middleware 對每個 immutable role key 做 Casbin enforce,任一 allow 即通過。 +> permission-server UserRole @ user @ role]Update л\^Fs]p**䴩h**AMiddleware C immutable role key Casbin enforceA@ allow YqLC -### 6.7 Casbin RBAC(核心授權引擎) +### 6.7 Casbin RBAC]֤߱v^ -#### 模型檔 `etc/rbac.conf`(Gateway 多租戶版) +#### ҫ `etc/rbac.conf`]Gateway h᪩^ ```ini [request_definition] @@ -1291,48 +1291,48 @@ e = some(where (p.eft == allow)) m = r.tenant == p.tenant && r.role == p.role && keyMatch2(r.path, p.path) && regexMatch(r.method, p.methods) ``` -- **`keyMatch2`**:支援 `/api/v1/members/*` 萬用 path -- **`regexMatch`**:支援 `GET|POST` 多 method 寫在同一 policy -- **SuperAdmin bypass**:不放在 Casbin matcher;由 Middleware 先驗證 platform role / allowlist 後短路,並寫入 audit log +- **`keyMatch2`**G䴩 `/api/v1/members/*` U path +- **`regexMatch`**G䴩 `GET|POST` h method gbP@ policy +- **SuperAdmin bypass**Gb Casbin matcherF Middleware platform role / allowlist uAügJ audit log -#### Policy 載入(`RBACUseCase.LoadPolicy`) +#### Policy J]`RBACUseCase.LoadPolicy`^ ``` -1. permissionRepo.GetAll → GeneratePermissionTree → filterOpenNodes -2. roleRepo.All(tenant_id) → 每個 role 取 rolePermissionRepo.Get -3. 對每個 (role, permission) 若 http_path + http_method 非空: +1. permissionRepo.GetAll GeneratePermissionTree filterOpenNodes +2. roleRepo.All(tenant_id) C role rolePermissionRepo.Get +3. C (role, permission) Y http_path + http_method DšG enforcer.AddPolicy(tenantID, role.Key, permission.HTTPPath, permission.HTTPMethods, permission.Name) -4. adapter.SavePolicy(tenant_id) → Redis List(tenant-scoped casbin rules) +4. adapter.SavePolicy(tenant_id) Redis List]tenant-scoped casbin rules^ 5. enforcer.LoadPolicy() ``` -#### 授權檢查(`RBACUseCase.Check`) +#### vˬd]`RBACUseCase.Check`^ ```go -// 輸入:tenantID, roleKey, requestPath, requestMethod +// JGtenantID, roleKey, requestPath, requestMethod ok, policy, err := enforcer.EnforceEx(tenantID, roleKey, path, method) -// 回傳 CheckRolePermissionStatus: +// ^ CheckRolePermissionStatusG // Allow: bool -// PermissionName: string // 命中的 permission.Name -// PlainCode: bool // 是否有 .plain_code 子權限(GET 時額外查) +// PermissionName: string // R permission.Name +// PlainCode: bool // O_ .plain_code lv]GET B~d^ ``` -#### Policy 同步策略 +#### Policy PB -| 觸發 | 動作 | +| IJo | ʧ@ | |------|------| -| RolePermission 變更 | 該 tenant `LoadPolicy` + 權限快取失效 | -| Permission status 變更(平台) | 全局 `LoadAllPolicies` + 權限快取失效 | -| 定時 cron(如 5min) | `SyncPolicy` 兜底 | -| Gateway 啟動 | 初始 `LoadPolicy` | +| RolePermission ܧ | tenant `LoadPolicy` + v֨ | +| Permission status ܧ]x^ | `LoadAllPolicies` + v֨ | +| w cron]p 5min^ | `SyncPolicy` © | +| Gateway Ұ | l `LoadPolicy` | -Redis 儲存 Casbin rules:`permission:casbin:rules:{tenant_id}`(List of JSON `rbac.Rule`)。全量載入時可掃描 tenant-scoped keys,或由 repository 依 MongoDB role/permission 重建。 +Redis xs Casbin rulesG`permission:casbin:rules:{tenant_id}`]List of JSON `rbac.Rule`^CqJɥiy tenant-scoped keysAΥ repository MongoDB role/permission ءC -### 6.8 UseCase 介面 +### 6.8 UseCase ```go -// --- Casbin 授權(核心)--- +// --- Casbin v]֤ߡ^--- type RBACUseCase interface { Check(ctx context.Context, req *CheckRequest) (*CheckResult, error) LoadPolicy(ctx context.Context, tenantID string) error @@ -1344,8 +1344,8 @@ type CheckRequest struct { TenantID string UID string RoleKey string // immutable Role.Key - Path string // 實際請求 path - Method string // 實際 HTTP method + Path string // ڽШD path + Method string // HTTP method } type CheckResult struct { @@ -1355,15 +1355,15 @@ type CheckResult struct { MatchedRole string } -// --- Permission Catalog(平台級)--- +// --- Permission Catalog]xš^--- type PermissionUseCase interface { All(ctx context.Context, status *enum.Status) ([]PermissionDTO, error) - FilterAll(ctx context.Context) ([]PermissionDTO, error) // 樹狀過濾 open 節點 - Insert(ctx context.Context, req *CreatePermissionRequest) error // 平台 Admin + FilterAll(ctx context.Context) ([]PermissionDTO, error) // 𪬹Lo open `I + Insert(ctx context.Context, req *CreatePermissionRequest) error // x Admin Update(ctx context.Context, id string, req *UpdatePermissionRequest) error } -// --- Role(租戶級,B2B 自定義)--- +// --- Role]šAB2B ۩wq^--- type RoleUseCase interface { List(ctx context.Context, req *ListRolesRequest) ([]RoleDTO, int64, error) All(ctx context.Context, tenantID string) ([]RoleDTO, error) @@ -1375,20 +1375,20 @@ type RoleUseCase interface { // --- RolePermission --- type RolePermissionUseCase interface { - Get(ctx context.Context, tenantID, roleID string) (enum.Permissions, error) // name → open/close - Replace(ctx context.Context, tenantID, roleID string, permNames []string) error // 全量取代 + Get(ctx context.Context, tenantID, roleID string) (enum.Permissions, error) // name open/close + Replace(ctx context.Context, tenantID, roleID string, permNames []string) error // qN } // --- UserRole --- type UserRoleUseCase interface { GetByUID(ctx context.Context, tenantID, uid string) ([]UserRoleDTO, error) - GetRoleKeys(ctx context.Context, tenantID, uid string) ([]string, error) // Middleware 用,走 cache + GetRoleKeys(ctx context.Context, tenantID, uid string) ([]string, error) // Middleware ΡA cache Assign(ctx context.Context, tenantID, uid, roleID string, source enum.RoleSource) error Revoke(ctx context.Context, tenantID, uid, roleID string) error - Replace(ctx context.Context, tenantID, uid string, roleIDs []string, source enum.RoleSource) error // 全量取代「該 source」的指派 + Replace(ctx context.Context, tenantID, uid string, roleIDs []string, source enum.RoleSource) error // qNu sourcev } -// --- 外部映射 --- +// --- ~Mg --- type RoleMappingUseCase interface { List(ctx context.Context, tenantID string) ([]RoleMappingDTO, error) Upsert(ctx context.Context, req *UpsertRoleMappingRequest) error @@ -1398,181 +1398,181 @@ type RoleMappingUseCase interface { SyncFromLDAPGroups(ctx context.Context, req *SyncFromLDAPGroupsRequest) error } -// --- 聚合查詢(前端菜单)--- +// --- EXdߡ]eݵ?^--- type AuthorizationQueryUseCase interface { GetMyPermissions(ctx context.Context, tenantID, uid string) (enum.Permissions, error) GetMyRoles(ctx context.Context, tenantID, uid string) ([]string, error) } ``` -> **跨租戶防呆**:所有 mutation usecase(Role*, RolePermission*, UserRole*, RoleMapping*)進入時必須驗證 target ID 屬於 `tenantID`;repository 查詢一律帶 `{tenant_id, _id}`,找不到回 `ErrRoleNotInTenant` / `ErrUserRoleNotInTenant`。 -> Logic 層**禁止**把 path 的 `:id` 直接丟 usecase 而不帶 `tenant_id`。 +> **󯲤ᨾb**GҦ mutation usecase]Role*, RolePermission*, UserRole*, RoleMapping*^iJɥ target ID ݩ `tenantID`Frepository dߤ@߱a `{tenant_id, _id}`A䤣^ `ErrRoleNotInTenant` / `ErrUserRoleNotInTenant`C +> Logic h**T** path `:id` usecase Ӥa `tenant_id`C -### 6.9 Middleware 授權流程 +### 6.9 Middleware vy{ ``` -Request(JwtRevokeMiddleware 已驗過 JWT + auth_gen) - 1. 取 ctx.tenant_id, ctx.uid - 2. userRoleUC.GetRoleKeys → []Role.Key(走 perm:user_roles cache) - 3. 對每個 roleKey enforce(tenantID, roleKey, path, method); - 聚合所有 allow 結果為 []CheckResult - 4. 若無任何 allow → 403 Forbidden - 5. 聚合規則: - - PermissionNames = 所有 allow 命中的 permission.Name(去重) - - PlainCode = 對每個命中 permission,額外 enforce - (permission.Name + ".plain_code") 變體;任一通過 → true - 6. 注入 ctx.permission_names, ctx.plain_code +Request]JwtRevokeMiddleware wL JWT + auth_gen^ + 1. ctx.tenant_id, ctx.uid + 2. userRoleUC.GetRoleKeys []Role.Key] perm:user_roles cache^ + 3. C roleKey enforce(tenantID, roleKey, path, method)F + EXҦ allow G []CheckResult + 4. YL allow 403 Forbidden + 5. EXWhG + - PermissionNames = Ҧ allow R permission.Name]h^ + - PlainCode = CөR permissionAB~ enforce + (permission.Name + ".plain_code") F@qL true + 6. `J ctx.permission_names, ctx.plain_code ``` -> **PlainCode 實作**:`*.plain_code` 與一般 leaf 一樣寫入 Casbin policy;Check 時主 permission 命中後,用同一 `(tenantID, roleKey, path, method)` 再做一次帶 `.plain_code` 的 EnforceEx。沒有 plain_code 變體 → false。 -> Logic 層讀 `ctx.plain_code` 決定是否回傳明碼欄位。 +> **PlainCode @**G`*.plain_code` P@ leaf @˼gJ Casbin policyFCheck ɥD permission RAΦP@ `(tenantID, roleKey, path, method)` A@a `.plain_code` EnforceExCS plain_code falseC +> Logic hŪ `ctx.plain_code` MwO_^ǩXC -> **Platform Admin bypass** 由 `JwtRevokeMiddleware` 第 0 步處理(見 §4.6),不進這個流程。 +> **Platform Admin bypass** `JwtRevokeMiddleware` 0 BBz] 4.6^AioӬy{C -### 6.10 外部 Group / Role 映射 +### 6.10 ~ Group / Role Mg ```go type RoleMapping struct { TenantID string ExternalSource enum.RoleSource // zitadel | ldap | scim ExternalKey string // ZITADEL role / LDAP group DN / SCIM group id - InternalRoleID string // 租戶 Role._id hex - InternalRoleKey string // denormalized Role.Key,方便查詢與審計 + InternalRoleID string // Role._id hex + InternalRoleKey string // denormalized Role.KeyAKd߻Pfp } // Index: { tenant_id, external_source, external_key } unique ``` -| 來源 | ExternalKey 範例 | 映射到 | +| ӷ | ExternalKey d | Mg | |------|------------------|--------| | ZITADEL | `org_admin` | `tenant_admin` | -| LDAP (AD) | `CN=CloudEP-Admins,OU=Groups,DC=acme,DC=com` | 租戶自訂 Role.Key | -| LDAP (OpenLDAP) | `cn=admins,ou=groups,dc=acme,dc=com` | 租戶自訂 Role.Key | -| SCIM Group | `group-uuid-xxx` | 租戶自訂 Role.Key | +| LDAP (AD) | `CN=CloudEP-Admins,OU=Groups,DC=acme,DC=com` | ۭq Role.Key | +| LDAP (OpenLDAP) | `cn=admins,ou=groups,dc=acme,dc=com` | ۭq Role.Key | +| SCIM Group | `group-uuid-xxx` | ۭq Role.Key | -由 B2B 租戶管理員在後台設定(需命中 `permission.role.write` 對應 API)。 + B2B ޲zbx]w]ݩR `permission.role.write` API^C -#### 外部來源同步規則(避免洗掉 manual 指派) +#### ~ӷPBWh]קK~ manual ^ -`SyncFromZitadelClaims` / `SyncFromScimGroup` / `SyncFromLDAPGroups` 一律以 **`source` 維度**做局部全量取代: +`SyncFromZitadelClaims` / `SyncFromScimGroup` / `SyncFromLDAPGroups` @ߥH **`source` **qNG ``` UserRoleUC.Replace(tenantID, uid, roleIDs, source = zitadel) - → DELETE user_roles WHERE tenant_id=? AND uid=? AND source='zitadel' - → INSERT 新的 roleIDs(source='zitadel') - → source='manual' / 'scim' / 'ldap' 的指派不受影響 + DELETE user_roles WHERE tenant_id=? AND uid=? AND source='zitadel' + INSERT s roleIDs]source='zitadel'^ + source='manual' / 'scim' / 'ldap' vT ``` -> 跨來源衝突原則:UserRole 為「並集」,任一 source 指派的 role 即生效;revoke 必須指定 source。 +> ӷĬhGUserRole uövA@ source role YͮġFrevoke w sourceC -### 6.11 權限變更生效 +### 6.11 vܧͮ -| 事件 | 動作 | +| ƥ | ʧ@ | |------|------| -| RolePermission Create/Delete | `LoadPolicy(tenant_id)` + `perm:role_perms:*` 快取失效 | +| RolePermission Create/Delete | `LoadPolicy(tenant_id)` + `perm:role_perms:*` ֨ | | Role Create/Update/Delete | `LoadPolicy(tenant_id)` | | UserRole Assign/Revoke | **`INCR auth:gen`** + `LoadPolicy(tenant_id)` | -| SCIM / LDAP Group 變更 | 更新 user_roles → `LoadPolicy` + **`INCR auth_gen`** | -| Permission status 變更(平台) | `LoadAllPolicies()` + 權限快取失效;若變更影響登入狀態再 batch `INCR auth_gen` | +| SCIM / LDAP Group ܧ | s user_roles `LoadPolicy` + **`INCR auth_gen`** | +| Permission status ܧ]x^ | `LoadAllPolicies()` + v֨ġFYܧvTnJAA batch `INCR auth_gen` | -#### 多 Pod 同步機制(已決策) +#### h Pod PB]wM^ ``` Channel: casbin:reload -Payload: { "tenant_id": "xxx", "ts": 1716120000000 } # tenant_id == "*" 代表全量 +Payload: { "tenant_id": "xxx", "ts": 1716120000000 } # tenant_id == "*" Nq ``` -- **即時通道**:Pub/Sub - - Writer:每次 `LoadPolicy(tenant_id)` 完成後 `PUBLISH casbin:reload {tenant_id}` - - Subscriber:每個 pod 啟動時 `SUBSCRIBE`;收到後在記憶體中 reload 對應 tenant 的 policy -- **兜底**:每 pod 啟動排程 `5min` 全量 `LoadAllPolicies()`;防 pub message 漏接(pod 啟動瞬間、Redis 連線抖動) -- **冪等**:reload 用單一 mutex per tenant;同時段多個 message 只觸發一次實際 IO -- **首次啟動**:pod 啟動先做一次 `LoadAllPolicies()`,再開始 SUBSCRIBE -- 設定:`Permission.PolicySyncInterval: 5m`、`Permission.PolicyReloadChannel: casbin:reload` +- **YɳqD**GPub/Sub + - WriterGC `LoadPolicy(tenant_id)` `PUBLISH casbin:reload {tenant_id}` + - SubscriberGC pod Ұʮ `SUBSCRIBE`FbO餤 reload tenant policy +- **©**GC pod ҰʱƵ{ `5min` q `LoadAllPolicies()`F pub message |]pod ҰBRedis suݰʡ^ +- ****Greload γ@ mutex per tenantFPɬqh message uIJo@ IO +- **Ұ**Gpod Ұʥ@ `LoadAllPolicies()`AA}l SUBSCRIBE +- ]wG`Permission.PolicySyncInterval: 5m`B`Permission.PolicyReloadChannel: casbin:reload` -### 6.12 B2C vs B2B 權限策略(已決策) +### 6.12 B2C vs B2B v]wM^ -| 租戶類型 | Role 自定義 | Permission 勾選 | API 限制 | +| | Role ۩wq | Permission Ŀ | API | |----------|-------------|-----------------|----------| -| **B2C** | **不可**(唯讀 seed 模板) | 固定,不可改 | 禁止 `POST/PUT/DELETE /permissions/roles*` | -| **B2B** | **完全自定義** | 從全局 Catalog 自由勾選 | 完整 permission API | -| **Hybrid** | 依 tenant.type 欄位判斷 | B2B 段可自定義 | middleware 檢查 tenant 類型 | +| **B2C** | **i**]Ū seed ҪO^ | TwAi | T `POST/PUT/DELETE /permissions/roles*` | +| **B2B** | **۩wq** | q Catalog ۥѤĿ | permission API | +| **Hybrid** | tenant.type P_ | B2B qi۩wq | middleware ˬd tenant | -B2C 租戶建立時只 seed 固定 Role(如 `member`、`viewer`),**不提供** Role CRUD 與 Permission 勾選 API(Casbin 直接載入 seed 結果)。 +B2C إ߮ɥu seed Tw Role]p `member`B`viewer`^A**** Role CRUD P Permission Ŀ API]Casbin J seed G^C --- -## 7. API 規劃 +## 7. API W -檔案:`generate/api/` +ɮסG`generate/api/` -### 7.1 auth.api(公開 / 需 JWT 視 API 而定) +### 7.1 auth.api]} / JWT API өw^ -| Method | Path | 說明 | 鑑權 | +| Method | Path | | Ųv | |--------|------|------|------| -| POST | `/api/v1/auth/token/exchange` | ZITADEL token → CloudEP JWT | 公開 | -| POST | `/api/v1/auth/token/refresh` | 刷新 JWT | 公開(帶 refresh) | -| POST | `/api/v1/auth/logout` | 登出(jti 黑名單) | JWT | -| POST | `/api/v1/auth/revoke-all` | 撤銷自己所有 session(INCR auth_gen) | JWT + Step-up `revoke_all_sessions` | -| POST | `/api/v1/auth/step-up/start` | 啟動 step-up MFA,寄 OTP | JWT | -| POST | `/api/v1/auth/step-up/confirm` | 確認 OTP → 簽發短壽 `step_up_token` | JWT | +| POST | `/api/v1/auth/token/exchange` | ZITADEL token CloudEP JWT | } | +| POST | `/api/v1/auth/token/refresh` | s JWT | }]a refresh^ | +| POST | `/api/v1/auth/logout` | nX]jti ¦W^ | JWT | +| POST | `/api/v1/auth/revoke-all` | MPۤvҦ session]INCR auth_gen^ | JWT + Step-up `revoke_all_sessions` | +| POST | `/api/v1/auth/step-up/start` | Ұ step-up MFAAH OTP | JWT | +| POST | `/api/v1/auth/step-up/confirm` | T{ OTP ñou `step_up_token` | JWT | -### 7.2 member.api(需 JWT + Casbin) +### 7.2 member.api] JWT + Casbin^ -| Method | Path | Casbin 命中 Permission(示例) | Step-up | +| Method | Path | Casbin R Permission]ܨҡ^ | Step-up | |--------|------|-------------------------------|---------| -| GET | `/api/v1/members/me` | `member.info.select` | — | -| PATCH | `/api/v1/members/me` | `member.info.update` | — | -| PATCH | `/api/v1/members/me/business-email` | `member.info.update` | ✅ `change_business_email` | -| PATCH | `/api/v1/members/me/business-phone` | `member.info.update` | ✅ `change_business_phone` | -| DELETE | `/api/v1/members/me` | `member.info.delete` | ✅ `delete_member` | -| POST | `/api/v1/members/me/verifications/email/start` | `member.info.update` | — | -| POST | `/api/v1/members/me/verifications/email/confirm` | `member.info.update` | — | -| POST | `/api/v1/members/me/verifications/phone/start` | `member.info.update` | — | -| POST | `/api/v1/members/me/verifications/phone/confirm` | `member.info.update` | — | -| GET | `/api/v1/members/me/totp` | `member.info.select` | — | -| POST | `/api/v1/members/me/totp/enroll-start` | `member.info.update` | — | -| POST | `/api/v1/members/me/totp/enroll-confirm` | `member.info.update` | — | -| POST | `/api/v1/members/me/totp/backup-codes` | `member.info.update` | ✅ `disable_totp` | -| DELETE | `/api/v1/members/me/totp` | `member.info.update` | ✅ `disable_totp` | -| GET | `/api/v1/members` | `member.admin.list` | — | -| GET | `/api/v1/members/:uid` | `member.admin.read` | — | -| PATCH | `/api/v1/members/:uid` | `member.admin.update` | — | -| PATCH | `/api/v1/members/:uid/status` | `member.admin.status` | ✅ `tenant_admin_force_status` | +| GET | `/api/v1/members/me` | `member.info.select` | X | +| PATCH | `/api/v1/members/me` | `member.info.update` | X | +| PATCH | `/api/v1/members/me/business-email` | `member.info.update` | ? `change_business_email` | +| PATCH | `/api/v1/members/me/business-phone` | `member.info.update` | ? `change_business_phone` | +| DELETE | `/api/v1/members/me` | `member.info.delete` | ? `delete_member` | +| POST | `/api/v1/members/me/verifications/email/start` | `member.info.update` | X | +| POST | `/api/v1/members/me/verifications/email/confirm` | `member.info.update` | X | +| POST | `/api/v1/members/me/verifications/phone/start` | `member.info.update` | X | +| POST | `/api/v1/members/me/verifications/phone/confirm` | `member.info.update` | X | +| GET | `/api/v1/members/me/totp` | `member.info.select` | X | +| POST | `/api/v1/members/me/totp/enroll-start` | `member.info.update` | X | +| POST | `/api/v1/members/me/totp/enroll-confirm` | `member.info.update` | X | +| POST | `/api/v1/members/me/totp/backup-codes` | `member.info.update` | ? `disable_totp` | +| DELETE | `/api/v1/members/me/totp` | `member.info.update` | ? `disable_totp` | +| GET | `/api/v1/members` | `member.admin.list` | X | +| GET | `/api/v1/members/:uid` | `member.admin.read` | X | +| PATCH | `/api/v1/members/:uid` | `member.admin.update` | X | +| PATCH | `/api/v1/members/:uid/status` | `member.admin.status` | ? `tenant_admin_force_status` | -> 授權由 **Casbin 比對實際 path + method** 決定,非硬編碼 permission 字串。 -> Step-up 欄為✅者需在 Header 帶 `X-Step-Up-Token`,且 token claim 的 `action` 必須與表列 action 一致(見 §5.6)。 +> v **Casbin path + method** MwADwsX permission rC +> Step-up 欰?̻ݦb Header a `X-Step-Up-Token`AB token claim `action` PC action @P] 5.6^C -### 7.3 permission.api(需 JWT + Casbin) +### 7.3 permission.api] JWT + Casbin^ -| Method | Path | 說明 | +| Method | Path | | |--------|------|------| -| GET | `/api/v1/permissions/catalog` | 全局 Permission Tree(open 節點) | -| GET | `/api/v1/permissions/me` | 當前使用者的 permission name → status map | -| GET | `/api/v1/permissions/roles` | 列出租戶 Role | -| POST | `/api/v1/permissions/roles` | 建立 Role(B2B) | -| PUT | `/api/v1/permissions/roles/:id` | 更新 Role | -| DELETE | `/api/v1/permissions/roles/:id` | 刪除 Role | -| GET | `/api/v1/permissions/roles/:id/permissions` | 取得 Role 勾選的 Permission | -| PUT | `/api/v1/permissions/roles/:id/permissions` | 全量取代 Role 勾選 `{ "permission_names": [...] }`(PermissionTree 驗證 + 補 parent) | -| GET | `/api/v1/permissions/users/:uid/roles` | 查使用者角色 | -| POST | `/api/v1/permissions/users/:uid/roles` | 指派 Role `{ "role_id": "..." }` | -| DELETE | `/api/v1/permissions/users/:uid/roles/:role_id` | 撤銷 Role | -| GET | `/api/v1/permissions/role-mappings` | 外部 Group 映射列表 | -| PUT | `/api/v1/permissions/role-mappings` | 新增/更新映射 | -| POST | `/api/v1/permissions/policy/reload` | 手動觸發 LoadPolicy(平台 Admin) | +| GET | `/api/v1/permissions/catalog` | Permission Tree]open `I^ | +| GET | `/api/v1/permissions/me` | eϥΪ̪ permission name status map | +| GET | `/api/v1/permissions/roles` | CX Role | +| POST | `/api/v1/permissions/roles` | إ Role]B2B^ | +| PUT | `/api/v1/permissions/roles/:id` | s Role | +| DELETE | `/api/v1/permissions/roles/:id` | R Role | +| GET | `/api/v1/permissions/roles/:id/permissions` | o Role Ŀ諸 Permission | +| PUT | `/api/v1/permissions/roles/:id/permissions` | qN Role Ŀ `{ "permission_names": [...] }`]PermissionTree + parent^ | +| GET | `/api/v1/permissions/users/:uid/roles` | dϥΪ̨ | +| POST | `/api/v1/permissions/users/:uid/roles` | Role `{ "role_id": "..." }` | +| DELETE | `/api/v1/permissions/users/:uid/roles/:role_id` | MP Role | +| GET | `/api/v1/permissions/role-mappings` | ~ Group MgC | +| PUT | `/api/v1/permissions/role-mappings` | sW/sMg | +| POST | `/api/v1/permissions/policy/reload` | IJo LoadPolicy]x Admin^ | -### 7.4 tenant.api(平台 / 租戶 Admin) +### 7.4 tenant.api]x / Admin^ -| Method | Path | Casbin 命中 Permission(示例) | +| Method | Path | Casbin R Permission]ܨҡ^ | |--------|------|-------------------------------| | POST | `/api/v1/admin/tenants` | `system.tenant.create` | | GET | `/api/v1/admin/tenants/:tenant_id` | `tenant.read` | | PUT | `/api/v1/admin/tenants/:tenant_id/ldap` | `tenant.ldap.write` | | POST | `/api/v1/admin/tenants/:tenant_id/directory-sync` | `tenant.sync.trigger` | -### 7.5 scim.api(SCIM Bearer Token,非 JWT) +### 7.5 scim.api]SCIM Bearer TokenAD JWT^ -**已決策路由**:以 **`tenant_id`** 為 path 參數(不用子域名) +**wM**GH **`tenant_id`** path Ѽơ]ΤlW^ ``` /scim/v2/tenants/{tenant_id}/Users @@ -1581,32 +1581,32 @@ B2C 租戶建立時只 seed 固定 Role(如 `member`、`viewer`),**不提 /scim/v2/tenants/{tenant_id}/Schemas ``` -認證:`Authorization: Bearer {tenant_scim_token}`(hash 存於 tenant 設定) +{ҡG`Authorization: Bearer {tenant_scim_token}`]hash s tenant ]w^ -- `{tenant_id}` = ZITADEL `org_id`,與 JWT `tenant_id` 一致 -- SCIM 請求不走 CloudEP JWT;授權由 tenant 級 SCIM token + 可選 Casbin 細分 +- `{tenant_id}` = ZITADEL `org_id`AP JWT `tenant_id` @P +- SCIM ШD CloudEP JWTFv tenant SCIM token + i Casbin Ӥ --- -## 8. Middleware 鏈 +## 8. Middleware -### 8.1 一般受保護 API +### 8.1 @O@ API ``` Request - → go-zero JWT 驗簽 - → JwtRevokeMiddleware(jti 黑名單 + auth_gen) - → TenantContextMiddleware(校驗 tenant_id 一致) - → CasbinRBACMiddleware(tenant_id × role_key × path × method → Allow) - → handler → logic → usecase + go-zero JWT ñ + JwtRevokeMiddleware]jti ¦W + auth_gen^ + TenantContextMiddleware] tenant_id @P^ + CasbinRBACMiddleware]tenant_id role_key path method Allow^ + handler logic usecase ``` ### 8.2 CasbinRBACMiddleware -> Platform Admin bypass 在前一層 `JwtRevokeMiddleware` 第 0 步處理(§4.6),此處不重複。 +> Platform Admin bypass be@h `JwtRevokeMiddleware` 0 BBz]4.6^ABơC ```go -// 伪代码 +// ?N? roleKeys, _ := userRoleUC.GetRoleKeys(ctx, tenantID, uid) var hits []rbac.CheckResult for _, roleKey := range roleKeys { @@ -1623,7 +1623,7 @@ if len(hits) == 0 { return } -names, plain := aggregate(hits) // 去重 + PlainCode OR +names, plain := aggregate(hits) // h + PlainCode OR ctx = withPermissionNames(ctx, names) ctx = withPlainCode(ctx, plain) next(w, r) @@ -1633,136 +1633,136 @@ next(w, r) ``` Request - → ScimAuthMiddleware(tenant_scim_token) - → TenantContextMiddleware - → handler + ScimAuthMiddleware]tenant_scim_token^ + TenantContextMiddleware + handler ``` -### 8.4 Logic 層補充授權 +### 8.4 Logic hɥRv -Casbin 處理 **API 級** 授權。Logic 內可追加 **資源級** 判斷: +Casbin Bz **API ** vCLogic il[ **귽** P_G -- `member.info.select` vs 查他人:若 path 含 `:uid` 且 uid ≠ caller,需命中 `member.admin.read` -- `PlainCode`:Logic 讀 `ctx.plain_code`,決定是否回傳明碼欄位 -- **Step-up 守門**(高風險 action): - 1. 從 Header `X-Step-Up-Token` 取 token +- `member.info.select` vs dLHGY path t `:uid` B uid callerAݩR `member.admin.read` +- `PlainCode`GLogic Ū `ctx.plain_code`AMwO_^ǩX +- **Step-up u**]I action^G + 1. q Header `X-Step-Up-Token` token 2. `auth.StepUpTokenUseCase.Verify(token, expectedAction, tenantID, uid)` - - 檢 `typ == "step_up"`、`action == expectedAction`、`tenant_id` / `uid` 與 ctx 一致、未過期 - 3. `SETNX auth:stepup:used:{jti}=1`,已存在 → `403 step_up_replay` - 4. 全部通過 → 執行業務操作 - 5. 失敗 → `403 step_up_required` + `{ required_action: "" }` + - `typ == "step_up"`B`action == expectedAction`B`tenant_id` / `uid` P ctx @PBL + 3. `SETNX auth:stepup:used:{jti}=1`Awsb `403 step_up_replay` + 4. qL ~Ⱦާ@ + 5. `403 step_up_required` + `{ required_action: "" }` --- -## 9. 核心流程 +## 9. ֤߬y{ -### 9.1 登入 / 換票 +### 9.1 nJ / ``` -Client → ZITADEL OIDC Login(含 LDAP IdP) -Client → POST /auth/token/exchange { tenant_slug, id_token } +Client ZITADEL OIDC Login]t LDAP IdP^ +Client POST /auth/token/exchange { tenant_slug, id_token } 1. zitadel.VerifyIDToken - 2. tenant.ResolveBySlug → 校驗 org_id - 3. member.EnsureFromOIDC → uid(如 AMEX-10000000) - 4. permission.SyncFromZitadelClaims → user_roles - 5. auth.IssueTokenPair(role keys 快照, auth_gen) -Client ← { access_token, refresh_token, uid } + 2. tenant.ResolveBySlug org_id + 3. member.EnsureFromOIDC uid]p AMEX-10000000^ + 4. permission.SyncFromZitadelClaims user_roles + 5. auth.IssueTokenPair]role keys ַ, auth_gen^ +Client { access_token, refresh_token, uid } ``` -### 9.2 受保護 API +### 9.2 O@ API ``` -Client → GET /api/v1/members/me (Bearer access_jwt) - 1. JWT + 黑名單 + auth_gen - 2. CasbinRBACMiddleware → Check(role, "/api/v1/members/me", "GET") +Client GET /api/v1/members/me (Bearer access_jwt) + 1. JWT + ¦W + auth_gen + 2. CasbinRBACMiddleware Check(role, "/api/v1/members/me", "GET") 3. member.GetByUID ``` -### 9.3 B2B 自定義 Role + 勾選 Permission +### 9.3 B2B ۩wq Role + Ŀ Permission ``` -Tenant Admin → PUT /api/v1/permissions/roles/{id}/permissions +Tenant Admin PUT /api/v1/permissions/roles/{id}/permissions { "permission_names": ["member.admin.list", "member.admin.read"] } - → RolePermissionUC.Replace(全量取代) - → PermissionTree.getFullParentPermissionIDs(自動補 parent) - → RBACUC.LoadPolicy(tenant_id) + 廣播 reload(見 §6.11) + RolePermissionUC.Replace]qN^ + PermissionTree.getFullParentPermissionIDs]۰ʸ parent^ + RBACUC.LoadPolicy(tenant_id) + s reload] 6.11^ -Tenant Admin → POST /api/v1/permissions/users/{uid}/roles +Tenant Admin POST /api/v1/permissions/users/{uid}/roles { "role_id": "..." } - → UserRoleUC.Assign(tenantID, uid, roleID, source=manual) - → INCR auth_gen + DEL perm:user_roles cache + UserRoleUC.Assign(tenantID, uid, roleID, source=manual) + INCR auth_gen + DEL perm:user_roles cache ``` -### 9.4 停權 +### 9.4 v ``` -Admin → PATCH /api/v1/members/:uid/status { status: "suspended" } +Admin PATCH /api/v1/members/:uid/status { status: "suspended" } Header: X-Step-Up-Token: - 1. Casbin enforce 命中 member.admin.status - 2. Logic 驗 step_up_token + action 一致 + 1. Casbin enforce R member.admin.status + 2. Logic step_up_token + action @P 3. member.UpdateStatus - 4. auth.RevokeAllForUser(INCR auth:gen:{tenant_id}:{uid}) + 4. auth.RevokeAllForUser]INCR auth:gen:{tenant_id}:{uid}^ ``` -### 9.5 業務 Email 驗證 +### 9.5 ~ Email ``` -Client → POST /api/v1/members/me/verifications/email/start { target: "biz@foo.com" } - 1. (可選) 檢查 target 未被同租戶其他 member 使用 - 2. 檢查 verify:rate:{tenant}:{uid}:email 不存在(60s 冷卻) - 3. 生成 6 碼 OTP → bcrypt 存 verify:otp:{tenant}:{uid}:email:{challenge_id} TTL 5min +Client POST /api/v1/members/me/verifications/email/start { target: "biz@foo.com" } + 1. (i) ˬd target QPL member ϥ + 2. ˬd verify:rate:{tenant}:{uid}:email sb]60s No^ + 3. ͦ 6 X OTP bcrypt s verify:otp:{tenant}:{uid}:email:{challenge_id} TTL 5min 4. NotificationClient.Email.Send(target, template=VerifyEmail, data={code}) 5. SETEX verify:rate:{tenant}:{uid}:email 60 6. audit log -Client ← { challenge_id, expires_in: 300 } +Client { challenge_id, expires_in: 300 } -Client → POST /api/v1/members/me/verifications/email/confirm { challenge_id, code } - 1. 讀 challenge;過期或失敗 5 次 → 拒絕 - 2. bcrypt compare;失敗 → INCR AttemptCnt → 拒絕 - 3. 成功 → member.BusinessEmail = target, BusinessEmailVerified = true, BusinessEmailVerifiedAt = now +Client POST /api/v1/members/me/verifications/email/confirm { challenge_id, code } + 1. Ū challengeFLΥ 5 ڵ + 2. bcrypt compareF INCR AttemptCnt ڵ + 3. \ member.BusinessEmail = target, BusinessEmailVerified = true, BusinessEmailVerifiedAt = now 4. DEL challenge 5. audit log -Client ← { verified: true } +Client { verified: true } ``` -> phone 流程同上,OTP 通道走 SMS Provider;template 為 `VerifyPhone`。 +> phone y{PWAOTP qD SMS ProviderFtemplate `VerifyPhone`C -### 9.6 Step-up MFA + 改業務 Email +### 9.6 Step-up MFA + ~ Email ``` -Client → POST /api/v1/auth/step-up/start { action: "change_business_email" } - 1. 從 ctx.uid 讀 member;要求 BusinessEmailVerified || BusinessPhoneVerified - 2. 選通道:優先 phone(如已 verified)否則 email - 3. 生成 OTP → 寄出(步驟同 §9.5) -Client ← { challenge_id, channel, expires_in: 300 } +Client POST /api/v1/auth/step-up/start { action: "change_business_email" } + 1. q ctx.uid Ū memberFnD BusinessEmailVerified || BusinessPhoneVerified + 2. qDGu phone]pw verified^_h email + 3. ͦ OTP HX]BJP 9.5^ +Client { challenge_id, channel, expires_in: 300 } -Client → POST /api/v1/auth/step-up/confirm { challenge_id, code, action: "change_business_email" } - 1. bcrypt 比對;驗 challenge.kind == step_up && target == action - 2. auth.StepUpTokenUseCase.Issue(tenant, uid, action) → JWT (typ=step_up, action, TTL 5min) -Client ← { step_up_token, token_type: "step_up", expires_in: 300 } +Client POST /api/v1/auth/step-up/confirm { challenge_id, code, action: "change_business_email" } + 1. bcrypt F challenge.kind == step_up && target == action + 2. auth.StepUpTokenUseCase.Issue(tenant, uid, action) JWT (typ=step_up, action, TTL 5min) +Client { step_up_token, token_type: "step_up", expires_in: 300 } -Client → PATCH /api/v1/members/me/business-email { new_email: "new@foo.com" } +Client PATCH /api/v1/members/me/business-email { new_email: "new@foo.com" } Header: X-Step-Up-Token: - 1. Middleware 通過(一般 JWT + Casbin) - 2. Logic step-up 守門(見 §8.4) - 3. 重設 BusinessEmailVerified = false,BusinessEmail = new_email - 4. 內部觸發 §9.5 對 new_email 重新發 OTP(或直接回 challenge_id 給前端) - 5. audit log(含舊 / 新 email、step_up jti、IP、UA) + 1. Middleware qL]@ JWT + Casbin^ + 2. Logic step-up u] 8.4^ + 3. ] BusinessEmailVerified = falseABusinessEmail = new_email + 4. IJo 9.5 new_email so OTP]Ϊ^ challenge_id eݡ^ + 5. audit log]t / s emailBstep_up jtiBIPBUA^ ``` --- -## 10. LDAP 與 SCIM +## 10. LDAP P SCIM -### 10.1 三條 Provisioning 路徑 +### 10.1 T Provisioning | -| 路徑 | 適用 | 說明 | +| | | A | | |------|------|------| -| **SCIM → ZITADEL → Gateway** | 有 HR / Entra ID / Okta | 企業推送使用者 | -| **ZITADEL LDAP IdP** | 用戶登入時 JIT | 首次登入建立 member | -| **Directory Sync Worker** | 無 SCIM 的 AD / OpenLDAP | 定時同步 + 離職偵測 | +| **SCIM ZITADEL Gateway** | HR / Entra ID / Okta | ~eϥΪ | +| **ZITADEL LDAP IdP** | ΤnJ JIT | nJإ member | +| **Directory Sync Worker** | L SCIM AD / OpenLDAP | wɦPB + ¾ | -### 10.2 LDAP 設定(AD + OpenLDAP) +### 10.2 LDAP ]w]AD + OpenLDAP^ ```go type TenantLDAPConfig struct { @@ -1791,90 +1791,90 @@ type LDAPAttrMap struct { ### 10.3 SCIM -- **SCIM `id` = Gateway Member UID**(`AMEX-10000000`)— 已決策;人讀、跨系統一致,便於 audit/支援交叉查詢 -- SCIM `externalId` = 客戶端 IdP / HR 系統提供的外部識別(如 Okta user id、Entra object id、employee id) -- `externalId` 以 `{tenant_id, external_id}` 做 idempotent upsert key;不可假設客戶端知道 Gateway UID -- ZITADEL `sub`、Mongo `_id` 不對外曝露;ZITADEL `sub` 透過 SCIM Extension Schema `urn:cloudep:scim:2.0:User:zitadelSub` 提供查詢,便於企業端 troubleshoot -- SCIM Groups PATCH → `permission.SyncFromScimGroup` -- SCIM deactivate → `member.suspended` + `auth.RevokeAllForUser` +- **SCIM `id` = Gateway Member UID**]`AMEX-10000000`^X wMFHŪBtΤ@PAK audit/䴩ed +- SCIM `externalId` = Ȥ IdP / HR tδѪ~ѧO]p Okta user idBEntra object idBemployee id^ +- `externalId` H `{tenant_id, external_id}` idempotent upsert keyFi]ȤݪD Gateway UID +- ZITADEL `sub`BMongo `_id` ~nSFZITADEL `sub` zL SCIM Extension Schema `urn:cloudep:scim:2.0:User:zitadelSub` ѬdߡAK~ troubleshoot +- SCIM Groups PATCH `permission.SyncFromScimGroup` +- SCIM deactivate `member.suspended` + `auth.RevokeAllForUser` -### 10.4 Directory Sync 誤判保護(已決策) +### 10.4 Directory Sync ~PO@]wM^ -| 機制 | 設定 | 行為 | +| | ]w | 欰 | |------|------|------| -| 連續找不到才停權 | `MissingThreshold: 3`(連續 3 次 cron) | 計數於 `members.directory_missing_count`;恢復偵測即歸零 | -| 單次異動上限 | `MaxChangeRatio: 0.20` | 單次 sync 異動超過該租戶 active members 20% → **強制轉 dry-run** + 告警,需人工確認 | -| 首次部署 | `DryRunOnFirstSync: true` | 首次同步只記 diff log,**不寫 DB** | -| Dry-run 模式 | `DryRun: true / false` | 全程不影響 DB,只產出 diff 報表(admin API 可下載) | -| 軟刪(離職) | guardrail 全通過才 `status=suspended`(**不直接 deleted**) | `deleted` 需人工或專門 workflow | -| Sync window | `Window: 24h` | 預設每 24h;可 tenant override | -| 告警通道 | `AlertSink: ops_webhook / mail` | 觸發 dry-run / 高異動率 / 連續失敗時通知 | +| s䤣~v | `MissingThreshold: 3`]s 3 cron^ | pƩ `members.directory_missing_count`F_Yks | +| 榸ʤW | `MaxChangeRatio: 0.20` | 榸 sync ʶWLӯ active members 20% **j dry-run** + iĵAݤHuT{ | +| p | `DryRunOnFirstSync: true` | PBuO diff logA**g DB** | +| Dry-run Ҧ | `DryRun: true / false` | {vT DBAuX diff ]admin API iU^ | +| nR]¾^ | guardrail qL~ `status=suspended`]** deleted**^ | `deleted` ݤHuαM workflow | +| Sync window | `Window: 24h` | w]C 24hFi tenant override | +| iĵqD | `AlertSink: ops_webhook / mail` | IJo dry-run / ʲv / s򥢱Ѯɳq | -> Worker 啟動順序:拉 LDAP snapshot → 計算 diff → 跑 guardrail 檢查(threshold + ratio)→ commit 或轉 dry-run → 寫 audit log。 +> Worker ҰʶǡG LDAP snapshot p diff ] guardrail ˬd]threshold + ratio^ commit dry-run g audit logC --- ## 11. Notification Module -路徑:`internal/model/notification/` +|G`internal/model/notification/` -獨立 model 模組,集中處理所有 **outbound 通訊**:Email、SMS、(預留)Push、Webhook。所有業務模組(member 業務驗證、auth step-up、tenant 系統通知、admin 警示等)**統一**透過 `NotifierUseCase` 發送,**不**直接 import provider SDK。 +W model ҲաABzҦ **outbound qT**GEmailBSMSB]wd^PushBWebhookCҦ~ȼҲա]member ~ҡBauth step-upBtenant tγqBadmin ĵܵ^**Τ@**zL `NotifierUseCase` oeA**** import provider SDKC -### 11.1 職責 +### 11.1 ¾d -- Provider 抽象:Email / SMS / Push / Webhook 可獨立替換 -- Template 渲染:含多語系(i18n)+ 變數注入 -- 同步發送與異步排程(idempotency + 重試 + DLQ) -- 通知紀錄:persist 到 Mongo(送達狀態、provider message id、retry 軌跡) -- Rate limit / 配額(防爆發、防濫用) -- Hook:供 audit log 與 metrics 攔截 +- Provider HGEmail / SMS / Push / Webhook iWߴ +- Template VGthyt]i18n^+ ܼƪ`J +- PBoePBƵ{]idempotency + + DLQ^ +- qGpersist Mongo]eFABprovider message idBretry y^ +- Rate limit / tB]zoBݥΡ^ +- HookG audit log P metrics dI -### 11.2 模組邊界 +### 11.2 Ҳ ``` member / auth / tenant / admin - │ - ▼ (NotifierUseCase.Send / Enqueue) -notification ── repository (audit + outbox) - │ - ▼ (interface) + x + (NotifierUseCase.Send / Enqueue) +notification ww repository (audit + outbox) + x + (interface) internal/library/notification/ - ├── email/ (sendgrid | ses | smtp 實作) - ├── sms/ (twilio | sns | smsapi 實作) - └── push/ (預留) + uww email/ (sendgrid | ses | smtp @) + uww sms/ (twilio | sns | smsapi @) + |ww push/ (wd) ``` -**library 層**:純 IO,封裝各家 SDK;**model 層**:流程、模板、retry、audit、idempotency。 +**library h**G IOAʸ˦Ua SDKF**model h**Gy{BҪOBretryBauditBidempotencyC -### 11.3 介面 +### 11.3 ```go type NotifierUseCase interface { - // 同步發送:取得結果與 provider id;失敗回 error + // PBoeGoGP provider idFѦ^ error Send(ctx context.Context, req *SendRequest) (*NotificationDTO, error) - // 異步排隊:寫 Mongo outbox + 入 channel,worker 拉走重試;高吞吐用 + // BƶGg Mongo outbox + J channelAworker ԨաF]R Enqueue(ctx context.Context, req *SendRequest) (*NotificationDTO, error) - // 查詢單筆狀態 + // d߳浧A Get(ctx context.Context, tenantID, notificationID string) (*NotificationDTO, error) } type SendRequest struct { TenantID string - UID string // 可為空(系統通知) + UID string // iš]tγq^ Channel enum.Channel // email | sms | push | webhook Kind enum.NotifyKind // verify_email | verify_phone | step_up | system_alert | ... - Target string // 收件位址(email / phone / device_token / url) - Locale string // zh-tw | en-us,未指定走 tenant.default_locale - Data map[string]any // 模板變數 + Target string // }]email / phone / device_token / url^ + Locale string // zh-tw | en-usAw tenant.default_locale + Data map[string]any // ҪOܼ Severity enum.Severity // info | warn | critical - IdempotencyKey string // 業務 key;同 key 不重發 - DoNotPersistBody bool // OTP 等敏感內容不入庫,只記 metadata + IdempotencyKey string // ~ keyFP key o + DoNotPersistBody bool // OTP ӷPeJwAuO metadata } ``` -> **OTP 等敏感內容**:`DoNotPersistBody=true` → notification.body 留空,只記 channel/kind/target hash/provider_message_id/status,避免 audit DB 出現明碼 OTP。 +> **OTP ӷPe**G`DoNotPersistBody=true` notification.body dšAuO channel/kind/target hash/provider_message_id/statusAקK audit DB X{X OTPC -### 11.4 Entity 與 Collection +### 11.4 Entity P Collection ```go // notification collection @@ -1884,22 +1884,22 @@ type Notification struct { UID string Channel enum.Channel Kind enum.NotifyKind - TargetHash string // sha256(target),避免明碼 PII - TemplateKey string // 對應 TemplateRegistry + TargetHash string // sha256(target)AקKX PII + TemplateKey string // TemplateRegistry Locale string Provider string // "sendgrid" | "twilio" | ... ProviderMessageID string Status enum.NotifyStatus // pending | sent | failed | retrying | dropped Attempts int LastError string - IdempotencyKey string // 唯一索引 {tenant_id, kind, idempotency_key} + IdempotencyKey string // ߤ@ {tenant_id, kind, idempotency_key} Severity enum.Severity OccurredAt int64 DeliveredAt int64 } ``` -**Template** 採 **in-code registry**(型別安全)+ provider 端模板 ID(如 SendGrid Dynamic Template): +**Template** **in-code registry**]Ow^+ provider ݼҪO ID]p SendGrid Dynamic Template^G ```go var TemplateRegistry = map[enum.NotifyKind]TemplateSpec{ @@ -1918,103 +1918,103 @@ var TemplateRegistry = map[enum.NotifyKind]TemplateSpec{ } ``` -### 11.5 Idempotency 與重試 +### 11.5 Idempotency P -- `IdempotencyKey` 唯一索引:`{TenantID, Kind, IdempotencyKey}` -- 重複 Send 同 key → 直接回上次結果(不重發給 provider) -- 異步 worker 失敗策略:指數退避 1s / 5s / 30s / 5min / 30min,最多 5 次;超過 → `status=dropped` + audit -- DLQ:失敗 5 次的紀錄保留在 `notification_dlq` collection,admin API 可手動 retry +- `IdempotencyKey` ߤ@ޡG`{TenantID, Kind, IdempotencyKey}` +- Send P key ^WG]o provider^ +- B worker ѵGưh 1s / 5s / 30s / 5min / 30minA̦h 5 FWL `status=dropped` + audit +- DLQG 5 Odb `notification_dlq` collectionAadmin API i retry -### 11.6 與業務模組的呼叫關係 +### 11.6 P~ȼҲժIsY -| 呼叫者 | Kind | Channel | 模式 | +| Is | Kind | Channel | Ҧ | |--------|------|---------|------| -| `member.VerificationUseCase` | `verify_email` / `verify_phone` | email / sms | **同步**(要立即知道送達 / 失敗) | -| `member.StepUpUseCase` | `step_up_email` / `step_up_phone` | email / sms | **同步** | -| `member.AdminUseCase`(停權告知) | `account_suspended` | email | **異步** | -| `tenant.UseCase`(租戶建立完成) | `tenant_welcome` | email | **異步** | -| ops alert(高異動率 / DLQ 滿) | `ops_alert` | email / webhook | **同步**(critical) | +| `member.VerificationUseCase` | `verify_email` / `verify_phone` | email / sms | **PB**]nߧYDeF / ѡ^ | +| `member.StepUpUseCase` | `step_up_email` / `step_up_phone` | email / sms | **PB** | +| `member.AdminUseCase`]vi^ | `account_suspended` | email | **B** | +| `tenant.UseCase`]إߧ^ | `tenant_welcome` | email | **B** | +| ops alert]ʲv / DLQ ^ | `ops_alert` | email / webhook | **PB**]critical^ | -> **OTP 必須同步**,否則 client 無法回報「OTP 已寄出」的明確錯誤;其他通知優先異步避免拖慢業務 API。 +> **OTP PB**A_h client Lk^uOTP wHXvT~FLquBקKC~ APIC -### 11.7 與 Audit Log 的關係 +### 11.7 P Audit Log Y -每筆 Notification 寫入時同步寫 audit log: +C Notification gJɦPBg audit logG ``` action = notification.sent | notification.failed | notification.dropped -actor = system 或 caller uid +actor = system caller uid target = { kind: notification, id: notification_id, channel, kind } metadata = { provider, provider_message_id, target_hash } ``` -audit log 不重複存 body(已決策 §20.1 critical 同步寫的範圍**不含**通知本體,僅元數據)。 +audit log Ʀs body]wM 20.1 critical PBgd**t**qAȤƾڡ^C -### 11.8 安全與 PII +### 11.8 wP PII -- `Target` 不直接 persist;存 `TargetHash`(sha256),便於去重、idempotency;明碼僅在 send 當下傳給 provider -- Email/SMS provider API key、Twilio token 等 → `etc/gateway.yaml` 走環境變數 + secret manager -- Webhook 通道強制 HTTPS + HMAC 簽章(`X-CloudEP-Signature`) +- `Target` persistFs `TargetHash`]sha256^AKhBidempotencyFXȦb send Uǵ provider +- Email/SMS provider API keyBTwilio token `etc/gateway.yaml` ܼ + secret manager +- Webhook qDj HTTPS + HMAC ñ]`X-CloudEP-Signature`^ --- -## 12. 可讀 UID 設計(已決策) +## 12. iŪ UID ]p]wM^ -### 12.1 格式 +### 12.1 榡 ``` {UIDPrefix}-{Sequence} -範例:AMEX-10000000、ACME-10000001、ACME-10000002 +dҡGAMEX-10000000BACME-10000001BACME-10000002 ``` -**已決策:帶租戶前綴**(不用純 Body、不用 UUID)。 +**wMGae**]ί BodyB UUID^C -| 部分 | 規則 | 範例 | +| | Wh | d | |------|------|------| -| `UIDPrefix` | 2~4 位大寫,來自 `tenant.UIDPrefix` 或 slug 縮寫 | `AMEX`、`ACME` | -| `Sequence` | 十進位遞增整數,**起始 `10000000`**(沿用 `InitAutoID` 語意) | `10000000` | -| 分隔符 | 固定 `-` | `AMEX-10000000` | +| `UIDPrefix` | 2~4 jgAӦ `tenant.UIDPrefix` slug Yg | `AMEX`B`ACME` | +| `Sequence` | Qi컼WơA**_l `10000000`**]u `InitAutoID` yN^ | `10000000` | +| j | Tw `-` | `AMEX-10000000` | -- 人類可讀、客服可逐字口述 -- 不含 UUID / base64 亂碼 -- **`UIDPrefix` 全平台唯一**(已決策);客服輸入 UID 即可定位 tenant + member -- 不同租戶不可相同 `UIDPrefix`;同 prefix 內 Sequence 從 `10000000` 起跳 +- HiŪBȪAivrfz +- t UUID / base64 ýX +- **`UIDPrefix` xߤ@**]wM^FȪAJ UID Yiw tenant + member +- PᤣiۦP `UIDPrefix`FP prefix Sequence q `10000000` _ -### 12.2 產生(Bucket 取號,支援單租戶 50 萬) +### 12.2 ͡]Bucket A䴩毲 50 U^ ``` -Redis: member:seq:{tenant_id} counter,初始 10000000 -每個 pod 啟動或耗盡時 INCRBY 500 取一個 bucket,在記憶體內遞號 +Redis: member:seq:{tenant_id} counterAl 10000000 +C pod ҰʩίӺɮ INCRBY 500 @ bucketAbO餺 UID = tenant.UIDPrefix + "-" + strconv.FormatInt(sequence, 10) ``` -- **並發保護**:`{ tenant_id, uid }` unique index。`EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM` / `CreateUnverified` 命中 dup key(E11000)→ fallback `GetByZitadelUserID` 或 `GetByEmail` 取既有 member。 -- **Pod crash 容忍**:bucket 內未用完的號丟失可接受(UID 不要求嚴格連續、不要求嚴格遞增;只要求租戶內唯一)。 -- **UIDPrefix unique index**:`tenants.{ uid_prefix: 1 } unique`;建租戶時若 prefix 已存在 → 409。 +- **õoO@**G`{ tenant_id, uid }` unique indexC`EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM` / `CreateUnverified` R dup key]E11000^ fallback `GetByZitadelUserID` `GetByEmail` J memberC +- **Pod crash e**Gbucket Χᥢi]UID nDYsBnDY滼WFunD᤺ߤ@^C +- **UIDPrefix unique index**G`tenants.{ uid_prefix: 1 } unique`FدɭY prefix wsb 409C --- -## 13. 資料模型與索引 +## 13. ƼҫP ### 13.1 Collections -| Collection | 模組 | 說明 | +| Collection | Ҳ | | |------------|------|------| -| `members` | member | Profile(含業務驗證旗標、TOTP cipher、Origin) | -| `identities` | member | zitadel_sub ↔ uid | -| `tenants` | member | 租戶 metadata | -| `tenant_ldap_configs` | member | LDAP 同步設定(加密) | -| `permissions` | permission | 全局 Permission Tree(平台 seed) | -| `roles` | permission | 租戶 Role(`tenant_id` + immutable `key`) | -| `role_permissions` | permission | Role ↔ Permission ID | -| `user_roles` | permission | uid ↔ Role(支援多角色) | -| `role_mappings` | permission | 外部 Group ↔ RoleID / Role.Key | -| `notifications` | notification | 通知發送紀錄(idempotency / 重試 / audit) | -| `notification_dlq` | notification | 重試 5 次失敗的 dead letter queue | -| `audit_logs` | (獨立 DB)| 跨模組審計日誌(TTL 90d,§20.1) | +| `members` | member | Profile]t~ҺXСBTOTP cipherBOrigin^ | +| `identities` | member | zitadel_sub ? uid | +| `tenants` | member | metadata | +| `tenant_ldap_configs` | member | LDAP PB]w][K^ | +| `permissions` | permission | Permission Tree]x seed^ | +| `roles` | permission | Role]`tenant_id` + immutable `key`^ | +| `role_permissions` | permission | Role ? Permission ID | +| `user_roles` | permission | uid ? Role]䴩h^ | +| `role_mappings` | permission | ~ Group ? RoleID / Role.Key | +| `notifications` | notification | qoe]idempotency / / audit^ | +| `notification_dlq` | notification | 5 Ѫ dead letter queue | +| `audit_logs` | ]W DB^| Ҳռfpx]TTL 90dA20.1^ | -### 13.2 主要索引 +### 13.2 Dn ```javascript // members @@ -2027,7 +2027,7 @@ UID = tenant.UIDPrefix + "-" + strconv.FormatInt(sequence, 10) { tenant_id: 1, uid: 1 } { tenant_id: 1, external_id: 1 } -// permissions(全局) +// permissions]^ { name: 1 } // unique { parent: 1, status: 1 } { http_path: 1, http_method: 1 } // sparse @@ -2048,222 +2048,222 @@ UID = tenant.UIDPrefix + "-" + strconv.FormatInt(sequence, 10) { tenant_id: 1, internal_role_id: 1 } // notifications -{ tenant_id: 1, kind: 1, idempotency_key: 1 } // unique(同 key 不重發) +{ tenant_id: 1, kind: 1, idempotency_key: 1 } // unique]P key o^ { tenant_id: 1, uid: 1, occurred_at: -1 } -{ status: 1, attempts: 1, occurred_at: 1 } // worker 撈待重試 +{ status: 1, attempts: 1, occurred_at: 1 } // worker ݭ // notification_dlq { tenant_id: 1, occurred_at: -1 } -// audit_logs(獨立 DB / replica set) +// audit_logs]W DB / replica set^ { tenant_id: 1, occurred_at: -1 } { tenant_id: 1, "actor.uid": 1, occurred_at: -1 } { tenant_id: 1, action: 1, occurred_at: -1 } { occurred_at: 1 } // TTL 90d ``` -> Identity 映射以 `identities` collection 為 source of truth;`members.zitadel_user_id` 若保留,只作反查快取/denormalized 欄位,更新需由同一 transaction 或補償流程維持一致。 +> Identity MgH `identities` collection source of truthF`members.zitadel_user_id` YOdAu@Ϭd֨/denormalized AsݥѦP@ transaction θvy{@PC -> **時間欄位**:`CreateAt` / `UpdateAt` 統一為 **epoch milliseconds(UTC)**。對外 SCIM `meta.created` / `meta.lastModified` 由 SCIM mapper 在序列化時轉 RFC3339Nano;前端展示由 client 負責 timezone。 +> **ɶ**G`CreateAt` / `UpdateAt` Τ@ **epoch milliseconds]UTC^**C~ SCIM `meta.created` / `meta.lastModified` SCIM mapper bǦCƮ RFC3339NanoFeݮiܥ client td timezoneC -### 13.3 分片鍵(100 萬+) +### 13.3 ]100 U+^ ``` Shard Key: { tenant_id: 1, uid: 1 } ``` -單租戶 50 萬會集中在同一 chunk,MongoDB 仍可承受;若預期單租戶千萬級再評估 hash 二次分片。 +毲 50 U|bP@ chunkAMongoDB iӨFYw毲dUŦA hash GC --- -## 14. Redis Key 命名 +## 14. Redis Key RW -### auth(`internal/model/auth/redis.go`) +### auth]`internal/model/auth/redis.go`^ ``` -auth:jwt:bl:{jti} # 單 token 黑名單,TTL = 剩餘壽命 -auth:jwt:pair:{access_jti} # access_jti → refresh_jti(登出時連 refresh 一起拉黑) -auth:gen:{tenant_id}:{uid} # 批量失效代號 -auth:exchange:nonce:{id_token_jti} # Token Exchange 防重放,TTL 10min -auth:stepup:used:{jti} # Step-up token 單次性,TTL = step_up_token TTL +auth:jwt:bl:{jti} # token ¦WATTL = ѾlةR +auth:jwt:pair:{access_jti} # access_jti refresh_jti]nXɳs refresh @_Զ¡^ +auth:gen:{tenant_id}:{uid} # qĥN +auth:exchange:nonce:{id_token_jti} # Token Exchange ATTL 10min +auth:stepup:used:{jti} # Step-up token 榸ʡATTL = step_up_token TTL ``` -### member(`internal/model/member/redis.go`) +### member]`internal/model/member/redis.go`^ ``` -member:profile:{tenant_id}:{uid} # profile cache,TTL 5~15min -member:sub:{tenant_id}:{sub} # zitadel_sub → uid,TTL 1h +member:profile:{tenant_id}:{uid} # profile cacheATTL 5~15min +member:sub:{tenant_id}:{sub} # zitadel_sub uidATTL 1h member:seq:{tenant_id} # UID bucket counter -otp:challenge:{tenant_id}:{challenge_id} # {purpose, identifier, code_hash, attempts, expire_at},TTL 5min -otp:rate:{tenant_id}:{purpose}:{identifier} # 重發冷卻 60s -otp:daily:{tenant_id}:{purpose}:{identifier} # 單日上限 INCR,TTL 24h +otp:challenge:{tenant_id}:{challenge_id} # {purpose, identifier, code_hash, attempts, expire_at}ATTL 5min +otp:rate:{tenant_id}:{purpose}:{identifier} # oNo 60s +otp:daily:{tenant_id}:{purpose}:{identifier} # W INCRATTL 24h -totp:enroll:{tenant_id}:{uid} # enroll 暫存 secret_cipher,TTL 10min -totp:used:{tenant_id}:{uid}:{timestep} # TOTP code 防重放,TTL 90s +totp:enroll:{tenant_id}:{uid} # enroll Ȧs secret_cipherATTL 10min +totp:used:{tenant_id}:{uid}:{timestep} # TOTP code ATTL 90s ``` -### notification(`internal/model/notification/redis.go`) +### notification]`internal/model/notification/redis.go`^ ``` -notif:idem:{tenant_id}:{kind}:{idempotency_key} # idempotency 結果快取,TTL 24h -notif:quota:{tenant_id}:{channel} # 每租戶每通道 quota,INCR + TTL -notif:retry:zset # 異步重試排程(score = next_retry_at_ms) +notif:idem:{tenant_id}:{kind}:{idempotency_key} # idempotency G֨ATTL 24h +notif:quota:{tenant_id}:{channel} # CCqD quotaAINCR + TTL +notif:retry:zset # BձƵ{]score = next_retry_at_ms^ ``` -### permission(`internal/model/permission/redis.go`) +### permission]`internal/model/permission/redis.go`^ ``` -permission:casbin:rules:{tenant_id} # Casbin policy rules(List of JSON) -permission:tree:open # 可選:open 節點 cache -perm:role_perms:{tenant_id}:{role_id} # role → permission names,TTL 30min -perm:user_roles:{tenant_id}:{uid} # uid → role keys,TTL 5min +permission:casbin:rules:{tenant_id} # Casbin policy rules]List of JSON^ +permission:tree:open # iGopen `I cache +perm:role_perms:{tenant_id}:{role_id} # role permission namesATTL 30min +perm:user_roles:{tenant_id}:{uid} # uid role keysATTL 5min ``` --- -## 15. 規模與性能(100 萬+ / 單租戶 50 萬) +## 15. WһPʯ]100 U+ / 毲 50 U^ -| 項目 | 策略 | +| | | |------|------| -| Gateway | 無狀態,水平擴展 | -| MongoDB | Sharding + Replica Set,讀走 secondary | -| ListMembers | Cursor 分頁,禁止 deep offset | -| Authorize | Casbin EnforceEx(内存 + Redis policy) | -| LoadPolicy | 变更时增量;cron 5min 全量兜底 | -| JWT → UID | Redis cache 1h | -| Directory Sync | 500 users / batch,rate limit ZITADEL API | -| Access Token TTL | 15min(降低撤銷窗口) | +| Gateway | LAAXi | +| MongoDB | Sharding + Replica SetAŪ secondary | +| ListMembers | Cursor AT deep offset | +| Authorize | Casbin EnforceEx]?s + Redis policy^ | +| LoadPolicy | ??WqFcron 5min q© | +| JWT UID | Redis cache 1h | +| Directory Sync | 500 users / batchArate limit ZITADEL API | +| Access Token TTL | 15min]CMPf^ | -### 容量粗估 +### eqʦ ``` -100 萬 members × ~2KB ≈ 2GB(不含 index) -indexes ≈ 1~2GB -→ 單集群可承受,建議 3 node replica set 起跳 +100 U members ~2KB ? 2GB]t index^ +indexes ? 1~2GB + 涰siӨAij 3 node replica set _ ``` --- -## 16. 目錄結構 +## 16. ؿc ``` gateway/ -├── generate/api/ -│ ├── auth.api -│ ├── member.api -│ ├── permission.api -│ ├── tenant.api -│ └── scim.api -│ -├── internal/ -│ ├── middleware/ -│ │ ├── jwt_revoke.go -│ │ ├── tenant_context.go -│ │ ├── require_permission.go -│ │ └── scim_auth.go -│ │ -│ ├── library/ -│ │ ├── zitadel/ -│ │ │ ├── oidc.go -│ │ │ └── management.go -│ │ ├── ldap/ -│ │ │ ├── client.go -│ │ │ └── attrmap.go -│ │ ├── casbin/ # Enforcer 初始化 helper -│ │ ├── uid/ -│ │ │ ├── encode.go -│ │ │ └── generator.go -│ │ ├── totp/ # RFC 6238 演算法、QR 生成 -│ │ │ ├── totp.go -│ │ │ └── backup_code.go -│ │ ├── crypto/ # AES-GCM secret 加解密 + KMS -│ │ │ └── secret.go -│ │ └── notification/ # Provider 實作(純 IO 封裝) -│ │ ├── email/ -│ │ │ ├── sendgrid.go -│ │ │ ├── ses.go -│ │ │ └── smtp.go -│ │ ├── sms/ -│ │ │ ├── twilio.go -│ │ │ ├── sns.go -│ │ │ └── smsapi.go -│ │ └── push/ # 預留 -│ │ -│ ├── model/ -│ │ ├── auth/ -│ │ │ └── ... -│ │ ├── member/ # 含 verification / step_up / totp usecase -│ │ │ └── ... -│ │ ├── notification/ # 統一通知入口 -│ │ │ ├── entity/ -│ │ │ │ └── notification.go -│ │ │ ├── enum/ -│ │ │ │ ├── channel.go -│ │ │ │ ├── kind.go -│ │ │ │ └── status.go -│ │ │ ├── repository/ -│ │ │ │ └── notification.go -│ │ │ ├── usecase/ -│ │ │ │ ├── notifier.go -│ │ │ │ ├── template.go -│ │ │ │ └── worker.go -│ │ │ ├── config/ -│ │ │ ├── errors.go -│ │ │ └── redis.go -│ │ └── permission/ -│ │ ├── entity/ -│ │ │ ├── permission.go -│ │ │ ├── role.go -│ │ │ ├── user_role.go -│ │ │ ├── role_permission.go -│ │ │ └── role_mapping.go -│ │ ├── enum/ -│ │ │ ├── status.go -│ │ │ └── permission_type.go -│ │ ├── repository/ -│ │ │ ├── permission.go -│ │ │ ├── role.go -│ │ │ ├── user_role.go -│ │ │ ├── role_permission.go -│ │ │ ├── role_mapping.go -│ │ │ └── casbin_redis_adapter.go # 沿用 permission-server -│ │ ├── usecase/ -│ │ │ ├── permission_tree.go # 沿用 permission-server -│ │ │ ├── rbac.go # Casbin LoadPolicy / Check -│ │ │ ├── permission.go -│ │ │ ├── role.go -│ │ │ ├── role_permission.go -│ │ │ ├── user_role.go -│ │ │ ├── role_mapping.go -│ │ │ └── authorization_query.go -│ │ ├── rbac/ -│ │ │ └── rule.go # Casbin Rule struct -│ │ ├── config/ -│ │ ├── errors.go -│ │ ├── redis.go -│ │ └── mock/ -│ │ -│ └── worker/ -│ ├── directory_sync/ -│ ├── policy_sync/ # 可選:定時 LoadPolicy -│ ├── notification_retry/ # 異步重試、DLQ 巡檢 -│ └── member_anonymize/ # 軟刪 30 天後匿名化(§5.7) -│ -├── etc/ -│ ├── gateway.yaml -│ └── rbac.conf # Casbin 模型(沿用 permission-server) -│ -└── docs/ - ├── model.md - └── identity-member-design.md # 本文件 +uww generate/api/ +x uww auth.api +x uww member.api +x uww permission.api +x uww tenant.api +x |ww scim.api +x +uww internal/ +x uww middleware/ +x x uww jwt_revoke.go +x x uww tenant_context.go +x x uww require_permission.go +x x |ww scim_auth.go +x x +x uww library/ +x x uww zitadel/ +x x x uww oidc.go +x x x |ww management.go +x x uww ldap/ +x x x uww client.go +x x x |ww attrmap.go +x x uww casbin/ # Enforcer l helper +x x uww uid/ +x x x uww encode.go +x x x |ww generator.go +x x uww totp/ # RFC 6238 tkBQR ͦ +x x x uww totp.go +x x x |ww backup_code.go +x x uww crypto/ # AES-GCM secret [ѱK + KMS +x x x |ww secret.go +x x |ww notification/ # Provider @] IO ʸˡ^ +x x uww email/ +x x x uww sendgrid.go +x x x uww ses.go +x x x |ww smtp.go +x x uww sms/ +x x x uww twilio.go +x x x uww sns.go +x x x |ww smsapi.go +x x |ww push/ # wd +x x +x uww model/ +x x uww auth/ +x x x |ww ... +x x uww member/ # t verification / step_up / totp usecase +x x x |ww ... +x x uww notification/ # Τ@qJf +x x x uww entity/ +x x x x |ww notification.go +x x x uww enum/ +x x x x uww channel.go +x x x x uww kind.go +x x x x |ww status.go +x x x uww repository/ +x x x x |ww notification.go +x x x uww usecase/ +x x x x uww notifier.go +x x x x uww template.go +x x x x |ww worker.go +x x x uww config/ +x x x uww errors.go +x x x |ww redis.go +x x |ww permission/ +x x uww entity/ +x x x uww permission.go +x x x uww role.go +x x x uww user_role.go +x x x uww role_permission.go +x x x |ww role_mapping.go +x x uww enum/ +x x x uww status.go +x x x |ww permission_type.go +x x uww repository/ +x x x uww permission.go +x x x uww role.go +x x x uww user_role.go +x x x uww role_permission.go +x x x uww role_mapping.go +x x x |ww casbin_redis_adapter.go # u permission-server +x x uww usecase/ +x x x uww permission_tree.go # u permission-server +x x x uww rbac.go # Casbin LoadPolicy / Check +x x x uww permission.go +x x x uww role.go +x x x uww role_permission.go +x x x uww user_role.go +x x x uww role_mapping.go +x x x |ww authorization_query.go +x x uww rbac/ +x x x |ww rule.go # Casbin Rule struct +x x uww config/ +x x uww errors.go +x x uww redis.go +x x |ww mock/ +x x +x |ww worker/ +x uww directory_sync/ +x uww policy_sync/ # iGw LoadPolicy +x uww notification_retry/ # BաBDLQ +x |ww member_anonymize/ # nR 30 ѫΦWơ]5.7^ +x +uww etc/ +x uww gateway.yaml +x |ww rbac.conf # Casbin ҫ]u permission-server^ +x +|ww docs/ + uww model.md + |ww identity-member-design.md # ``` --- -## 17. 設定檔 +## 17. ]w -`etc/gateway.yaml` 擴充草案: +`etc/gateway.yaml` XRסG ```yaml Name: gateway @@ -2279,13 +2279,13 @@ RefreshAuth: AccessExpire: 604800 Zitadel: - Issuer: https://id.internal.example.com # self-hosted 內網 + Issuer: https://id.internal.example.com # self-hosted ClientID: ${ZITADEL_CLIENT_ID} JWKSUrl: https://id.internal.example.com/oauth/v2/keys MgmtURL: https://id.internal.example.com/management/v1 MgmtToken: ${ZITADEL_MGMT_TOKEN} - EnforceAdminMFA: true # admin 級 role(tenant_owner/tenant_admin/platform_super_admin)強制 TOTP - # Self-hosted:LDAP IdP 由 ZITADEL 直連企業 AD/OpenLDAP + EnforceAdminMFA: true # admin role]tenant_owner/tenant_admin/platform_super_admin^j TOTP + # Self-hostedGLDAP IdP ZITADEL s~ AD/OpenLDAP StepUp: TokenSecret: ${JWT_STEPUP_SECRET} @@ -2305,31 +2305,31 @@ Verification: MaxAttempts: 5 TOTP: - Issuer: CloudEP # 顯示在 Authenticator App 上的名稱 - Algorithm: SHA1 # 相容 Google Authenticator + Issuer: CloudEP # ܦb Authenticator App WW + Algorithm: SHA1 # ۮe Google Authenticator Digits: 6 PeriodSeconds: 30 - Window: 1 # 容忍 ±1 個 30s 區間 + Window: 1 # e 1 30s ϶ BackupCodeCount: 10 BackupCodeLength: 12 # hex chars - SecretKEK: ${TOTP_KEK} # AES-256 KEK;建議走 KMS / Vault + SecretKEK: ${TOTP_KEK} # AES-256 KEKFij KMS / Vault EnrollTTLSeconds: 600 Notification: DefaultLocale: zh-tw Async: QueueRedisKey: notif:retry:zset - Worker: 4 # worker goroutine 數 + Worker: 4 # worker goroutine MaxRetry: 5 BackoffSeconds: [1, 5, 30, 300, 1800] - RatePerTenant: # 每租戶通道配額(防爆發 / 防濫用) - Email: 10000 # 每天 + RatePerTenant: # CqDtB]zo / ݥΡ^ + Email: 10000 # C SMS: 5000 Email: Provider: sendgrid # sendgrid | ses | smtp APIKey: ${SENDGRID_API_KEY} From: noreply@example.com - Templates: # 對應 TemplateRegistry key → provider template id + Templates: # TemplateRegistry key provider template id verify_email: d-xxxxxxxxxxxxx step_up_email: d-yyyyyyyyyyyyy account_suspended: d-zzzzzzzzzzzzz @@ -2343,12 +2343,12 @@ Notification: verify_phone: "Your verification code is {code} (valid {expires_in}s)" step_up_phone: "Step-up code: {code}" Push: - Enabled: false # 預留 + Enabled: false # wd Webhook: HMACSecret: ${NOTIF_WEBHOOK_HMAC} Mongo: - # 見 internal/library/mongo 設定 + # internal/library/mongo ]w Redis: Host: 127.0.0.1:6379 @@ -2361,10 +2361,10 @@ Member: Permission: RBACModelPath: etc/rbac.conf PolicySyncInterval: 5m - PolicyReloadChannel: casbin:reload # Redis Pub/Sub 通道(即時通知,5m cron 兜底) + PolicyReloadChannel: casbin:reload # Redis Pub/Sub qD]YɳqA5m cron ©^ PlatformAdminTenantID: ${PLATFORM_ADMIN_TENANT_ID} PlatformAdminRoleKey: platform_super_admin - PlatformAdminAllowlistUIDs: ${PLATFORM_ADMIN_ALLOWLIST_UIDS} # break-glass 用,必須 audit + PlatformAdminAllowlistUIDs: ${PLATFORM_ADMIN_ALLOWLIST_UIDS} # break-glass ΡA audit CacheTTLSeconds: 300 DirectorySync: @@ -2377,13 +2377,13 @@ DirectorySync: AuditLog: Sink: mongo # mongo | otel | dual Mongo: - DB: gateway_audit # 建議獨立 DB instance / replica set + DB: gateway_audit # ijW DB instance / replica set Collection: audit_logs BatchSize: 100 FlushInterval: 1s TTLDays: 90 OTEL: - Endpoint: ${OTEL_ENDPOINT} # Sink = otel / dual 時生效 + Endpoint: ${OTEL_ENDPOINT} # Sink = otel / dual ɥͮ RateLimit: Enabled: true @@ -2392,93 +2392,93 @@ RateLimit: Rules: - Match: /api/v1/auth/* ByIP: 60 # 60 req / min / IP - ByUID: 30 # 30 req / min / UID(已登入時) + ByUID: 30 # 30 req / min / UID]wnJɡ^ - Match: /api/v1/auth/step-up/* ByUID: 10 - Match: /scim/v2/* - ByToken: 6000 # 6000 req / min / SCIM token(約 100rps) + ByToken: 6000 # 6000 req / min / SCIM token] 100rps^ - Match: /api/v1/* - ByUID: 600 # 一般 API 上限 + ByUID: 600 # @ API W ByIP: 1200 ``` --- -## 18. 實施順序 +## 18. I -| 階段 | 內容 | 產出 | +| q | e | X | |------|------|------| -| **P0** | 目錄骨架、entity、redis key、config、**`make seed-platform-admin` CLI**(建首位 platform admin uid + role) | 可啟動、可連 Mongo/Redis,平台 admin 可登入 | -| **P1** | UID generator + ProvisioningUseCase(OIDC/LDAP/SCIM 三變體)+ token exchange | 可登入取得 JWT + 可讀 UID | -| **P2** | JWT middleware + jti 黑名單 + auth_gen + logout/refresh | 完整 Token 生命週期 | -| **P3** | Permission seed + PermissionTree + Casbin RBAC + Redis Adapter | 可 LoadPolicy / Check | -| **P3.5** | Notification Module(統一入口 + Email/SMS Provider)+ Verification + Step-up MFA + **TOTP** | 業務驗證 + TOTP step-up + 高風險守門 | -| **P4** | member profile API + 預設 Role seed + CasbinRBACMiddleware | `/members/me` + API 授權生效 | -| **P5** | RolePermission + UserRole + B2B Role CRUD + Permission 勾選 API | 租戶完全自定義 | -| **P6** | Tenant 建立 + ZITADEL CreateOrg + LDAP 設定 | 多租戶 | -| **P7** | Directory Sync Worker(AD + OpenLDAP)+ §10.4 guardrail | 企業目錄同步(誤判保護完備) | -| **P8** | SCIM 2.0 endpoint + Group 映射 | 企業 provisioning | -| **P8.5** | Audit log sink(Mongo 獨立 collection)+ Rate Limit middleware(見 §20) | 可審計 / 防濫用 | -| **P9** | 壓測(100 萬 seed)、sharding、調優、JWT kid 多版本驗證 | 上線準備 | +| **P0** | ؿ[BentityBredis keyBconfigB**`make seed-platform-admin` CLI**]ح platform admin uid + role^ | iҰʡBis Mongo/RedisAx admin inJ | +| **P1** | UID generator + ProvisioningUseCase]OIDC/LDAP/SCIM T^+ token exchange | inJo JWT + iŪ UID | +| **P2** | JWT middleware + jti ¦W + auth_gen + logout/refresh | Token ͩRg | +| **P3** | Permission seed + PermissionTree + Casbin RBAC + Redis Adapter | i LoadPolicy / Check | +| **P3.5** | Notification Module]Τ@Jf + Email/SMS Provider^+ Verification + Step-up MFA + **TOTP** | ~ + TOTP step-up + Iu | +| **P4** | member profile API + w] Role seed + CasbinRBACMiddleware | `/members/me` + API vͮ | +| **P5** | RolePermission + UserRole + B2B Role CRUD + Permission Ŀ API | ᧹۩wq | +| **P6** | Tenant إ + ZITADEL CreateOrg + LDAP ]w | h | +| **P7** | Directory Sync Worker]AD + OpenLDAP^+ 10.4 guardrail | ~ؿPB]~PO@ơ^ | +| **P8** | SCIM 2.0 endpoint + Group Mg | ~ provisioning | +| **P8.5** | Audit log sink]Mongo W collection^+ Rate Limit middleware] 20^ | ifp / ݥ | +| **P9** | ]100 U seed^BshardingBuBJWT kid h | Wudz | --- -## 19. 已決策事項 +## 19. wMƶ -| # | 議題 | **決策** | 設計影響 | +| # | ijD | **M** | ]pvT | |---|------|----------|----------| -| 1 | UID 格式 | **`{Prefix}-{Sequence}`**,如 `AMEX-10000000` | §12;Sequence 起跳 `10000000` | -| 2 | SCIM 路由 | **`/scim/v2/tenants/{tenant_id}/...`** | §7.5、§10.3 | -| 3 | ZITADEL 部署 | **Self-hosted** | §3.3;LDAP 內網/VPN 連線 | -| 4 | 權限變更生效 | **UserRole 變更 `INCR auth_gen`;RolePermission 變更 reload policy + cache invalidate** | §4.5、§6.11 | -| 5 | B2C 租戶 | **唯讀 seed 模板**,不可自定義 Role | §6.12;B2C 禁用 Role CRUD API | -| 6 | Refresh Token | **輪換 + 舊 refresh jti 黑名單** | §4.5 Refresh 輪換 | -| 7 | Casbin 多租戶隔離 | **policy 帶 `tenant_id` + immutable `role_key`** | §6.7;避免同名 role 跨租戶污染 | -| 8 | SCIM externalId | **保留給客戶端外部識別,不等於 Gateway UID** | §10.3;Gateway UID 作為 SCIM id 或 extension | -| 9 | Platform Admin bypass | **平台 role + allowlist,必須 audit** | §6.7、§8.2;不放在 Casbin matcher | -| 10 | UIDPrefix | **全平台唯一**(`tenants.uid_prefix` unique index) | §12.2 | -| 11 | JWT Claims 內容 | **不放 role / permission 快照**,每次查 cache | §4.3 | -| 12 | Refresh Token Reuse | **舊 refresh 二次使用 = 盜用 → INCR auth_gen + audit** | §4.5 | -| 13 | Token Exchange 防重放 | **id_token nonce SETNX + iat 5 分鐘窗口** | §4.5 | -| 14 | Logout 對應 | **Issue 時 redis 記 access↔refresh jti pair** | §4.5 | -| 15 | RolePermission API 語意 | **PUT 全量取代** `{ permission_names: [...] }` + 強制帶 tenant_id | §6.8、§7.3、§9.3 | -| 16 | 外部來源 UserRole | **按 source 隔離 Replace**,manual 永不被洗 | §6.10 | -| 17 | PlainCode 實作 | **Casbin 額外查 `.plain_code` 變體**,多 role allow 結果取 OR | §6.9 | -| 18 | Permission.Name | **建立後不可改名**;廢棄走 `status=close` + 新建 | §6.4 | -| 19 | 註冊路徑 | **預設**走 ZITADEL Hosted UI(B2C)/ LDAP / SCIM(B2B);**保留** platform-native usecase(`LifecycleUseCase.CreateUnverified` + `OTPUseCase` + `Activate`)供未來開通 Gateway 原生註冊(含 email OTP 驗證) | §3.4、§5.2.1、§5.9 | -| 20 | 身份 vs 業務驗證分層 | **ZITADEL 管登入身份;Gateway member 自驗業務 email / phone** | §1.2、§5.4 | -| 21 | Step-up MFA | **啟用**;高風險 action 需 5min 單次性 `step_up_token` | §5.6、§9.6 | -| 22 | OTP 投遞通道 | **自送**(透過 Notification Module 包 Email / SMS Provider) | §5.5、§11、§17 | -| 23 | MFA 強制策略 | **平台強制 admin role 走 ZITADEL TOTP**;一般 user 預設不強制,高風險走 Step-up | §3.5 | -| 24 | KYC | **不在初版範圍** | — | -| 25 | 業務 TOTP(Authenticator App) | **啟用**,Gateway 自存 AES-GCM 加密 secret;與 ZITADEL 身份 TOTP 獨立 | §5.8 | -| 26 | Step-up 通道優先序 | **TOTP > SMS > Email**;Start 時依 enrolled 狀態挑通道,可由 client `prefer_channel` 覆寫 | §5.6 | -| 27 | Notification Module | 獨立 model 模組 `internal/model/notification/`,**所有 outbound 通訊**統一走 `NotifierUseCase`;library 層為 provider 純 IO 封裝 | §11 | -| 28 | OTP 入庫策略 | OTP / step-up 等敏感內容 `DoNotPersistBody=true`,notification 紀錄僅留 metadata(target_hash、provider_message_id、status) | §11.3、§11.8 | -| 29 | UseCase 分層 | **Atomic primitives + Composite** 兩層:原子動作(Profile / Lifecycle / Provisioning / OTP / TOTP)可任意組合;Composite(Verification / StepUp)為常用組合預封裝;logic 可選擇路徑 | §5.2 | -| 30 | OTP 設計 | **Purpose-agnostic atomic primitive**:`OTPUseCase.Generate / Verify / Invalidate`;`purpose` 標識用途(registration_email / business_email / step_up / ...),caller 自負投遞與後續副作用 | §5.2.1、§5.2.4、§5.9 | -| 31 | Provisioning 拆分 | `EnsureMember` 拆成 **`EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM`** 三個 atomic;不同來源驗證邏輯互不耦合 | §5.2.1 | -| 32 | 平台註冊狀態 | 加回 `unverified` 狀態,**僅** platform-native 路徑會出現;OIDC / LDAP / SCIM 直接 `active` | §5.3 | -| A | SCIM `id` | **SCIM `id` = Gateway UID**(人讀、跨系統一致);`externalId` 留給客戶端;ZITADEL `sub` 放 extension `urn:cloudep:scim:2.0:User:zitadelSub` | §10.3 | -| B | Casbin 多 pod 同步 | **Redis Pub/Sub 即時通知 + 5min cron 全量 reload 兜底**(雙保險,pod 重啟不漏) | §6.11 | -| C | Tenant 建立順序 | **Gateway 先建 tenant 草稿(`status=provisioning`)→ 呼叫 ZITADEL Mgmt 建 Org → 回填 `org_id` → `status=active`;失敗走補償 cron 重試或人工標 failed** | §3.1、§7.4 | -| D | Platform Admin Bootstrap | **`make seed-platform-admin` CLI**(建首個 platform admin uid + role)為主;`PLATFORM_ADMIN_ALLOWLIST_UIDS` 環境變數作 break-glass,**強制 audit** | §18 P0 | -| E | Hybrid 租戶分流 | **雙欄並存**:`Member.Origin`(主來源:zitadel_local / ldap / scim)+ `UserRole.Source`(每個 role 指派來源);sync replace 看 source、唯讀欄位看 origin | §3.2、§5、§6.10 | -| F | SCIM endpoint 授權 | **初版 tenant 級 SCIM Token 全權**(read+write)+ IP allowlist + rate limit + token rotation;v2 再加 `scim.users.write` / `scim.groups.write` scope | §7.5 | -| G | Audit log sink | **獨立 Mongo `audit_logs` collection**(建議獨立 DB instance 或獨立 replica set)+ TTL 90 天 + 異步 batch flush;高風險事件同步寫;可選 OTEL log 雙寫歸檔 | §4.5、§8.2、§20 | -| H | 帳號刪除策略 | **軟刪 30 天後匿名化**:立即 `status=deleted` + 撤銷 token + ZITADEL disable;30 天 cron 匿名化 PII 欄位(email/phone/displayName/avatar/zitadel_sub/business_*);保留 uid/tenant_id/timestamps/audit 連續性 | §5.3、§5.7 | -| I | Member 欄位 SoT | **分欄位策略**:身份欄位(zitadel_sub、IdP email/name、ZITADEL status)→ ZITADEL 為準;業務欄位(business_email/phone、language、currency、avatar)→ Gateway 為準;provisioning 欄位(external_id、ldap_dn)→ 來源系統為準 | §5、§9.1 | -| J | Directory Sync 誤判保護 | **連 3 次(連續 3 天)找不到才 suspend**、單次 sync 異動 > 20% 自動轉 dry-run + 告警、首次部署強制 dry-run、刪除須 cron 通過全部 guardrail | §10.4 | -| K | Rate Limiting | **go-zero middleware + Redis sliding-window 多維**:IP / UID / TenantSCIMToken 三層;`/auth/*` 每 IP 60rpm + 每 UID 30rpm;`/scim/*` 每 token 100rps;一般 API 每 UID 600rpm;OTP 走 §5.5 既有冷卻 | §17 RateLimit、§20 | -| L | JWT Secret Rotation | **支援 `kid` header + 多 key 並存**:Access / Refresh / Step-up 各自獨立 key set;簽發用最新 kid,驗證走 active kid 名單;輪換流程:發新 kid → 新 token 用新 kid → 等舊 token expire → 移除舊 kid | §4.4 | +| 1 | UID 榡 | **`{Prefix}-{Sequence}`**Ap `AMEX-10000000` | 12FSequence _ `10000000` | +| 2 | SCIM | **`/scim/v2/tenants/{tenant_id}/...`** | 7.5B10.3 | +| 3 | ZITADEL p | **Self-hosted** | 3.3FLDAP /VPN su | +| 4 | vܧͮ | **UserRole ܧ `INCR auth_gen`FRolePermission ܧ reload policy + cache invalidate** | 4.5B6.11 | +| 5 | B2C | **Ū seed ҪO**Ai۩wq Role | 6.12FB2C T Role CRUD API | +| 6 | Refresh Token | ** + refresh jti ¦W** | 4.5 Refresh | +| 7 | Casbin hj | **policy a `tenant_id` + immutable `role_key`** | 6.7FקKPW role 󯲤ìV | +| 8 | SCIM externalId | **OdȤݥ~ѧOA Gateway UID** | 10.3FGateway UID @ SCIM id extension | +| 9 | Platform Admin bypass | **x role + allowlistA audit** | 6.7B8.2Fb Casbin matcher | +| 10 | UIDPrefix | **xߤ@**]`tenants.uid_prefix` unique index^ | 12.2 | +| 11 | JWT Claims e | ** role / permission ַ**ACd cache | 4.3 | +| 12 | Refresh Token Reuse | ** refresh Gϥ = s INCR auth_gen + audit** | 4.5 | +| 13 | Token Exchange | **id_token nonce SETNX + iat 5 f** | 4.5 | +| 14 | Logout | **Issue redis O access?refresh jti pair** | 4.5 | +| 15 | RolePermission API yN | **PUT qN** `{ permission_names: [...] }` + ja tenant_id | 6.8B7.3B9.3 | +| 16 | ~ӷ UserRole | ** source j Replace**Amanual äQ~ | 6.10 | +| 17 | PlainCode @ | **Casbin B~d `.plain_code` **Ah role allow G OR | 6.9 | +| 18 | Permission.Name | **إ߫ᤣiW**Fo `status=close` + s | 6.4 | +| 19 | U| | **w]** ZITADEL Hosted UI]B2C^/ LDAP / SCIM]B2B^F**Od** platform-native usecase]`LifecycleUseCase.CreateUnverified` + `OTPUseCase` + `Activate`^ѥӶ}q Gateway ͵U]t email OTP ҡ^ | 3.4B5.2.1B5.9 | +| 20 | vs ~Ҥh | **ZITADEL ޵nJFGateway member ~ email / phone** | 1.2B5.4 | +| 21 | Step-up MFA | **ҥ**FI action 5min 榸 `step_up_token` | 5.6B9.6 | +| 22 | OTP 뻼qD | **۰e**]zL Notification Module ] Email / SMS Provider^ | 5.5B11B17 | +| 23 | MFA j | **xj admin role ZITADEL TOTP**F@ user w]jAI Step-up | 3.5 | +| 24 | KYC | **b쪩d** | X | +| 25 | ~ TOTP]Authenticator App^ | **ҥ**AGateway ۦs AES-GCM [K secretFP ZITADEL TOTP W | 5.8 | +| 26 | Step-up qDu | **TOTP > SMS > Email**FStart ɨ enrolled ADqDAi client `prefer_channel` мg | 5.6 | +| 27 | Notification Module | W model Ҳ `internal/model/notification/`A**Ҧ outbound qT**Τ@ `NotifierUseCase`Flibrary h provider IO ʸ | 11 | +| 28 | OTP Jw | OTP / step-up ӷPe `DoNotPersistBody=true`Anotification ȯd metadata]target_hashBprovider_message_idBstatus^ | 11.3B11.8 | +| 29 | UseCase h | **Atomic primitives + Composite** hGlʧ@]Profile / Lifecycle / Provisioning / OTP / TOTP^iNզXFComposite]Verification / StepUp^`βզXwʸˡFlogic iܸ| | 5.2 | +| 30 | OTP ]p | **Purpose-agnostic atomic primitive**G`OTPUseCase.Generate / Verify / Invalidate`F`purpose` ѥγ~]registration_email / business_email / step_up / ...^Acaller ۭt뻼PƧ@ | 5.2.1B5.2.4B5.9 | +| 31 | Provisioning | `EnsureMember`  **`EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM`** T atomicFPӷ޿褬X | 5.2.1 | +| 32 | xUA | [^ `unverified` AA**** platform-native ||X{FOIDC / LDAP / SCIM `active` | 5.3 | +| A | SCIM `id` | **SCIM `id` = Gateway UID**]HŪBtΤ@P^F`externalId` dȤݡFZITADEL `sub` extension `urn:cloudep:scim:2.0:User:zitadelSub` | 10.3 | +| B | Casbin h pod PB | **Redis Pub/Sub Yɳq + 5min cron q reload ©**]OIApod Ҥ|^ | 6.11 | +| C | Tenant إ߶ | **Gateway tenant Z]`status=provisioning`^ Is ZITADEL Mgmt Org ^ `org_id` `status=active`FѨv cron թΤHu failed** | 3.1B7.4 | +| D | Platform Admin Bootstrap | **`make seed-platform-admin` CLI**]ح platform admin uid + role^DF`PLATFORM_ADMIN_ALLOWLIST_UIDS` ܼƧ@ break-glassA**j audit** | 18 P0 | +| E | Hybrid y | **æs**G`Member.Origin`]DӷGzitadel_local / ldap / scim^+ `UserRole.Source`]C role ӷ^Fsync replace sourceBŪ origin | 3.2B5B6.10 | +| F | SCIM endpoint v | **쪩 tenant SCIM Token v**]read+write^+ IP allowlist + rate limit + token rotationFv2 A[ `scim.users.write` / `scim.groups.write` scope | 7.5 | +| G | Audit log sink | **W Mongo `audit_logs` collection**]ijW DB instance οW replica set^+ TTL 90 + B batch flushFIƥPBgFi OTEL log gk | 4.5B8.2B20 | +| H | bR | **nR 30 ѫΦW**GߧY `status=deleted` + MP token + ZITADEL disableF30 cron ΦW PII ]email/phone/displayName/avatar/zitadel_sub/business_*^FOd uid/tenant_id/timestamps/audit s | 5.3B5.7 | +| I | Member SoT | **쵦**G]zitadel_subBIdP email/nameBZITADEL status^ ZITADEL ǡF~]business_email/phoneBlanguageBcurrencyBavatar^ Gateway ǡFprovisioning ]external_idBldap_dn^ ӷtά | 5B9.1 | +| J | Directory Sync ~PO@ | **s 3 ]s 3 ѡ^䤣~ suspend**B榸 sync > 20% ۰ dry-run + iĵBpj dry-runBR cron qL guardrail | 10.4 | +| K | Rate Limiting | **go-zero middleware + Redis sliding-window h**GIP / UID / TenantSCIMToken ThF`/auth/*` C IP 60rpm + C UID 30rpmF`/scim/*` C token 100rpsF@ API C UID 600rpmFOTP 5.5 JNo | 17 RateLimitB20 | +| L | JWT Secret Rotation | **䴩 `kid` header + h key æs**GAccess / Refresh / Step-up UۿW key setFñoγ̷s kidAҨ active kid WFy{Gos kid s token ηs kid token expire kid | 4.4 | --- -## 20. Audit Log 與 Rate Limit +## 20. Audit Log P Rate Limit ### 20.1 Audit Log -**Sink(已決策)**:獨立 Mongo `audit_logs` collection(建議**獨立 DB instance** 或 replica set,避免 OLTP 互拖)。 +**Sink]wM^**GW Mongo `audit_logs` collection]ij**W DB instance** replica setAקK OLTP ^C ```go type AuditLog struct { @@ -2489,80 +2489,80 @@ type AuditLog struct { Target Target // {kind: member|role|tenant, id, before, after} Severity enum.Severity // info | warn | critical Result enum.Result // success | denied | error - Reason string // 失敗原因 / denied 理由 - Metadata bson.M // 動態欄位,如 step_up_jti、scim_op、source + Reason string // ѭ] / denied z + Metadata bson.M // ʺAAp step_up_jtiBscim_opBsource OccurredAt int64 // epoch ms } ``` -**寫入策略:** +**gJG** -| Severity | 模式 | 失敗處理 | +| Severity | Ҧ | ѳBz | |----------|------|---------| -| `critical`(停權、刪除、step-up、Platform Admin bypass、權限撤銷) | **同步**寫入;寫失敗則整個業務操作回滾 | 拒絕請求,避免無紀錄通過 | -| `info`(讀取、權限通過) | **異步**:buffered channel → batch insert(`BatchSize=100`、`FlushInterval=1s`) | drop + metrics(告警,但不影響業務) | +| `critical`]vBRBstep-upBPlatform Admin bypassBvMP^ | **PB**gJFgѫhӷ~Ⱦާ@^u | ڵШDAקKLqL | +| `info`]ŪBvqL^ | **B**Gbuffered channel batch insert]`BatchSize=100`B`FlushInterval=1s`^ | drop + metrics]iĵAvT~ȡ^ | -- TTL index:`{ OccurredAt: 1 }` TTL 90 天;超過則歸檔(可選 OTEL log 雙寫保留更久) -- Index:`{ TenantID: 1, OccurredAt: -1 }`、`{ TenantID: 1, Actor.uid: 1, OccurredAt: -1 }`、`{ TenantID: 1, Action: 1, OccurredAt: -1 }` -- **匿名化不影響 audit**:actor / target uid 仍保留(即使 member 已匿名化),達成「最少必要 PII + 連續性」 +- TTL indexG`{ OccurredAt: 1 }` TTL 90 ѡFWLhkɡ]i OTEL log gOd[^ +- IndexG`{ TenantID: 1, OccurredAt: -1 }`B`{ TenantID: 1, Actor.uid: 1, OccurredAt: -1 }`B`{ TenantID: 1, Action: 1, OccurredAt: -1 }` +- **ΦWƤvT audit**Gactor / target uid Od]Y member wΦWơ^AFṳ֥n PII + sʡv ### 20.2 Rate Limit -**技術選型(已決策)**:go-zero middleware(自製 / 衍生)+ Redis sliding-window。 +**޳N﫬]wM^**Ggo-zero middleware]ۻs / l͡^+ Redis sliding-windowC ``` Key: rl:{dimension}:{key}:{path_pattern} # dimension = ip | uid | scim_token -Value: ZSET(timestamp_ms : nonce)TTL = WindowSeconds +Value: ZSET]timestamp_ms : nonce^TTL = WindowSeconds ``` -**演算法**: +**tk**G ``` 1. now := time.Now().UnixMilli() 2. ZREMRANGEBYSCORE rl:... 0 (now - window_ms) 3. count := ZCARD rl:... -4. if count >= limit → 429 + Retry-After +4. if count >= limit 429 + Retry-After 5. ZADD rl:... now {random} 6. EXPIRE rl:... window ``` -**分層命中規則(順序匹配):** +**hRWh]Ǥǰt^G** -| 路徑 | 維度 | 上限 | +| | | | W | |------|------|------| | `/api/v1/auth/step-up/*` | UID | 10 req/min | | `/api/v1/auth/*` | IP / UID | 60 / 30 req/min | -| `/scim/v2/*` | SCIM token | 6000 req/min(約 100rps) | -| `/api/v1/*`(其餘) | UID / IP | 600 / 1200 req/min | +| `/scim/v2/*` | SCIM token | 6000 req/min] 100rps^ | +| `/api/v1/*`]l^ | UID / IP | 600 / 1200 req/min | -- **公開 endpoint**(exchange / refresh)以 IP 為主、UID 為輔(未登入時無 UID) -- 命中後回 `429` + `Retry-After: {seconds}` + `X-RateLimit-Remaining` -- OTP / 業務驗證走 §5.5 內 `verify:rate` / `verify:daily`,**不重複**經 RateLimit middleware(避免冷卻被消耗) -- 設定見 §17 `RateLimit` +- **} endpoint**]exchange / refresh^H IP DBUID ]nJɵL UID^ +- R^ `429` + `Retry-After: {seconds}` + `X-RateLimit-Remaining` +- OTP / ~Ҩ 5.5 `verify:rate` / `verify:daily`A****g RateLimit middleware]קKNoQӡ^ +- ]w 17 `RateLimit` --- -## 附錄 A:與 model.md 的關係 +## AGP model.md Y -- 本文件:**做什麼**(架構、流程、API、權限模型) -- [model.md](./model.md):**怎麼寫**(entity / repository / usecase 程式碼規範) +- G****][cBy{BAPIBvҫ^ +- [model.md](./model.md)G**g**]entity / repository / usecase {XWd^ -實作時兩份文件搭配使用。 +@ɨftϥΡC --- -## 附錄 B:ServiceContext 組裝草案 +## BGServiceContext ո˯ ```go type ServiceContext struct { Config config.Config Validator validate.Validate - // library clients(純 IO,純粹封裝外部 SDK) + // library clients] IOAºʸ˥~ SDK^ Zitadel *zitadel.Client EmailSender libemail.Sender SMSSender libsms.Sender - SecretCipher libcrypto.Cipher // TOTP secret 加解密 + SecretCipher libcrypto.Cipher // TOTP secret [ѱK TOTPGen libtotp.Generator // usecases @@ -2580,7 +2580,7 @@ type ServiceContext struct { // notification module NotifierUC notifusecase.NotifierUseCase - // permission usecases(對齊 permission-server 拆分) + // permission usecases] permission-server ^ PermRBACUC permusecase.RBACUseCase PermUC permusecase.PermissionUseCase RoleUC permusecase.RoleUseCase @@ -2593,35 +2593,35 @@ type ServiceContext struct { --- -## 附錄 C:permission-server 遷移對照(程式碼級) +## CGpermission-server Eӡ]{Xš^ -| permission-server 檔案 | Gateway 目標 | 遷移方式 | +| permission-server ɮ | Gateway ؼ | E覡 | |------------------------|--------------|----------| -| `pkg/usecase/permission_tree.go` | `model/permission/usecase/permission_tree.go` | 幾乎原樣搬移 | -| `pkg/usecase/casbin_redis_rbac.go` | `model/permission/usecase/rbac.go` | 加 `tenant_id` + `role_key` 維度 | -| `pkg/repository/casbin_redis_adapter.go` | `model/permission/repository/casbin_redis_adapter.go` | 改為 tenant-scoped policy key | -| `pkg/domain/rbac/rule.go` | `model/permission/rbac/rule.go` | 原樣搬移 | -| `etc/rbac.conf` | `etc/rbac.conf` | 加入 tenant request / policy 維度 | -| `pkg/usecase/role.go` | `model/permission/usecase/role.go` | `ClientID`→`TenantID` | -| `pkg/usecase/role_permission.go` | `model/permission/usecase/role_permission.go` | 加 `tenant_id` 防呆與查詢維度 | -| `pkg/usecase/user_role.go` | `model/permission/usecase/user_role.go` | 改支援多角色 | -| `pkg/usecase/token.go` | **`model/auth/usecase/token.go`** | 不在 permission 模組 | -| `generate/database/seeders/*_permission*` | `generate/database/seeders/` 或 Mongo seed | 改為 Gateway seed job | +| `pkg/usecase/permission_tree.go` | `model/permission/usecase/permission_tree.go` | XG˷h | +| `pkg/usecase/casbin_redis_rbac.go` | `model/permission/usecase/rbac.go` | [ `tenant_id` + `role_key` | +| `pkg/repository/casbin_redis_adapter.go` | `model/permission/repository/casbin_redis_adapter.go` | אּ tenant-scoped policy key | +| `pkg/domain/rbac/rule.go` | `model/permission/rbac/rule.go` | ˷h | +| `etc/rbac.conf` | `etc/rbac.conf` | [J tenant request / policy | +| `pkg/usecase/role.go` | `model/permission/usecase/role.go` | `ClientID``TenantID` | +| `pkg/usecase/role_permission.go` | `model/permission/usecase/role_permission.go` | [ `tenant_id` bPdߺ | +| `pkg/usecase/user_role.go` | `model/permission/usecase/user_role.go` | 䴩h | +| `pkg/usecase/token.go` | **`model/auth/usecase/token.go`** | b permission Ҳ | +| `generate/database/seeders/*_permission*` | `generate/database/seeders/` Mongo seed | אּ Gateway seed job | --- -## 修訂紀錄 +## ׭q -| 日期 | 版本 | 說明 | +| | | | |------|------|------| -| 2026-05-19 | 0.1.0 | 初稿:auth + member + permission(B2B 自定義)+ ZITADEL/LDAP/SCIM | -| 2026-05-19 | 0.2.0 | 對齊 app-cloudep-permission-server:Casbin RBAC、Permission Tree、Role/RolePermission | -| 2026-05-19 | 0.3.0 | 已定案 §19(1–6):UID 前綴格式、SCIM tenant_id 路由、ZITADEL self-hosted、auth_gen 強制刷新、B2C 唯讀、Refresh 輪換 | -| 2026-05-19 | 0.4.0 | 補強多租戶 Casbin、immutable Role.Key、SCIM externalId、Platform Admin bypass 與權限生效策略 | -| 2026-05-20 | 0.5.0 | Best-practice 收斂:JWT 不放 role 快照、Refresh Reuse Detection、Token Exchange Nonce、Logout pair、RolePermission tenant 防呆 + PUT 全量取代、外部來源 source 隔離、PlainCode 聚合、Permission.Name 不可改、UIDPrefix 全平台唯一、Role.Key 規則、附錄重排為 A→B→C | -| 2026-05-20 | 0.6.0 | 補入業務驗證分層:Gateway 不提供註冊 API(§3.4);新增業務 Email / Phone 自驗(§5.4、§9.5);Step-up MFA 啟用(§5.6、§9.6);OTP 自送 Email + SMS Provider(§5.5、§17 Notification);平台 admin 強制 ZITADEL TOTP(§3.5);新增對應 Redis key、API、設定、決策列 19–24 | -| 2026-05-20 | 0.7.0 | 待決策 A–L 全數拍板:SCIM id = Gateway UID + ZITADEL sub extension(§10.3);Casbin 多 pod Pub/Sub + 5min cron 兜底(§6.11);Tenant 建立 saga(§3.1);Platform Admin seed CLI(§18 P0);Member.Origin + UserRole.Source 雙欄(§5.4、§6.10);SCIM token 全權 + IP allowlist(§7.5);獨立 audit_logs collection + TTL 90d(§20.1);軟刪 30 天匿名化(§5.7);分欄位 SoT(§5.3);Directory Sync guardrail(§10.4);Redis sliding-window rate limit(§20.2);JWT kid 多 key 並存(§4.4) | -| 2026-05-20 | 0.8.0 | 抽出獨立 **Notification Module**(§11):所有 outbound 通訊統一入口、含 idempotency / 重試 / DLQ / 模板 / 多語、敏感內容 `DoNotPersistBody`;新增 **業務 TOTP**(§5.8)支援 Google Authenticator,與 ZITADEL 身份 TOTP 獨立;step-up 通道優先序改為 **TOTP > SMS > Email**(§5.6);目錄、ServiceContext、Mongo collections、Redis key、設定檔、實施順序、決策列 25–28 同步更新;§11–§19 章節編號全部 +1 | -| 2026-05-20 | 0.9.0 | **UseCase 介面契約凍結(業務邏輯暫不實作)**:§5.2 重寫為 Atomic primitives + Composite 兩層;新增 `OTPUseCase`(purpose-agnostic atomic)、`LifecycleUseCase`(CreateUnverified / Activate / Suspend / Reactivate / SoftDelete / AbortPending);`ProvisioningUseCase` 拆 `EnsureFromOIDC / LDAP / SCIM` 三變體;`ProfileUseCase` 加 `SetBusinessEmailVerified` / `SetBusinessPhoneVerified` atomic;加回 `unverified` 狀態(僅 platform-native 路徑);補完 Member entity 欄位、Enum 草案、Request DTO;新增 §5.9 編排示例(5 case);§14 OTP Redis key 改 purpose-based;決策列 19 修正、新增 29–32 | +| 2026-05-19 | 0.1.0 | ZGauth + member + permission]B2B ۩wq^+ ZITADEL/LDAP/SCIM | +| 2026-05-19 | 0.2.0 | app-cloudep-permission-serverGCasbin RBACBPermission TreeBRole/RolePermission | +| 2026-05-19 | 0.3.0 | ww 19]1V6^GUID e榡BSCIM tenant_id ѡBZITADEL self-hostedBauth_gen jsBB2C ŪBRefresh | +| 2026-05-19 | 0.4.0 | ɱjh CasbinBimmutable Role.KeyBSCIM externalIdBPlatform Admin bypass Pvͮĵ | +| 2026-05-20 | 0.5.0 | Best-practice ġGJWT role ַӡBRefresh Reuse DetectionBToken Exchange NonceBLogout pairBRolePermission tenant b + PUT qNB~ӷ source jBPlainCode EXBPermission.Name iBUIDPrefix xߤ@BRole.Key WhBƬ ABC | +| 2026-05-20 | 0.6.0 | ɤJ~ҤhGGateway ѵU API]3.4^FsW~ Email / Phone ]5.4B9.5^FStep-up MFA ҥΡ]5.6B9.6^FOTP ۰e Email + SMS Provider]5.5B17 Notification^Fx admin j ZITADEL TOTP]3.5^FsW Redis keyBAPIB]wBMC 19V24 | +| 2026-05-20 | 0.7.0 | ݨM AVL ƩOGSCIM id = Gateway UID + ZITADEL sub extension]10.3^FCasbin h pod Pub/Sub + 5min cron ©]6.11^FTenant إ saga]3.1^FPlatform Admin seed CLI]18 P0^FMember.Origin + UserRole.Source ]5.4B6.10^FSCIM token v + IP allowlist]7.5^FW audit_logs collection + TTL 90d]20.1^FnR 30 ѰΦWơ]5.7^F SoT]5.3^FDirectory Sync guardrail]10.4^FRedis sliding-window rate limit]20.2^FJWT kid h key æs]4.4^ | +| 2026-05-20 | 0.8.0 | XW **Notification Module**]11^GҦ outbound qTΤ@JfBt idempotency / / DLQ / ҪO / hyBӷPe `DoNotPersistBody`FsW **~ TOTP**]5.8^䴩 Google AuthenticatorAP ZITADEL TOTP WߡFstep-up qDuǧאּ **TOTP > SMS > Email**]5.6^FؿBServiceContextBMongo collectionsBRedis keyB]wɡBIǡBMC 25V28 PBsF11V19 `s +1 | +| 2026-05-20 | 0.9.0 | **UseCase ᵲ]~޿Ȥ@^**G5.2 g Atomic primitives + Composite hFsW `OTPUseCase`]purpose-agnostic atomic^B`LifecycleUseCase`]CreateUnverified / Activate / Suspend / Reactivate / SoftDelete / AbortPending^F`ProvisioningUseCase` `EnsureFromOIDC / LDAP / SCIM` TF`ProfileUseCase` [ `SetBusinessEmailVerified` / `SetBusinessPhoneVerified` atomicF[^ `unverified` A] platform-native |^Fɧ Member entity BEnum סBRequest DTOFsW 5.9 sƥܨҡ]5 case^F14 OTP Redis key purpose-basedFMC 19 ץBsW 29V32 | diff --git a/docs/model.md b/docs/model.md index decb2a1..eb48eb2 100644 --- a/docs/model.md +++ b/docs/model.md @@ -2,48 +2,113 @@ 本文件定義 Gateway 專案中 **業務模型**(`internal/model/{module}/`)的目錄結構與撰寫約定,參考 Clean Architecture 分層。 -> Gateway 的 HTTP 層(handler / logic / types)仍由 goctl 生成;本規範適用於 **`internal/model/{module}/`**(例如 `internal/model/member/`)——entity、repository、usecase 等。**不使用 `pkg/`。** +> Gateway 的 HTTP 層(handler / logic / types)仍由 goctl 生成;本規範適用於 **`internal/model/{module}/`**(例如 `internal/model/member/`、`internal/model/notification/`)。**不使用 `pkg/`。** 專案總覽與錯誤分層見 [README.md](../README.md)。 -## 目錄結構 +## 目錄結構(`domain/` + 外層實作) -以下以 `member` 模組為例,路徑前綴為 `internal/model/member/`: +**新模組與重構中的模組**(`notification`、`member`、`permission` 等)採 **domain 分包**:契約與領域型別在 `domain/`,Mongo / Redis / Provider / embed 等實作在模組根下對應目錄。對齊 `app-cloudep-notification-service/pkg/` 的 `domain/` 慣例。 + +路徑前綴:`internal/model/{module}/` ``` internal/model/ -└── member/ - ├── entity/ # 持久化資料模型(MongoDB document) - ├── enum/ # 領域值物件 / 列舉(Platform、Status…) - ├── repository/ # Repository 介面 + 實作 - ├── usecase/ # UseCase 介面、Request/Response DTO、實作 - ├── config/ # 模組用設定 struct - ├── errors.go # 模組 sentinel(ErrNotFound 等),非第二套 8 碼 - ├── const.go # 模組常數 - ├── redis.go # Redis key 命名與 helper - └── mock/ # mockgen 產物 +└── {module}/ # 例:notification、member、permission + ├── domain/ # 純領域:介面、實體、列舉、DTO(不依賴 mongo/redis/provider) + │ ├── entity/ # Mongo document 結構 + CollectionName() + │ ├── enum/ # Channel、Status、Platform… + │ ├── repository/ # Repository / Cache 介面 only + │ ├── usecase/ # UseCase 介面 + Request/Response DTO + │ └── template/ # 可選:模板 Spec、Registry、Renderer 介面(notification) + ├── repository/ # domain/repository 的 Mongo / Redis / memory 實作 + ├── usecase/ # domain/usecase 的實作 + factory 組裝 + ├── template/ # 可選:go:embed、DefaultRegistry、Renderer 實作 + ├── provider/ # 可選:僅本模組用的 Sender(email/sms),不放 library/ + ├── config/ # 模組設定 struct(嵌入 gateway Config) + ├── errors.go # 模組 sentinel + ├── const.go # BSON 欄位名、模組常數 + ├── redis.go # Redis key 命名 + └── mock/ # mockgen(路徑對應 domain/) ├── repository/ └── usecase/ ``` +**參考實作:** [`internal/model/notification/`](../internal/model/notification/)(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(介面) - → entity、enum - → usecase(介面 + DTO) +domain/entity、domain/enum → 僅標準庫 / 列舉底層型別 -repository(實作) → repository(介面) - → entity +domain/repository、domain/usecase、domain/template + → domain/entity、domain/enum(彼此不 import 實作層) -entity、enum → 只依賴標準庫或第三方型別 +usecase(實作) → domain/usecase(實作介面) + → domain/repository(介面) + → repository/、provider/、template/(具體型別) -internal/logic → model/{module}/usecase 介面 only(不 import repository、entity) +repository(實作) → domain/repository(實作介面) + → domain/entity + +internal/logic、其他 model → domain/usecase 介面 only + (不 import repository 實作、entity、provider) ``` -## 1. Entity(`entity/`) +**Import 範例(notification):** -每個 MongoDB collection 對應一個 struct,放在 `internal/model/{module}/entity/`。 +```go +import ( + domusecase "gateway/internal/model/notification/domain/usecase" + "gateway/internal/model/notification/usecase" +) + +// 業務模組(如 member)只依賴介面 +var _ domusecase.NotifierUseCase = (*usecase.notifierUseCase)(nil) + +// ServiceContext 組裝實作 +notifier, err := usecase.NewNotifierUseCaseFromParam(usecase.FactoryParam{...}) +``` + +### 基礎設施連線(Mongo / Redis) + +**一個 Pod(一個進程)內,連線在 `internal/svc/service_context.go` 建立一次,再注入各 module。** + +| 資源 | 建立位置 | 共用方式 | 模組內 | +|------|----------|----------|--------| +| Mongo | `library/mongo` + go-zero `mon` | 同一 URI → **一個 `*mongo.Client` pool**(`mon` 的 `clientManager`) | 每 collection 一個 `DocumentDB` / repository,**不**各自 `Connect` | +| Redis | `library/redis.NewClient` + go-zero `redis.MustNewRedis` | 同一 `Addr` → **一個 connection pool** | 只收 `*redis.Client`,**禁止**在 factory 內 `go-redis.NewClient` | + +```go +// internal/svc/service_context.go(示意) +rds, _ := redislib.NewClient(c.Redis) // 全進程共用 +notifier, _ := notifusecase.NewNotifierUseCaseFromParam(notifusecase.FactoryParam{ + MongoConf: &c.Mongo, + Redis: rds, // 注入,非 RedisConf + Config: c.Notification, +}) +``` + +- `ServiceContext.Redis` 為 `*library/redis.Client`,`Host` 空則 `nil`(模組可 fallback memory)。 +- 之後 **member / permission** 的 factory、`MustXxxUseCase` **必須**接受 `*redislib.Client`,不得在 module 內新建 Redis 連線。 +- 模組 `redis.go` 只定義 **key 前綴**,不建立 client。 + +### 與舊版扁平目錄的關係 + +早期 scaffold 可能仍為 `member/entity`(無 `domain/`)。**新增 `permission`、重構 `member` 時請遷移到上表結構**,與 `notification` 一致。遷移步驟:先搬 `entity`/`enum` → `domain/`,再搬 repository/usecase **介面** → `domain/`,最後保留外層實作並修正 import。 + +## 1. Entity(`domain/entity/`) + +每個 MongoDB collection 對應一個 struct,放在 `internal/model/{module}/domain/entity/`。 **規則:** @@ -51,10 +116,10 @@ internal/logic → model/{module}/usecase 介面 only(不 import repository - struct 名稱使用 PascalCase 單數,如 `Account`、`User`。 - 必須實作 `CollectionName() string`,回傳 MongoDB collection 名稱。 - 欄位 tag:`bson` 必填;對外 JSON 序列化才加 `json`。 -- 主鍵使用 `primitive.ObjectID`,tag 為 `` `bson:"_id,omitempty" json:"id,omitempty"` ``。 +- 主鍵使用 MongoDB driver **v2** 的 `bson.ObjectID`(`go.mongodb.org/mongo-driver/v2/bson`),tag 為 `` `bson:"_id,omitempty" json:"id,omitempty"` ``。舊模組若仍為 `primitive.ObjectID`,遷移時一併改為 v2。 - 時間戳記統一用 `*int64`,欄位名 `CreateAt` / `UpdateAt`,值為 UTC nanoseconds。 - 可選欄位用指標型別(`*string`、`*int64`)。 -- 領域列舉引用 `enum/` 下的型別,不在 entity 內重複定義。 +- 領域列舉引用 `domain/enum/` 下的型別,不在 entity 內重複定義。 **範例:** @@ -62,12 +127,12 @@ internal/logic → model/{module}/usecase 介面 only(不 import repository package entity import ( - "gateway/internal/model/member/enum" - "go.mongodb.org/mongo-driver/bson/primitive" + "gateway/internal/model/member/domain/enum" + "go.mongodb.org/mongo-driver/v2/bson" ) type Account struct { - ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"` + ID bson.ObjectID `bson:"_id,omitempty" json:"id,omitempty"` LoginID string `bson:"login_id"` Token string `bson:"token"` Platform enum.Platform `bson:"platform"` @@ -80,9 +145,9 @@ func (a *Account) CollectionName() string { } ``` -## 2. 值物件 / 列舉(`enum/`) +## 2. 值物件 / 列舉(`domain/enum/`) -業務列舉、狀態碼等放在 `internal/model/{module}/enum/`。 +業務列舉、狀態碼等放在 `internal/model/{module}/domain/enum/`。 **規則:** @@ -111,15 +176,15 @@ func (p Platform) ToString() string { /* map lookup */ } 同一檔案或目錄下可放 `*_test.go` 驗證轉換邏輯。 -## 3. Repository 介面(`repository/`) +## 3. Repository 介面(`domain/repository/`) **規則:** -- 一個 entity 一個 `XxxRepository` interface。 +- 一個 entity 一個 `XxxRepository` interface,檔案放在 `domain/repository/`。 - 方法第一個參數固定為 `context.Context`。 -- 參數 / 回傳值使用 `entity` 型別,不暴露 driver 細節(除 index migration 等必要場景)。 +- 參數 / 回傳值使用 `domain/entity` 型別,不暴露 driver 細節(除 index migration 等必要場景)。 - Index migration 以獨立 interface 嵌入,命名 `{Entity}IndexUP`,方法名含版本號,如 `Index20241226001UP`。 -- 介面檔案不含實作、不含 import 基礎設施 package(`mon`、`mongo` 實作層等僅在 repository 實作出現)。 +- **此目錄僅介面**:不含 `NewXxxRepository`、不含 `mongo` / `redis` import。 **範例:** @@ -128,31 +193,27 @@ package repository import ( "context" - "gateway/internal/model/member/entity" - "go.mongodb.org/mongo-driver/mongo" + "gateway/internal/model/member/domain/entity" ) type AccountRepository interface { Insert(ctx context.Context, data *entity.Account) error FindOne(ctx context.Context, id string) (*entity.Account, error) - Update(ctx context.Context, data *entity.Account) (*mongo.UpdateResult, error) + Update(ctx context.Context, data *entity.Account) error Delete(ctx context.Context, id string) (int64, error) - AccountIndexUP -} - -type AccountIndexUP interface { - Index20241226001UP(ctx context.Context) (*mongo.Cursor, error) } ``` +輔助介面(冪等、配額等)亦放在 `domain/repository/`,由外層 `repository/redis_store.go` 等實作。 + ## 4. Repository 實作(`repository/`) **規則:** -- struct 名稱 `{Entity}Repository`,建構子 `New{Entity}Repository(param {Entity}RepositoryParam)`。 -- Param struct 集中注入 `Conf`、`CacheConf`、`DBOpts`、`CacheOpts`。 -- 建構時以 entity 的 `CollectionName()` 初始化 DocumentDB;失敗時 `panic`(啟動期錯誤)。 -- CRUD 透過 `mongo.DocumentDBWithCacheUseCase`(`gateway/internal/library/mongo`)操作,搭配模組 `redis.go` 的 key helper。 +- struct 名稱 `{entity}Repository`(小寫)或 `{Entity}Repository`,建構子 `New{Entity}Repository(param {Entity}RepositoryParam)`,**回傳型別為 `domain/repository` 的 interface**。 +- Param struct 集中注入 `*mongo.Conf` 等;實作 import `domain/entity`、`domain/repository`。 +- 建構時以 `domain/entity` 的 `CollectionName()` 初始化 DocumentDB;失敗時 `panic`(啟動期錯誤)。 +- CRUD 透過 `gateway/internal/library/mongo` 的 DocumentDB helper,搭配模組 `redis.go` 的 key helper。 - `Insert`:ID 為 zero 時自動產生 ObjectID 並寫入 `CreateAt` / `UpdateAt`。 - `Update`:自動更新 `UpdateAt`。 - `FindOne` / `Delete`:無效 ObjectID → `*errs.Error`(`ResInvalidMeasureID`)或模組 `ErrInvalidObjectID`;查無資料 → 模組 `ErrNotFound`(見第 7 節錯誤)。 @@ -161,54 +222,54 @@ type AccountIndexUP interface { **範例:** ```go -type AccountRepositoryParam struct { - Conf *mongo.Conf - CacheConf cache.CacheConf - DBOpts []mon.Option - CacheOpts []cache.Option -} +import ( + domentity "gateway/internal/model/member/domain/entity" + domrepo "gateway/internal/model/member/domain/repository" +) type accountRepository struct { - DB mongo.DocumentDBWithCacheUseCase + db mongo.DocumentDBUseCase } -func NewAccountRepository(param AccountRepositoryParam) AccountRepository { - e := entity.Account{} - documentDB, err := mongo.MustDocumentDBWithCache( - param.Conf, e.CollectionName(), param.CacheConf, param.DBOpts, param.CacheOpts, - ) +func NewAccountRepository(param AccountRepositoryParam) domrepo.AccountRepository { + e := domentity.Account{} + documentDB, err := mongo.NewDocumentDB(param.Conf, e.CollectionName()) if err != nil { panic(err) } - return &accountRepository{DB: documentDB} + return &accountRepository{db: documentDB} } ``` -## 5. UseCase 介面與 DTO(`usecase/`) +## 5. UseCase 介面與 DTO(`domain/usecase/`) **規則:** -- 業務入口定義為 interface,如 `AccountUseCase`;大介面可拆成多個小 interface 再 compose。 -- Request / Response struct 放在同一 package,命名 `{Action}Request`、`{Action}Response`。 -- DTO 只含 `json` tag 與欄位註解,不含 bson tag(DTO 不直接映射 DB)。 +- 業務入口定義為 interface,如 `NotifierUseCase`、`AccountUseCase`,放在 `domain/usecase/`。 +- Request / Response struct 與 interface **同 package**,命名 `{Action}Request`、`NotificationDTO`。 +- DTO 只含 `json` tag(若需序列化),不含 bson tag。 - 更新類 Request 的可選欄位用指標,以便區分「未傳入」與「傳入零值」。 -- 共用分頁 struct 放 `common.go`,如 `Pager`。 **範例:** ```go package usecase -import "gateway/internal/model/member/enum" +import ( + "context" + "gateway/internal/model/notification/domain/enum" +) -type AccountUseCase interface { - CreateLoginUser(ctx context.Context, req *CreateLoginUserRequest) (*CreateLoginUserResponse, error) +type NotifierUseCase interface { + Send(ctx context.Context, req *SendRequest) (*NotificationDTO, error) + Enqueue(ctx context.Context, req *SendRequest) (*NotificationDTO, error) + Get(ctx context.Context, tenantID, id string) (*NotificationDTO, error) } -type CreateLoginUserRequest struct { - LoginID string `json:"login_id"` - Platform enum.Platform `json:"platform"` - Token string `json:"token"` +type SendRequest struct { + TenantID string + Channel enum.Channel + // ... } ``` @@ -216,29 +277,34 @@ type CreateLoginUserRequest struct { **規則:** -- struct 名稱描述業務聚合,如 `MemberUseCase`。 -- 以 `{Name}UseCaseParam` 注入所有 repository 與 `config.Config`。 -- 建構子命名 `Must{Name}UseCase(param) AccountUseCase`,回傳 interface 型別。 -- 實作 struct 嵌入 Param:`type MemberUseCase struct { MemberUseCaseParam }`。 -- 方法簽名與 interface 一致;內部組裝 `entity`,呼叫 repository。 +- 實作 struct 如 `notifierUseCase`、`memberUseCase`,放在模組根 `usecase/`。 +- 以 `{Name}UseCaseParam` 注入 `domain/repository` 介面、provider、renderer、`config`。 +- 建構子 `Must{Name}UseCase(param) domusecase.XxxUseCase`,回傳 **domain** interface。 +- 跨模組組裝可用 `New{Name}UseCaseFromParam` / `factory.go`(見 `notification/usecase/factory.go`)。 +- 方法簽名與 `domain/usecase` interface 一致;內部組裝 `domain/entity`,呼叫 repository 介面。 - 錯誤一律回傳 `gateway/internal/library/errors` 的 `*errs.Error`(見第 7 節)。 - 可測性:將難 mock 的純函式抽成 package 級變數(如 `var HashPasswordFunc = HashPassword`)。 **範例:** ```go +import ( + domrepo "gateway/internal/model/member/domain/repository" + domusecase "gateway/internal/model/member/domain/usecase" +) + type MemberUseCaseParam struct { - Account repository.AccountRepository - User repository.UserRepository + Account domrepo.AccountRepository + User domrepo.UserRepository Config config.Config } -type MemberUseCase struct { +type memberUseCase struct { MemberUseCaseParam } -func MustMemberUseCase(param MemberUseCaseParam) AccountUseCase { - return &MemberUseCase{param} +func MustMemberUseCase(param MemberUseCaseParam) domusecase.AccountUseCase { + return &memberUseCase{param} } ``` @@ -251,14 +317,16 @@ func MustMemberUseCase(param MemberUseCaseParam) AccountUseCase { ```go package member -import "errors" +import "fmt" var ( - ErrNotFound = errors.New("member: not found") - ErrInvalidObjectID = errors.New("member: invalid object id") + ErrNotFound = fmt.Errorf("member: not found") + ErrInvalidObjectID = fmt.Errorf("member: invalid object id") ) ``` +(專案慣例:`fmt.Errorf` 定義 sentinel,便於 `%w` 包裝;見 `notification/errors.go`。) + ### 7.2 Repository | 狀況 | 回傳 | @@ -304,18 +372,31 @@ func GetAccountRedisKey(id string) string { } ``` -## 9. Mock(`mock/`) +## 9. Mock(`mock/` + gomock) -Repository / UseCase 介面變更後,用 mockgen 重新生成: +**方案 A(本專案採用):** + +| 測試對象 | 做法 | +|----------|------| +| `domain/repository` 契約行為(冪等重複、FindByIdempotency) | `repository/*_test.go` + **in-memory 實作**(如 `MemoryNotificationRepository`) | +| `usecase` 編排、分支、配額 | **gomock** `domain/repository` 介面,`EXPECT` / `DoAndReturn` | +| `logic` 呼叫其他模組 | gomock `domain/usecase` | +| Provider Chain | 保留 `provider/*/mock_sender.go`(記錄呼叫、failover) | + +介面變更後執行: ```bash -mockgen -source=./internal/model/member/repository/account.go \ - -destination=./internal/model/member/mock/repository/account.go \ - -package=mockrepository +make gen-mock ``` -- 產物放在 `mock/repository/` 或 `mock/usecase/`,**不要手改**。 -- UseCase 單元測試注入 mock,不啟動真實 DB。 +`domain/repository/generate.go` 範例: + +```go +//go:generate go run go.uber.org/mock/mockgen@latest -typed -destination=../../mock/repository/repository_mock.go -package=mocknotifrepo gateway/internal/model/{module}/domain/repository NotificationRepository,IdempotencyCache,QuotaCounter +``` + +- 產物在 `mock/repository/`、`mock/usecase/`,**不要手改**。 +- **勿**在 usecase test 手寫 70 行 fake repo;有狀態的儲存邏輯放在 `repository/memory_*.go` 並由 **repository 層測試** 覆蓋。 ## 10. 命名對照表 @@ -337,17 +418,22 @@ mockgen -source=./internal/model/member/repository/account.go \ ## 11. 新增模組 / Model 檢查清單 -1. 建立 `internal/model/{module}/` 目錄結構。 -2. 在 `entity/` 新增 struct + `CollectionName()`。 -3. 若有列舉 / 狀態,在 `enum/` 定義值物件。 -4. 在 `repository/` 宣告 interface 並實作 CRUD + index migration + `*_test.go`。 -5. 在 `errors.go` 補充 sentinel(若需要);在 `redis.go` 補 cache key(若需要)。 -6. 在 `usecase/` 定義 interface、DTO 與實作 + 單元測試。 -7. 執行 mockgen 更新 `mock/`。 -8. 在 `internal/svc/service_context.go` 組裝 repository → usecase。 -9. 在 `generate/api/` 定義路由,`make gen-api`。 -10. 在 `internal/logic/` 實作 types 映射,**只**呼叫 UseCase interface。 -11. `make gen-doc`、`go test ./...`。 +1. 建立 `internal/model/{module}/domain/{entity,enum,repository,usecase}/` 與外層 `repository/`、`usecase/`。 +2. 在 `domain/entity/` 新增 struct + `CollectionName()`。 +3. 在 `domain/enum/` 定義值物件(若有)。 +4. 在 `domain/repository/` 宣告 interface;於 `repository/` 實作 CRUD + index + `*_test.go`。 +5. 在 `domain/usecase/` 宣告 interface + DTO;於 `usecase/` 實作 + 單元測試(可 fake `domain/repository`)。 +6. 模組專用整合放 `provider/`、`template/`(勿放入 `library/`)。 +7. `errors.go`、`const.go`、`redis.go`、`config/` 按需補齊。 +8. 執行 `make gen-mock`(`go:generate` 在 `domain/repository/generate.go` 等)。 +9. 在 `internal/config/config.go` 嵌入模組 `config`;`etc/gateway.yaml` 加區塊。 +10. 在 `internal/svc/service_context.go` 建立 **共用** `*redislib.Client`,再注入 `NewXxxUseCaseFromParam`(Mongo / Redis 未配置時對應欄位可為 `nil`)。 +11. 在 `generate/api/` 定義路由,`make gen-api`;`internal/logic/` **只** import `domain/usecase`。 +12. `make gen-doc`、`go test ./...`。 + +**Notification 模組進度(參考):** 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 層的關係 @@ -358,13 +444,16 @@ handler(goctl 生成)→ response.Write ↓ logic(goctl 生成框架,手寫映射) ↓ 轉換 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 ``` - `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。 diff --git a/etc/README.md b/etc/README.md new file mode 100644 index 0000000..ee59b1b --- /dev/null +++ b/etc/README.md @@ -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 存取層 diff --git a/etc/gateway.dev.yaml b/etc/gateway.dev.yaml new file mode 100644 index 0000000..342c7de --- /dev/null +++ b/etc/gateway.dev.yaml @@ -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 diff --git a/etc/gateway.yaml b/etc/gateway.yaml index 70d230c..a05a1f3 100644 --- a/etc/gateway.yaml +++ b/etc/gateway.yaml @@ -1,3 +1,42 @@ +# 預設:不需 Docker 即可啟動(僅 health API) +# 完整開發(Mongo + Redis + Notification):複製 gateway.dev.yaml 或 make run-dev +# 欄位說明:etc/README.md + Name: gateway Host: 0.0.0.0 Port: 8888 + +# Mongo 整段註解 = 不連線(Notifier 不注入) +# Mongo: +# Host: 127.0.0.1 +# Port: 27017 +# Database: gateway + +# Redis 留空 Host = 不連線;要 Notification 異步 / Member OTP 請用 gateway.dev.yaml +Redis: + Host: "" + Type: node + +Notification: + DefaultLocale: zh-tw + Email: + Provider: mock + From: noreply@example.com + SMS: + Provider: mock + Async: + QueueRedisKey: notification:queue + Worker: 2 + MaxRetry: 5 + BackoffSeconds: [1, 5, 30, 300, 1800] + RatePerTenant: + Email: 100 + SMS: 50 + +Member: + OTP: + Length: 6 + TTLSeconds: 300 + MaxAttempts: 5 + ResendCooldownSeconds: 60 + DailyVerifyLimit: 10 diff --git a/gateway.go b/gateway.go index 54a375c..8dac706 100644 --- a/gateway.go +++ b/gateway.go @@ -4,8 +4,12 @@ package main import ( + "context" "flag" "fmt" + "os" + "os/signal" + "syscall" "gateway/internal/config" "gateway/internal/handler" @@ -26,8 +30,13 @@ func main() { server := rest.MustNewServer(c.RestConf) defer server.Stop() - ctx := svc.NewServiceContext(c) - handler.RegisterHandlers(server, ctx) + sc := svc.NewServiceContext(c) + handler.RegisterHandlers(server, sc) + + workerCtx, stopWorkers := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stopWorkers() + sc.StartWorkers(workerCtx) + defer sc.StopWorkers() fmt.Printf("Starting server at %s:%d...\n", c.Host, c.Port) server.Start() diff --git a/go.mod b/go.mod index d2deb90..8ab66b1 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,27 @@ module gateway go 1.26.1 require ( + github.com/alicebob/miniredis/v2 v2.37.0 github.com/go-playground/locales v0.14.1 github.com/go-playground/universal-translator v0.18.1 github.com/go-playground/validator/v10 v10.30.2 + github.com/google/uuid v1.6.0 github.com/shopspring/decimal v1.4.0 github.com/stretchr/testify v1.11.1 github.com/zeromicro/go-zero v1.10.1 go.mongodb.org/mongo-driver/v2 v2.5.0 + go.uber.org/mock v0.6.0 + golang.org/x/crypto v0.49.0 google.golang.org/grpc v1.79.3 ) require ( + github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.61 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect + github.com/aws/aws-sdk-go-v2/service/ses v1.30.0 // indirect + github.com/aws/smithy-go v1.22.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -24,7 +34,6 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/grafana/pyroscope-go v1.2.8 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect @@ -32,6 +41,7 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/minchao/go-mitake v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/openzipkin/zipkin-go v0.4.3 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect @@ -47,6 +57,7 @@ require ( github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect @@ -60,9 +71,7 @@ require ( go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect - go.uber.org/mock v0.6.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect @@ -70,6 +79,8 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ecdba86..8896387 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,17 @@ github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= +github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= +github.com/aws/aws-sdk-go-v2/credentials v1.17.61 h1:Hd/uX6Wo2iUW1JWII+rmyCD7MMhOe7ALwQXN6sKDd1o= +github.com/aws/aws-sdk-go-v2/credentials v1.17.61/go.mod h1:L7vaLkwHY1qgW0gG1zG0z/X0sQ5tpIY5iI13+j3qI80= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= +github.com/aws/aws-sdk-go-v2/service/ses v1.30.0 h1:PysTMRJ3Eq5TKQVjMKJ1JT5XLZ1YtJ9BXdzQ3RUi7XE= +github.com/aws/aws-sdk-go-v2/service/ses v1.30.0/go.mod h1:eZW5lSNTE1tQfMpl6crr/YVJYgEcnk2JQoodg6E63qM= +github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= +github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -64,6 +76,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/minchao/go-mitake v1.0.0 h1:OgfCUkSRftd6sWibpJyeKU3/gPQhq1t0ttHsnoaeVgQ= +github.com/minchao/go-mitake v1.0.0/go.mod h1:RAo0TijPUqhM2ZLMqP9x76wsomL11Ud4sDSwRYwbeGU= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg= @@ -175,6 +189,7 @@ golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= @@ -194,9 +209,13 @@ google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= diff --git a/internal/config/config.go b/internal/config/config.go index 0fb5680..6cc8778 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,8 +3,19 @@ package config -import "github.com/zeromicro/go-zero/rest" +import ( + "github.com/zeromicro/go-zero/core/stores/redis" + "github.com/zeromicro/go-zero/rest" + + "gateway/internal/library/mongo" + memberconfig "gateway/internal/model/member/config" + notifconfig "gateway/internal/model/notification/config" +) type Config struct { rest.RestConf + Mongo mongo.Conf `json:",optional"` + Redis redis.RedisConf `json:",optional"` + Notification notifconfig.Config `json:",optional"` + Member memberconfig.Config `json:",optional"` } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..58aec89 --- /dev/null +++ b/internal/config/config_test.go @@ -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") + } +} diff --git a/internal/library/mongo/README.md b/internal/library/mongo/README.md index dcddcac..02c59f5 100644 --- a/internal/library/mongo/README.md +++ b/internal/library/mongo/README.md @@ -255,14 +255,15 @@ func (r *accountRepository) FindOne(ctx context.Context, id string) (*entity.Acc | 欄位 | 說明 | |------|------| | `Schema` | `mongodb` 或 `mongodb+srv`(Atlas) | -| `Host` | `host:port` 或 srv 的 host | +| `Host` | `host:port`;也可只寫 host 並用 `Port`(整數) | +| `Port` | 選用;`Host` 未含埠時會組成 `host:port` | | `User` / `Password` | 會經 URL 編碼,勿手拼 URI | | `Database` | 傳入 `mon.NewModel` 的 db 名稱 | | `AuthSource` | 查詢參數 `authSource` | | `ReplicaName` | 查詢參數 `replicaSet` | | `TLS` | 查詢參數 `tls=true` | | `MaxPoolSize` / `MinPoolSize` / `MaxConnIdleTime` | client pool | -| `Compressors` | 預設 `zstd`、`snappy` | +| `Compressors` | 選用 YAML **陣列**(`["zstd","snappy"]`);勿寫單一字串。省略時程式預設 `zstd`、`snappy` | | `ConnectTimeoutMs` | 啟動 Ping 逾時(預設 10s) | 尚未接到 `etc/gateway.yaml` 時,可在 `ServiceContext` 從環境變數或本地 yaml 填入 `Conf`。 diff --git a/internal/library/mongo/conf_load_test.go b/internal/library/mongo/conf_load_test.go new file mode 100644 index 0000000..a22e15e --- /dev/null +++ b/internal/library/mongo/conf_load_test.go @@ -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") + } +} diff --git a/internal/library/mongo/config.go b/internal/library/mongo/config.go index 323b4e5..a451781 100644 --- a/internal/library/mongo/config.go +++ b/internal/library/mongo/config.go @@ -3,18 +3,20 @@ package mongo import "time" // Conf is MongoDB client configuration for DocumentDB helpers. +// Use json tags for go-zero conf (see etc/gateway.yaml). type Conf struct { - Schema string - User string - Password string - Host string - Database string - AuthSource string - ReplicaName string - TLS bool - MaxPoolSize uint64 - MinPoolSize uint64 - MaxConnIdleTime time.Duration - Compressors []string - ConnectTimeoutMs int64 + Schema string `json:",default=mongodb"` + User string `json:",optional"` + Password string `json:",optional"` + Host string `json:",optional"` + Port int `json:",optional"` // if Host has no ":port", appended in buildConnectionURI + Database string `json:",optional"` + AuthSource string `json:",optional"` + ReplicaName string `json:",optional"` + TLS bool `json:",optional"` + MaxPoolSize uint64 `json:",optional"` + MinPoolSize uint64 `json:",optional"` + MaxConnIdleTime time.Duration `json:",optional"` + Compressors []string `json:",optional"` + ConnectTimeoutMs int64 `json:",optional"` } diff --git a/internal/library/mongo/uri.go b/internal/library/mongo/uri.go index e5470bc..f7525ef 100644 --- a/internal/library/mongo/uri.go +++ b/internal/library/mongo/uri.go @@ -2,6 +2,7 @@ package mongo import ( "fmt" + "net" "net/url" ) @@ -10,13 +11,17 @@ func buildConnectionURI(c Conf) (string, error) { if scheme == "" { scheme = "mongodb" } - if c.Host == "" { + host := c.Host + if host == "" { return "", fmt.Errorf("mongo: host is required") } + if c.Port > 0 && !hostHasPort(host) { + host = fmt.Sprintf("%s:%d", host, c.Port) + } u := &url.URL{ Scheme: scheme, - Host: c.Host, + Host: host, } if c.User != "" { u.User = url.UserPassword(c.User, c.Password) @@ -39,6 +44,14 @@ func buildConnectionURI(c Conf) (string, error) { return u.String(), nil } +func hostHasPort(host string) bool { + // IPv6 [::1]:27017 or hostname:27017 + if h, _, err := net.SplitHostPort(host); err == nil && h != "" { + return true + } + return false +} + func redactConnectionURI(uri string) string { u, err := url.Parse(uri) if err != nil || u.User == nil { diff --git a/internal/library/redis/README.md b/internal/library/redis/README.md new file mode 100644 index 0000000..5897fb3 --- /dev/null +++ b/internal/library/redis/README.md @@ -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 | diff --git a/internal/library/redis/client.go b/internal/library/redis/client.go new file mode 100644 index 0000000..1d84a08 --- /dev/null +++ b/internal/library/redis/client.go @@ -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 +} diff --git a/internal/library/redis/client_test.go b/internal/library/redis/client_test.go new file mode 100644 index 0000000..013cf6a --- /dev/null +++ b/internal/library/redis/client_test.go @@ -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) +} diff --git a/internal/model/member/config/config.go b/internal/model/member/config/config.go new file mode 100644 index 0000000..031c2c1 --- /dev/null +++ b/internal/model/member/config/config.go @@ -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 +} diff --git a/internal/model/member/domain/enum/otp_purpose.go b/internal/model/member/domain/enum/otp_purpose.go new file mode 100644 index 0000000..181e9bd --- /dev/null +++ b/internal/model/member/domain/enum/otp_purpose.go @@ -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) +} diff --git a/internal/model/member/domain/enum/verify_kind.go b/internal/model/member/domain/enum/verify_kind.go new file mode 100644 index 0000000..42cc026 --- /dev/null +++ b/internal/model/member/domain/enum/verify_kind.go @@ -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) +} diff --git a/internal/model/member/domain/repository/otp_challenge.go b/internal/model/member/domain/repository/otp_challenge.go new file mode 100644 index 0000000..eb146ae --- /dev/null +++ b/internal/model/member/domain/repository/otp_challenge.go @@ -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) +} diff --git a/internal/model/member/domain/repository/profile.go b/internal/model/member/domain/repository/profile.go new file mode 100644 index 0000000..fc30b4d --- /dev/null +++ b/internal/model/member/domain/repository/profile.go @@ -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 +} diff --git a/internal/model/member/domain/usecase/dto.go b/internal/model/member/domain/usecase/dto.go new file mode 100644 index 0000000..bb543d8 --- /dev/null +++ b/internal/model/member/domain/usecase/dto.go @@ -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"` +} diff --git a/internal/model/member/domain/usecase/otp.go b/internal/model/member/domain/usecase/otp.go new file mode 100644 index 0000000..bebf96c --- /dev/null +++ b/internal/model/member/domain/usecase/otp.go @@ -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 +} diff --git a/internal/model/member/domain/usecase/verification.go b/internal/model/member/domain/usecase/verification.go new file mode 100644 index 0000000..2e569d7 --- /dev/null +++ b/internal/model/member/domain/usecase/verification.go @@ -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 +} diff --git a/internal/model/member/errors.go b/internal/model/member/errors.go new file mode 100644 index 0000000..8ffb118 --- /dev/null +++ b/internal/model/member/errors.go @@ -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") +) diff --git a/internal/model/member/redis.go b/internal/model/member/redis.go new file mode 100644 index 0000000..db82698 --- /dev/null +++ b/internal/model/member/redis.go @@ -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() +} diff --git a/internal/model/member/repository/otp_store_redis.go b/internal/model/member/repository/otp_store_redis.go new file mode 100644 index 0000000..57354f5 --- /dev/null +++ b/internal/model/member/repository/otp_store_redis.go @@ -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 +} diff --git a/internal/model/member/repository/profile_memory.go b/internal/model/member/repository/profile_memory.go new file mode 100644 index 0000000..f6b6d11 --- /dev/null +++ b/internal/model/member/repository/profile_memory.go @@ -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) diff --git a/internal/model/member/usecase/module.go b/internal/model/member/usecase/module.go new file mode 100644 index 0000000..64bc15d --- /dev/null +++ b/internal/model/member/usecase/module.go @@ -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 +} diff --git a/internal/model/member/usecase/otp_usecase.go b/internal/model/member/usecase/otp_usecase.go new file mode 100644 index 0000000..5e07dd8 --- /dev/null +++ b/internal/model/member/usecase/otp_usecase.go @@ -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 +} diff --git a/internal/model/member/usecase/otp_usecase_test.go b/internal/model/member/usecase/otp_usecase_test.go new file mode 100644 index 0000000..7a1fc3e --- /dev/null +++ b/internal/model/member/usecase/otp_usecase_test.go @@ -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) +} diff --git a/internal/model/member/usecase/verification_usecase.go b/internal/model/member/usecase/verification_usecase.go new file mode 100644 index 0000000..9c85213 --- /dev/null +++ b/internal/model/member/usecase/verification_usecase.go @@ -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 +} diff --git a/internal/model/notification/README.md b/internal/model/notification/README.md new file mode 100644 index 0000000..41f9e26 --- /dev/null +++ b/internal/model/notification/README.md @@ -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 存取層 diff --git a/internal/model/notification/config/config.go b/internal/model/notification/config/config.go new file mode 100644 index 0000000..bc40580 --- /dev/null +++ b/internal/model/notification/config/config.go @@ -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"` +} diff --git a/internal/model/notification/const.go b/internal/model/notification/const.go new file mode 100644 index 0000000..267d1f0 --- /dev/null +++ b/internal/model/notification/const.go @@ -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" +) diff --git a/internal/model/notification/domain/entity/notification.go b/internal/model/notification/domain/entity/notification.go new file mode 100644 index 0000000..e20939a --- /dev/null +++ b/internal/model/notification/domain/entity/notification.go @@ -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" +} diff --git a/internal/model/notification/domain/entity/notification_dlq.go b/internal/model/notification/domain/entity/notification_dlq.go new file mode 100644 index 0000000..c970467 --- /dev/null +++ b/internal/model/notification/domain/entity/notification_dlq.go @@ -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" +} diff --git a/internal/model/notification/domain/enum/channel.go b/internal/model/notification/domain/enum/channel.go new file mode 100644 index 0000000..3033ee1 --- /dev/null +++ b/internal/model/notification/domain/enum/channel.go @@ -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 + } +} diff --git a/internal/model/notification/domain/enum/kind.go b/internal/model/notification/domain/enum/kind.go new file mode 100644 index 0000000..bda32cc --- /dev/null +++ b/internal/model/notification/domain/enum/kind.go @@ -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 + } +} diff --git a/internal/model/notification/domain/enum/severity.go b/internal/model/notification/domain/enum/severity.go new file mode 100644 index 0000000..6dd0e0c --- /dev/null +++ b/internal/model/notification/domain/enum/severity.go @@ -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) +} diff --git a/internal/model/notification/domain/enum/status.go b/internal/model/notification/domain/enum/status.go new file mode 100644 index 0000000..92aa13a --- /dev/null +++ b/internal/model/notification/domain/enum/status.go @@ -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) +} diff --git a/internal/model/notification/domain/repository/generate.go b/internal/model/notification/domain/repository/generate.go new file mode 100644 index 0000000..b16fcc2 --- /dev/null +++ b/internal/model/notification/domain/repository/generate.go @@ -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 diff --git a/internal/model/notification/domain/repository/notification.go b/internal/model/notification/domain/repository/notification.go new file mode 100644 index 0000000..343a0b9 --- /dev/null +++ b/internal/model/notification/domain/repository/notification.go @@ -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 +} diff --git a/internal/model/notification/domain/repository/notification_dlq.go b/internal/model/notification/domain/repository/notification_dlq.go new file mode 100644 index 0000000..2e241f5 --- /dev/null +++ b/internal/model/notification/domain/repository/notification_dlq.go @@ -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 +} diff --git a/internal/model/notification/domain/repository/retry_queue.go b/internal/model/notification/domain/repository/retry_queue.go new file mode 100644 index 0000000..8351bec --- /dev/null +++ b/internal/model/notification/domain/repository/retry_queue.go @@ -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) +} diff --git a/internal/model/notification/domain/repository/store.go b/internal/model/notification/domain/repository/store.go new file mode 100644 index 0000000..ca8f71a --- /dev/null +++ b/internal/model/notification/domain/repository/store.go @@ -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) +} diff --git a/internal/model/notification/domain/template/const.go b/internal/model/notification/domain/template/const.go new file mode 100644 index 0000000..ecc3b5b --- /dev/null +++ b/internal/model/notification/domain/template/const.go @@ -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" +) diff --git a/internal/model/notification/domain/template/registry.go b/internal/model/notification/domain/template/registry.go new file mode 100644 index 0000000..6a1b01a --- /dev/null +++ b/internal/model/notification/domain/template/registry.go @@ -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) +} diff --git a/internal/model/notification/domain/template/renderer.go b/internal/model/notification/domain/template/renderer.go new file mode 100644 index 0000000..d9754b4 --- /dev/null +++ b/internal/model/notification/domain/template/renderer.go @@ -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) +} diff --git a/internal/model/notification/domain/template/spec.go b/internal/model/notification/domain/template/spec.go new file mode 100644 index 0000000..30c9246 --- /dev/null +++ b/internal/model/notification/domain/template/spec.go @@ -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 +} diff --git a/internal/model/notification/domain/usecase/admin.go b/internal/model/notification/domain/usecase/admin.go new file mode 100644 index 0000000..9869932 --- /dev/null +++ b/internal/model/notification/domain/usecase/admin.go @@ -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"` +} diff --git a/internal/model/notification/domain/usecase/dto.go b/internal/model/notification/domain/usecase/dto.go new file mode 100644 index 0000000..383545e --- /dev/null +++ b/internal/model/notification/domain/usecase/dto.go @@ -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"` +} diff --git a/internal/model/notification/domain/usecase/notifier.go b/internal/model/notification/domain/usecase/notifier.go new file mode 100644 index 0000000..9e5bf7d --- /dev/null +++ b/internal/model/notification/domain/usecase/notifier.go @@ -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) +} diff --git a/internal/model/notification/domain/usecase/retry_job.go b/internal/model/notification/domain/usecase/retry_job.go new file mode 100644 index 0000000..a022000 --- /dev/null +++ b/internal/model/notification/domain/usecase/retry_job.go @@ -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"` +} diff --git a/internal/model/notification/errors.go b/internal/model/notification/errors.go new file mode 100644 index 0000000..fec79c2 --- /dev/null +++ b/internal/model/notification/errors.go @@ -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") +) diff --git a/internal/model/notification/mock/repository/repository_mock.go b/internal/model/notification/mock/repository/repository_mock.go new file mode 100644 index 0000000..3d28dfc --- /dev/null +++ b/internal/model/notification/mock/repository/repository_mock.go @@ -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 +} diff --git a/internal/model/notification/provider/email/chain.go b/internal/model/notification/provider/email/chain.go new file mode 100644 index 0000000..5ce6172 --- /dev/null +++ b/internal/model/notification/provider/email/chain.go @@ -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) +} diff --git a/internal/model/notification/provider/email/chain_test.go b/internal/model/notification/provider/email/chain_test.go new file mode 100644 index 0000000..435e13e --- /dev/null +++ b/internal/model/notification/provider/email/chain_test.go @@ -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: "

code

", + }) + + 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") +} diff --git a/internal/model/notification/provider/email/message.go b/internal/model/notification/provider/email/message.go new file mode 100644 index 0000000..c81d170 --- /dev/null +++ b/internal/model/notification/provider/email/message.go @@ -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 +} diff --git a/internal/model/notification/provider/email/mock_sender.go b/internal/model/notification/provider/email/mock_sender.go new file mode 100644 index 0000000..866d6e0 --- /dev/null +++ b/internal/model/notification/provider/email/mock_sender.go @@ -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) +} diff --git a/internal/model/notification/provider/email/sender.go b/internal/model/notification/provider/email/sender.go new file mode 100644 index 0000000..7a8067d --- /dev/null +++ b/internal/model/notification/provider/email/sender.go @@ -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) +} diff --git a/internal/model/notification/provider/email/ses_sender.go b/internal/model/notification/provider/email/ses_sender.go new file mode 100644 index 0000000..efb07fb --- /dev/null +++ b/internal/model/notification/provider/email/ses_sender.go @@ -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 +} diff --git a/internal/model/notification/provider/email/smtp_sender.go b/internal/model/notification/provider/email/smtp_sender.go new file mode 100644 index 0000000..a781e85 --- /dev/null +++ b/internal/model/notification/provider/email/smtp_sender.go @@ -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 +} diff --git a/internal/model/notification/provider/sms/chain.go b/internal/model/notification/provider/sms/chain.go new file mode 100644 index 0000000..b9baa7b --- /dev/null +++ b/internal/model/notification/provider/sms/chain.go @@ -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) +} diff --git a/internal/model/notification/provider/sms/chain_test.go b/internal/model/notification/provider/sms/chain_test.go new file mode 100644 index 0000000..6e4ed21 --- /dev/null +++ b/internal/model/notification/provider/sms/chain_test.go @@ -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") +} diff --git a/internal/model/notification/provider/sms/message.go b/internal/model/notification/provider/sms/message.go new file mode 100644 index 0000000..69e35d7 --- /dev/null +++ b/internal/model/notification/provider/sms/message.go @@ -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 +} diff --git a/internal/model/notification/provider/sms/mitake_sender.go b/internal/model/notification/provider/sms/mitake_sender.go new file mode 100644 index 0000000..677795b --- /dev/null +++ b/internal/model/notification/provider/sms/mitake_sender.go @@ -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()) + } +} diff --git a/internal/model/notification/provider/sms/mock_sender.go b/internal/model/notification/provider/sms/mock_sender.go new file mode 100644 index 0000000..2710d0e --- /dev/null +++ b/internal/model/notification/provider/sms/mock_sender.go @@ -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) +} diff --git a/internal/model/notification/provider/sms/sender.go b/internal/model/notification/provider/sms/sender.go new file mode 100644 index 0000000..06248aa --- /dev/null +++ b/internal/model/notification/provider/sms/sender.go @@ -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) +} diff --git a/internal/model/notification/redis.go b/internal/model/notification/redis.go new file mode 100644 index 0000000..69b79af --- /dev/null +++ b/internal/model/notification/redis.go @@ -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() +} diff --git a/internal/model/notification/repository/memory_notification.go b/internal/model/notification/repository/memory_notification.go new file mode 100644 index 0000000..64f8f93 --- /dev/null +++ b/internal/model/notification/repository/memory_notification.go @@ -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 +} diff --git a/internal/model/notification/repository/memory_store.go b/internal/model/notification/repository/memory_store.go new file mode 100644 index 0000000..3958a53 --- /dev/null +++ b/internal/model/notification/repository/memory_store.go @@ -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 +} diff --git a/internal/model/notification/repository/notification.go b/internal/model/notification/repository/notification.go new file mode 100644 index 0000000..4319ff7 --- /dev/null +++ b/internal/model/notification/repository/notification.go @@ -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 +} diff --git a/internal/model/notification/repository/notification_dlq.go b/internal/model/notification/repository/notification_dlq.go new file mode 100644 index 0000000..806cb53 --- /dev/null +++ b/internal/model/notification/repository/notification_dlq.go @@ -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) +} diff --git a/internal/model/notification/repository/notification_memory_test.go b/internal/model/notification/repository/notification_memory_test.go new file mode 100644 index 0000000..7204d5b --- /dev/null +++ b/internal/model/notification/repository/notification_memory_test.go @@ -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) +} diff --git a/internal/model/notification/repository/redis_store.go b/internal/model/notification/repository/redis_store.go new file mode 100644 index 0000000..db414c3 --- /dev/null +++ b/internal/model/notification/repository/redis_store.go @@ -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 +} diff --git a/internal/model/notification/repository/retry_queue_redis.go b/internal/model/notification/repository/retry_queue_redis.go new file mode 100644 index 0000000..58f335f --- /dev/null +++ b/internal/model/notification/repository/retry_queue_redis.go @@ -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 +} diff --git a/internal/model/notification/template/embed.go b/internal/model/notification/template/embed.go new file mode 100644 index 0000000..3f4005b --- /dev/null +++ b/internal/model/notification/template/embed.go @@ -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 +} diff --git a/internal/model/notification/template/html/account_suspended.zh-tw.html b/internal/model/notification/template/html/account_suspended.zh-tw.html new file mode 100644 index 0000000..7717d0f --- /dev/null +++ b/internal/model/notification/template/html/account_suspended.zh-tw.html @@ -0,0 +1,28 @@ + + + + + + 帳號狀態通知 + + + + + + +
+ + + + + + + +
+

帳號已停權

+
+

您的 CloudEP 帳號已被停權,暫時無法登入或使用服務。如有疑問,請聯絡租戶管理員。

+
+
+ + diff --git a/internal/model/notification/template/html/step_up_email.en-us.html b/internal/model/notification/template/html/step_up_email.en-us.html new file mode 100644 index 0000000..bc1ccf2 --- /dev/null +++ b/internal/model/notification/template/html/step_up_email.en-us.html @@ -0,0 +1,41 @@ + + + + + + Security verification + + + + + + +
+ + + + + + + + + + +
+

CloudEP Security

+

Security check

+
+

You are performing a sensitive action. Enter this code within {{.expires_in}} seconds. Never share it with anyone.

+ + + + +
+ {{.code}} +
+
+

© CloudEP

+
+
+ + diff --git a/internal/model/notification/template/html/step_up_email.zh-tw.html b/internal/model/notification/template/html/step_up_email.zh-tw.html new file mode 100644 index 0000000..24ddfd5 --- /dev/null +++ b/internal/model/notification/template/html/step_up_email.zh-tw.html @@ -0,0 +1,42 @@ + + + + + + 安全驗證碼 + + + + + + +
+ + + + + + + + + + +
+

CloudEP Security

+

安全驗證

+
+

您正在進行高風險操作,請輸入以下驗證碼以繼續。

+

驗證碼 {{.expires_in}} 秒內有效,請勿將驗證碼提供給他人。

+ + + + +
+ {{.code}} +
+
+

© CloudEP · 若非本人操作請立即聯絡管理員

+
+
+ + diff --git a/internal/model/notification/template/html/tenant_welcome.zh-tw.html b/internal/model/notification/template/html/tenant_welcome.zh-tw.html new file mode 100644 index 0000000..b7bd5ea --- /dev/null +++ b/internal/model/notification/template/html/tenant_welcome.zh-tw.html @@ -0,0 +1,29 @@ + + + + + + 歡迎使用 CloudEP + + + + + + +
+ + + + + + + +
+

租戶已就緒

+
+

您好,

+

租戶 {{.tenant_name}} 已建立完成,您可以開始設定成員與權限。

+
+
+ + diff --git a/internal/model/notification/template/html/verify_email.en-us.html b/internal/model/notification/template/html/verify_email.en-us.html new file mode 100644 index 0000000..a2829cf --- /dev/null +++ b/internal/model/notification/template/html/verify_email.en-us.html @@ -0,0 +1,43 @@ + + + + + + Email verification code + + + + + + +
+ + + + + + + + + + +
+

CloudEP

+

Email verification

+
+

Hello,

+

Use the code below to verify your email. It expires in {{.expires_in}} seconds.

+ + + + +
+ {{.code}} +
+

If you did not request this, you can safely ignore this email.

+
+

© CloudEP · Automated message, please do not reply

+
+
+ + diff --git a/internal/model/notification/template/html/verify_email.zh-tw.html b/internal/model/notification/template/html/verify_email.zh-tw.html new file mode 100644 index 0000000..faddf8e --- /dev/null +++ b/internal/model/notification/template/html/verify_email.zh-tw.html @@ -0,0 +1,43 @@ + + + + + + 電子郵件驗證碼 + + + + + + +
+ + + + + + + + + + +
+

CloudEP

+

電子郵件驗證

+
+

您好,

+

請使用以下驗證碼完成綁定。此驗證碼將於 {{.expires_in}} 秒後失效。

+ + + + +
+ {{.code}} +
+

若您未提出此請求,請忽略本信,無需進一步操作。

+
+

© CloudEP · 本信由系統自動發送,請勿直接回覆

+
+
+ + diff --git a/internal/model/notification/template/html/verify_registration_email.en-us.html b/internal/model/notification/template/html/verify_registration_email.en-us.html new file mode 100644 index 0000000..057c54c --- /dev/null +++ b/internal/model/notification/template/html/verify_registration_email.en-us.html @@ -0,0 +1,41 @@ + + + + + + Registration verification + + + + + + +
+ + + + + + + + + + +
+

CloudEP

+

Welcome

+
+

Thanks for signing up. Your verification code expires in {{.expires_in}} seconds.

+ + + + +
+ {{.code}} +
+
+

© CloudEP

+
+
+ + diff --git a/internal/model/notification/template/html/verify_registration_email.zh-tw.html b/internal/model/notification/template/html/verify_registration_email.zh-tw.html new file mode 100644 index 0000000..5ad7fb5 --- /dev/null +++ b/internal/model/notification/template/html/verify_registration_email.zh-tw.html @@ -0,0 +1,42 @@ + + + + + + 註冊驗證碼 + + + + + + +
+ + + + + + + + + + +
+

CloudEP

+

歡迎註冊

+
+

感謝您註冊 CloudEP。

+

您的註冊驗證碼如下,請於 {{.expires_in}} 秒內完成驗證。

+ + + + +
+ {{.code}} +
+
+

© CloudEP · 本信由系統自動發送

+
+
+ + diff --git a/internal/model/notification/template/registry.go b/internal/model/notification/template/registry.go new file mode 100644 index 0000000..8d5b454 --- /dev/null +++ b/internal/model/notification/template/registry.go @@ -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 +} diff --git a/internal/model/notification/template/render.go b/internal/model/notification/template/render.go new file mode 100644 index 0000000..03f52df --- /dev/null +++ b/internal/model/notification/template/render.go @@ -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 +} diff --git a/internal/model/notification/template/render_test.go b/internal/model/notification/template/render_test.go new file mode 100644 index 0000000..7dfade5 --- /dev/null +++ b/internal/model/notification/template/render_test.go @@ -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: "", + domtpl.VarExpiresIn: 300, + }) + require.NoError(t, err) + assert.NotContains(t, out.Body, "