add member totp

This commit is contained in:
王性驊 2026-05-21 07:51:22 +08:00
parent 240fa92f6f
commit 2ae86e9002
69 changed files with 3137 additions and 116 deletions

View File

@ -16,7 +16,7 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test fmt lint lint-fix fix check run \ .PHONY: help tools gen-api gen-mock build-go-doc gen-doc test fmt lint lint-fix fix check run \
deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index notify-test totp-test setup-dev run-local deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index notify-test totp-test member-seed setup-dev run-local
help: ## 顯示可用指令 help: ## 顯示可用指令
@echo "Gateway Makefile" @echo "Gateway Makefile"
@ -118,5 +118,9 @@ totp-test: setup-dev ## 互動式 TOTP 綁定 + 驗證Google Authenticator
$(if $(ACCOUNT),-account "$(ACCOUNT)",) $(if $(STEP),-step "$(STEP)",) \ $(if $(ACCOUNT),-account "$(ACCOUNT)",) $(if $(STEP),-step "$(STEP)",) \
$(if $(CODE),-code "$(CODE)",) $(if $(CODE),-code "$(CODE)",)
member-seed: setup-dev ## 建立 dev tenant + member需 Mongo+Redis
$(GO) run ./cmd/member-seed -f etc/gateway.dev.yaml \
$(if $(TENANT),-tenant "$(TENANT)",) $(if $(EMAIL),-email "$(EMAIL)",)
config-check: ## 驗證 gateway.yaml / gateway.dev.yaml 可載入 config-check: ## 驗證 gateway.yaml / gateway.dev.yaml 可載入
$(GO) test ./internal/config/ -run TestLoadGatewayYAML -v $(GO) test ./internal/config/ -run TestLoadGatewayYAML -v

85
cmd/member-seed/main.go Normal file
View File

@ -0,0 +1,85 @@
// Command member-seed creates a dev tenant and member for local API testing.
//
// make deps-up && make mongo-index && make member-seed
package main
import (
"context"
"flag"
"fmt"
"os"
"gateway/internal/config"
redislib "gateway/internal/library/redis"
domusecase "gateway/internal/model/member/domain/usecase"
memberusecase "gateway/internal/model/member/usecase"
"github.com/zeromicro/go-zero/core/conf"
)
var (
configFile = flag.String("f", "etc/gateway.dev.yaml", "config file")
tenantID = flag.String("tenant", "dev-tenant", "tenant_id")
slug = flag.String("slug", "dev", "tenant slug")
uidPrefix = flag.String("prefix", "DEV", "uid prefix")
email = flag.String("email", "dev@example.com", "member email")
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
if c.Mongo.Host == "" || c.Redis.Host == "" {
return fmt.Errorf("member-seed: Mongo and Redis are required")
}
ctx := context.Background()
rds, err := redislib.NewClient(c.Redis)
if err != nil {
return fmt.Errorf("member-seed: redis: %w", err)
}
mod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
Redis: rds,
MongoConf: &c.Mongo,
Config: c.Member,
})
if err != nil {
return fmt.Errorf("member-seed: module: %w", err)
}
if mod.Tenant == nil || mod.Lifecycle == nil {
return fmt.Errorf("member-seed: tenant/lifecycle not wired (need Mongo)")
}
if _, err := mod.Tenant.Create(ctx, &domusecase.CreateTenantRequest{
TenantID: *tenantID,
Slug: *slug,
Name: "Dev Tenant",
UIDPrefix: *uidPrefix,
}); err != nil {
fmt.Printf("tenant create skipped (may exist): %v\n", err)
}
m, err := mod.Lifecycle.CreateUnverified(ctx, &domusecase.CreatePlatformMemberRequest{
TenantID: *tenantID,
Email: *email,
DisplayName: "Dev User",
Language: "zh-tw",
})
if err != nil {
return fmt.Errorf("member-seed: create member: %w", err)
}
if err := mod.Lifecycle.Activate(ctx, *tenantID, m.UID); err != nil {
return fmt.Errorf("member-seed: activate: %w", err)
}
fmt.Printf("tenant_id=%s uid=%s\n", *tenantID, m.UID)
fmt.Printf("Use headers: X-Tenant-ID=%s X-UID=%s\n", *tenantID, m.UID)
return nil
}

View File

@ -1,5 +1,4 @@
// Command mongo-index ensures Gateway notification MongoDB indexes exist. // Command mongo-index ensures Gateway MongoDB indexes exist.
// Use when docker-entrypoint-initdb.d did not run (existing mongo_data volume).
package main package main
import ( import (
@ -10,12 +9,13 @@ import (
"time" "time"
"gateway/internal/config" "gateway/internal/config"
memberrepo "gateway/internal/model/member/repository"
notifrepo "gateway/internal/model/notification/repository" notifrepo "gateway/internal/model/notification/repository"
"github.com/zeromicro/go-zero/core/conf" "github.com/zeromicro/go-zero/core/conf"
) )
var configFile = flag.String("f", "etc/gateway.dev.yaml", "config file (local; copy from etc/gateway.dev.example.yaml)") var configFile = flag.String("f", "etc/gateway.dev.yaml", "config file")
func main() { func main() {
if err := run(); err != nil { if err := run(); err != nil {
@ -45,7 +45,10 @@ func run() error {
if err := dlqRepo.Index20260520001UP(ctx); err != nil { if err := dlqRepo.Index20260520001UP(ctx); err != nil {
return fmt.Errorf("mongo-index: notification_dlq: %w", err) return fmt.Errorf("mongo-index: notification_dlq: %w", err)
} }
if err := memberrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil {
return fmt.Errorf("mongo-index: member: %w", err)
}
fmt.Println("mongo-index: notifications + notification_dlq indexes OK") fmt.Println("mongo-index: notifications + notification_dlq + member indexes OK")
return nil return nil
} }

View File

@ -15,6 +15,7 @@ info (
import ( import (
"common.api" "common.api"
"member.api"
"normal.api" "normal.api"
) )

132
generate/api/member.api Normal file
View File

@ -0,0 +1,132 @@
syntax = "v1"
type (
MemberMeData {
TenantID string `json:"tenant_id"`
UID string `json:"uid"`
ZitadelEmail string `json:"zitadel_email,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Avatar string `json:"avatar,omitempty"`
Phone string `json:"phone,omitempty"`
Language string `json:"language,omitempty"`
Currency string `json:"currency,omitempty"`
Status string `json:"status"`
Origin string `json:"origin"`
BusinessEmail string `json:"business_email,omitempty"`
BusinessEmailVerified bool `json:"business_email_verified"`
BusinessPhone string `json:"business_phone,omitempty"`
BusinessPhoneVerified bool `json:"business_phone_verified"`
TOTPEnrolled bool `json:"totp_enrolled"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
UpdateMemberMeReq {
DisplayName string `json:"display_name,optional"`
Avatar string `json:"avatar,optional"`
Language string `json:"language,optional"`
Currency string `json:"currency,optional"`
Phone string `json:"phone,optional"`
}
VerificationStartReq {
Target string `json:"target"`
}
VerificationStartData {
ChallengeID string `json:"challenge_id"`
ExpiresIn int `json:"expires_in"`
}
VerificationConfirmReq {
ChallengeID string `json:"challenge_id"`
Code string `json:"code"`
}
TOTPStatusData {
Enrolled bool `json:"enrolled"`
EnrolledAt int64 `json:"enrolled_at,omitempty"`
BackupCodesRemaining int `json:"backup_codes_remaining"`
Digits int `json:"digits,omitempty"`
PeriodSeconds int `json:"period_seconds,omitempty"`
}
TOTPEnrollStartData {
OtpauthURL string `json:"otpauth_url"`
Issuer string `json:"issuer"`
Account string `json:"account"`
Digits int `json:"digits"`
PeriodSec int `json:"period_seconds"`
ExpiresIn int `json:"expires_in"`
}
TOTPEnrollConfirmReq {
Code string `json:"code"`
}
TOTPEnrollConfirmData {
BackupCodes []string `json:"backup_codes"`
}
TOTPVerifyReq {
Code string `json:"code"`
}
TOTPBackupCodesData {
BackupCodes []string `json:"backup_codes"`
}
)
@server(
group: member
prefix: /api/v1/members
)
service gateway {
@doc "取得當前會員 profiledevHeader X-Tenant-ID + X-UID"
@handler getMemberMe
get /me returns (MemberMeData)
@doc "更新當前會員 profile"
@handler updateMemberMe
patch /me (UpdateMemberMeReq) returns (MemberMeData)
@doc "開始業務 email 驗證"
@handler startEmailVerification
post /me/verifications/email/start (VerificationStartReq) returns (VerificationStartData)
@doc "確認業務 email 驗證"
@handler confirmEmailVerification
post /me/verifications/email/confirm (VerificationConfirmReq)
@doc "開始業務 phone 驗證"
@handler startPhoneVerification
post /me/verifications/phone/start (VerificationStartReq) returns (VerificationStartData)
@doc "確認業務 phone 驗證"
@handler confirmPhoneVerification
post /me/verifications/phone/confirm (VerificationConfirmReq)
@doc "TOTP 狀態"
@handler getTOTPStatus
get /me/totp returns (TOTPStatusData)
@doc "開始 TOTP 綁定"
@handler startTOTPEnroll
post /me/totp/enroll-start returns (TOTPEnrollStartData)
@doc "確認 TOTP 綁定"
@handler confirmTOTPEnroll
post /me/totp/enroll-confirm (TOTPEnrollConfirmReq) returns (TOTPEnrollConfirmData)
@doc "驗證 TOTPstep-up 測試)"
@handler verifyTOTP
post /me/totp/verify (TOTPVerifyReq)
@doc "重產 TOTP 備援碼"
@handler regenerateTOTPBackupCodes
post /me/totp/backup-codes returns (TOTPBackupCodesData)
@doc "解除 TOTP 綁定"
@handler disableTOTP
delete /me/totp
}

View File

@ -0,0 +1,34 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"net/http"
"gateway/internal/logic/member"
"gateway/internal/response"
"gateway/internal/svc"
"gateway/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 確認業務 email 驗證
func ConfirmEmailVerificationHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.VerificationConfirmReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := member.NewConfirmEmailVerificationLogic(actorContext(r.Context(), r), svcCtx)
err := l.ConfirmEmailVerification(&req)
response.Write(r.Context(), w, nil, err)
}
}

View File

@ -0,0 +1,34 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"net/http"
"gateway/internal/logic/member"
"gateway/internal/response"
"gateway/internal/svc"
"gateway/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 確認業務 phone 驗證
func ConfirmPhoneVerificationHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.VerificationConfirmReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := member.NewConfirmPhoneVerificationLogic(actorContext(r.Context(), r), svcCtx)
err := l.ConfirmPhoneVerification(&req)
response.Write(r.Context(), w, nil, err)
}
}

View File

@ -0,0 +1,34 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"net/http"
"gateway/internal/logic/member"
"gateway/internal/response"
"gateway/internal/svc"
"gateway/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 確認 TOTP 綁定
func ConfirmTOTPEnrollHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.TOTPEnrollConfirmReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := member.NewConfirmTOTPEnrollLogic(actorContext(r.Context(), r), svcCtx)
data, err := l.ConfirmTOTPEnroll(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,12 @@
package member
import (
"context"
"net/http"
logic "gateway/internal/logic/member"
)
func actorContext(ctx context.Context, r *http.Request) context.Context {
return logic.WithActor(ctx, r.Header.Get("X-Tenant-ID"), r.Header.Get("X-UID"))
}

View File

@ -0,0 +1,21 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"net/http"
"gateway/internal/logic/member"
"gateway/internal/response"
"gateway/internal/svc"
)
// 解除 TOTP 綁定
func DisableTOTPHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := member.NewDisableTOTPLogic(actorContext(r.Context(), r), svcCtx)
err := l.DisableTOTP()
response.Write(r.Context(), w, nil, err)
}
}

View File

@ -0,0 +1,21 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"net/http"
"gateway/internal/logic/member"
"gateway/internal/response"
"gateway/internal/svc"
)
// 取得當前會員 profiledevHeader X-Tenant-ID + X-UID
func GetMemberMeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := member.NewGetMemberMeLogic(actorContext(r.Context(), r), svcCtx)
data, err := l.GetMemberMe()
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,21 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"net/http"
"gateway/internal/logic/member"
"gateway/internal/response"
"gateway/internal/svc"
)
// TOTP 狀態
func GetTOTPStatusHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := member.NewGetTOTPStatusLogic(actorContext(r.Context(), r), svcCtx)
data, err := l.GetTOTPStatus()
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,21 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"net/http"
"gateway/internal/logic/member"
"gateway/internal/response"
"gateway/internal/svc"
)
// 重產 TOTP 備援碼
func RegenerateTOTPBackupCodesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := member.NewRegenerateTOTPBackupCodesLogic(actorContext(r.Context(), r), svcCtx)
data, err := l.RegenerateTOTPBackupCodes()
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,34 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"net/http"
"gateway/internal/logic/member"
"gateway/internal/response"
"gateway/internal/svc"
"gateway/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 開始業務 email 驗證
func StartEmailVerificationHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.VerificationStartReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := member.NewStartEmailVerificationLogic(actorContext(r.Context(), r), svcCtx)
data, err := l.StartEmailVerification(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,34 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"net/http"
"gateway/internal/logic/member"
"gateway/internal/response"
"gateway/internal/svc"
"gateway/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 開始業務 phone 驗證
func StartPhoneVerificationHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.VerificationStartReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := member.NewStartPhoneVerificationLogic(actorContext(r.Context(), r), svcCtx)
data, err := l.StartPhoneVerification(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,21 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"net/http"
"gateway/internal/logic/member"
"gateway/internal/response"
"gateway/internal/svc"
)
// 開始 TOTP 綁定
func StartTOTPEnrollHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := member.NewStartTOTPEnrollLogic(actorContext(r.Context(), r), svcCtx)
data, err := l.StartTOTPEnroll()
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,34 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"net/http"
"gateway/internal/logic/member"
"gateway/internal/response"
"gateway/internal/svc"
"gateway/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 更新當前會員 profile
func UpdateMemberMeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateMemberMeReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := member.NewUpdateMemberMeLogic(actorContext(r.Context(), r), svcCtx)
data, err := l.UpdateMemberMe(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,34 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"net/http"
"gateway/internal/logic/member"
"gateway/internal/response"
"gateway/internal/svc"
"gateway/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 驗證 TOTPstep-up 測試)
func VerifyTOTPHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.TOTPVerifyReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := member.NewVerifyTOTPLogic(actorContext(r.Context(), r), svcCtx)
err := l.VerifyTOTP(&req)
response.Write(r.Context(), w, nil, err)
}
}

View File

@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"time" "time"
member "gateway/internal/handler/member"
normal "gateway/internal/handler/normal" normal "gateway/internal/handler/normal"
"gateway/internal/svc" "gateway/internal/svc"
@ -14,6 +15,84 @@ import (
) )
func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
server.AddRoutes(
[]rest.Route{
{
// 取得當前會員 profiledevHeader X-Tenant-ID + X-UID
Method: http.MethodGet,
Path: "/me",
Handler: member.GetMemberMeHandler(serverCtx),
},
{
// 更新當前會員 profile
Method: http.MethodPatch,
Path: "/me",
Handler: member.UpdateMemberMeHandler(serverCtx),
},
{
// TOTP 狀態
Method: http.MethodGet,
Path: "/me/totp",
Handler: member.GetTOTPStatusHandler(serverCtx),
},
{
// 解除 TOTP 綁定
Method: http.MethodDelete,
Path: "/me/totp",
Handler: member.DisableTOTPHandler(serverCtx),
},
{
// 重產 TOTP 備援碼
Method: http.MethodPost,
Path: "/me/totp/backup-codes",
Handler: member.RegenerateTOTPBackupCodesHandler(serverCtx),
},
{
// 確認 TOTP 綁定
Method: http.MethodPost,
Path: "/me/totp/enroll-confirm",
Handler: member.ConfirmTOTPEnrollHandler(serverCtx),
},
{
// 開始 TOTP 綁定
Method: http.MethodPost,
Path: "/me/totp/enroll-start",
Handler: member.StartTOTPEnrollHandler(serverCtx),
},
{
// 驗證 TOTPstep-up 測試)
Method: http.MethodPost,
Path: "/me/totp/verify",
Handler: member.VerifyTOTPHandler(serverCtx),
},
{
// 確認業務 email 驗證
Method: http.MethodPost,
Path: "/me/verifications/email/confirm",
Handler: member.ConfirmEmailVerificationHandler(serverCtx),
},
{
// 開始業務 email 驗證
Method: http.MethodPost,
Path: "/me/verifications/email/start",
Handler: member.StartEmailVerificationHandler(serverCtx),
},
{
// 確認業務 phone 驗證
Method: http.MethodPost,
Path: "/me/verifications/phone/confirm",
Handler: member.ConfirmPhoneVerificationHandler(serverCtx),
},
{
// 開始業務 phone 驗證
Method: http.MethodPost,
Path: "/me/verifications/phone/start",
Handler: member.StartPhoneVerificationHandler(serverCtx),
},
},
rest.WithPrefix("/api/v1/members"),
)
server.AddRoutes( server.AddRoutes(
[]rest.Route{ []rest.Route{
{ {

View File

@ -0,0 +1,28 @@
package member
import (
"context"
"fmt"
)
type actorKey struct{}
// Actor identifies the calling member in dev mode (JWT middleware not wired yet).
type Actor struct {
TenantID string
UID string
}
// WithActor stores tenant/uid on the context for member logic handlers.
func WithActor(ctx context.Context, tenantID, uid string) context.Context {
return context.WithValue(ctx, actorKey{}, Actor{TenantID: tenantID, UID: uid})
}
// ActorFromContext reads the dev actor injected by handlers.
func ActorFromContext(ctx context.Context) (Actor, error) {
v, ok := ctx.Value(actorKey{}).(Actor)
if !ok || v.TenantID == "" || v.UID == "" {
return Actor{}, fmt.Errorf("missing X-Tenant-ID or X-UID header")
}
return v, nil
}

View File

@ -0,0 +1,30 @@
// Code scaffolded by goctl. Safe to edit.
package member
import (
"context"
"gateway/internal/model/member/domain/enum"
"gateway/internal/svc"
"gateway/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ConfirmEmailVerificationLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewConfirmEmailVerificationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ConfirmEmailVerificationLogic {
return &ConfirmEmailVerificationLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *ConfirmEmailVerificationLogic) ConfirmEmailVerification(req *types.VerificationConfirmReq) error {
actor, err := actorOrErr(l.ctx)
if err != nil {
return err
}
return confirmVerification(l.ctx, l.svcCtx, actor, req, enum.OTPPurposeBusinessEmail, l.svcCtx.MemberProfile.SetBusinessEmailVerified)
}

View File

@ -0,0 +1,30 @@
// Code scaffolded by goctl. Safe to edit.
package member
import (
"context"
"gateway/internal/model/member/domain/enum"
"gateway/internal/svc"
"gateway/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ConfirmPhoneVerificationLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewConfirmPhoneVerificationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ConfirmPhoneVerificationLogic {
return &ConfirmPhoneVerificationLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *ConfirmPhoneVerificationLogic) ConfirmPhoneVerification(req *types.VerificationConfirmReq) error {
actor, err := actorOrErr(l.ctx)
if err != nil {
return err
}
return confirmVerification(l.ctx, l.svcCtx, actor, req, enum.OTPPurposeBusinessPhone, l.svcCtx.MemberProfile.SetBusinessPhoneVerified)
}

View File

@ -0,0 +1,40 @@
// Code scaffolded by goctl. Safe to edit.
package member
import (
"context"
"gateway/internal/svc"
"gateway/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ConfirmTOTPEnrollLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewConfirmTOTPEnrollLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ConfirmTOTPEnrollLogic {
return &ConfirmTOTPEnrollLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *ConfirmTOTPEnrollLogic) ConfirmTOTPEnroll(req *types.TOTPEnrollConfirmReq) (*types.TOTPEnrollConfirmData, error) {
actor, err := actorOrErr(l.ctx)
if err != nil {
return nil, err
}
if err := requireTOTP(l.svcCtx); err != nil {
return nil, err
}
code := ""
if req != nil {
code = req.Code
}
backup, err := l.svcCtx.MemberTOTP.ConfirmEnroll(l.ctx, actor.TenantID, actor.UID, code)
if err != nil {
return nil, err
}
return &types.TOTPEnrollConfirmData{BackupCodes: backup}, nil
}

View File

@ -0,0 +1,31 @@
// Code scaffolded by goctl. Safe to edit.
package member
import (
"context"
"gateway/internal/svc"
"github.com/zeromicro/go-zero/core/logx"
)
type DisableTOTPLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewDisableTOTPLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DisableTOTPLogic {
return &DisableTOTPLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *DisableTOTPLogic) DisableTOTP() error {
actor, err := actorOrErr(l.ctx)
if err != nil {
return err
}
if err := requireTOTP(l.svcCtx); err != nil {
return err
}
return l.svcCtx.MemberTOTP.Disable(l.ctx, actor.TenantID, actor.UID)
}

View File

@ -0,0 +1,40 @@
// Code scaffolded by goctl. Safe to edit.
package member
import (
"context"
domusecase "gateway/internal/model/member/domain/usecase"
"gateway/internal/svc"
"gateway/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GetMemberMeLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetMemberMeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetMemberMeLogic {
return &GetMemberMeLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *GetMemberMeLogic) GetMemberMe() (*types.MemberMeData, error) {
actor, err := actorOrErr(l.ctx)
if err != nil {
return nil, err
}
if l.svcCtx.MemberProfile == nil {
return nil, errb.SysInternal("member profile not configured")
}
dto, err := l.svcCtx.MemberProfile.GetByUID(l.ctx, &domusecase.GetMemberRequest{
TenantID: actor.TenantID,
UID: actor.UID,
})
if err != nil {
return nil, err
}
return memberDTOToTypes(dto), nil
}

View File

@ -0,0 +1,43 @@
// Code scaffolded by goctl. Safe to edit.
package member
import (
"context"
"gateway/internal/svc"
"gateway/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GetTOTPStatusLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetTOTPStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetTOTPStatusLogic {
return &GetTOTPStatusLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *GetTOTPStatusLogic) GetTOTPStatus() (*types.TOTPStatusData, error) {
actor, err := actorOrErr(l.ctx)
if err != nil {
return nil, err
}
if err := requireTOTP(l.svcCtx); err != nil {
return nil, err
}
status, err := l.svcCtx.MemberTOTP.Status(l.ctx, actor.TenantID, actor.UID)
if err != nil {
return nil, err
}
cfg := l.svcCtx.Config.Member.Defaults().TOTP
return &types.TOTPStatusData{
Enrolled: status.Enrolled,
EnrolledAt: status.EnrolledAt,
BackupCodesRemaining: status.BackupCodesRemaining,
Digits: cfg.Digits,
PeriodSeconds: cfg.PeriodSeconds,
}, nil
}

View File

@ -0,0 +1,31 @@
package member
import (
domusecase "gateway/internal/model/member/domain/usecase"
"gateway/internal/types"
)
func memberDTOToTypes(d *domusecase.MemberDTO) *types.MemberMeData {
if d == nil {
return nil
}
return &types.MemberMeData{
TenantID: d.TenantID,
UID: d.UID,
ZitadelEmail: d.ZitadelEmail,
DisplayName: d.DisplayName,
Avatar: d.Avatar,
Phone: d.Phone,
Language: d.Language,
Currency: d.Currency,
Status: string(d.Status),
Origin: string(d.Origin),
BusinessEmail: d.BusinessEmail,
BusinessEmailVerified: d.BusinessEmailVerified,
BusinessPhone: d.BusinessPhone,
BusinessPhoneVerified: d.BusinessPhoneVerified,
TOTPEnrolled: d.TOTPEnrolled,
CreateAt: d.CreateAt,
UpdateAt: d.UpdateAt,
}
}

View File

@ -0,0 +1,36 @@
// Code scaffolded by goctl. Safe to edit.
package member
import (
"context"
"gateway/internal/svc"
"gateway/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type RegenerateTOTPBackupCodesLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewRegenerateTOTPBackupCodesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegenerateTOTPBackupCodesLogic {
return &RegenerateTOTPBackupCodesLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *RegenerateTOTPBackupCodesLogic) RegenerateTOTPBackupCodes() (*types.TOTPBackupCodesData, error) {
actor, err := actorOrErr(l.ctx)
if err != nil {
return nil, err
}
if err := requireTOTP(l.svcCtx); err != nil {
return nil, err
}
codes, err := l.svcCtx.MemberTOTP.RegenerateBackupCodes(l.ctx, actor.TenantID, actor.UID)
if err != nil {
return nil, err
}
return &types.TOTPBackupCodesData{BackupCodes: codes}, nil
}

View File

@ -0,0 +1,35 @@
// Code scaffolded by goctl. Safe to edit.
package member
import (
"context"
"gateway/internal/model/member/domain/enum"
notifenum "gateway/internal/model/notification/domain/enum"
"gateway/internal/svc"
"gateway/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type StartEmailVerificationLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewStartEmailVerificationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartEmailVerificationLogic {
return &StartEmailVerificationLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *StartEmailVerificationLogic) StartEmailVerification(req *types.VerificationStartReq) (*types.VerificationStartData, error) {
actor, err := actorOrErr(l.ctx)
if err != nil {
return nil, err
}
target := ""
if req != nil {
target = req.Target
}
return startVerification(l.ctx, l.svcCtx, actor, enum.OTPPurposeBusinessEmail, notifenum.ChannelEmail, notifenum.NotifyVerifyEmail, target)
}

View File

@ -0,0 +1,35 @@
// Code scaffolded by goctl. Safe to edit.
package member
import (
"context"
"gateway/internal/model/member/domain/enum"
notifenum "gateway/internal/model/notification/domain/enum"
"gateway/internal/svc"
"gateway/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type StartPhoneVerificationLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewStartPhoneVerificationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPhoneVerificationLogic {
return &StartPhoneVerificationLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *StartPhoneVerificationLogic) StartPhoneVerification(req *types.VerificationStartReq) (*types.VerificationStartData, error) {
actor, err := actorOrErr(l.ctx)
if err != nil {
return nil, err
}
target := ""
if req != nil {
target = req.Target
}
return startVerification(l.ctx, l.svcCtx, actor, enum.OTPPurposeBusinessPhone, notifenum.ChannelSMS, notifenum.NotifyVerifyPhone, target)
}

View File

@ -0,0 +1,53 @@
// Code scaffolded by goctl. Safe to edit.
package member
import (
"context"
domusecase "gateway/internal/model/member/domain/usecase"
"gateway/internal/svc"
"gateway/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type StartTOTPEnrollLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewStartTOTPEnrollLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartTOTPEnrollLogic {
return &StartTOTPEnrollLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *StartTOTPEnrollLogic) StartTOTPEnroll() (*types.TOTPEnrollStartData, error) {
actor, err := actorOrErr(l.ctx)
if err != nil {
return nil, err
}
if err := requireTOTP(l.svcCtx); err != nil {
return nil, err
}
account := actor.UID
if l.svcCtx.MemberProfile != nil {
if me, getErr := l.svcCtx.MemberProfile.GetByUID(l.ctx, &domusecase.GetMemberRequest{
TenantID: actor.TenantID,
UID: actor.UID,
}); getErr == nil && me.BusinessEmail != "" {
account = me.BusinessEmail
}
}
start, err := l.svcCtx.MemberTOTP.StartEnroll(l.ctx, actor.TenantID, actor.UID, account)
if err != nil {
return nil, err
}
return &types.TOTPEnrollStartData{
OtpauthURL: start.OtpauthURL,
Issuer: start.Issuer,
Account: start.Account,
Digits: start.Digits,
PeriodSec: start.PeriodSec,
ExpiresIn: start.ExpiresIn,
}, nil
}

View File

@ -0,0 +1,55 @@
// Code scaffolded by goctl. Safe to edit.
package member
import (
"context"
domusecase "gateway/internal/model/member/domain/usecase"
"gateway/internal/svc"
"gateway/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UpdateMemberMeLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpdateMemberMeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateMemberMeLogic {
return &UpdateMemberMeLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *UpdateMemberMeLogic) UpdateMemberMe(req *types.UpdateMemberMeReq) (*types.MemberMeData, error) {
actor, err := actorOrErr(l.ctx)
if err != nil {
return nil, err
}
if l.svcCtx.MemberProfile == nil {
return nil, errb.SysInternal("member profile not configured")
}
update := &domusecase.UpdateMemberRequest{TenantID: actor.TenantID, UID: actor.UID}
if req != nil {
if req.DisplayName != "" {
update.DisplayName = &req.DisplayName
}
if req.Avatar != "" {
update.Avatar = &req.Avatar
}
if req.Language != "" {
update.Language = &req.Language
}
if req.Currency != "" {
update.Currency = &req.Currency
}
if req.Phone != "" {
update.Phone = &req.Phone
}
}
dto, err := l.svcCtx.MemberProfile.Update(l.ctx, update)
if err != nil {
return nil, err
}
return memberDTOToTypes(dto), nil
}

View File

@ -0,0 +1,130 @@
package member
import (
"context"
"time"
errs "gateway/internal/library/errors"
"gateway/internal/library/errors/code"
memberdom "gateway/internal/model/member/domain"
"gateway/internal/model/member/domain/enum"
domusecase "gateway/internal/model/member/domain/usecase"
notifenum "gateway/internal/model/notification/domain/enum"
notifuc "gateway/internal/model/notification/domain/usecase"
"gateway/internal/svc"
"gateway/internal/types"
)
var errb = errs.For(code.Facade)
func startVerification(
ctx context.Context,
sc *svc.ServiceContext,
actor Actor,
purpose enum.OTPPurpose,
channel notifenum.Channel,
kind notifenum.NotifyKind,
target string,
) (*types.VerificationStartData, error) {
if sc.MemberOTP == nil {
return nil, errb.SysInternal("member OTP not configured")
}
if sc.Notifier == nil {
return nil, errb.SysInternal("notifier not configured")
}
if target == "" {
return nil, errb.InputMissingRequired("target is required")
}
cfg := sc.Config.Member.Defaults()
rateKey := memberdom.GetVerifyRateRedisKey(actor.TenantID, actor.UID, string(purpose))
ok, err := sc.MemberVerifyRate.TryResendLock(ctx, rateKey, time.Duration(cfg.OTP.ResendCooldownSeconds)*time.Second)
if err != nil {
return nil, errb.SysInternal("rate limit check failed").WithCause(err)
}
if !ok {
return nil, errb.AuthForbidden("resend cooldown active").WithCause(memberdom.ErrResendCooldown)
}
dailyKey := memberdom.GetVerifyDailyRedisKey(actor.TenantID, actor.UID, string(purpose))
count, err := sc.MemberVerifyRate.IncrDaily(ctx, dailyKey, 24*time.Hour)
if err != nil {
return nil, errb.SysInternal("daily limit check failed").WithCause(err)
}
if count > int64(cfg.OTP.DailyVerifyLimit) {
return nil, errb.AuthForbidden("daily verification limit exceeded").WithCause(memberdom.ErrDailyLimit)
}
dto, plainCode, err := sc.MemberOTP.Generate(ctx, &domusecase.GenerateOTPRequest{
TenantID: actor.TenantID,
UID: actor.UID,
Purpose: purpose,
Target: target,
})
if err != nil {
return nil, err
}
locale := sc.Config.Notification.DefaultLocale
if _, sendErr := sc.Notifier.Send(ctx, &notifuc.SendRequest{
TenantID: actor.TenantID,
UID: actor.UID,
Channel: channel,
Kind: kind,
Target: target,
Locale: locale,
Data: map[string]any{"code": plainCode, "expires_in": dto.ExpiresIn},
IdempotencyKey: dto.ChallengeID,
DoNotPersistBody: true,
Severity: notifenum.SeverityInfo,
}); sendErr != nil {
if invErr := sc.MemberOTP.Invalidate(ctx, dto.ChallengeID); invErr != nil {
return nil, errb.SysInternal("invalidate otp after send failure").WithCause(invErr)
}
return nil, sendErr
}
return &types.VerificationStartData{
ChallengeID: dto.ChallengeID,
ExpiresIn: dto.ExpiresIn,
}, nil
}
func confirmVerification(
ctx context.Context,
sc *svc.ServiceContext,
actor Actor,
req *types.VerificationConfirmReq,
purpose enum.OTPPurpose,
setVerified func(context.Context, string, string, string) error,
) error {
if sc.MemberOTP == nil || sc.MemberProfile == nil {
return errb.SysInternal("member module not configured")
}
if req == nil || req.ChallengeID == "" || req.Code == "" {
return errb.InputMissingRequired("challenge_id and code are required")
}
target, err := sc.MemberOTP.Verify(ctx, &domusecase.VerifyOTPRequest{
TenantID: actor.TenantID,
UID: actor.UID,
ChallengeID: req.ChallengeID,
Code: req.Code,
Purpose: purpose,
})
if err != nil {
return err
}
return setVerified(ctx, actor.TenantID, actor.UID, target)
}
func requireTOTP(sc *svc.ServiceContext) error {
if sc.MemberTOTP == nil {
return errb.SysInternal("member TOTP not configured")
}
return nil
}
func actorOrErr(ctx context.Context) (Actor, error) {
actor, err := ActorFromContext(ctx)
if err != nil {
return Actor{}, errb.AuthForbidden(err.Error())
}
return actor, nil
}

View File

@ -0,0 +1,36 @@
// Code scaffolded by goctl. Safe to edit.
package member
import (
"context"
"gateway/internal/svc"
"gateway/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type VerifyTOTPLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewVerifyTOTPLogic(ctx context.Context, svcCtx *svc.ServiceContext) *VerifyTOTPLogic {
return &VerifyTOTPLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *VerifyTOTPLogic) VerifyTOTP(req *types.TOTPVerifyReq) error {
actor, err := actorOrErr(l.ctx)
if err != nil {
return err
}
if err := requireTOTP(l.svcCtx); err != nil {
return err
}
code := ""
if req != nil {
code = req.Code
}
return l.svcCtx.MemberTOTP.VerifyCode(l.ctx, actor.TenantID, actor.UID, code)
}

View File

@ -308,6 +308,30 @@ Helper 函式見 `domain/redis.go``GetOTPChallengeRedisKey` 等)。
## 測試 ## 測試
### 本機 APIP4
> JWT / Casbin 尚未接入dev 模式用 Header 帶身份:
> `X-Tenant-ID`、`X-UID`
```bash
make deps-up
make mongo-index
make member-seed # 建立 dev tenant + member輸出 headers
make run-local # 或 make run
# 範例
curl -s -H "X-Tenant-ID:dev-tenant" -H "X-UID:DEV-10000000" \
http://127.0.0.1:8888/api/v1/members/me | jq
# 業務 email 驗證logic 層OTP.Generate → Notifier.Send
curl -s -X POST -H "Content-Type: application/json" \
-H "X-Tenant-ID:dev-tenant" -H "X-UID:DEV-10000000" \
-d '{"target":"you@example.com"}' \
http://127.0.0.1:8888/api/v1/members/me/verifications/email/start | jq
```
完整 API 見 `generate/api/member.api`§7.2 對照表)。
### 單元測試 ### 單元測試
```bash ```bash

View File

@ -0,0 +1,49 @@
package domain
// MongoDB BSON field names for member module collections.
const (
BSONFieldID = "_id"
BSONFieldTenantID = "tenant_id"
BSONFieldUID = "uid"
BSONFieldZitadelUserID = "zitadel_user_id"
BSONFieldZitadelEmail = "zitadel_email"
BSONFieldDisplayName = "display_name"
BSONFieldAvatar = "avatar"
BSONFieldPhone = "phone"
BSONFieldLanguage = "language"
BSONFieldCurrency = "currency"
BSONFieldMemberStatus = "member_status"
BSONFieldOrigin = "origin"
BSONFieldPasswordHash = "password_hash"
BSONFieldBusinessEmail = "business_email"
BSONFieldBusinessEmailVerified = "business_email_verified"
BSONFieldBusinessEmailVerifiedAt = "business_email_verified_at"
BSONFieldBusinessPhone = "business_phone"
BSONFieldBusinessPhoneVerified = "business_phone_verified"
BSONFieldBusinessPhoneVerifiedAt = "business_phone_verified_at"
BSONFieldTOTPEnrolled = "totp_enrolled"
BSONFieldTOTPSecretCipher = "totp_secret_cipher" //nolint:gosec // BSON field name, not a credential
BSONFieldTOTPEnrolledAt = "totp_enrolled_at"
BSONFieldTOTPBackupCodesHash = "totp_backup_codes_hash"
BSONFieldCreateAt = "create_at"
BSONFieldUpdateAt = "update_at"
BSONFieldDeletedAt = "deleted_at"
BSONFieldAnonymizedAt = "anonymized_at"
BSONFieldSuspendReason = "suspend_reason"
BSONFieldSlug = "slug"
BSONFieldUIDPrefix = "uid_prefix"
BSONFieldName = "name"
BSONFieldStatus = "status"
BSONFieldOrgID = "org_id"
BSONFieldExternalID = "external_id"
)
// UID sequence defaults (identity-member-design.md §12).
const (
UIDSequenceStart int64 = 10_000_000
UIDSequenceBucket int64 = 500
UIDPrefixMinLength = 2
UIDPrefixMaxLength = 4
)

View File

@ -0,0 +1,19 @@
package entity
import "go.mongodb.org/mongo-driver/v2/bson"
// Identity maps external identity keys to a member UID within a tenant.
type Identity struct {
ID bson.ObjectID `bson:"_id,omitempty"`
TenantID string `bson:"tenant_id"`
UID string `bson:"uid"`
ZitadelUserID string `bson:"zitadel_user_id,omitempty"`
ExternalID string `bson:"external_id,omitempty"`
CreateAt int64 `bson:"create_at"`
UpdateAt int64 `bson:"update_at"`
}
// CollectionName returns the MongoDB collection for identity mappings.
func (Identity) CollectionName() string {
return "identities"
}

View File

@ -0,0 +1,44 @@
package entity
import (
"gateway/internal/model/member/domain/enum"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Member is the tenant-scoped member profile document.
type Member struct {
ID bson.ObjectID `bson:"_id,omitempty"`
TenantID string `bson:"tenant_id"`
UID string `bson:"uid"`
ZitadelUserID string `bson:"zitadel_user_id,omitempty"`
ZitadelEmail string `bson:"zitadel_email,omitempty"`
DisplayName string `bson:"display_name,omitempty"`
Avatar string `bson:"avatar,omitempty"`
Phone string `bson:"phone,omitempty"`
Language string `bson:"language,omitempty"`
Currency string `bson:"currency,omitempty"`
Status enum.MemberStatus `bson:"member_status"`
Origin enum.MemberOrigin `bson:"origin"`
PasswordHash string `bson:"password_hash,omitempty"`
BusinessEmail string `bson:"business_email,omitempty"`
BusinessEmailVerified bool `bson:"business_email_verified"`
BusinessEmailVerifiedAt int64 `bson:"business_email_verified_at,omitempty"`
BusinessPhone string `bson:"business_phone,omitempty"`
BusinessPhoneVerified bool `bson:"business_phone_verified"`
BusinessPhoneVerifiedAt int64 `bson:"business_phone_verified_at,omitempty"`
TOTPEnrolled bool `bson:"totp_enrolled"`
TOTPSecretCipher []byte `bson:"totp_secret_cipher,omitempty"`
TOTPEnrolledAt int64 `bson:"totp_enrolled_at,omitempty"`
TOTPBackupCodesHash []string `bson:"totp_backup_codes_hash,omitempty"`
SuspendReason string `bson:"suspend_reason,omitempty"`
CreateAt int64 `bson:"create_at"`
UpdateAt int64 `bson:"update_at"`
DeletedAt int64 `bson:"deleted_at,omitempty"`
AnonymizedAt int64 `bson:"anonymized_at,omitempty"`
}
// CollectionName returns the MongoDB collection for members.
func (Member) CollectionName() string {
return "members"
}

View File

@ -0,0 +1,25 @@
package entity
import (
"gateway/internal/model/member/domain/enum"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Tenant holds tenant metadata including UID prefix for readable UIDs.
type Tenant struct {
ID bson.ObjectID `bson:"_id,omitempty"`
TenantID string `bson:"tenant_id"`
Slug string `bson:"slug"`
Name string `bson:"name"`
UIDPrefix string `bson:"uid_prefix"`
Status enum.TenantStatus `bson:"status"`
OrgID string `bson:"org_id,omitempty"`
CreateAt int64 `bson:"create_at"`
UpdateAt int64 `bson:"update_at"`
}
// CollectionName returns the MongoDB collection for tenants.
func (Tenant) CollectionName() string {
return "tenants"
}

View File

@ -0,0 +1,25 @@
package enum
// MemberOrigin identifies how the member was provisioned.
type MemberOrigin string
const (
MemberOriginPlatformNative MemberOrigin = "platform_native"
MemberOriginOIDC MemberOrigin = "oidc"
MemberOriginLDAP MemberOrigin = "ldap"
MemberOriginSCIM MemberOrigin = "scim"
)
func (o MemberOrigin) String() string {
return string(o)
}
// IsValid reports whether the origin is a known enum value.
func (o MemberOrigin) IsValid() bool {
switch o {
case MemberOriginPlatformNative, MemberOriginOIDC, MemberOriginLDAP, MemberOriginSCIM:
return true
default:
return false
}
}

View File

@ -0,0 +1,25 @@
package enum
// MemberStatus is the lifecycle state of a member within a tenant.
type MemberStatus string
const (
MemberStatusUnverified MemberStatus = "unverified"
MemberStatusActive MemberStatus = "active"
MemberStatusSuspended MemberStatus = "suspended"
MemberStatusDeleted MemberStatus = "deleted"
)
func (s MemberStatus) String() string {
return string(s)
}
// IsValid reports whether the status is a known enum value.
func (s MemberStatus) IsValid() bool {
switch s {
case MemberStatusUnverified, MemberStatusActive, MemberStatusSuspended, MemberStatusDeleted:
return true
default:
return false
}
}

View File

@ -4,9 +4,11 @@ package enum
type OTPPurpose string type OTPPurpose string
const ( const (
OTPPurposeRegistrationEmail OTPPurpose = "registration_email"
OTPPurposeBusinessEmail OTPPurpose = "business_email" OTPPurposeBusinessEmail OTPPurpose = "business_email"
OTPPurposeBusinessPhone OTPPurpose = "business_phone" OTPPurposeBusinessPhone OTPPurpose = "business_phone"
OTPPurposeStepUp OTPPurpose = "step_up" OTPPurposeStepUp OTPPurpose = "step_up"
OTPPurposePasswordReset OTPPurpose = "password_reset"
) )
func (p OTPPurpose) String() string { func (p OTPPurpose) String() string {

View File

@ -0,0 +1,14 @@
package enum
// TenantStatus tracks tenant provisioning lifecycle.
type TenantStatus string
const (
TenantStatusProvisioning TenantStatus = "provisioning"
TenantStatusActive TenantStatus = "active"
TenantStatusFailed TenantStatus = "failed"
)
func (s TenantStatus) String() string {
return string(s)
}

View File

@ -20,4 +20,8 @@ var (
ErrTOTPAlreadyEnroll = fmt.Errorf("member: totp already enrolled") ErrTOTPAlreadyEnroll = fmt.Errorf("member: totp already enrolled")
ErrTOTPInvalidCode = fmt.Errorf("member: invalid totp code") ErrTOTPInvalidCode = fmt.Errorf("member: invalid totp code")
ErrTOTPCodeReplay = fmt.Errorf("member: totp code already used") ErrTOTPCodeReplay = fmt.Errorf("member: totp code already used")
ErrDuplicateMember = fmt.Errorf("member: duplicate member")
ErrDuplicateTenant = fmt.Errorf("member: duplicate tenant")
ErrInvalidStatus = fmt.Errorf("member: invalid member status transition")
ErrTenantNotFound = fmt.Errorf("member: tenant not found")
) )

View File

@ -15,6 +15,7 @@ const (
VerifyDailyRedisKey RedisKey = "member:verify:daily" VerifyDailyRedisKey RedisKey = "member:verify:daily"
TOTPEnrollRedisKey RedisKey = "member:totp:enroll" TOTPEnrollRedisKey RedisKey = "member:totp:enroll"
TOTPUsedRedisKey RedisKey = "member:totp:used" TOTPUsedRedisKey RedisKey = "member:totp:used"
MemberSeqRedisKey RedisKey = "member:seq"
) )
// With appends colon-separated parts to the key. // With appends colon-separated parts to the key.
@ -59,3 +60,8 @@ func GetTOTPEnrollRedisKey(tenantID, uid string) string {
func GetTOTPUsedRedisKey(tenantID, uid, timestep string) string { func GetTOTPUsedRedisKey(tenantID, uid, timestep string) string {
return TOTPUsedRedisKey.With(tenantID, uid, timestep).String() return TOTPUsedRedisKey.With(tenantID, uid, timestep).String()
} }
// GetMemberSeqRedisKey returns the UID sequence counter key for a tenant.
func GetMemberSeqRedisKey(tenantID string) string {
return MemberSeqRedisKey.With(tenantID).String()
}

View File

@ -0,0 +1,15 @@
package repository
import (
"context"
"gateway/internal/model/member/domain/entity"
)
// IdentityRepository persists zitadel_sub / external_id to uid mappings.
type IdentityRepository interface {
Insert(ctx context.Context, identity *entity.Identity) error
GetByZitadelUserID(ctx context.Context, tenantID, zitadelUserID string) (*entity.Identity, error)
GetByExternalID(ctx context.Context, tenantID, externalID string) (*entity.Identity, error)
GetByUID(ctx context.Context, tenantID, uid string) (*entity.Identity, error)
}

View File

@ -0,0 +1,37 @@
package repository
import (
"context"
"gateway/internal/model/member/domain/entity"
"gateway/internal/model/member/domain/enum"
)
// MemberUpdate carries mutable profile fields for PATCH-style updates.
type MemberUpdate struct {
DisplayName *string
Avatar *string
Language *string
Currency *string
Phone *string
}
// ListMembersFilter scopes a paginated member list query.
type ListMembersFilter struct {
TenantID string
Status enum.MemberStatus
Offset int64
Limit int64
}
// MemberRepository persists member profiles (Mongo).
type MemberRepository interface {
Insert(ctx context.Context, member *entity.Member) error
GetByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error)
GetByZitadelUserID(ctx context.Context, tenantID, zitadelUserID string) (*entity.Member, error)
UpdateProfile(ctx context.Context, tenantID, uid string, update *MemberUpdate) (*entity.Member, error)
UpdateStatus(ctx context.Context, tenantID, uid string, status enum.MemberStatus, suspendReason string) error
List(ctx context.Context, filter ListMembersFilter) ([]*entity.Member, int64, error)
SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error
SetBusinessPhoneVerified(ctx context.Context, tenantID, uid, phone string) error
}

View File

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

View File

@ -0,0 +1,15 @@
package repository
import (
"context"
"gateway/internal/model/member/domain/entity"
)
// TenantRepository persists tenant metadata.
type TenantRepository interface {
Insert(ctx context.Context, tenant *entity.Tenant) error
GetByTenantID(ctx context.Context, tenantID string) (*entity.Tenant, error)
GetBySlug(ctx context.Context, slug string) (*entity.Tenant, error)
GetByUIDPrefix(ctx context.Context, uidPrefix string) (*entity.Tenant, error)
}

View File

@ -0,0 +1,8 @@
package repository
import "context"
// UIDGenerator allocates readable UIDs in the form {UIDPrefix}-{Sequence}.
type UIDGenerator interface {
Next(ctx context.Context, tenantID, uidPrefix string) (string, error)
}

View File

@ -0,0 +1,22 @@
package usecase
import "context"
// LifecycleUseCase performs single-step member status transitions.
type LifecycleUseCase interface {
CreateUnverified(ctx context.Context, req *CreatePlatformMemberRequest) (*MemberDTO, error)
Activate(ctx context.Context, tenantID, uid string) error
Suspend(ctx context.Context, tenantID, uid, reason string) error
Reactivate(ctx context.Context, tenantID, uid string) error
SoftDelete(ctx context.Context, tenantID, uid string) error
AbortPending(ctx context.Context, tenantID, uid string) error
}
// CreatePlatformMemberRequest creates an unverified platform-native member.
type CreatePlatformMemberRequest struct {
TenantID string
Email string
PasswordHash string
DisplayName string
Language string
}

View File

@ -0,0 +1,72 @@
package usecase
import (
"context"
"gateway/internal/model/member/domain/enum"
)
// ProfileUseCase reads and updates member profile fields (not lifecycle transitions).
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)
SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error
SetBusinessPhoneVerified(ctx context.Context, tenantID, uid, phone string) error
}
// GetMemberRequest loads a member by tenant + uid.
type GetMemberRequest struct {
TenantID string
UID string
}
// UpdateMemberRequest patches mutable profile fields.
type UpdateMemberRequest struct {
TenantID string
UID string
DisplayName *string
Avatar *string
Language *string
Currency *string
Phone *string
}
// ListMembersRequest lists members within a tenant.
type ListMembersRequest struct {
TenantID string
Status enum.MemberStatus
Offset int64
Limit int64
}
// ListMembersResponse is a paginated member list.
type ListMembersResponse struct {
Items []*MemberDTO `json:"items"`
Total int64 `json:"total"`
Offset int64 `json:"offset"`
Limit int64 `json:"limit"`
}
// MemberDTO is the outward member profile shape.
type MemberDTO struct {
TenantID string `json:"tenant_id"`
UID string `json:"uid"`
ZitadelEmail string `json:"zitadel_email,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Avatar string `json:"avatar,omitempty"`
Phone string `json:"phone,omitempty"`
Language string `json:"language,omitempty"`
Currency string `json:"currency,omitempty"`
Status enum.MemberStatus `json:"status"`
Origin enum.MemberOrigin `json:"origin"`
BusinessEmail string `json:"business_email,omitempty"`
BusinessEmailVerified bool `json:"business_email_verified"`
BusinessEmailVerifiedAt int64 `json:"business_email_verified_at,omitempty"`
BusinessPhone string `json:"business_phone,omitempty"`
BusinessPhoneVerified bool `json:"business_phone_verified"`
BusinessPhoneVerifiedAt int64 `json:"business_phone_verified_at,omitempty"`
TOTPEnrolled bool `json:"totp_enrolled"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}

View File

@ -0,0 +1,42 @@
package usecase
import "context"
// ProvisioningUseCase upserts members from external identity sources.
type ProvisioningUseCase interface {
EnsureFromOIDC(ctx context.Context, req *EnsureFromOIDCRequest) (*MemberDTO, error)
EnsureFromLDAP(ctx context.Context, req *EnsureFromLDAPRequest) (*MemberDTO, error)
EnsureFromSCIM(ctx context.Context, req *EnsureFromSCIMRequest) (*MemberDTO, error)
}
// EnsureFromOIDCRequest upserts from ZITADEL OIDC id_token claims.
type EnsureFromOIDCRequest struct {
TenantID string
ZitadelSub string
Email string
EmailVerified bool
DisplayName string
Locale string
}
// EnsureFromLDAPRequest upserts from LDAP / Directory Sync.
type EnsureFromLDAPRequest struct {
TenantID string
ExternalID string
LDAPDN string
Username string
Email string
DisplayName string
ZitadelSub string
}
// EnsureFromSCIMRequest upserts from SCIM provisioning.
type EnsureFromSCIMRequest struct {
TenantID string
ExternalID string
UserName string
Email string
DisplayName string
Active bool
ZitadelSub string
}

View File

@ -0,0 +1,29 @@
package usecase
import "context"
// TenantUseCase manages tenant metadata.
type TenantUseCase interface {
Create(ctx context.Context, req *CreateTenantRequest) (*TenantDTO, error)
ResolveBySlug(ctx context.Context, slug string) (*TenantDTO, error)
}
// CreateTenantRequest creates a new tenant draft.
type CreateTenantRequest struct {
TenantID string
Slug string
Name string
UIDPrefix string
}
// TenantDTO is the outward tenant shape.
type TenantDTO struct {
TenantID string `json:"tenant_id"`
Slug string `json:"slug"`
Name string `json:"name"`
UIDPrefix string `json:"uid_prefix"`
Status string `json:"status"`
OrgID string `json:"org_id,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}

View File

@ -0,0 +1,105 @@
package repository
import (
"context"
"errors"
"time"
libmongo "gateway/internal/library/mongo"
member "gateway/internal/model/member/domain"
"gateway/internal/model/member/domain/entity"
domrepo "gateway/internal/model/member/domain/repository"
"go.mongodb.org/mongo-driver/v2/bson"
mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
)
// IdentityRepositoryParam configures the Mongo identity repository.
type IdentityRepositoryParam struct {
Conf *libmongo.Conf
}
type identityRepository struct {
db libmongo.DocumentDBUseCase
}
// NewIdentityRepository creates a Mongo-backed IdentityRepository.
func NewIdentityRepository(param IdentityRepositoryParam) domrepo.IdentityRepository {
documentDB, err := libmongo.NewDocumentDB(param.Conf, entity.Identity{}.CollectionName())
if err != nil {
panic(err)
}
return &identityRepository{db: documentDB}
}
func (r *identityRepository) Insert(ctx context.Context, identity *entity.Identity) error {
now := time.Now().UTC().UnixMilli()
if identity.ID.IsZero() {
identity.ID = bson.NewObjectID()
}
if identity.CreateAt == 0 {
identity.CreateAt = now
}
if identity.UpdateAt == 0 {
identity.UpdateAt = now
}
_, err := r.db.GetClient().InsertOne(ctx, identity)
if err != nil {
if mongodriver.IsDuplicateKeyError(err) {
return member.ErrDuplicateMember
}
return err
}
return nil
}
func (r *identityRepository) GetByZitadelUserID(ctx context.Context, tenantID, zitadelUserID string) (*entity.Identity, error) {
return r.findOne(ctx, bson.M{
member.BSONFieldTenantID: tenantID,
member.BSONFieldZitadelUserID: zitadelUserID,
})
}
func (r *identityRepository) GetByExternalID(ctx context.Context, tenantID, externalID string) (*entity.Identity, error) {
return r.findOne(ctx, bson.M{
member.BSONFieldTenantID: tenantID,
member.BSONFieldExternalID: externalID,
})
}
func (r *identityRepository) GetByUID(ctx context.Context, tenantID, uid string) (*entity.Identity, error) {
return r.findOne(ctx, bson.M{
member.BSONFieldTenantID: tenantID,
member.BSONFieldUID: uid,
})
}
func (r *identityRepository) findOne(ctx context.Context, filter bson.M) (*entity.Identity, error) {
var doc entity.Identity
if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil {
if errors.Is(err, mongodriver.ErrNoDocuments) {
return nil, member.ErrNotFound
}
return nil, err
}
return &doc, nil
}
// Index20260520001UP ensures identities collection indexes exist.
func (r *identityRepository) Index20260520001UP(ctx context.Context) error {
if err := r.db.PopulateMultiIndex(ctx,
[]string{member.BSONFieldTenantID, member.BSONFieldZitadelUserID},
[]int32{1, 1}, true); err != nil {
return err
}
if err := r.db.PopulateMultiIndex(ctx,
[]string{member.BSONFieldTenantID, member.BSONFieldUID},
[]int32{1, 1}, false); err != nil {
return err
}
return r.db.PopulateMultiIndex(ctx,
[]string{member.BSONFieldTenantID, member.BSONFieldExternalID},
[]int32{1, 1}, false)
}
var _ domrepo.IdentityRepository = (*identityRepository)(nil)

View File

@ -0,0 +1,49 @@
package repository
import (
"context"
"fmt"
libmongo "gateway/internal/library/mongo"
)
// EnsureMongoIndexes creates indexes for member module collections.
func EnsureMongoIndexes(ctx context.Context, conf *libmongo.Conf) error {
if conf == nil || conf.Host == "" {
return nil
}
if err := ensureMemberIndexes(ctx, conf); err != nil {
return err
}
if err := ensureTenantIndexes(ctx, conf); err != nil {
return err
}
return ensureIdentityIndexes(ctx, conf)
}
func ensureMemberIndexes(ctx context.Context, conf *libmongo.Conf) error {
//nolint:contextcheck // repository ctor pings Mongo at startup without caller ctx
repo, ok := NewMemberRepository(MemberRepositoryParam{Conf: conf}).(*memberRepository)
if !ok {
return fmt.Errorf("member: unexpected repository type")
}
return repo.Index20260520001UP(ctx)
}
func ensureTenantIndexes(ctx context.Context, conf *libmongo.Conf) error {
//nolint:contextcheck // repository ctor pings Mongo at startup without caller ctx
repo, ok := NewTenantRepository(TenantRepositoryParam{Conf: conf}).(*tenantRepository)
if !ok {
return fmt.Errorf("member: unexpected tenant repository type")
}
return repo.Index20260520001UP(ctx)
}
func ensureIdentityIndexes(ctx context.Context, conf *libmongo.Conf) error {
//nolint:contextcheck // repository ctor pings Mongo at startup without caller ctx
repo, ok := NewIdentityRepository(IdentityRepositoryParam{Conf: conf}).(*identityRepository)
if !ok {
return fmt.Errorf("member: unexpected identity repository type")
}
return repo.Index20260520001UP(ctx)
}

View File

@ -0,0 +1,221 @@
package repository
import (
"context"
"errors"
"time"
libmongo "gateway/internal/library/mongo"
member "gateway/internal/model/member/domain"
"gateway/internal/model/member/domain/entity"
"gateway/internal/model/member/domain/enum"
domrepo "gateway/internal/model/member/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"
)
// MemberRepositoryParam configures the Mongo member repository.
type MemberRepositoryParam struct {
Conf *libmongo.Conf
}
type memberRepository struct {
db libmongo.DocumentDBUseCase
}
// NewMemberRepository creates a Mongo-backed MemberRepository.
func NewMemberRepository(param MemberRepositoryParam) domrepo.MemberRepository {
documentDB, err := libmongo.NewDocumentDB(param.Conf, entity.Member{}.CollectionName())
if err != nil {
panic(err)
}
return &memberRepository{db: documentDB}
}
func (r *memberRepository) Insert(ctx context.Context, rec *entity.Member) error {
now := time.Now().UTC().UnixMilli()
if rec.ID.IsZero() {
rec.ID = bson.NewObjectID()
}
if rec.CreateAt == 0 {
rec.CreateAt = now
}
if rec.UpdateAt == 0 {
rec.UpdateAt = now
}
_, err := r.db.GetClient().InsertOne(ctx, rec)
if err != nil {
if mongodriver.IsDuplicateKeyError(err) {
return member.ErrDuplicateMember
}
return err
}
return nil
}
func (r *memberRepository) GetByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error) {
var doc entity.Member
filter := bson.M{
member.BSONFieldTenantID: tenantID,
member.BSONFieldUID: uid,
}
if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil {
if errors.Is(err, mongodriver.ErrNoDocuments) {
return nil, member.ErrNotFound
}
return nil, err
}
return &doc, nil
}
func (r *memberRepository) GetByZitadelUserID(ctx context.Context, tenantID, zitadelUserID string) (*entity.Member, error) {
var doc entity.Member
filter := bson.M{
member.BSONFieldTenantID: tenantID,
member.BSONFieldZitadelUserID: zitadelUserID,
}
if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil {
if errors.Is(err, mongodriver.ErrNoDocuments) {
return nil, member.ErrNotFound
}
return nil, err
}
return &doc, nil
}
func (r *memberRepository) UpdateProfile(ctx context.Context, tenantID, uid string, update *domrepo.MemberUpdate) (*entity.Member, error) {
set := bson.M{member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli()}
if update.DisplayName != nil {
set[member.BSONFieldDisplayName] = *update.DisplayName
}
if update.Avatar != nil {
set[member.BSONFieldAvatar] = *update.Avatar
}
if update.Language != nil {
set[member.BSONFieldLanguage] = *update.Language
}
if update.Currency != nil {
set[member.BSONFieldCurrency] = *update.Currency
}
if update.Phone != nil {
set[member.BSONFieldPhone] = *update.Phone
}
filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid}
opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
var doc entity.Member
if err := r.db.GetClient().FindOneAndUpdate(ctx, &doc, filter, bson.M{"$set": set}, opts); err != nil {
if errors.Is(err, mongodriver.ErrNoDocuments) {
return nil, member.ErrNotFound
}
return nil, err
}
return &doc, nil
}
func (r *memberRepository) UpdateStatus(ctx context.Context, tenantID, uid string, status enum.MemberStatus, suspendReason string) error {
set := bson.M{
member.BSONFieldMemberStatus: status,
member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli(),
}
if status == enum.MemberStatusSuspended {
set[member.BSONFieldSuspendReason] = suspendReason
}
if status == enum.MemberStatusDeleted {
set[member.BSONFieldDeletedAt] = time.Now().UTC().UnixMilli()
}
filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid}
res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return member.ErrNotFound
}
return nil
}
func (r *memberRepository) List(ctx context.Context, filter domrepo.ListMembersFilter) ([]*entity.Member, int64, error) {
q := bson.M{member.BSONFieldTenantID: filter.TenantID}
if filter.Status != "" {
q[member.BSONFieldMemberStatus] = filter.Status
}
total, err := r.db.GetClient().CountDocuments(ctx, q)
if err != nil {
return nil, 0, err
}
limit := filter.Limit
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
opts := options.Find().
SetSkip(filter.Offset).
SetLimit(limit).
SetSort(bson.D{{Key: member.BSONFieldCreateAt, Value: -1}})
var docs []*entity.Member
if err := r.db.GetClient().Find(ctx, &docs, q, opts); err != nil {
return nil, 0, err
}
return docs, total, nil
}
func (r *memberRepository) SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error {
now := time.Now().UTC().UnixMilli()
set := bson.M{
member.BSONFieldBusinessEmail: email,
member.BSONFieldBusinessEmailVerified: true,
member.BSONFieldBusinessEmailVerifiedAt: now,
member.BSONFieldUpdateAt: now,
}
filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid}
res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return member.ErrNotFound
}
return nil
}
func (r *memberRepository) SetBusinessPhoneVerified(ctx context.Context, tenantID, uid, phone string) error {
now := time.Now().UTC().UnixMilli()
set := bson.M{
member.BSONFieldBusinessPhone: phone,
member.BSONFieldBusinessPhoneVerified: true,
member.BSONFieldBusinessPhoneVerifiedAt: now,
member.BSONFieldUpdateAt: now,
}
filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid}
res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return member.ErrNotFound
}
return nil
}
// Index20260520001UP ensures members collection indexes exist.
func (r *memberRepository) Index20260520001UP(ctx context.Context) error {
if err := r.db.PopulateMultiIndex(ctx,
[]string{member.BSONFieldTenantID, member.BSONFieldUID},
[]int32{1, 1}, true); err != nil {
return err
}
if err := r.db.PopulateMultiIndex(ctx,
[]string{member.BSONFieldTenantID, member.BSONFieldZitadelUserID},
[]int32{1, 1}, true); err != nil {
return err
}
return r.db.PopulateMultiIndex(ctx,
[]string{member.BSONFieldTenantID, member.BSONFieldMemberStatus, member.BSONFieldCreateAt},
[]int32{1, 1, -1}, false)
}
var _ domrepo.MemberRepository = (*memberRepository)(nil)

View File

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

View File

@ -0,0 +1,90 @@
package repository
import (
"context"
"errors"
"time"
libmongo "gateway/internal/library/mongo"
member "gateway/internal/model/member/domain"
"gateway/internal/model/member/domain/entity"
domrepo "gateway/internal/model/member/domain/repository"
"go.mongodb.org/mongo-driver/v2/bson"
mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
)
// TenantRepositoryParam configures the Mongo tenant repository.
type TenantRepositoryParam struct {
Conf *libmongo.Conf
}
type tenantRepository struct {
db libmongo.DocumentDBUseCase
}
// NewTenantRepository creates a Mongo-backed TenantRepository.
func NewTenantRepository(param TenantRepositoryParam) domrepo.TenantRepository {
documentDB, err := libmongo.NewDocumentDB(param.Conf, entity.Tenant{}.CollectionName())
if err != nil {
panic(err)
}
return &tenantRepository{db: documentDB}
}
func (r *tenantRepository) Insert(ctx context.Context, tenant *entity.Tenant) error {
now := time.Now().UTC().UnixMilli()
if tenant.ID.IsZero() {
tenant.ID = bson.NewObjectID()
}
if tenant.CreateAt == 0 {
tenant.CreateAt = now
}
if tenant.UpdateAt == 0 {
tenant.UpdateAt = now
}
_, err := r.db.GetClient().InsertOne(ctx, tenant)
if err != nil {
if mongodriver.IsDuplicateKeyError(err) {
return member.ErrDuplicateTenant
}
return err
}
return nil
}
func (r *tenantRepository) GetByTenantID(ctx context.Context, tenantID string) (*entity.Tenant, error) {
return r.findOne(ctx, bson.M{member.BSONFieldTenantID: tenantID})
}
func (r *tenantRepository) GetBySlug(ctx context.Context, slug string) (*entity.Tenant, error) {
return r.findOne(ctx, bson.M{member.BSONFieldSlug: slug})
}
func (r *tenantRepository) GetByUIDPrefix(ctx context.Context, uidPrefix string) (*entity.Tenant, error) {
return r.findOne(ctx, bson.M{member.BSONFieldUIDPrefix: uidPrefix})
}
func (r *tenantRepository) findOne(ctx context.Context, filter bson.M) (*entity.Tenant, error) {
var doc entity.Tenant
if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil {
if errors.Is(err, mongodriver.ErrNoDocuments) {
return nil, member.ErrTenantNotFound
}
return nil, err
}
return &doc, nil
}
// Index20260520001UP ensures tenants collection indexes exist.
func (r *tenantRepository) Index20260520001UP(ctx context.Context) error {
if err := r.db.PopulateIndex(ctx, member.BSONFieldTenantID, 1, true); err != nil {
return err
}
if err := r.db.PopulateIndex(ctx, member.BSONFieldSlug, 1, true); err != nil {
return err
}
return r.db.PopulateIndex(ctx, member.BSONFieldUIDPrefix, 1, true)
}
var _ domrepo.TenantRepository = (*tenantRepository)(nil)

View File

@ -0,0 +1,126 @@
package repository
import (
"context"
"errors"
"time"
libmongo "gateway/internal/library/mongo"
member "gateway/internal/model/member/domain"
"gateway/internal/model/member/domain/entity"
domrepo "gateway/internal/model/member/domain/repository"
"go.mongodb.org/mongo-driver/v2/bson"
mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
)
// MongoTOTPProfileRepository stores TOTP fields on the members document.
type MongoTOTPProfileRepository struct {
db libmongo.DocumentDBUseCase
}
// NewMongoTOTPProfileRepository creates a TOTP profile repo backed by members.
func NewMongoTOTPProfileRepository(conf *libmongo.Conf) domrepo.TOTPProfileRepository {
documentDB, err := libmongo.NewDocumentDB(conf, entity.Member{}.CollectionName())
if err != nil {
panic(err)
}
return &MongoTOTPProfileRepository{db: documentDB}
}
func (r *MongoTOTPProfileRepository) Get(ctx context.Context, tenantID, uid string) (*domrepo.TOTPProfileRecord, error) {
var doc entity.Member
filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid}
if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil {
if errors.Is(err, mongodriver.ErrNoDocuments) {
return &domrepo.TOTPProfileRecord{}, nil
}
return nil, err
}
return memberToTOTPRecord(&doc), nil
}
func (r *MongoTOTPProfileRepository) Save(ctx context.Context, tenantID, uid string, rec *domrepo.TOTPProfileRecord) error {
set := bson.M{
member.BSONFieldTOTPEnrolled: rec.Enrolled,
member.BSONFieldTOTPSecretCipher: rec.SecretCipher,
member.BSONFieldTOTPBackupCodesHash: rec.BackupCodesHash,
member.BSONFieldTOTPEnrolledAt: rec.EnrolledAt,
member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli(),
}
filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid}
res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return member.ErrNotFound
}
return nil
}
func (r *MongoTOTPProfileRepository) Clear(ctx context.Context, tenantID, uid string) error {
set := bson.M{
member.BSONFieldTOTPEnrolled: false,
member.BSONFieldTOTPSecretCipher: []byte{},
member.BSONFieldTOTPBackupCodesHash: []string{},
member.BSONFieldTOTPEnrolledAt: int64(0),
member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli(),
}
filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid}
res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return member.ErrNotFound
}
return nil
}
func (r *MongoTOTPProfileRepository) ConsumeBackupCode(ctx context.Context, tenantID, uid, hash string) (bool, error) {
filter := bson.M{
member.BSONFieldTenantID: tenantID,
member.BSONFieldUID: uid,
member.BSONFieldTOTPBackupCodesHash: hash,
}
update := bson.M{
"$pull": bson.M{member.BSONFieldTOTPBackupCodesHash: hash},
"$set": bson.M{member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli()},
}
res, err := r.db.GetClient().UpdateOne(ctx, filter, update)
if err != nil {
return false, err
}
return res.ModifiedCount > 0, nil
}
func (r *MongoTOTPProfileRepository) ReplaceBackupCodes(ctx context.Context, tenantID, uid string, hashes []string) error {
set := bson.M{
member.BSONFieldTOTPBackupCodesHash: hashes,
member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli(),
}
filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid}
res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return member.ErrNotFound
}
return nil
}
func memberToTOTPRecord(doc *entity.Member) *domrepo.TOTPProfileRecord {
if doc == nil {
return &domrepo.TOTPProfileRecord{}
}
return &domrepo.TOTPProfileRecord{
Enrolled: doc.TOTPEnrolled,
SecretCipher: append([]byte(nil), doc.TOTPSecretCipher...),
BackupCodesHash: append([]string(nil), doc.TOTPBackupCodesHash...),
EnrolledAt: doc.TOTPEnrolledAt,
}
}
var _ domrepo.TOTPProfileRepository = (*MongoTOTPProfileRepository)(nil)

View File

@ -0,0 +1,41 @@
package repository
import (
"context"
"fmt"
"strconv"
"strings"
redislib "gateway/internal/library/redis"
member "gateway/internal/model/member/domain"
domrepo "gateway/internal/model/member/domain/repository"
)
type uidGenerator struct {
redis *redislib.Client
}
// NewRedisUIDGenerator allocates readable UIDs using Redis INCR.
func NewRedisUIDGenerator(rds *redislib.Client) domrepo.UIDGenerator {
return &uidGenerator{redis: rds}
}
func (g *uidGenerator) Next(ctx context.Context, tenantID, uidPrefix string) (string, error) {
if tenantID == "" || uidPrefix == "" {
return "", fmt.Errorf("member: tenant_id and uid_prefix are required")
}
key := member.GetMemberSeqRedisKey(tenantID)
seq, err := g.redis.Zero().IncrCtx(ctx, key)
if err != nil {
return "", err
}
if seq == 1 {
seq, err = g.redis.Zero().IncrbyCtx(ctx, key, member.UIDSequenceStart-1)
if err != nil {
return "", err
}
}
return strings.ToUpper(uidPrefix) + "-" + strconv.FormatInt(seq, 10), nil
}
var _ domrepo.UIDGenerator = (*uidGenerator)(nil)

View File

@ -0,0 +1,128 @@
package usecase
import (
"context"
"errors"
"time"
member "gateway/internal/model/member/domain"
"gateway/internal/model/member/domain/entity"
"gateway/internal/model/member/domain/enum"
domrepo "gateway/internal/model/member/domain/repository"
domusecase "gateway/internal/model/member/domain/usecase"
)
type lifecycleUseCase struct {
members domrepo.MemberRepository
tenants domrepo.TenantRepository
uidGen domrepo.UIDGenerator
}
// LifecycleUseCaseParam wires LifecycleUseCase.
type LifecycleUseCaseParam struct {
Members domrepo.MemberRepository
Tenants domrepo.TenantRepository
UIDGen domrepo.UIDGenerator
}
// MustLifecycleUseCase constructs LifecycleUseCase.
func MustLifecycleUseCase(param LifecycleUseCaseParam) domusecase.LifecycleUseCase {
if param.Members == nil || param.Tenants == nil || param.UIDGen == nil {
panic("member: lifecycle dependencies are required")
}
return &lifecycleUseCase{
members: param.Members,
tenants: param.Tenants,
uidGen: param.UIDGen,
}
}
func (uc *lifecycleUseCase) CreateUnverified(ctx context.Context, req *domusecase.CreatePlatformMemberRequest) (*domusecase.MemberDTO, error) {
if req == nil || req.TenantID == "" || req.Email == "" {
return nil, errb.InputMissingRequired("tenant_id and email are required")
}
tenant, err := uc.tenants.GetByTenantID(ctx, req.TenantID)
if err != nil {
if errors.Is(err, member.ErrTenantNotFound) {
return nil, errb.ResNotFound("tenant", req.TenantID).WithCause(err)
}
return nil, errb.SysInternal("read tenant failed").WithCause(err)
}
uid, err := uc.uidGen.Next(ctx, req.TenantID, tenant.UIDPrefix)
if err != nil {
return nil, errb.SysInternal("allocate uid failed").WithCause(err)
}
now := time.Now().UTC().UnixMilli()
rec := &entity.Member{
TenantID: req.TenantID,
UID: uid,
ZitadelEmail: req.Email,
DisplayName: req.DisplayName,
Language: defaultLanguage(req.Language),
Status: enum.MemberStatusUnverified,
Origin: enum.MemberOriginPlatformNative,
PasswordHash: req.PasswordHash,
CreateAt: now,
UpdateAt: now,
}
if err := uc.members.Insert(ctx, rec); err != nil {
if errors.Is(err, member.ErrDuplicateMember) {
return nil, errb.ResAlreadyExist("member already exists").WithCause(err)
}
return nil, errb.SysInternal("create member failed").WithCause(err)
}
return memberToDTO(rec), nil
}
func (uc *lifecycleUseCase) Activate(ctx context.Context, tenantID, uid string) error {
return uc.transition(ctx, tenantID, uid, enum.MemberStatusUnverified, enum.MemberStatusActive, "")
}
func (uc *lifecycleUseCase) Suspend(ctx context.Context, tenantID, uid, reason string) error {
return uc.transition(ctx, tenantID, uid, enum.MemberStatusActive, enum.MemberStatusSuspended, reason)
}
func (uc *lifecycleUseCase) Reactivate(ctx context.Context, tenantID, uid string) error {
return uc.transition(ctx, tenantID, uid, enum.MemberStatusSuspended, enum.MemberStatusActive, "")
}
func (uc *lifecycleUseCase) SoftDelete(ctx context.Context, tenantID, uid string) error {
rec, err := uc.members.GetByUID(ctx, tenantID, uid)
if err != nil {
if errors.Is(err, member.ErrNotFound) {
return errb.ResNotFound("member", uid).WithCause(err)
}
return errb.SysInternal("read member failed").WithCause(err)
}
if rec.Status == enum.MemberStatusDeleted {
return nil
}
if err := uc.members.UpdateStatus(ctx, tenantID, uid, enum.MemberStatusDeleted, ""); err != nil {
return errb.SysInternal("soft delete member failed").WithCause(err)
}
return nil
}
func (uc *lifecycleUseCase) AbortPending(ctx context.Context, tenantID, uid string) error {
return uc.transition(ctx, tenantID, uid, enum.MemberStatusUnverified, enum.MemberStatusDeleted, "")
}
func (uc *lifecycleUseCase) transition(ctx context.Context, tenantID, uid string, from, to enum.MemberStatus, reason string) error {
if tenantID == "" || uid == "" {
return errb.InputMissingRequired("tenant_id and uid are required")
}
rec, err := uc.members.GetByUID(ctx, tenantID, uid)
if err != nil {
if errors.Is(err, member.ErrNotFound) {
return errb.ResNotFound("member", uid).WithCause(err)
}
return errb.SysInternal("read member failed").WithCause(err)
}
if rec.Status != from {
return errb.ResInvalidState("invalid member status transition").WithCause(member.ErrInvalidStatus)
}
if err := uc.members.UpdateStatus(ctx, tenantID, uid, to, reason); err != nil {
return errb.SysInternal("update member status failed").WithCause(err)
}
return nil
}

View File

@ -0,0 +1,70 @@
package usecase
import (
"gateway/internal/model/member/domain/entity"
domusecase "gateway/internal/model/member/domain/usecase"
)
func memberToDTO(m *entity.Member) *domusecase.MemberDTO {
if m == nil {
return nil
}
return &domusecase.MemberDTO{
TenantID: m.TenantID,
UID: m.UID,
ZitadelEmail: m.ZitadelEmail,
DisplayName: m.DisplayName,
Avatar: m.Avatar,
Phone: m.Phone,
Language: m.Language,
Currency: m.Currency,
Status: m.Status,
Origin: m.Origin,
BusinessEmail: m.BusinessEmail,
BusinessEmailVerified: m.BusinessEmailVerified,
BusinessEmailVerifiedAt: m.BusinessEmailVerifiedAt,
BusinessPhone: m.BusinessPhone,
BusinessPhoneVerified: m.BusinessPhoneVerified,
BusinessPhoneVerifiedAt: m.BusinessPhoneVerifiedAt,
TOTPEnrolled: m.TOTPEnrolled,
CreateAt: m.CreateAt,
UpdateAt: m.UpdateAt,
}
}
func tenantToDTO(t *entity.Tenant) *domusecase.TenantDTO {
if t == nil {
return nil
}
return &domusecase.TenantDTO{
TenantID: t.TenantID,
Slug: t.Slug,
Name: t.Name,
UIDPrefix: t.UIDPrefix,
Status: string(t.Status),
OrgID: t.OrgID,
CreateAt: t.CreateAt,
UpdateAt: t.UpdateAt,
}
}
func normalizeUIDPrefix(prefix string) string {
out := make([]byte, 0, len(prefix))
for i := 0; i < len(prefix); i++ {
c := prefix[i]
if c >= 'a' && c <= 'z' {
c -= 'a' - 'A'
}
if (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
out = append(out, c)
}
}
return string(out)
}
func defaultLanguage(lang string) string {
if lang != "" {
return lang
}
return "zh-tw"
}

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
libcrypto "gateway/internal/library/crypto" libcrypto "gateway/internal/library/crypto"
libmongo "gateway/internal/library/mongo"
redislib "gateway/internal/library/redis" redislib "gateway/internal/library/redis"
memberconfig "gateway/internal/model/member/config" memberconfig "gateway/internal/model/member/config"
domrepo "gateway/internal/model/member/domain/repository" domrepo "gateway/internal/model/member/domain/repository"
@ -16,52 +17,95 @@ import (
// business_email verified") are assembled at the logic / driver layer and // business_email verified") are assembled at the logic / driver layer and
// MUST NOT live inside another usecase. // MUST NOT live inside another usecase.
type Module struct { type Module struct {
// OTP issues and verifies one-time codes (purpose-agnostic).
OTP domusecase.OTPUseCase OTP domusecase.OTPUseCase
// TOTP is nil when Member.TOTP.SecretKEK is empty / invalid; downstream
// code must gracefully degrade (e.g. fall back to SMS/email OTP at the
// logic layer).
TOTP domusecase.TOTPUseCase TOTP domusecase.TOTPUseCase
Profile domusecase.ProfileUseCase
// Stores exposed for logic-layer orchestration (rate limit, profile Lifecycle domusecase.LifecycleUseCase
// flips). They are intentionally surfaced so the logic layer can compose Provisioning domusecase.ProvisioningUseCase
// atomic usecases with rate-limit + profile mutation without re-wiring. Tenant domusecase.TenantUseCase
VerifyRate domrepo.VerifyRateStore VerifyRate domrepo.VerifyRateStore
Profile domrepo.ProfileRepository
Members domrepo.MemberRepository
Tenants domrepo.TenantRepository
Identities domrepo.IdentityRepository
} }
// ModuleParam wires member module dependencies. // ModuleParam wires member module dependencies.
type ModuleParam struct { type ModuleParam struct {
Redis *redislib.Client Redis *redislib.Client
MongoConf *libmongo.Conf
Config memberconfig.Config Config memberconfig.Config
// Profile is optional; defaults to memory repository. // Optional overrides for tests.
Profile domrepo.ProfileRepository Members domrepo.MemberRepository
// TOTPProfile is optional; defaults to memory repository. Tenants domrepo.TenantRepository
Identities domrepo.IdentityRepository
TOTPProfile domrepo.TOTPProfileRepository TOTPProfile domrepo.TOTPProfileRepository
UIDGen domrepo.UIDGenerator
} }
// NewModuleFromParam builds member atomic usecases. // NewModuleFromParam builds member atomic usecases.
//
// TOTP is wired only when Member.TOTP.SecretKEK is provided; this lets local
// dev / unit tests boot without a KMS-backed key while production deployments
// fail loud when the operator forgets to configure it.
func NewModuleFromParam(param ModuleParam) (*Module, error) { func NewModuleFromParam(param ModuleParam) (*Module, error) {
if param.Redis == nil || param.Redis.Zero() == nil { if param.Redis == nil || param.Redis.Zero() == nil {
return nil, fmt.Errorf("member: redis is required") return nil, fmt.Errorf("member: redis is required")
} }
cfg := param.Config.Defaults()
otpStore := repository.NewRedisOTPChallengeStore(param.Redis) otpStore := repository.NewRedisOTPChallengeStore(param.Redis)
rateStore := repository.NewRedisVerifyRateStore(param.Redis) rateStore := repository.NewRedisVerifyRateStore(param.Redis)
profile := param.Profile
if profile == nil { members := param.Members
profile = repository.NewMemoryProfileRepository() tenants := param.Tenants
identities := param.Identities
uidGen := param.UIDGen
totpProfile := param.TOTPProfile
if param.MongoConf != nil && param.MongoConf.Host != "" {
if members == nil {
members = repository.NewMemberRepository(repository.MemberRepositoryParam{Conf: param.MongoConf})
}
if tenants == nil {
tenants = repository.NewTenantRepository(repository.TenantRepositoryParam{Conf: param.MongoConf})
}
if identities == nil {
identities = repository.NewIdentityRepository(repository.IdentityRepositoryParam{Conf: param.MongoConf})
}
if totpProfile == nil {
totpProfile = repository.NewMongoTOTPProfileRepository(param.MongoConf)
}
}
if uidGen == nil {
uidGen = repository.NewRedisUIDGenerator(param.Redis)
}
if totpProfile == nil {
totpProfile = repository.NewMemoryTOTPProfileRepository()
} }
cfg := param.Config.Defaults()
mod := &Module{ mod := &Module{
OTP: MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg}), OTP: MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg}),
VerifyRate: rateStore, VerifyRate: rateStore,
Profile: profile, Members: members,
Tenants: tenants,
Identities: identities,
}
if members != nil {
mod.Profile = MustProfileUseCase(ProfileUseCaseParam{Members: members})
}
if members != nil && tenants != nil && uidGen != nil {
mod.Lifecycle = MustLifecycleUseCase(LifecycleUseCaseParam{
Members: members,
Tenants: tenants,
UIDGen: uidGen,
})
mod.Tenant = MustTenantUseCase(TenantUseCaseParam{Tenants: tenants})
}
if members != nil && identities != nil && tenants != nil && uidGen != nil {
mod.Provisioning = MustProvisioningUseCase(ProvisioningUseCaseParam{
Members: members,
Identities: identities,
Tenants: tenants,
UIDGen: uidGen,
})
} }
if cfg.TOTP.SecretKEK != "" { if cfg.TOTP.SecretKEK != "" {
@ -69,10 +113,6 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("member: totp kek: %w", err) return nil, fmt.Errorf("member: totp kek: %w", err)
} }
totpProfile := param.TOTPProfile
if totpProfile == nil {
totpProfile = repository.NewMemoryTOTPProfileRepository()
}
mod.TOTP = MustTOTPUseCase(TOTPUseCaseParam{ mod.TOTP = MustTOTPUseCase(TOTPUseCaseParam{
Profile: totpProfile, Profile: totpProfile,
Enroll: repository.NewRedisTOTPEnrollStore(param.Redis), Enroll: repository.NewRedisTOTPEnrollStore(param.Redis),

View File

@ -0,0 +1,112 @@
package usecase
import (
"context"
"errors"
member "gateway/internal/model/member/domain"
domrepo "gateway/internal/model/member/domain/repository"
domusecase "gateway/internal/model/member/domain/usecase"
)
type profileUseCase struct {
members domrepo.MemberRepository
}
// ProfileUseCaseParam wires ProfileUseCase.
type ProfileUseCaseParam struct {
Members domrepo.MemberRepository
}
// MustProfileUseCase constructs ProfileUseCase.
func MustProfileUseCase(param ProfileUseCaseParam) domusecase.ProfileUseCase {
if param.Members == nil {
panic("member: member repository is required")
}
return &profileUseCase{members: param.Members}
}
func (uc *profileUseCase) GetByUID(ctx context.Context, req *domusecase.GetMemberRequest) (*domusecase.MemberDTO, error) {
if req == nil || req.TenantID == "" || req.UID == "" {
return nil, errb.InputMissingRequired("tenant_id and uid are required")
}
rec, err := uc.members.GetByUID(ctx, req.TenantID, req.UID)
if err != nil {
if errors.Is(err, member.ErrNotFound) {
return nil, errb.ResNotFound("member", req.UID).WithCause(err)
}
return nil, errb.SysInternal("read member failed").WithCause(err)
}
return memberToDTO(rec), nil
}
func (uc *profileUseCase) Update(ctx context.Context, req *domusecase.UpdateMemberRequest) (*domusecase.MemberDTO, error) {
if req == nil || req.TenantID == "" || req.UID == "" {
return nil, errb.InputMissingRequired("tenant_id and uid are required")
}
rec, err := uc.members.UpdateProfile(ctx, req.TenantID, req.UID, &domrepo.MemberUpdate{
DisplayName: req.DisplayName,
Avatar: req.Avatar,
Language: req.Language,
Currency: req.Currency,
Phone: req.Phone,
})
if err != nil {
if errors.Is(err, member.ErrNotFound) {
return nil, errb.ResNotFound("member", req.UID).WithCause(err)
}
return nil, errb.SysInternal("update member failed").WithCause(err)
}
return memberToDTO(rec), nil
}
func (uc *profileUseCase) List(ctx context.Context, req *domusecase.ListMembersRequest) (*domusecase.ListMembersResponse, error) {
if req == nil || req.TenantID == "" {
return nil, errb.InputMissingRequired("tenant_id is required")
}
items, total, err := uc.members.List(ctx, domrepo.ListMembersFilter{
TenantID: req.TenantID,
Status: req.Status,
Offset: req.Offset,
Limit: req.Limit,
})
if err != nil {
return nil, errb.SysInternal("list members failed").WithCause(err)
}
out := make([]*domusecase.MemberDTO, 0, len(items))
for _, item := range items {
out = append(out, memberToDTO(item))
}
return &domusecase.ListMembersResponse{
Items: out,
Total: total,
Offset: req.Offset,
Limit: req.Limit,
}, nil
}
func (uc *profileUseCase) SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error {
if tenantID == "" || uid == "" || email == "" {
return errb.InputMissingRequired("tenant_id, uid and email are required")
}
if err := uc.members.SetBusinessEmailVerified(ctx, tenantID, uid, email); err != nil {
if errors.Is(err, member.ErrNotFound) {
return errb.ResNotFound("member", uid).WithCause(err)
}
return errb.SysInternal("set business email verified failed").WithCause(err)
}
return nil
}
func (uc *profileUseCase) SetBusinessPhoneVerified(ctx context.Context, tenantID, uid, phone string) error {
if tenantID == "" || uid == "" || phone == "" {
return errb.InputMissingRequired("tenant_id, uid and phone are required")
}
if err := uc.members.SetBusinessPhoneVerified(ctx, tenantID, uid, phone); err != nil {
if errors.Is(err, member.ErrNotFound) {
return errb.ResNotFound("member", uid).WithCause(err)
}
return errb.SysInternal("set business phone verified failed").WithCause(err)
}
return nil
}

View File

@ -0,0 +1,149 @@
package usecase
import (
"context"
"errors"
"time"
member "gateway/internal/model/member/domain"
"gateway/internal/model/member/domain/entity"
"gateway/internal/model/member/domain/enum"
domrepo "gateway/internal/model/member/domain/repository"
domusecase "gateway/internal/model/member/domain/usecase"
)
type provisioningUseCase struct {
members domrepo.MemberRepository
identities domrepo.IdentityRepository
tenants domrepo.TenantRepository
uidGen domrepo.UIDGenerator
}
// ProvisioningUseCaseParam wires ProvisioningUseCase.
type ProvisioningUseCaseParam struct {
Members domrepo.MemberRepository
Identities domrepo.IdentityRepository
Tenants domrepo.TenantRepository
UIDGen domrepo.UIDGenerator
}
// MustProvisioningUseCase constructs ProvisioningUseCase.
func MustProvisioningUseCase(param ProvisioningUseCaseParam) domusecase.ProvisioningUseCase {
if param.Members == nil || param.Identities == nil || param.Tenants == nil || param.UIDGen == nil {
panic("member: provisioning dependencies are required")
}
return &provisioningUseCase{
members: param.Members,
identities: param.Identities,
tenants: param.Tenants,
uidGen: param.UIDGen,
}
}
func (uc *provisioningUseCase) EnsureFromOIDC(ctx context.Context, req *domusecase.EnsureFromOIDCRequest) (*domusecase.MemberDTO, error) {
if req == nil || req.TenantID == "" || req.ZitadelSub == "" {
return nil, errb.InputMissingRequired("tenant_id and zitadel_sub are required")
}
if existing, err := uc.members.GetByZitadelUserID(ctx, req.TenantID, req.ZitadelSub); err == nil {
return memberToDTO(existing), nil
} else if !errors.Is(err, member.ErrNotFound) {
return nil, errb.SysInternal("read member failed").WithCause(err)
}
return uc.createProvisioned(ctx, req.TenantID, req.ZitadelSub, "", req.Email, req.DisplayName, req.Locale, enum.MemberOriginOIDC)
}
func (uc *provisioningUseCase) EnsureFromLDAP(ctx context.Context, req *domusecase.EnsureFromLDAPRequest) (*domusecase.MemberDTO, error) {
if req == nil || req.TenantID == "" {
return nil, errb.InputMissingRequired("tenant_id is required")
}
sub := req.ZitadelSub
if sub == "" {
sub = req.ExternalID
}
if sub != "" {
if existing, err := uc.members.GetByZitadelUserID(ctx, req.TenantID, sub); err == nil {
return memberToDTO(existing), nil
} else if !errors.Is(err, member.ErrNotFound) {
return nil, errb.SysInternal("read member failed").WithCause(err)
}
}
if req.ExternalID != "" {
if idn, err := uc.identities.GetByExternalID(ctx, req.TenantID, req.ExternalID); err == nil {
rec, getErr := uc.members.GetByUID(ctx, req.TenantID, idn.UID)
if getErr == nil {
return memberToDTO(rec), nil
}
}
}
return uc.createProvisioned(ctx, req.TenantID, sub, req.ExternalID, req.Email, req.DisplayName, "", enum.MemberOriginLDAP)
}
func (uc *provisioningUseCase) EnsureFromSCIM(ctx context.Context, req *domusecase.EnsureFromSCIMRequest) (*domusecase.MemberDTO, error) {
if req == nil || req.TenantID == "" {
return nil, errb.InputMissingRequired("tenant_id is required")
}
if req.ExternalID != "" {
if idn, err := uc.identities.GetByExternalID(ctx, req.TenantID, req.ExternalID); err == nil {
rec, getErr := uc.members.GetByUID(ctx, req.TenantID, idn.UID)
if getErr == nil {
return memberToDTO(rec), nil
}
}
}
sub := req.ZitadelSub
return uc.createProvisioned(ctx, req.TenantID, sub, req.ExternalID, req.Email, req.DisplayName, "", enum.MemberOriginSCIM)
}
func (uc *provisioningUseCase) createProvisioned(
ctx context.Context,
tenantID, zitadelSub, externalID, email, displayName, language string,
origin enum.MemberOrigin,
) (*domusecase.MemberDTO, error) {
tenant, err := uc.tenants.GetByTenantID(ctx, tenantID)
if err != nil {
if errors.Is(err, member.ErrTenantNotFound) {
return nil, errb.ResNotFound("tenant", tenantID).WithCause(err)
}
return nil, errb.SysInternal("read tenant failed").WithCause(err)
}
uid, err := uc.uidGen.Next(ctx, tenantID, tenant.UIDPrefix)
if err != nil {
return nil, errb.SysInternal("allocate uid failed").WithCause(err)
}
now := time.Now().UTC().UnixMilli()
rec := &entity.Member{
TenantID: tenantID,
UID: uid,
ZitadelUserID: zitadelSub,
ZitadelEmail: email,
DisplayName: displayName,
Language: defaultLanguage(language),
Status: enum.MemberStatusActive,
Origin: origin,
CreateAt: now,
UpdateAt: now,
}
if err := uc.members.Insert(ctx, rec); err != nil {
if errors.Is(err, member.ErrDuplicateMember) {
if zitadelSub != "" {
if existing, getErr := uc.members.GetByZitadelUserID(ctx, tenantID, zitadelSub); getErr == nil {
return memberToDTO(existing), nil
}
}
return nil, errb.ResAlreadyExist("member already exists").WithCause(err)
}
return nil, errb.SysInternal("create member failed").WithCause(err)
}
idn := &entity.Identity{
TenantID: tenantID,
UID: uid,
ZitadelUserID: zitadelSub,
ExternalID: externalID,
CreateAt: now,
UpdateAt: now,
}
if err := uc.identities.Insert(ctx, idn); err != nil && !errors.Is(err, member.ErrDuplicateMember) {
return nil, errb.SysInternal("create identity failed").WithCause(err)
}
return memberToDTO(rec), nil
}

View File

@ -0,0 +1,80 @@
package usecase
import (
"context"
"errors"
"strings"
"time"
member "gateway/internal/model/member/domain"
"gateway/internal/model/member/domain/entity"
"gateway/internal/model/member/domain/enum"
domrepo "gateway/internal/model/member/domain/repository"
domusecase "gateway/internal/model/member/domain/usecase"
)
type tenantUseCase struct {
tenants domrepo.TenantRepository
}
// TenantUseCaseParam wires TenantUseCase.
type TenantUseCaseParam struct {
Tenants domrepo.TenantRepository
}
// MustTenantUseCase constructs TenantUseCase.
func MustTenantUseCase(param TenantUseCaseParam) domusecase.TenantUseCase {
if param.Tenants == nil {
panic("member: tenant repository is required")
}
return &tenantUseCase{tenants: param.Tenants}
}
func (uc *tenantUseCase) Create(ctx context.Context, req *domusecase.CreateTenantRequest) (*domusecase.TenantDTO, error) {
if req == nil || req.TenantID == "" || req.Slug == "" || req.Name == "" {
return nil, errb.InputMissingRequired("tenant_id, slug and name are required")
}
prefix := normalizeUIDPrefix(req.UIDPrefix)
if prefix == "" {
prefix = normalizeUIDPrefix(req.Slug)
}
if len(prefix) < member.UIDPrefixMinLength || len(prefix) > member.UIDPrefixMaxLength {
return nil, errb.InputInvalidFormat("uid_prefix must be 2-4 uppercase letters")
}
if _, err := uc.tenants.GetByUIDPrefix(ctx, prefix); err == nil {
return nil, errb.ResAlreadyExist("uid_prefix already exists")
} else if !errors.Is(err, member.ErrTenantNotFound) {
return nil, errb.SysInternal("read tenant failed").WithCause(err)
}
now := time.Now().UTC().UnixMilli()
rec := &entity.Tenant{
TenantID: req.TenantID,
Slug: strings.ToLower(req.Slug),
Name: req.Name,
UIDPrefix: prefix,
Status: enum.TenantStatusActive,
CreateAt: now,
UpdateAt: now,
}
if err := uc.tenants.Insert(ctx, rec); err != nil {
if errors.Is(err, member.ErrDuplicateTenant) {
return nil, errb.ResAlreadyExist("tenant already exists").WithCause(err)
}
return nil, errb.SysInternal("create tenant failed").WithCause(err)
}
return tenantToDTO(rec), nil
}
func (uc *tenantUseCase) ResolveBySlug(ctx context.Context, slug string) (*domusecase.TenantDTO, error) {
if slug == "" {
return nil, errb.InputMissingRequired("slug is required")
}
rec, err := uc.tenants.GetBySlug(ctx, strings.ToLower(slug))
if err != nil {
if errors.Is(err, member.ErrTenantNotFound) {
return nil, errb.ResNotFound("tenant", slug).WithCause(err)
}
return nil, errb.SysInternal("read tenant failed").WithCause(err)
}
return tenantToDTO(rec), nil
}

View File

@ -7,6 +7,7 @@ import (
"context" "context"
"gateway/internal/config" "gateway/internal/config"
libmongo "gateway/internal/library/mongo"
redislib "gateway/internal/library/redis" redislib "gateway/internal/library/redis"
"gateway/internal/library/validate" "gateway/internal/library/validate"
domrepo "gateway/internal/model/member/domain/repository" domrepo "gateway/internal/model/member/domain/repository"
@ -20,28 +21,19 @@ import (
type ServiceContext struct { type ServiceContext struct {
Config config.Config Config config.Config
Validator validate.Validate Validator validate.Validate
// Redis is the process-wide client (one pool per Addr); nil when Redis.Host is empty.
Redis *redislib.Client Redis *redislib.Client
// Notifier is nil when Mongo is not configured (local scaffold without DB).
Notifier domnotif.NotifierUseCase Notifier domnotif.NotifierUseCase
// NotificationAdmin is nil when Mongo is not configured.
NotificationAdmin domnotif.AdminNotifierUseCase NotificationAdmin domnotif.AdminNotifierUseCase
// NotificationRetry runs async delivery when Mongo + Redis are configured.
NotificationRetry *notification_retry.Runner NotificationRetry *notification_retry.Runner
// MemberOTP is the atomic OTP usecase (Generate / Verify / Invalidate).
// nil when Redis is not configured. Logic layer composes it with the
// Notifier + Profile flips; usecases MUST NOT call other usecases.
MemberOTP dommember.OTPUseCase MemberOTP dommember.OTPUseCase
// MemberTOTP is the atomic TOTP usecase; nil when Member.TOTP.SecretKEK
// is unset or Redis is missing.
MemberTOTP dommember.TOTPUseCase MemberTOTP dommember.TOTPUseCase
// MemberVerifyRate exposes resend-cooldown / daily-cap helpers for the MemberProfile dommember.ProfileUseCase
// logic layer. MemberLifecycle dommember.LifecycleUseCase
MemberProvisioning dommember.ProvisioningUseCase
MemberTenant dommember.TenantUseCase
MemberVerifyRate domrepo.VerifyRateStore MemberVerifyRate domrepo.VerifyRateStore
// MemberProfile flips BusinessEmail/Phone verified flags; consumed by MemberRepo domrepo.MemberRepository
// the logic layer after a successful OTP confirmation.
MemberProfile domrepo.ProfileRepository
} }
func NewServiceContext(c config.Config) *ServiceContext { func NewServiceContext(c config.Config) *ServiceContext {
@ -74,8 +66,13 @@ func NewServiceContext(c config.Config) *ServiceContext {
sc.NotificationRetry = notification_retry.NewRunner(mod.RetryWorker) sc.NotificationRetry = notification_retry.NewRunner(mod.RetryWorker)
} }
if rds != nil && rds.Zero() != nil { if rds != nil && rds.Zero() != nil {
var mongoConf *libmongo.Conf
if c.Mongo.Host != "" {
mongoConf = &c.Mongo
}
memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{ memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
Redis: rds, Redis: rds,
MongoConf: mongoConf,
Config: c.Member, Config: c.Member,
}) })
if err != nil { if err != nil {
@ -83,20 +80,22 @@ func NewServiceContext(c config.Config) *ServiceContext {
} }
sc.MemberOTP = memberMod.OTP sc.MemberOTP = memberMod.OTP
sc.MemberTOTP = memberMod.TOTP sc.MemberTOTP = memberMod.TOTP
sc.MemberVerifyRate = memberMod.VerifyRate
sc.MemberProfile = memberMod.Profile sc.MemberProfile = memberMod.Profile
sc.MemberLifecycle = memberMod.Lifecycle
sc.MemberProvisioning = memberMod.Provisioning
sc.MemberTenant = memberMod.Tenant
sc.MemberVerifyRate = memberMod.VerifyRate
sc.MemberRepo = memberMod.Members
} }
return sc return sc
} }
// StartWorkers launches background workers (notification retry, etc.).
func (sc *ServiceContext) StartWorkers(ctx context.Context) { func (sc *ServiceContext) StartWorkers(ctx context.Context) {
if sc.NotificationRetry != nil { if sc.NotificationRetry != nil {
sc.NotificationRetry.Start(ctx) sc.NotificationRetry.Start(ctx)
} }
} }
// StopWorkers waits for background workers to shut down.
func (sc *ServiceContext) StopWorkers() { func (sc *ServiceContext) StopWorkers() {
if sc.NotificationRetry != nil { if sc.NotificationRetry != nil {
sc.NotificationRetry.Stop() sc.NotificationRetry.Stop()

View File

@ -16,6 +16,26 @@ type ErrorDetail struct {
Detail uint32 `json:"detail,omitempty"` Detail uint32 `json:"detail,omitempty"`
} }
type MemberMeData struct {
TenantID string `json:"tenant_id"`
UID string `json:"uid"`
ZitadelEmail string `json:"zitadel_email,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Avatar string `json:"avatar,omitempty"`
Phone string `json:"phone,omitempty"`
Language string `json:"language,omitempty"`
Currency string `json:"currency,omitempty"`
Status string `json:"status"`
Origin string `json:"origin"`
BusinessEmail string `json:"business_email,omitempty"`
BusinessEmailVerified bool `json:"business_email_verified"`
BusinessPhone string `json:"business_phone,omitempty"`
BusinessPhoneVerified bool `json:"business_phone_verified"`
TOTPEnrolled bool `json:"totp_enrolled"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
type PingData struct { type PingData struct {
Pong string `json:"pong"` Pong string `json:"pong"`
} }
@ -25,3 +45,58 @@ type PingOKStatus struct {
Message string `json:"message"` Message string `json:"message"`
Data PingData `json:"data"` Data PingData `json:"data"`
} }
type TOTPBackupCodesData struct {
BackupCodes []string `json:"backup_codes"`
}
type TOTPEnrollConfirmData struct {
BackupCodes []string `json:"backup_codes"`
}
type TOTPEnrollConfirmReq struct {
Code string `json:"code"`
}
type TOTPEnrollStartData struct {
OtpauthURL string `json:"otpauth_url"`
Issuer string `json:"issuer"`
Account string `json:"account"`
Digits int `json:"digits"`
PeriodSec int `json:"period_seconds"`
ExpiresIn int `json:"expires_in"`
}
type TOTPStatusData struct {
Enrolled bool `json:"enrolled"`
EnrolledAt int64 `json:"enrolled_at,omitempty"`
BackupCodesRemaining int `json:"backup_codes_remaining"`
Digits int `json:"digits,omitempty"`
PeriodSeconds int `json:"period_seconds,omitempty"`
}
type TOTPVerifyReq struct {
Code string `json:"code"`
}
type UpdateMemberMeReq struct {
DisplayName string `json:"display_name,optional"`
Avatar string `json:"avatar,optional"`
Language string `json:"language,optional"`
Currency string `json:"currency,optional"`
Phone string `json:"phone,optional"`
}
type VerificationConfirmReq struct {
ChallengeID string `json:"challenge_id"`
Code string `json:"code"`
}
type VerificationStartData struct {
ChallengeID string `json:"challenge_id"`
ExpiresIn int `json:"expires_in"`
}
type VerificationStartReq struct {
Target string `json:"target"`
}