# Member 模組 Gateway 的會員核心:涵蓋 **Tenant(租戶)**、**Member(會員 profile)**、**Identity(外部身份對映)** 三大實體,以及租戶內 readable UID、業務 email/phone OTP 驗證、TOTP step-up MFA、resend / daily 配額等業務功能。 > **架構原則**(`docs/model.md` §6.1): > usecase **不可** 呼叫其他 usecase。多步流程(例如「發起 OTP → 寄信 → 驗碼 → flip business_email_verified」)由 **logic 層** 編排。 > 本 module 所有 usecase 都是 **atomic primitives**。 --- ## 目錄 - [核心概念](#核心概念) - [目錄結構](#目錄結構) - [Module 結構與依賴](#module-結構與依賴) - [Atomic UseCase 一覽](#atomic-usecase-一覽) - [資料儲存](#資料儲存) - [生命週期與狀態機](#生命週期與狀態機) - [核心流程時序圖](#核心流程時序圖) - [1. 模組裝配 (NewModuleFromParam)](#1-模組裝配-newmodulefromparam) - [2. Tenant 建立](#2-tenant-建立) - [3. Platform 註冊 (auth + member.Lifecycle)](#3-platform-註冊-auth--memberlifecycle) - [4. Provisioning — OIDC / LDAP / SCIM](#4-provisioning--oidc--ldap--scim) - [5. 業務 Email / Phone OTP 驗證](#5-業務-email--phone-otp-驗證) - [6. TOTP 綁定 / Step-up](#6-totp-綁定--step-up) - [7. UID 生成](#7-uid-生成) - [Redis Key 命名](#redis-key-命名) - [設定](#設定) - [ServiceContext 注入](#servicecontext-注入) - [測試](#測試) --- ## 核心概念 | 實體 | 用途 | 主要欄位 | 儲存 | | --- | --- | --- | --- | | **Tenant** | 租戶元資料 | `tenant_id`、`slug`、`uid_prefix`、`status`、`org_id` | Mongo `tenants` | | **Member** | 會員 profile(租戶範圍) | `tenant_id`+`uid`、`zitadel_user_id`、`status`、`origin`、business email/phone、TOTP cipher | Mongo `members` | | **Identity** | 外部 ID → UID 對映表 | `zitadel_user_id`、`external_id`、`uid` | Mongo `identities` | **Member 雙鍵**:`(tenant_id, uid)` 為對外的可讀主鍵;`zitadel_user_id` 是 OIDC 來源的對映鍵。 **多租戶等級**:每個 Member 必屬於一個 Tenant,UID 用 `{TenantUIDPrefix}-{Sequence}` 格式(例:`ACME-10000003`)。 ### 來源(Origin) ``` platform_native // 前台註冊(auth.RegisterLogic + Lifecycle.CreateUnverified) oidc // ZITADEL 社群登入 / SSO(Provisioning.EnsureFromOIDC) ldap // Directory Sync(Provisioning.EnsureFromLDAP) scim // SCIM 2.0(Provisioning.EnsureFromSCIM) ``` --- ## 目錄結構 ``` internal/model/member/ ├── config/ # OTP / TOTP / Registration 設定 ├── domain/ # 介面、enum、entity、errors、redis key helper │ ├── const.go # BSON 欄位、UID 常數 │ ├── entity/ # Member、Tenant、Identity Mongo doc │ ├── enum/ # MemberStatus、MemberOrigin、OTPPurpose、TenantStatus、VerifyKind │ ├── errors.go # ErrNotFound、ErrDuplicateMember 等 │ ├── redis.go # GetOTPChallengeRedisKey 等 helper │ ├── repository/ # 7 個 repository 介面 │ └── usecase/ # 7 個 usecase 介面 + DTO ├── repository/ # Mongo / Redis 實作 ├── totp/ # RFC 6238 純函式(secret、verify、otpauth URL) ├── usecase/ # 7 個 usecase 實作 + module factory + mapper └── README.md # 本檔 ``` `domain/` 純介面 + 常數,**不依賴外部 lib**(除 `bson.ObjectID`)。 `usecase/` 只依賴 `domain/`。 `repository/` 依賴 `library/mongo`、`library/redis`。 --- ## Module 結構與依賴 ```mermaid flowchart TB Logic["logic 層
(handler 編排)"] subgraph M["member.Module (atomic usecases)"] direction LR OTP["OTP"] TOTP["TOTP"] Profile["Profile"] Lifecycle["Lifecycle"] Provisioning["Provisioning"] Tenant["Tenant"] VerifyRate["VerifyRate"] end subgraph R["domain.Repository (介面)"] MemberRepo["MemberRepository"] TenantRepo["TenantRepository"] IdentityRepo["IdentityRepository"] OTPStore["OTPChallengeStore"] RateStore["VerifyRateStore"] TOTPProf["TOTPProfileRepository"] TOTPEnroll["TOTPEnrollStore"] TOTPReplay["TOTPReplayStore"] UIDGen["UIDGenerator"] end subgraph I["repository/ 實作"] Mongo[(MongoDB)] Redis[(Redis)] end Logic -->|單呼叫| M OTP --> OTPStore TOTP --> TOTPProf TOTP --> TOTPEnroll TOTP --> TOTPReplay Profile --> MemberRepo Lifecycle --> MemberRepo Lifecycle --> TenantRepo Lifecycle --> UIDGen Provisioning --> MemberRepo Provisioning --> IdentityRepo Provisioning --> TenantRepo Provisioning --> UIDGen Tenant --> TenantRepo VerifyRate --> RateStore MemberRepo --- Mongo TenantRepo --- Mongo IdentityRepo --- Mongo TOTPProf --- Mongo OTPStore --- Redis RateStore --- Redis TOTPEnroll --- Redis TOTPReplay --- Redis UIDGen --- Redis ``` **注入規則**:Module factory 依條件啟用 usecase: - `Redis` 必填 → `OTP`、`VerifyRate` 永遠存在。 - `MongoConf` 設定 → 啟用 `Profile`、`Lifecycle`、`Tenant`、`Provisioning`。 - `TOTP.SecretKEK` 設定 → 啟用 `TOTP`(否則 `mod.TOTP == nil`)。 --- ## Atomic UseCase 一覽 | UseCase | 介面方法 | 職責 | | --- | --- | --- | | **TenantUseCase** | `Create` / `ResolveBySlug` | 建立租戶、依 slug 反查 | | **LifecycleUseCase** | `CreateUnverified` / `Activate` / `Suspend` / `Reactivate` / `SoftDelete` / `AbortPending` | platform 會員建立 + 狀態轉換(嚴格的 from→to 檢查) | | **ProfileUseCase** | `GetByUID` / `GetByZitadelUserID` / `Update` / `List` / `SetBusinessEmailVerified` / `SetBusinessPhoneVerified` | 讀取 / patch 可變欄位、業務 contact 標記已驗證 | | **ProvisioningUseCase** | `EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM` | 外部身份首登/同步 upsert(Member + Identity) | | **OTPUseCase** | `Generate` / `Verify` / `Invalidate` / `GetChallenge` / `MatchChallenge` | 產出/驗證一次性數字碼(bcrypt + Redis) | | **TOTPUseCase** | `StartEnroll` / `ConfirmEnroll` / `VerifyCode` / `Disable` / `RegenerateBackupCodes` / `Status` | RFC 6238 step-up MFA(AES-GCM 保護 secret) | | **VerifyRateUseCase** | `AssertResendAllowed` / `AssertDailyAllowed` | OTP 重發冷卻 + 每日上限 | --- ## 資料儲存 ### MongoDB Collections | Collection | Entity | 主要索引 | | --- | --- | --- | | `members` | `Member` | unique `(tenant_id, uid)`、unique `(tenant_id, zitadel_user_id)`(sparse) | | `tenants` | `Tenant` | unique `slug`、unique `uid_prefix` | | `identities` | `Identity` | unique `(tenant_id, external_id)`、unique `(tenant_id, zitadel_user_id)` | 索引建立由 `repository.EnsureMongoIndexes` 在啟動時執行(對應 `cmd/mongo-index`)。 ### Redis Keys | Key 前綴 | 用途 | TTL | | --- | --- | --- | | `member:otp:challenge:{id}` | OTP challenge 主紀錄(bcrypt hash) | `OTP.TTLSeconds`(預設 300) | | `member:otp:challenge:{id}:attempts` | OTP 錯誤次數計數 | 同 challenge | | `member:verify:rate:{tenant}:{uid}:{kind}` | resend 冷卻 lock | `OTP.ResendCooldownSeconds`(預設 60) | | `member:verify:daily:{tenant}:{uid}:{kind}` | 每日上限計數 | 24h | | `member:totp:enroll:{tenant}:{uid}` | 綁定中的 staged secret(AES-GCM cipher) | `TOTP.EnrollTTLSeconds`(預設 600) | | `member:totp:used:{tenant}:{uid}:{timestep}` | TOTP 重放保護 | `TOTP.ReplayTTLSeconds`(預設 90) | | `member:seq:{tenant}` | UID 序號(`INCR`) | 永久 | Helper 函式見 `domain/redis.go`,**禁止** 在他處字串拼接 key。 --- ## 生命週期與狀態機 ```mermaid stateDiagram-v2 [*] --> unverified: Lifecycle.CreateUnverified
(platform 註冊) [*] --> active: Provisioning.Ensure*
(OIDC/LDAP/SCIM 首登) unverified --> active: Activate
(OTP 驗證通過) unverified --> deleted: AbortPending
(註冊逾時) active --> suspended: Suspend(reason) suspended --> active: Reactivate active --> deleted: SoftDelete suspended --> deleted: SoftDelete deleted --> [*] ``` `transition()` 強制 `from → to`,不符回 `ErrInvalidStatus`。 --- ## 核心流程時序圖 ### 1. 模組裝配 (NewModuleFromParam) ```mermaid sequenceDiagram autonumber participant SVC as svc.NewServiceContext participant Mod as member.NewModuleFromParam participant Repo as repository participant Redis participant Mongo SVC->>Mod: ModuleParam{Redis, MongoConf, Config} Mod->>Repo: NewRedisOTPChallengeStore(redis) Mod->>Repo: NewRedisVerifyRateStore(redis) alt MongoConf.Host != "" Mod->>Repo: NewMemberRepository / NewTenantRepository / NewIdentityRepository Mod->>Repo: NewMongoTOTPProfileRepository Repo->>Mongo: ping (lazy) end Mod->>Repo: NewRedisUIDGenerator(redis) Mod->>Mod: MustOTPUseCase / MustVerifyRateUseCase alt Mongo 就緒 Mod->>Mod: MustProfileUseCase / MustLifecycleUseCase / MustTenantUseCase / MustProvisioningUseCase end alt TOTP.SecretKEK != "" Mod->>Mod: NewAESGCMFromString(KEK) Mod->>Repo: NewRedisTOTPEnrollStore / NewRedisTOTPReplayStore Mod->>Mod: MustTOTPUseCase end Mod-->>SVC: *Module(7 usecase + 3 repo) SVC->>SVC: sc.MemberOTP / sc.MemberLifecycle / ... ``` ### 2. Tenant 建立 ```mermaid sequenceDiagram autonumber participant CLI as cmd/member-seed participant TenantUC as TenantUseCase participant Repo as TenantRepository participant Mongo CLI->>TenantUC: Create(req{TenantID, Slug, Name, UIDPrefix}) TenantUC->>TenantUC: normalizeUIDPrefix + 長度檢查 (2-4) TenantUC->>Repo: GetByUIDPrefix(prefix) Repo->>Mongo: findOne alt prefix 已存在 TenantUC-->>CLI: ErrAlreadyExist("uid_prefix already exists") else 不存在 TenantUC->>Repo: Insert(Tenant{Status: active}) Repo->>Mongo: insertOne TenantUC-->>CLI: TenantDTO end ``` ### 3. Platform 註冊 (auth + member.Lifecycle) > 屬於 `internal/logic/auth/register_logic.go` 的編排;Member module 只負責 atomic 動作。 ```mermaid sequenceDiagram autonumber participant Client participant RegLogic as logic/auth.RegisterLogic participant TenantUC as TenantUseCase participant Zitadel as library/zitadel participant Lifecycle as LifecycleUseCase participant OTP as OTPUseCase participant Notifier participant Confirm as logic/auth.RegisterConfirmLogic Client->>RegLogic: POST /auth/register {tenant_slug, email, password} RegLogic->>TenantUC: ResolveBySlug(slug) TenantUC-->>RegLogic: TenantDTO RegLogic->>Zitadel: CreateHumanUser(...) Zitadel-->>RegLogic: zitadel_user_id RegLogic->>Lifecycle: CreateUnverified(req{tenant, email, hash, zitadel_user_id}) Lifecycle->>Lifecycle: 取 tenant.UIDPrefix → UIDGenerator.Next Lifecycle->>Lifecycle: members.Insert(status=unverified) Lifecycle-->>RegLogic: MemberDTO(uid) RegLogic->>OTP: Generate(purpose=Register, uid, target=email) OTP-->>RegLogic: challenge_id, plainCode RegLogic->>Notifier: Send(VerifyEmail, code) alt Notifier 失敗 RegLogic->>Lifecycle: AbortPending(uid) RegLogic-->>Client: 5xx else 成功 RegLogic-->>Client: {challenge_id, expires_in} end Note over Client,Confirm: 使用者收到信 Client->>Confirm: POST /auth/register/confirm {challenge_id, code} Confirm->>OTP: MatchChallenge(challenge_id, tenant, purpose=Register, RequireUID) OTP-->>Confirm: OTPChallengeInfo{uid} Confirm->>OTP: Verify(challenge_id, code, uid, purpose) OTP-->>Confirm: target(email) Confirm->>Lifecycle: Activate(tenant, uid) // unverified → active Confirm-->>Client: JWT (auth 簽發) ``` ### 4. Provisioning — OIDC / LDAP / SCIM 外部身份首次登入時透過 `EnsureFromOIDC` upsert,**冪等**(既存即回傳)。 ```mermaid sequenceDiagram autonumber participant Logic as logic/auth.LoginSocialCallback participant Prov as ProvisioningUseCase participant MR as MemberRepository participant IR as IdentityRepository participant TR as TenantRepository participant UID as UIDGenerator participant Redis participant Mongo Logic->>Prov: EnsureFromOIDC(tenant, zitadel_sub, email, ...) Prov->>MR: GetByZitadelUserID(tenant, sub) MR->>Mongo: find alt 已存在 MR-->>Prov: Member Prov-->>Logic: MemberDTO (origin=oidc, status=active) else ErrNotFound Prov->>TR: GetByTenantID(tenant) TR-->>Prov: Tenant{UIDPrefix} Prov->>UID: Next(tenant, prefix) UID->>Redis: INCR member:seq:{tenant} UID-->>Prov: "ACME-10000003" Prov->>MR: Insert(Member{status=active, origin=oidc, zitadel_user_id}) MR->>Mongo: insertOne alt duplicate(競態) MR-->>Prov: ErrDuplicateMember Prov->>MR: GetByZitadelUserID // 再讀一次回傳 end Prov->>IR: Insert(Identity{zitadel_user_id, uid}) IR->>Mongo: insertOne(忽略 dup) Prov-->>Logic: MemberDTO end ``` LDAP / SCIM 同樣模式,額外查 `IdentityRepository.GetByExternalID` 處理沒有 zitadel_sub 的情境。 ### 5. 業務 Email / Phone OTP 驗證 由 `internal/logic/member/verify_helper.go` 編排(`startVerification` + `confirmVerification`),展示 logic 層如何把多個 atomic usecase 串起來。 ```mermaid sequenceDiagram autonumber participant Client participant Logic as logic/member.startVerification participant Rate as VerifyRateUseCase participant OTP as OTPUseCase participant Notif as Notifier participant Profile as ProfileUseCase participant Redis Client->>Logic: POST /me/verifications/email/start {target} Logic->>Rate: AssertResendAllowed(rateKey, cooldown=60s) Rate->>Redis: SETNX member:verify:rate:{t}:{u}:business_email alt cooldown 中 Rate-->>Logic: ErrTooManyRequest Logic-->>Client: 429 end Logic->>Rate: AssertDailyAllowed(dailyKey, 24h, limit=10) Rate->>Redis: INCR member:verify:daily:{t}:{u}:business_email Logic->>OTP: Generate(uid, purpose=BusinessEmail, target=email) OTP->>Redis: SET member:otp:challenge:{id} (bcrypt hash, TTL=300s) OTP-->>Logic: challenge_id, plainCode Logic->>Notif: Send(channel=email, kind=VerifyEmail, data={code, expires_in}) alt Notifier 失敗 Logic->>OTP: Invalidate(challenge_id) Logic-->>Client: 5xx else 成功 Logic-->>Client: {challenge_id, expires_in} end Note over Client,Profile: 使用者收到信 Client->>Logic: POST /me/verifications/email/confirm {challenge_id, code} Logic->>OTP: Verify(challenge_id, code, uid, purpose=BusinessEmail) OTP->>Redis: GET + bcrypt compare alt 失敗 OTP->>Redis: INCR attempts alt attempts >= 5 OTP-->>Logic: ErrChallengeLocked else OTP-->>Logic: ErrInvalidOTP end else 成功 OTP->>Redis: DEL challenge OTP-->>Logic: target(email) Logic->>Profile: SetBusinessEmailVerified(tenant, uid, target) Profile-->>Logic: nil Logic-->>Client: 204 end ``` **關鍵設計**:`Verify` 成功後 challenge **立刻刪除**(一次性);`Generate` 一定要先過 `VerifyRate` 兩道閘門。 ### 6. TOTP 綁定 / Step-up ```mermaid sequenceDiagram autonumber participant Client participant Logic participant TOTP as TOTPUseCase participant Profile as TOTPProfileRepository participant Enroll as TOTPEnrollStore participant Replay as TOTPReplayStore participant Cipher as crypto.Cipher (AES-GCM) Note over Client,Cipher: A. 綁定階段 Client->>Logic: POST /me/totp/enroll Logic->>TOTP: StartEnroll(tenant, uid, account) TOTP->>Profile: Get → 必須未 enrolled TOTP->>TOTP: totp.GenerateSecret() (隨機 20 byte) TOTP->>Cipher: Encrypt(secret) → cipherBlob TOTP->>Enroll: Save(cipherBlob, TTL=600s) TOTP-->>Logic: {otpauth_url, digits=6, period=30} Logic-->>Client: QR code 資料 Client->>Client: 掃 QR 加入 Authenticator Client->>Logic: POST /me/totp/enroll/confirm {code} Logic->>TOTP: ConfirmEnroll(tenant, uid, code) TOTP->>Enroll: Get → cipherBlob TOTP->>Cipher: Decrypt → secret TOTP->>TOTP: totp.Verify(secret, code, ±window) alt 驗碼失敗 TOTP-->>Logic: ErrTOTPInvalidCode else 成功 TOTP->>TOTP: 產生 N 個 backup codes + bcrypt hashes TOTP->>Profile: Save({Enrolled, SecretCipher, BackupCodesHash}) TOTP->>Enroll: Delete (清掉 staged) TOTP-->>Logic: plainCodes[](僅此一次回傳) end Note over Client,Replay: B. 日常 step-up Client->>Logic: 任意敏感操作攜 6 碼 Logic->>TOTP: VerifyCode(tenant, uid, code) TOTP->>Profile: Get → 必須 enrolled TOTP->>Cipher: Decrypt(SecretCipher) alt code 長度 = 6 TOTP->>TOTP: totp.Verify(±window) → step alt OK TOTP->>Replay: MarkUsed(timestep, TTL=90s) → fresh? alt 已用過 TOTP-->>Logic: ErrTOTPCodeReplay else 未用過 TOTP-->>Logic: nil end else 失敗 TOTP->>TOTP: fall through to backup code end end alt 嘗試備援碼 loop 每組 hash TOTP->>TOTP: bcrypt.CompareHashAndPassword end alt 命中 TOTP->>Profile: ConsumeBackupCode(hash) (atomic) TOTP-->>Logic: nil else 全失敗 TOTP-->>Logic: ErrTOTPInvalidCode end end ``` ### 7. UID 生成 ```mermaid sequenceDiagram autonumber participant Caller as Lifecycle / Provisioning participant Gen as UIDGenerator participant Redis Caller->>Gen: Next(tenant, uidPrefix) Gen->>Redis: INCR member:seq:{tenant} Redis-->>Gen: seq alt seq == 1 (首次) Note right of Gen: 一次補上起始值
(避開像 ACME-1 這種短 UID) Gen->>Redis: INCRBY (UIDSequenceStart - 1) = 9_999_999 Redis-->>Gen: 10_000_000 end Gen-->>Caller: "{PREFIX}-{seq}" 例:ACME-10000003 ``` `UIDSequenceStart = 10_000_000`(7 位起跳),`UIDPrefix` 限制 2~4 個大寫字母。 --- ## Redis Key 命名 | Helper | 對應 key | 使用者 | | --- | --- | --- | | `GetOTPChallengeRedisKey(id)` | `member:otp:challenge:{id}` | `OTPChallengeStore` | | `GetOTPAttemptsRedisKey(id)` | `member:otp:challenge:{id}:attempts` | `OTPChallengeStore` | | `GetVerifyRateRedisKey(tenant, uid, kind)` | `member:verify:rate:...` | `VerifyRate` (logic 層) | | `GetVerifyDailyRedisKey(tenant, uid, kind)` | `member:verify:daily:...` | 同上 | | `GetTOTPEnrollRedisKey(tenant, uid)` | `member:totp:enroll:...` | `TOTPEnrollStore` | | `GetTOTPUsedRedisKey(tenant, uid, step)` | `member:totp:used:...` | `TOTPReplayStore` | | `GetMemberSeqRedisKey(tenant)` | `member:seq:{tenant}` | `UIDGenerator` | `kind` 通常是 `enum.OTPPurpose` 字串(`business_email`、`business_phone`、`step_up` 等)。 --- ## 設定 `etc/gateway.dev.yaml` → `Member` 區塊: ```yaml Member: Registration: RequireInviteCode: true # 平台註冊是否強制邀請碼 TrustSocialEmailVerified: true # OIDC email_verified=true 時直接 active OTP: Length: 6 # 驗證碼位數 TTLSeconds: 300 # challenge 存活時間 MaxAttempts: 5 # 單 challenge 最大錯誤次數 ResendCooldownSeconds: 60 # 重發冷卻 DailyVerifyLimit: 10 # 每日上限 TOTP: Issuer: CloudEP Algorithm: SHA1 Digits: 6 PeriodSeconds: 30 Window: 1 # ±1 time step 容忍 BackupCodeCount: 10 BackupCodeLength: 12 EnrollTTLSeconds: 600 ReplayTTLSeconds: 90 SecretKEK: "" # 32-byte AES key(hex 64 字元或 base64);留空關閉 TOTP ``` **`SecretKEK`** 可改用環境變數 `TOTP_SECRET_KEK`(prod 建議走 KMS / secret manager)。 --- ## ServiceContext 注入 ```go // internal/svc/service_context.go sc.MemberOTP // domusecase.OTPUseCase (一定有) sc.MemberVerifyRate // domusecase.VerifyRateUseCase (一定有) sc.MemberProfile // domusecase.ProfileUseCase (Mongo 設定後) sc.MemberLifecycle // domusecase.LifecycleUseCase (Mongo 設定後) sc.MemberTenant // domusecase.TenantUseCase (Mongo 設定後) sc.MemberProvisioning // domusecase.ProvisioningUseCase(Mongo 設定後) sc.MemberTOTP // domusecase.TOTPUseCase (TOTP.SecretKEK 設定後;否則 nil) ``` Logic 層使用前務必檢查可能 `nil` 的欄位: ```go if sc.MemberTOTP == nil { return errb.SysNotImplemented("member TOTP not configured") } ``` --- ## 測試 ### 單元測試 ```bash go test ./internal/model/member/... -v make check ``` | 檔案 | 覆蓋 | | --- | --- | | `usecase/otp_usecase_test.go` | Generate/Verify、UID/purpose mismatch、attempts lock | | `usecase/totp_usecase_test.go` | 綁定、VerifyCode、備援碼、重放、Disable、Regenerate | | `totp/totp_test.go` | RFC 6238 測試向量、window、otpauth URL | ### 本機 API(P4) ```bash make deps-up # docker compose: mongo + redis make mongo-index # 建索引 make member-seed # 建 dev tenant + 一筆 member,輸出 X-Tenant-ID/X-UID headers make run-local # 啟動 gateway # Profile 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 驗證(start → confirm) 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`。 ### 互動式 TOTP(Google Authenticator) ```bash make deps-up make totp-test # 預設 STEP=flow:整套綁定 + 驗碼 + 重放 make totp-test STEP=status make totp-test STEP=disable ``` 需在 `etc/gateway.dev.yaml` 設定 `Member.TOTP.SecretKEK`(example 已附 dev-only 占位 key)。 --- ## 設計參考 - 詳細領域模型 / 多租戶設計 / B2B Permission 對接:`docs/identity-member-design.md` - 模組分層公約(usecase 不可呼叫 usecase):`docs/model.md` §6.1 - 統一錯誤格式(`errb.*`):`internal/library/errors/README.md`