# 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。
