template-monorepo/docs/model.md

371 lines
12 KiB
Markdown
Raw Normal View History

2026-05-19 12:56:32 +00:00
# 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 # 模組 sentinelErrNotFound 等),非第二套 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`
2026-05-19 13:33:04 +00:00
- 介面檔案不含實作、不含 import 基礎設施 package`mon`、`mongo` 實作層等僅在 repository 實作出現)。
2026-05-19 12:56:32 +00:00
**範例:**
```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`(啟動期錯誤)。
2026-05-19 13:33:04 +00:00
- CRUD 透過 `mongo.DocumentDBWithCacheUseCase``gateway/internal/library/mongo`)操作,搭配模組 `redis.go` 的 key helper。
2026-05-19 12:56:32 +00:00
- `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 {
2026-05-19 13:33:04 +00:00
Conf *mongo.Conf
2026-05-19 12:56:32 +00:00
CacheConf cache.CacheConf
DBOpts []mon.Option
CacheOpts []cache.Option
}
type accountRepository struct {
2026-05-19 13:33:04 +00:00
DB mongo.DocumentDBWithCacheUseCase
2026-05-19 12:56:32 +00:00
}
func NewAccountRepository(param AccountRepositoryParam) AccountRepository {
e := entity.Account{}
2026-05-19 13:33:04 +00:00
documentDB, err := mongo.MustDocumentDBWithCache(
2026-05-19 12:56:32 +00:00
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 tagDTO 不直接映射 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
handlergoctl 生成)→ response.Write
logicgoctl 生成框架,手寫映射)
↓ 轉換 types ↔ usecase DTO
usecaseinternal/model/{module}/usecase
repositoryinternal/model/{module}/repository
MongoDB / Redis
```
- `internal/types`HTTP 請求 / 回應型別,由 `.api` 生成。
- `internal/model/{module}/usecase` DTO業務層資料結構logic 負責兩者映射。
- 錯誤自 usecase 以 `*errs.Error` 往上冒泡logic 原樣傳遞handler 經 `response.Write` 輸出 8 碼 JSON。