template-monorepo/docs/model.md

488 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 # 模組 sentinelpackage 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/ # 可選:僅本模組用的 Senderemail/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/)N0N5 核心已完成;流程圖與設定見 [**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 模組進度(參考):** N0N5 核心 ✅(含 `RetryWorker`、`AdminNotifierUseCase`);文件見 [notification README](../internal/model/notification/README.md)。待做HTTP admin APIgoctl
**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 APIgoctl+ logic 層編排(含 rate-limit + step-up 守門)。
## 12. 與 Gateway HTTP 層的關係
```
HTTP Request
handlergoctl 生成)→ response.Write
logicgoctl 生成框架,手寫映射)
↓ 轉換 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。