# Model 分層規範 本文件定義 Gateway 專案中 **業務模型**(`internal/model/{module}/`)的目錄結構與撰寫約定,參考 Clean Architecture 分層。 > Gateway 的 HTTP 層(handler / logic / types)仍由 goctl 生成;本規範適用於 **`internal/model/{module}/`**(例如 `internal/model/member/`、`internal/model/notification/`)。**不使用 `pkg/`。** 專案總覽與錯誤分層見 [README.md](../README.md)。 ## 目錄結構(`domain/` + 外層實作) **新模組與重構中的模組**(`notification`、`member`、`permission` 等)採 **domain 分包**:契約與領域型別在 `domain/`,Mongo / Redis / Provider / embed 等實作在模組根下對應目錄。對齊 `app-cloudep-notification-service/pkg/` 的 `domain/` 慣例。 路徑前綴:`internal/model/{module}/` ``` internal/model/ └── {module}/ # 例:notification、member、permission ├── domain/ # 純領域:介面、實體、列舉、DTO、模組級定義 │ ├── entity/ # Mongo document 結構 + CollectionName() │ ├── enum/ # Channel、Status、Platform… │ ├── repository/ # Repository / Cache 介面 only │ ├── usecase/ # UseCase 介面 + Request/Response DTO │ ├── template/ # 可選:模板 Spec、Registry、Renderer 介面(notification) │ ├── errors.go # 模組 sentinel(package domain) │ ├── const.go # BSON 欄位名、模組常數(package domain) │ └── redis.go # Redis key 命名(package domain) ├── repository/ # domain/repository 的 Mongo / Redis / memory 實作 ├── usecase/ # domain/usecase 的實作 + factory 組裝 ├── template/ # 可選:go:embed、DefaultRegistry、Renderer 實作 ├── provider/ # 可選:僅本模組用的 Sender(email/sms),不放 library/ ├── totp/、xxx/ # 可選:模組專屬純函式 library(不放 internal/library/) ├── config/ # 模組設定 struct(嵌入 gateway Config) └── mock/ # mockgen(路徑對應 domain/) ├── repository/ └── usecase/ ``` > **定義類(errors / const / redis key)統一放 `domain/`**:caller 端以 `member "gateway/internal/model/{module}/domain"` 取用,引用形式仍為 `member.ErrXxx` / `member.Get…RedisKey`,但這些 sentinel 與 key helper 都在 `package domain`,與 `domain/entity` 等子套件平行。 > > **`internal/library/` 只放跨模組真正共用的東西**(如 `library/errors`、`library/mongo`、`library/redis`、`library/crypto`)。僅某個模組會用的純函式 / 演算法(例如 member 的 RFC 6238 TOTP helper)應落在該模組底下,例如 `internal/model/member/totp/`,避免污染 library 命名空間。 **參考實作:** [`internal/model/notification/`](../internal/model/notification/)(N0–N5 核心已完成;流程圖與設定見 [**notification README**](../internal/model/notification/README.md))。 | 層 | 路徑 | 內容 | |----|------|------| | 領域契約 | `domain/entity`、`domain/enum` | 實體、值物件 | | 領域契約 | `domain/repository` | `XxxRepository`、`IdempotencyCache` 等 **interface** | | 領域契約 | `domain/usecase` | `XxxUseCase`、`SendRequest` 等 **interface + DTO** | | 基礎設施 | `repository/` | `NewXxxRepository`、index migration | | 應用服務 | `usecase/` | `MustXxxUseCase`、`NewXxxUseCaseFromParam` | | 模組專用整合 | `provider/`、`template/` | 不進 `internal/library/` | ### 依賴方向 ``` domain/entity、domain/enum → 僅標準庫 / 列舉底層型別 domain/repository、domain/usecase、domain/template → domain/entity、domain/enum(彼此不 import 實作層) usecase(實作) → domain/usecase(實作介面) → domain/repository(介面) → repository/、provider/、template/(具體型別) repository(實作) → domain/repository(實作介面) → domain/entity internal/logic、其他 model → domain/usecase 介面 only (不 import repository 實作、entity、provider) ``` **Import 範例(notification):** ```go import ( domusecase "gateway/internal/model/notification/domain/usecase" "gateway/internal/model/notification/usecase" ) // 業務模組(如 member)只依賴介面 var _ domusecase.NotifierUseCase = (*usecase.notifierUseCase)(nil) // ServiceContext 組裝實作 notifier, err := usecase.NewNotifierUseCaseFromParam(usecase.FactoryParam{...}) ``` ### 基礎設施連線(Mongo / Redis) **一個 Pod(一個進程)內,連線在 `internal/svc/service_context.go` 建立一次,再注入各 module。** | 資源 | 建立位置 | 共用方式 | 模組內 | |------|----------|----------|--------| | Mongo | `library/mongo` + go-zero `mon` | 同一 URI → **一個 `*mongo.Client` pool**(`mon` 的 `clientManager`) | 每 collection 一個 `DocumentDB` / repository,**不**各自 `Connect` | | Redis | `library/redis.NewClient` + go-zero `redis.MustNewRedis` | 同一 `Addr` → **一個 connection pool** | 只收 `*redis.Client`,**禁止**在 factory 內 `go-redis.NewClient` | ```go // internal/svc/service_context.go(示意) rds, _ := redislib.NewClient(c.Redis) // 全進程共用 notifier, _ := notifusecase.NewNotifierUseCaseFromParam(notifusecase.FactoryParam{ MongoConf: &c.Mongo, Redis: rds, // 注入,非 RedisConf Config: c.Notification, }) ``` - `ServiceContext.Redis` 為 `*library/redis.Client`,`Host` 空則 `nil`(模組可 fallback memory)。 - 之後 **member / permission** 的 factory、`MustXxxUseCase` **必須**接受 `*redislib.Client`,不得在 module 內新建 Redis 連線。 - 模組 `redis.go` 只定義 **key 前綴**,不建立 client。 ### 與舊版扁平目錄的關係 早期 scaffold 可能仍為 `member/entity`(無 `domain/`)。**新增 `permission`、重構 `member` 時請遷移到上表結構**,與 `notification` 一致。遷移步驟:先搬 `entity`/`enum` → `domain/`,再搬 repository/usecase **介面** → `domain/`,最後保留外層實作並修正 import。 ## 1. Entity(`domain/entity/`) 每個 MongoDB collection 對應一個 struct,放在 `internal/model/{module}/domain/entity/`。 **規則:** - 檔名使用 snake_case 對應 collection 語意,如 `account.go`、`user.go`。 - struct 名稱使用 PascalCase 單數,如 `Account`、`User`。 - 必須實作 `CollectionName() string`,回傳 MongoDB collection 名稱。 - 欄位 tag:`bson` 必填;對外 JSON 序列化才加 `json`。 - 主鍵使用 MongoDB driver **v2** 的 `bson.ObjectID`(`go.mongodb.org/mongo-driver/v2/bson`),tag 為 `` `bson:"_id,omitempty" json:"id,omitempty"` ``。舊模組若仍為 `primitive.ObjectID`,遷移時一併改為 v2。 - 時間戳記統一用 `*int64`,欄位名 `CreateAt` / `UpdateAt`,值為 UTC nanoseconds。 - 可選欄位用指標型別(`*string`、`*int64`)。 - 領域列舉引用 `domain/enum/` 下的型別,不在 entity 內重複定義。 **範例:** ```go package entity import ( "gateway/internal/model/member/domain/enum" "go.mongodb.org/mongo-driver/v2/bson" ) type Account struct { ID bson.ObjectID `bson:"_id,omitempty" json:"id,omitempty"` LoginID string `bson:"login_id"` Token string `bson:"token"` Platform enum.Platform `bson:"platform"` UpdateAt *int64 `bson:"update_at,omitempty" json:"update_at,omitempty"` CreateAt *int64 `bson:"create_at,omitempty" json:"create_at,omitempty"` } func (a *Account) CollectionName() string { return "account" } ``` ## 2. 值物件 / 列舉(`domain/enum/`) 業務列舉、狀態碼等放在 `internal/model/{module}/domain/enum/`。 **規則:** - 以具名型別包裝底層型別(`type Status int32`)。 - 常數集中定義,附中文或英文註解說明語意。 - 提供轉換方法:`ToInt32()`、`ToInt64()`、`ToString()`、`CodeToString()` 等。 - 字串 ↔ 列舉映射用 private map + public getter,未知值回傳零值或 sentinel。 **範例:** ```go package enum type Platform int8 const ( Digimon Platform = 1 + iota Google Line Apple ) func (p Platform) ToInt64() int64 { return int64(p) } func (p Platform) ToString() string { /* map lookup */ } ``` 同一檔案或目錄下可放 `*_test.go` 驗證轉換邏輯。 ## 3. Repository 介面(`domain/repository/`) **規則:** - 一個 entity 一個 `XxxRepository` interface,檔案放在 `domain/repository/`。 - 方法第一個參數固定為 `context.Context`。 - 參數 / 回傳值使用 `domain/entity` 型別,不暴露 driver 細節(除 index migration 等必要場景)。 - Index migration 以獨立 interface 嵌入,命名 `{Entity}IndexUP`,方法名含版本號,如 `Index20241226001UP`。 - **此目錄僅介面**:不含 `NewXxxRepository`、不含 `mongo` / `redis` import。 **範例:** ```go package repository import ( "context" "gateway/internal/model/member/domain/entity" ) type AccountRepository interface { Insert(ctx context.Context, data *entity.Account) error FindOne(ctx context.Context, id string) (*entity.Account, error) Update(ctx context.Context, data *entity.Account) error Delete(ctx context.Context, id string) (int64, error) } ``` 輔助介面(冪等、配額等)亦放在 `domain/repository/`,由外層 `repository/redis_store.go` 等實作。 ## 4. Repository 實作(`repository/`) **規則:** - struct 名稱 `{entity}Repository`(小寫)或 `{Entity}Repository`,建構子 `New{Entity}Repository(param {Entity}RepositoryParam)`,**回傳型別為 `domain/repository` 的 interface**。 - Param struct 集中注入 `*mongo.Conf` 等;實作 import `domain/entity`、`domain/repository`。 - 建構時以 `domain/entity` 的 `CollectionName()` 初始化 DocumentDB;失敗時 `panic`(啟動期錯誤)。 - CRUD 透過 `gateway/internal/library/mongo` 的 DocumentDB helper,搭配模組 `redis.go` 的 key helper。 - `Insert`:ID 為 zero 時自動產生 ObjectID 並寫入 `CreateAt` / `UpdateAt`。 - `Update`:自動更新 `UpdateAt`。 - `FindOne` / `Delete`:無效 ObjectID → `*errs.Error`(`ResInvalidMeasureID`)或模組 `ErrInvalidObjectID`;查無資料 → 模組 `ErrNotFound`(見第 7 節錯誤)。 - 整合測試放在同 package 的 `*_test.go`,可用 testcontainer + miniredis。 **範例:** ```go import ( domentity "gateway/internal/model/member/domain/entity" domrepo "gateway/internal/model/member/domain/repository" ) type accountRepository struct { db mongo.DocumentDBUseCase } func NewAccountRepository(param AccountRepositoryParam) domrepo.AccountRepository { e := domentity.Account{} documentDB, err := mongo.NewDocumentDB(param.Conf, e.CollectionName()) if err != nil { panic(err) } return &accountRepository{db: documentDB} } ``` ## 5. UseCase 介面與 DTO(`domain/usecase/`) **規則:** - 業務入口定義為 interface,如 `NotifierUseCase`、`AccountUseCase`,放在 `domain/usecase/`。 - Request / Response struct 與 interface **同 package**,命名 `{Action}Request`、`NotificationDTO`。 - DTO 只含 `json` tag(若需序列化),不含 bson tag。 - 更新類 Request 的可選欄位用指標,以便區分「未傳入」與「傳入零值」。 **範例:** ```go package usecase import ( "context" "gateway/internal/model/notification/domain/enum" ) type NotifierUseCase interface { Send(ctx context.Context, req *SendRequest) (*NotificationDTO, error) Enqueue(ctx context.Context, req *SendRequest) (*NotificationDTO, error) Get(ctx context.Context, tenantID, id string) (*NotificationDTO, error) } type SendRequest struct { TenantID string Channel enum.Channel // ... } ``` ## 6. UseCase 實作(`usecase/`) **規則:** - 實作 struct 如 `notifierUseCase`、`memberUseCase`,放在模組根 `usecase/`。 - 以 `{Name}UseCaseParam` 注入 `domain/repository` 介面、provider、renderer、`config`。 - 建構子 `Must{Name}UseCase(param) domusecase.XxxUseCase`,回傳 **domain** interface。 - 跨模組組裝可用 `New{Name}UseCaseFromParam` / `factory.go`(見 `notification/usecase/factory.go`)。 - 方法簽名與 `domain/usecase` interface 一致;內部組裝 `domain/entity`,呼叫 repository 介面。 - 錯誤一律回傳 `gateway/internal/library/errors` 的 `*errs.Error`(見第 7 節)。 - 可測性:將難 mock 的純函式抽成 package 級變數(如 `var HashPasswordFunc = HashPassword`)。 ### 6.1 UseCase 互不呼叫(atomic-only) > **強制規則**:UseCase 是 **atomic primitive**,**禁止**在 usecase 內部呼叫其他 usecase(不論是同模組或跨模組)。 > > - usecase struct 的依賴**只能**是 `domain/repository` 介面、`provider/`、`template/`、library helper、`config`。 > - **不可**在 `XxxUseCaseParam` 出現另一個 `domain/usecase.XxxUseCase` 欄位。 > - 需要把多個 atomic 串成一個業務流程(例如「OTP.Generate → Notifier.Send → Profile.SetVerified」)時,**編排在 `internal/logic/`**;logic handler 持有多個 usecase interface 並負責順序、補償、rate-limit、step-up 守門。 > - CLI / driver(如 `cmd/notify-test/`)扮演 logic 同等角色:直接組 atomic,不應該被包成 composite usecase。 > > 這條規則優先於 [identity-member-design.md §5.2](./identity-member-design.md) 提到的 Composite UseCase;該節保留為「**邏輯流的描述**」,不代表 `domain/usecase` 會出現 composite interface。 **範例:** ```go import ( domrepo "gateway/internal/model/member/domain/repository" domusecase "gateway/internal/model/member/domain/usecase" ) type MemberUseCaseParam struct { Account domrepo.AccountRepository User domrepo.UserRepository Config config.Config } type memberUseCase struct { MemberUseCaseParam } func MustMemberUseCase(param MemberUseCaseParam) domusecase.AccountUseCase { return &memberUseCase{param} } ``` ## 7. 錯誤處理 全專案對外只使用 `gateway/internal/library/errors`。各模組綁定對應 scope:`code.Auth(28)`、`code.Member(29)`、`code.Notification(30)`;handler 層 parse/validate 使用 `code.Facade(10)`(`response.RequestErrScope`)。`domain/errors.go` **只放 sentinel**,不另建 8 碼常數表。 ### 7.1 模組 sentinel(`domain/errors.go`) ```go package domain import "fmt" var ( ErrNotFound = fmt.Errorf("member: not found") ErrInvalidObjectID = fmt.Errorf("member: invalid object id") ) ``` (專案慣例:sentinel 一律以 `fmt.Errorf` 定義,便於 `%w` 包裝。caller 端 `member "gateway/internal/model/member/domain"` 後即可 `member.ErrNotFound`。) ### 7.2 Repository | 狀況 | 回傳 | |------|------| | `mongo.ErrNoDocuments` | `ErrNotFound`(由 usecase 轉 `ResNotFound`) | | ObjectID 格式錯 | `errb.ResInvalidMeasureID(...)` 或 `ErrInvalidObjectID` | | duplicate key | `errb.DBDuplicate(...)` | | 連線 / 暫時不可用 | `errb.DBUnavailable(...).WithCause(err)` | | 其他 driver 錯 | `errb.DBError(...).WithCause(err)` | 基礎設施錯誤須在 repository 邊界對照完成,並以 `WithCause` 保留原始 err 供 `errlog` 使用。 ### 7.3 UseCase - 業務規則:`errb.ResAlreadyExist`、`errb.ResInvalidState`、`errb.AuthForbidden` 等。 - `errors.Is(err, member.ErrNotFound)` → `errb.ResNotFound("member", id).WithCause(err)`。 - 已是 `*errs.Error` 且語意正確(如 `DBDuplicate`)→ 原樣 `return err`。 ### 7.4 Logic / Handler - Logic:僅 `InputMissingRequired` 等 HTTP 輸入錯誤;usecase 回傳的 err **不二次包裝**。 - Handler:`response.Write`,由 `errs.FromError` 決定 HTTP 狀態與 body。 ## 8. 模組共用檔案 | 檔案 | 用途 | |------|------| | `domain/errors.go` | 模組 sentinel(`ErrNotFound` 等,`package domain`) | | `domain/const.go` | 模組字面常數(`package domain`) | | `domain/redis.go` | Redis key 型別、`With()` 組合、`GetXxxRedisKey()` helper(`package domain`) | | `config/config.go` | UseCase 需要的設定 struct(不含 go-zero RestConf) | Redis key 統一帶業務 prefix,避免跨服務衝突: ```go package domain type RedisKey string const AccountRedisKey RedisKey = "member:account" func (key RedisKey) With(s ...string) RedisKey { /* join with ":" */ } func GetAccountRedisKey(id string) string { return AccountRedisKey.With(id).String() } ``` Caller 端: ```go import ( member "gateway/internal/model/member/domain" ) // 使用:member.GetAccountRedisKey(id)、member.ErrNotFound ``` ## 9. Mock(`mock/` + gomock) **方案 A(本專案採用):** | 測試對象 | 做法 | |----------|------| | `domain/repository` 契約行為(冪等重複、FindByIdempotency) | `repository/*_test.go` + **in-memory 實作**(如 `MemoryNotificationRepository`) | | `usecase` 編排、分支、配額 | **gomock** `domain/repository` 介面,`EXPECT` / `DoAndReturn` | | `logic` 呼叫其他模組 | gomock `domain/usecase` | | Provider Chain | 保留 `provider/*/mock_sender.go`(記錄呼叫、failover) | 介面變更後執行: ```bash make gen-mock ``` `domain/repository/generate.go` 範例: ```go //go:generate go run go.uber.org/mock/mockgen@latest -typed -destination=../../mock/repository/repository_mock.go -package=mocknotifrepo gateway/internal/model/{module}/domain/repository NotificationRepository,IdempotencyCache,QuotaCounter ``` - 產物在 `mock/repository/`、`mock/usecase/`,**不要手改**。 - **勿**在 usecase test 手寫 70 行 fake repo;有狀態的儲存邏輯放在 `repository/memory_*.go` 並由 **repository 層測試** 覆蓋。 ## 10. 命名對照表 | 概念 | 命名 | |------|------| | Entity struct | `Account`、`User` | | Collection 方法 | `CollectionName()` | | Repository 介面 | `AccountRepository` | | Repository 實作 struct | `accountRepository` 或 `AccountRepository` | | Repository 建構子 | `NewAccountRepository` | | Repository 參數 | `AccountRepositoryParam` | | UseCase 介面 | `AccountUseCase` | | UseCase 實作 | `MemberUseCase` | | UseCase 建構子 | `MustMemberUseCase` | | 請求 DTO | `CreateLoginUserRequest` | | 回應 DTO | `GetUIDByAccountResponse` | | Index migration | `Index{YYYYMMDD}{seq}UP` | | 測試檔 | 與被測檔案同目錄,`*_test.go` | ## 11. 新增模組 / Model 檢查清單 1. 建立 `internal/model/{module}/domain/{entity,enum,repository,usecase}/` 與外層 `repository/`、`usecase/`。 2. 在 `domain/entity/` 新增 struct + `CollectionName()`。 3. 在 `domain/enum/` 定義值物件(若有)。 4. 在 `domain/repository/` 宣告 interface;於 `repository/` 實作 CRUD + index + `*_test.go`。 5. 在 `domain/usecase/` 宣告 interface + DTO;於 `usecase/` 實作 + 單元測試(可 fake `domain/repository`)。 6. 模組專用整合放 `provider/`、`template/`(勿放入 `library/`)。 7. `errors.go`、`const.go`、`redis.go`、`config/` 按需補齊。 8. 執行 `make gen-mock`(`go:generate` 在 `domain/repository/generate.go` 等)。 9. 在 `internal/config/config.go` 嵌入模組 `config`;`etc/gateway.yaml` 加區塊。 10. 在 `internal/svc/service_context.go` 建立 **共用** `*redislib.Client`,再注入 `NewXxxUseCaseFromParam`(Mongo / Redis 未配置時對應欄位可為 `nil`)。 11. 在 `generate/api/` 定義路由,`make gen-api`;`internal/logic/` **只** import `domain/usecase`。 12. `make gen-doc`、`go test ./...`。 **Notification 模組進度(參考):** N0–N5 核心 ✅(含 `RetryWorker`、`AdminNotifierUseCase`);文件見 [notification README](../internal/model/notification/README.md)。待做:HTTP admin API(goctl)。 **Member 模組進度(P3.5):** atomic primitives `OTPUseCase`(Generate/Verify/Invalidate)+ `TOTPUseCase`(enroll/verify/backup/disable)+ `VerifyRateStore` + `ProfileRepository` ✅。**usecase 之間不互相呼叫**:「業務 email/phone 驗證 = OTP.Generate → Notifier.Send → Profile.SetXxxVerified」的編排由 **logic 層**負責(尚未實作);參考實作見 `cmd/notify-test/main.go::startMemberVerify`(driver 等同 logic 角色)。後續:HTTP API(goctl)+ logic 層編排(含 rate-limit + step-up 守門)。 ## 12. 與 Gateway HTTP 層的關係 ``` HTTP Request ↓ handler(goctl 生成)→ response.Write ↓ logic(goctl 生成框架,手寫映射) ↓ 轉換 types ↔ usecase DTO usecase 介面(internal/model/{module}/domain/usecase) ↓ usecase 實作(internal/model/{module}/usecase) ↓ repository 實作(internal/model/{module}/repository) ↓ 實作 domain/repository 介面 ↓ MongoDB / Redis ``` - `internal/types`:HTTP 請求 / 回應型別,由 `.api` 生成。 - `internal/model/{module}/domain/usecase` DTO:業務層資料結構,logic 負責與 `types` 映射。 - 錯誤自 usecase 以 `*errs.Error` 往上冒泡;logic 原樣傳遞,handler 經 `response.Write` 輸出 8 碼 JSON。