# Model 分層規範 本文件定義 Gateway 專案中 **業務模型**(`internal/model/{module}/`)的目錄結構與撰寫約定,參考 Clean Architecture 分層。 > Gateway 的 HTTP 層(handler / logic / types)仍由 goctl 生成;本規範適用於 **`internal/model/{module}/`**(例如 `internal/model/member/`)——entity、repository、usecase 等。**不使用 `pkg/`。** 專案總覽與錯誤分層見 [README.md](../README.md)。 ## 目錄結構 以下以 `member` 模組為例,路徑前綴為 `internal/model/member/`: ``` internal/model/ └── member/ ├── entity/ # 持久化資料模型(MongoDB document) ├── enum/ # 領域值物件 / 列舉(Platform、Status…) ├── repository/ # Repository 介面 + 實作 ├── usecase/ # UseCase 介面、Request/Response DTO、實作 ├── config/ # 模組用設定 struct ├── errors.go # 模組 sentinel(ErrNotFound 等),非第二套 8 碼 ├── const.go # 模組常數 ├── redis.go # Redis key 命名與 helper └── mock/ # mockgen 產物 ├── repository/ └── usecase/ ``` ### 依賴方向 ``` usecase(實作) → repository(介面) → entity、enum → usecase(介面 + DTO) repository(實作) → repository(介面) → entity entity、enum → 只依賴標準庫或第三方型別 internal/logic → model/{module}/usecase 介面 only(不 import repository、entity) ``` ## 1. Entity(`entity/`) 每個 MongoDB collection 對應一個 struct,放在 `internal/model/{module}/entity/`。 **規則:** - 檔名使用 snake_case 對應 collection 語意,如 `account.go`、`user.go`。 - struct 名稱使用 PascalCase 單數,如 `Account`、`User`。 - 必須實作 `CollectionName() string`,回傳 MongoDB collection 名稱。 - 欄位 tag:`bson` 必填;對外 JSON 序列化才加 `json`。 - 主鍵使用 `primitive.ObjectID`,tag 為 `` `bson:"_id,omitempty" json:"id,omitempty"` ``。 - 時間戳記統一用 `*int64`,欄位名 `CreateAt` / `UpdateAt`,值為 UTC nanoseconds。 - 可選欄位用指標型別(`*string`、`*int64`)。 - 領域列舉引用 `enum/` 下的型別,不在 entity 內重複定義。 **範例:** ```go package entity import ( "gateway/internal/model/member/enum" "go.mongodb.org/mongo-driver/bson/primitive" ) type Account struct { ID primitive.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. 值物件 / 列舉(`enum/`) 業務列舉、狀態碼等放在 `internal/model/{module}/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 介面(`repository/`) **規則:** - 一個 entity 一個 `XxxRepository` interface。 - 方法第一個參數固定為 `context.Context`。 - 參數 / 回傳值使用 `entity` 型別,不暴露 driver 細節(除 index migration 等必要場景)。 - Index migration 以獨立 interface 嵌入,命名 `{Entity}IndexUP`,方法名含版本號,如 `Index20241226001UP`。 - 介面檔案不含實作、不含 import 基礎設施 package(`mon`、`mongo` 實作層等僅在 repository 實作出現)。 **範例:** ```go package repository import ( "context" "gateway/internal/model/member/entity" "go.mongodb.org/mongo-driver/mongo" ) type AccountRepository interface { Insert(ctx context.Context, data *entity.Account) error FindOne(ctx context.Context, id string) (*entity.Account, error) Update(ctx context.Context, data *entity.Account) (*mongo.UpdateResult, error) Delete(ctx context.Context, id string) (int64, error) AccountIndexUP } type AccountIndexUP interface { Index20241226001UP(ctx context.Context) (*mongo.Cursor, error) } ``` ## 4. Repository 實作(`repository/`) **規則:** - struct 名稱 `{Entity}Repository`,建構子 `New{Entity}Repository(param {Entity}RepositoryParam)`。 - Param struct 集中注入 `Conf`、`CacheConf`、`DBOpts`、`CacheOpts`。 - 建構時以 entity 的 `CollectionName()` 初始化 DocumentDB;失敗時 `panic`(啟動期錯誤)。 - CRUD 透過 `mongo.DocumentDBWithCacheUseCase`(`gateway/internal/library/mongo`)操作,搭配模組 `redis.go` 的 key helper。 - `Insert`:ID 為 zero 時自動產生 ObjectID 並寫入 `CreateAt` / `UpdateAt`。 - `Update`:自動更新 `UpdateAt`。 - `FindOne` / `Delete`:無效 ObjectID → `*errs.Error`(`ResInvalidMeasureID`)或模組 `ErrInvalidObjectID`;查無資料 → 模組 `ErrNotFound`(見第 7 節錯誤)。 - 整合測試放在同 package 的 `*_test.go`,可用 testcontainer + miniredis。 **範例:** ```go type AccountRepositoryParam struct { Conf *mongo.Conf CacheConf cache.CacheConf DBOpts []mon.Option CacheOpts []cache.Option } type accountRepository struct { DB mongo.DocumentDBWithCacheUseCase } func NewAccountRepository(param AccountRepositoryParam) AccountRepository { e := entity.Account{} documentDB, err := mongo.MustDocumentDBWithCache( param.Conf, e.CollectionName(), param.CacheConf, param.DBOpts, param.CacheOpts, ) if err != nil { panic(err) } return &accountRepository{DB: documentDB} } ``` ## 5. UseCase 介面與 DTO(`usecase/`) **規則:** - 業務入口定義為 interface,如 `AccountUseCase`;大介面可拆成多個小 interface 再 compose。 - Request / Response struct 放在同一 package,命名 `{Action}Request`、`{Action}Response`。 - DTO 只含 `json` tag 與欄位註解,不含 bson tag(DTO 不直接映射 DB)。 - 更新類 Request 的可選欄位用指標,以便區分「未傳入」與「傳入零值」。 - 共用分頁 struct 放 `common.go`,如 `Pager`。 **範例:** ```go package usecase import "gateway/internal/model/member/enum" type AccountUseCase interface { CreateLoginUser(ctx context.Context, req *CreateLoginUserRequest) (*CreateLoginUserResponse, error) } type CreateLoginUserRequest struct { LoginID string `json:"login_id"` Platform enum.Platform `json:"platform"` Token string `json:"token"` } ``` ## 6. UseCase 實作(`usecase/`) **規則:** - struct 名稱描述業務聚合,如 `MemberUseCase`。 - 以 `{Name}UseCaseParam` 注入所有 repository 與 `config.Config`。 - 建構子命名 `Must{Name}UseCase(param) AccountUseCase`,回傳 interface 型別。 - 實作 struct 嵌入 Param:`type MemberUseCase struct { MemberUseCaseParam }`。 - 方法簽名與 interface 一致;內部組裝 `entity`,呼叫 repository。 - 錯誤一律回傳 `gateway/internal/library/errors` 的 `*errs.Error`(見第 7 節)。 - 可測性:將難 mock 的純函式抽成 package 級變數(如 `var HashPasswordFunc = HashPassword`)。 **範例:** ```go type MemberUseCaseParam struct { Account repository.AccountRepository User repository.UserRepository Config config.Config } type MemberUseCase struct { MemberUseCaseParam } func MustMemberUseCase(param MemberUseCaseParam) AccountUseCase { return &MemberUseCase{param} } ``` ## 7. 錯誤處理 全專案對外只使用 `gateway/internal/library/errors`(`var errb = errs.For(code.Facade)`)。模組根目錄的 `errors.go` **只放 sentinel**,不另建 8 碼常數表。 ### 7.1 模組 sentinel(`errors.go`) ```go package member import "errors" var ( ErrNotFound = errors.New("member: not found") ErrInvalidObjectID = errors.New("member: invalid object id") ) ``` ### 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. 模組共用檔案 | 檔案 | 用途 | |------|------| | `errors.go` | 模組 sentinel(`ErrNotFound` 等) | | `const.go` | 模組字面常數 | | `redis.go` | Redis key 型別、`With()` 組合、`GetXxxRedisKey()` helper | | `config/config.go` | UseCase 需要的設定 struct(不含 go-zero RestConf) | Redis key 統一帶業務 prefix,避免跨服務衝突: ```go 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).ToString() } ``` ## 9. Mock(`mock/`) Repository / UseCase 介面變更後,用 mockgen 重新生成: ```bash mockgen -source=./internal/model/member/repository/account.go \ -destination=./internal/model/member/mock/repository/account.go \ -package=mockrepository ``` - 產物放在 `mock/repository/` 或 `mock/usecase/`,**不要手改**。 - UseCase 單元測試注入 mock,不啟動真實 DB。 ## 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}/` 目錄結構。 2. 在 `entity/` 新增 struct + `CollectionName()`。 3. 若有列舉 / 狀態,在 `enum/` 定義值物件。 4. 在 `repository/` 宣告 interface 並實作 CRUD + index migration + `*_test.go`。 5. 在 `errors.go` 補充 sentinel(若需要);在 `redis.go` 補 cache key(若需要)。 6. 在 `usecase/` 定義 interface、DTO 與實作 + 單元測試。 7. 執行 mockgen 更新 `mock/`。 8. 在 `internal/svc/service_context.go` 組裝 repository → usecase。 9. 在 `generate/api/` 定義路由,`make gen-api`。 10. 在 `internal/logic/` 實作 types 映射,**只**呼叫 UseCase interface。 11. `make gen-doc`、`go test ./...`。 ## 12. 與 Gateway HTTP 層的關係 ``` HTTP Request ↓ handler(goctl 生成)→ response.Write ↓ logic(goctl 生成框架,手寫映射) ↓ 轉換 types ↔ usecase DTO usecase(internal/model/{module}/usecase) ↓ repository(internal/model/{module}/repository) ↓ MongoDB / Redis ``` - `internal/types`:HTTP 請求 / 回應型別,由 `.api` 生成。 - `internal/model/{module}/usecase` DTO:業務層資料結構,logic 負責兩者映射。 - 錯誤自 usecase 以 `*errs.Error` 往上冒泡;logic 原樣傳遞,handler 經 `response.Write` 輸出 8 碼 JSON。