From 79c12702ecd8827ff991a49d10d2aa3bc2c21274 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Tue, 19 May 2026 20:56:32 +0800 Subject: [PATCH] add validate --- README.md | 333 +++++++++++++++++++ docs/model.md | 370 +++++++++++++++++++++ generate/api/README.md | 1 + generate/goctl/api/handler.tpl | 4 + go.mod | 22 +- go.sum | 26 +- internal/library/validate/custom/README.md | 9 + internal/library/validate/errors.go | 69 ++++ internal/library/validate/errors_test.go | 39 +++ internal/library/validate/option.go | 13 + internal/library/validate/validate.go | 110 ++++++ internal/library/validate/validate_test.go | 112 +++++++ internal/response/README.md | 12 +- internal/response/request.go | 4 + internal/response/request_test.go | 22 ++ internal/response/response_test.go | 2 +- internal/svc/service_context.go | 12 +- internal/types/response.go | 8 - 18 files changed, 1144 insertions(+), 24 deletions(-) create mode 100644 README.md create mode 100644 docs/model.md create mode 100644 internal/library/validate/custom/README.md create mode 100644 internal/library/validate/errors.go create mode 100644 internal/library/validate/errors_test.go create mode 100644 internal/library/validate/option.go create mode 100644 internal/library/validate/validate.go create mode 100644 internal/library/validate/validate_test.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..faadbda --- /dev/null +++ b/README.md @@ -0,0 +1,333 @@ +y94# Portal API Gateway (PGW) + +基於 [go-zero](https://github.com/zeromicro/go-zero) 的 API Gateway,提供統一 HTTP JSON 回應、8 碼業務錯誤碼,以及由 `.api` 定義驅動的程式碼與 OpenAPI 3.0 文件生成。 + +## 功能概覽 + +- **REST API**:go-zero `rest` 服務,預設 `http://0.0.0.0:8888` +- **統一回應格式**:成功 / 失敗皆為 `Status` envelope(`code`、`message`、`data`、`error`) +- **結構化錯誤**:8 碼 `SSCCCDDD`(Scope + Category + Detail),可映射 HTTP / gRPC +- **程式碼生成**:`goctl` + 自訂 handler 模板(自動 `response.Write`) +- **API 文件**:`go-doc` 由同一套 `.api` 產出 OpenAPI 3.0 YAML + +## 環境需求 + +| 工具 | 說明 | +|------|------| +| Go | ≥ 1.26.1(見 `go.mod`) | +| [goctl](https://go-zero.dev/docs/tasks/installation/goctl) | go-zero 程式碼生成 | +| Make | 執行 `gen-api` / `gen-doc`(可選) | + +安裝 goctl(若尚未安裝): + +```bash +go install github.com/zeromicro/go-zero/tools/goctl@latest +``` + +## 快速開始 + +```bash +# 克隆後進入專案目錄 +cd gateway + +# 下載依賴 +go mod download + +# 依 .api 生成 handler / logic / types(若已生成可跳過) +make gen-api + +# 啟動服務 +go run gateway.go -f etc/gateway.yaml +``` + +健康檢查: + +```bash +curl -s http://127.0.0.1:8888/api/v1/health | jq +``` + +預期回應: + +```json +{ + "code": 0, + "message": "SUCCESS", + "data": { "pong": "ok" } +} +``` + +## 常用指令 + +| 指令 | 說明 | +|------|------| +| `make gen-api` | 由 `generate/api/gateway.api` 生成 Go 程式碼(使用 `generate/goctl` 模板) | +| `make gen-doc` | 生成 `docs/openapi/gateway.yaml`(OpenAPI 3.0) | +| `go run gateway.go -f etc/gateway.yaml` | 啟動 Gateway | +| `go test ./...` | 執行測試 | + +## 專案結構 + +``` +gateway/ +├── gateway.go # 程式入口 +├── etc/gateway.yaml # 服務設定(埠號等) +├── generate/ +│ ├── api/ # API 定義(goctl + go-doc 共用) +│ ├── goctl/api/handler.tpl # 自訂 handler 模板 → response.Write +│ └── doc-generate/ # go-doc(.api → OpenAPI) +├── internal/ +│ ├── handler/ # HTTP handler(goctl 生成,response.Write) +│ ├── logic/ # HTTP 編排:types ↔ usecase DTO,只回 data + error +│ ├── types/ # goctl 生成的請求/回應型別 +│ ├── response/ # 統一 JSON 回應封裝 +│ ├── svc/ # ServiceContext:組裝各模組 UseCase +│ ├── config/ # go-zero RestConf 等服務設定 +│ ├── library/errors/ # 全專案唯一結構化錯誤(8 碼 SSCCCDDD) +│ ├── library/validate/ # struct 驗證(go-playground/validator + 翻譯) +│ ├── library/errlog/ # 可選 slog 日誌輔助 +│ └── model/ # 業務模型根目錄(見下方) +│ └── {module}/ # 例如 member/、order/ +└── docs/ + ├── model.md # model/{module} 內 entity / repository / usecase 細節 + └── openapi/ # gen-doc 輸出(預設 .gitignore) +``` + +### 業務模型(`internal/model/{module}/`) + +業務領域程式碼一律放在 **`internal/model/`** 底下,再依模組分子目錄(例如 `internal/model/member/`)。**不使用 `pkg/`**。HTTP 層(handler / logic / types)仍由 goctl 管理;持久化與業務規則在 `model/{module}/` 內。 + +``` +internal/model/ +└── member/ # 模組名稱(member、order…) + ├── entity/ # MongoDB document(Account、User…) + ├── enum/ # 列舉 / 值物件(Platform、Status…) + ├── repository/ # Repository 介面 + 實作(Mongo / cache) + ├── usecase/ # UseCase 介面、Request/Response DTO、實作 + ├── config/ # 模組用設定 struct(不含 go-zero RestConf) + ├── errors.go # 模組 sentinel(如 ErrNotFound),非第二套錯誤碼 + ├── const.go # 模組常數 + ├── redis.go # Redis key 命名與 helper + └── mock/ # mockgen 產物,勿手改 +``` + +**依賴方向:** + +``` +handler → logic → model/{module}/usecase(介面) + ↓ + model/{module}/repository(介面) + ↓ + entity / enum / MongoDB·Redis + +entity、enum 不依賴 repository、usecase、logic +logic 不 import model/{module}/repository 或 model/{module}/entity +``` + +**請求鏈:** + +``` +HTTP Request + → handler(response.Write) + → logic(types ↔ usecase DTO) + → model/{module}/usecase + → model/{module}/repository + → MongoDB / Redis +``` + +更細的 entity / repository / usecase 撰寫規則見 [docs/model.md](docs/model.md)。 + +更細的說明見各子目錄 README: + +- [generate/api/README.md](generate/api/README.md) — `.api` 與 `@respdoc` 約定 +- [internal/response/README.md](internal/response/README.md) — Handler / Logic 分工 +- [internal/library/errors/README.md](internal/library/errors/README.md) — 錯誤碼與 HTTP 對照 +- [docs/model.md](docs/model.md) — `internal/model/{module}` 分層(entity / repository / usecase) + +## 開發約定 + +### 1. 新增 API 流程 + +1. 在 `generate/api/` 新增或修改 `.api`(並在 `gateway.api` import)。 +2. `make gen-api` 生成程式碼(**新** handler 會自動使用 `response.Write`;已存在檔案不會覆寫,需手動刪除後再生成或自行修改)。 +3. 在 `internal/logic/` 做 types 映射並呼叫模組 UseCase,只回傳 **data** 與 **error**。 +4. `make gen-doc` 更新 OpenAPI 文件。 +5. `go test ./...` + +### 2. Logic 與 Handler + +```go +// internal/logic/... — 編排與映射;有持久化時呼叫 svcCtx.{Module}UC +var errb = errs.For(code.Facade) + +func (l *PingLogic) Ping() (*types.PingData, error) { + return &types.PingData{Pong: "ok"}, nil // 簡單 API 可直接回 types +} + +// internal/handler/... — 由模板生成 +data, err := l.Ping() +response.Write(r.Context(), w, data, err) +``` + +有 request 的 API 會自動包含 `httpx.Parse`;解析失敗會映射為 **400** `InputInvalidFormat`。 + +### 3. HTTP JSON 格式 + +**成功(HTTP 200)** + +```json +{ + "code": 0, + "message": "SUCCESS", + "data": { } +} +``` + +**失敗(HTTP 依錯誤類別,如 404)** + +```json +{ + "code": 10301000, + "message": "user not found", + "error": { + "biz_code": "10301000", + "scope": 10, + "category": 301, + "detail": 0 + } +} +``` + +### 4. 錯誤處理(全專案單一型別) + +對外 API 只使用 `gateway/internal/library/errors` 的 `*errs.Error`(8 碼 `SSCCCDDD`)。**不要**在模組內再維護第二套業務錯誤碼套件。 + +各層職責: + +| 層 | 職責 | 回傳 | +|----|------|------| +| **repository** | 忠實反映基礎設施(Mongo / Redis / driver) | `*errs.Error`(DB*、ResInvalidMeasureID 等)+ `WithCause`;可預期「無資料」可回模組 `errors.go` 的 **sentinel** | +| **usecase** | 業務規則(狀態、權限、組合多 repo) | `*errs.Error`(Res*、Auth*、Svc* 等);sentinel 轉成對外語意;已是正確的 `*errs.Error` 可原樣往上傳 | +| **logic** | HTTP 輸入檢查、types 映射 | 僅在進 usecase 前用 `Input*`;其餘 **原樣** `return nil, err`,不二次包裝 | +| **handler** | 序列化 | `response.Write`(內建 `errs.FromError`) | + +模組頂層 sentinel 範例(`internal/model/member/errors.go`,`package member`): + +```go +var ( + ErrNotFound = errors.New("member: not found") + ErrInvalidObjectID = errors.New("member: invalid object id") +) +``` + +Repository 對照建議: + +| 狀況 | 回傳 | +|------|------| +| `mongo.ErrNoDocuments` | `member.ErrNotFound`(由 usecase 轉 `ResNotFound`) | +| ObjectID 格式錯 | `errb.ResInvalidMeasureID("account_id")` | +| duplicate key | `errb.DBDuplicate(...)` | +| 連線 / 暫時不可用 | `errb.DBUnavailable(...).WithCause(err)` | +| 其他 driver 錯 | `errb.DBError(...).WithCause(err)` | + +Usecase 範例: + +```go +var errb = errs.For(code.Facade) + +acc, err := uc.Account.FindOne(ctx, id) +if err != nil { + if errors.Is(err, member.ErrNotFound) { + return nil, errb.ResNotFound("member", id).WithCause(err) + } + if e := errs.FromError(err); e != nil { + return nil, err // DB* 等基礎設施錯誤原樣傳遞 + } + return nil, errb.DBError("get member failed").WithCause(err) +} +``` + +Logic 只做輸入與映射,錯誤直接往上: + +```go +out, err := l.svcCtx.MemberUC.GetByID(l.ctx, &memberusecase.GetByIDRequest{ID: req.Id}) +if err != nil { + return nil, err +} +``` + +**禁止:** repository 回傳裸 `fmt.Errorf` 給上層;logic 再包一層無語意錯誤;logic 直接操作 Mongo / repository 實作。 + +### 5. 請求驗證(`library/validate`) + +- `svc.ServiceContext.Validator`:啟動時以 `validate.NewWithDefaultEN()` 建立,可傳入 `validate.Option` 註冊自訂 tag。 +- **Handler 模板**(`generate/goctl/api/handler.tpl`)在 `httpx.Parse` 之後自動 `ValidateAll(&req)`,失敗經 `WrapRequestError` → **400** `InputInvalidFormat`;Logic 通常不必再驗證。 +- 自訂規則放在 `internal/library/validate/custom/`(依業務新增),透過 `NewWithDefaultEN(custom.YourOption)` 註冊。 +- `validate.ValidationErrors` 含 `field` / `message`;勿與 `internal/library/errors/validate.go`(驗證 8 碼組成)混淆。 + +```go +if err := l.svcCtx.Validator.ValidateAll(req); err != nil { + return nil, err // handler: response.WrapRequestError(err) +} +``` + +### 6. 新增業務模組檢查清單 + +1. 建立 `internal/model/{module}/`,依上方目錄放置 `entity`、`enum`、`repository`、`usecase`。 +2. 在 `internal/svc/service_context.go` 組裝 repository → usecase,掛上介面欄位。 +3. 在 `generate/api/` 定義路由,`make gen-api`。 +4. 在 `internal/logic/` 實作:types ↔ usecase DTO,呼叫 `svcCtx.{Module}UC`。 +5. `make gen-doc`、`go test ./...`;介面變更後執行 mockgen 更新 `internal/model/{module}/mock/`。 + +完整步驟見 [docs/model.md](docs/model.md) 第 11 節。 + +### 7. 錯誤碼 API 速查 + +```go +import ( + errs "gateway/internal/library/errors" + "gateway/internal/library/errors/code" +) + +var errb = errs.For(code.Facade) + +return nil, errb.ResNotFound("user", id) +return nil, errb.InputMissingRequired("email").WithCause(err) +``` + +Category 與 HTTP 對照見 [internal/library/errors/README.md](internal/library/errors/README.md)。 + +### 8. API 文件(`@` 註解) + +在 `.api` 中: + +- `returns (FooVO)`:只描述 **data**(給 goctl / Logic)。 +- `/* @respdoc-200 (FooOKStatus) ... */`:描述實際 HTTP body(給 OpenAPI)。 +- `@doc(summary, description)`:單一接口說明。 + +範例見 `generate/api/normal.api`。 + +### 9. OpenAPI 產物與 Git + +預設 `.gitignore` 會忽略 `docs/openapi/*.yaml`。若需提交給前端,請在 `.gitignore` 註解相關規則後執行 `make gen-doc` 並加入版本控制。 + +## 設定 + +主設定檔:`etc/gateway.yaml` + +```yaml +Name: gateway +Host: 0.0.0.0 +Port: 8888 +``` + +本地覆寫可新增 `etc/gateway-local.yaml`(已列入 `.gitignore`,勿提交機密)。 + +## 測試 + +```bash +go test ./... +``` + +## 授權 + +內部專案,授權依組織規範為準。 diff --git a/docs/model.md b/docs/model.md new file mode 100644 index 0000000..dbec016 --- /dev/null +++ b/docs/model.md @@ -0,0 +1,370 @@ +# 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`、`mgo` 等僅在實作檔出現)。 + +**範例:** + +```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 透過 `mgo.DocumentDBWithCacheUseCase` 操作,搭配模組 `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 *mgo.Conf + CacheConf cache.CacheConf + DBOpts []mon.Option + CacheOpts []cache.Option +} + +type accountRepository struct { + DB mgo.DocumentDBWithCacheUseCase +} + +func NewAccountRepository(param AccountRepositoryParam) AccountRepository { + e := entity.Account{} + documentDB, err := mgo.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。 diff --git a/generate/api/README.md b/generate/api/README.md index c090432..70730b9 100644 --- a/generate/api/README.md +++ b/generate/api/README.md @@ -21,6 +21,7 @@ make gen-doc # 生成 docs/openapi/gateway.yaml(OpenAPI 3.0) - **文件 `@respdoc`**:寫實際 HTTP JSON(如 `PingOKStatus`、`APIErrorStatus`) - **`@doc`**:單一 API 的 summary / description - 多狀態碼用 `/* @respdoc-200 ... */` 區塊,放在 `@handler` 前 +- **Request 驗證**:欄位可加 `validate:"required,email"` 等 tag;`make gen-api` 後 handler 會自動 `ValidateAll`(見 `generate/goctl/api/handler.tpl`) ## 與 runtime 對齊 diff --git a/generate/goctl/api/handler.tpl b/generate/goctl/api/handler.tpl index 24edaf6..6cc7ca1 100644 --- a/generate/goctl/api/handler.tpl +++ b/generate/goctl/api/handler.tpl @@ -19,6 +19,10 @@ func {{.HandlerName}}(svcCtx *svc.ServiceContext) http.HandlerFunc { response.Write(r.Context(), w, nil, response.WrapRequestError(err)) return } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } {{end}}l := {{.LogicName}}.New{{.LogicType}}(r.Context(), svcCtx) {{if .HasResp}}data, {{end}}err := l.{{.Call}}({{if .HasRequest}}&req{{end}}) diff --git a/go.mod b/go.mod index ad30b3b..a4aa0fd 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,22 @@ module gateway go 1.26.1 -require github.com/zeromicro/go-zero v1.10.1 +require ( + github.com/go-playground/locales v0.14.1 + github.com/go-playground/universal-translator v0.18.1 + github.com/go-playground/validator/v10 v10.30.2 + github.com/stretchr/testify v1.11.1 + github.com/zeromicro/go-zero v1.10.1 + google.golang.org/grpc v1.79.3 +) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/color v1.18.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect @@ -17,11 +26,13 @@ require ( github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/openzipkin/zipkin-go v0.4.3 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect @@ -41,12 +52,13 @@ require ( go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7423c18..82a52ec 100644 --- a/go.sum +++ b/go.sum @@ -8,11 +8,21 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -37,6 +47,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -104,14 +116,16 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= diff --git a/internal/library/validate/custom/README.md b/internal/library/validate/custom/README.md new file mode 100644 index 0000000..8b19132 --- /dev/null +++ b/internal/library/validate/custom/README.md @@ -0,0 +1,9 @@ +# 自訂驗證規則 + +在此目錄新增 Gateway 專用的 `validate.Option`,並於 `svc.NewServiceContext` 傳入: + +```go +validate.NewWithDefaultEN(custom.YourOption) +``` + +範例實作見 `validate_test.go` 的 `status` / `is-awesome` 選項。 diff --git a/internal/library/validate/errors.go b/internal/library/validate/errors.go new file mode 100644 index 0000000..cd176cd --- /dev/null +++ b/internal/library/validate/errors.go @@ -0,0 +1,69 @@ +package validate + +import ( + "errors" + "fmt" + "strings" + + ut "github.com/go-playground/universal-translator" + "github.com/go-playground/validator/v10" + + errs "gateway/internal/library/errors" + "gateway/internal/library/errors/code" +) + +// ValidationError holds information about a single validation error. +type ValidationError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +// ValidationErrors is a slice of ValidationError that implements the error interface. +type ValidationErrors []ValidationError + +func (ve ValidationErrors) Error() string { + if len(ve) == 0 { + return "" + } + var sb strings.Builder + sb.WriteString("validation failed:") + for i, e := range ve { + sb.WriteString(fmt.Sprintf(" field='%s', message='%s'", e.Field, e.Message)) + if i < len(ve)-1 { + sb.WriteString(";") + } + } + return sb.String() +} + +// Is supports errors.Is when comparing against ValidationErrors. +func (ValidationErrors) Is(target error) bool { + _, ok := target.(ValidationErrors) + return ok +} + +// AsErrors unwraps err into ValidationErrors when present. +func AsErrors(err error) (ValidationErrors, bool) { + var ve ValidationErrors + if errors.As(err, &ve) { + return ve, true + } + return nil, false +} + +// ToBusinessError maps field validation failures to InputInvalidFormat (HTTP 400). +func (ve ValidationErrors) ToBusinessError(scope code.Scope) *errs.Error { + return errs.For(scope).InputInvalidFormat(ve.Error()) +} + +// translateValidationErrors converts validator.ValidationErrors to our custom ValidationErrors. +func translateValidationErrors(errs validator.ValidationErrors, trans ut.Translator) ValidationErrors { + customErrors := make(ValidationErrors, 0, len(errs)) + for _, err := range errs { + customErrors = append(customErrors, ValidationError{ + Field: err.Field(), // Already transformed by RegisterTagNameFunc + Message: err.Translate(trans), + }) + } + return customErrors +} diff --git a/internal/library/validate/errors_test.go b/internal/library/validate/errors_test.go new file mode 100644 index 0000000..b7b588d --- /dev/null +++ b/internal/library/validate/errors_test.go @@ -0,0 +1,39 @@ +package validate + +import ( + "errors" + "testing" + + errs "gateway/internal/library/errors" + "gateway/internal/library/errors/code" +) + +func TestValidationErrors_AsErrors(t *testing.T) { + t.Parallel() + + ve := ValidationErrors{{Field: "id", Message: "id is required"}} + var target ValidationErrors + if !errors.As(ve, &target) { + t.Fatal("errors.As should match ValidationErrors") + } + got, ok := AsErrors(ve) + if !ok || len(got) != 1 { + t.Fatalf("AsErrors = %v, %v", got, ok) + } +} + +func TestValidationErrors_ToBusinessError(t *testing.T) { + t.Parallel() + + ve := ValidationErrors{{Field: "email", Message: "invalid email"}} + be := ve.ToBusinessError(code.Facade) + if be == nil { + t.Fatal("expected business error") + } + if be.Category() != code.InputInvalidFormat { + t.Fatalf("category = %d, want %d", be.Category(), code.InputInvalidFormat) + } + if errs.FromError(be) == nil { + t.Fatal("FromError should unwrap") + } +} diff --git a/internal/library/validate/option.go b/internal/library/validate/option.go new file mode 100644 index 0000000..30e14a1 --- /dev/null +++ b/internal/library/validate/option.go @@ -0,0 +1,13 @@ +package validate + +import ( + "github.com/go-playground/validator/v10" +) + +// Option contains all the necessary functions for a custom validator. +type Option struct { + ValidatorName string + ValidatorFunc validator.Func + RegisterTranslationFunc validator.RegisterTranslationsFunc + TranslationFunc validator.TranslationFunc +} diff --git a/internal/library/validate/validate.go b/internal/library/validate/validate.go new file mode 100644 index 0000000..4d2d608 --- /dev/null +++ b/internal/library/validate/validate.go @@ -0,0 +1,110 @@ +package validate + +import ( + "errors" + "fmt" + "reflect" + "strings" + + "github.com/go-playground/locales/en" + ut "github.com/go-playground/universal-translator" + "github.com/go-playground/validator/v10" + entranslations "github.com/go-playground/validator/v10/translations/en" +) + +// Validate ... +type Validate interface { + ValidateAll(obj any) error // 驗證整個結構體 + BindToValidator(opts ...Option) error // 綁定客製化驗證規則 + GetValidator() *validator.Validate // 取得原始 validator + GetTranslator() ut.Translator // 新增此方法以獲取翻譯器 +} + +// Validator is the concrete implementation of the Validate interface. +type Validator struct { + V *validator.Validate + Trans ut.Translator +} + +// GetTranslator returns the universal translator instance. +func (v *Validator) GetTranslator() ut.Translator { + return v.Trans +} + +// GetValidator returns the raw *validator.Validate instance. +func (v *Validator) GetValidator() *validator.Validate { + return v.V +} + +// ValidateAll validates a struct. +func (v *Validator) ValidateAll(obj any) error { + err := v.V.Struct(obj) + if err != nil { + var validationErrors validator.ValidationErrors + if errors.As(err, &validationErrors) { + return translateValidationErrors(validationErrors, v.Trans) + } + // This could be an invalid argument error (e.g., not a struct) + return err + } + return nil +} + +// BindToValidator registers custom validation rules and their translations. +func (v *Validator) BindToValidator(opts ...Option) error { + for _, item := range opts { + // Register validation function + if err := v.V.RegisterValidation(item.ValidatorName, item.ValidatorFunc); err != nil { + return fmt.Errorf("failed to register validator '%s': %w", item.ValidatorName, err) + } + + // Register translation message if provided + if item.TranslationFunc != nil && item.RegisterTranslationFunc != nil { + if err := v.V.RegisterTranslation(item.ValidatorName, v.Trans, item.RegisterTranslationFunc, item.TranslationFunc); err != nil { + return fmt.Errorf("failed to register translation for validator '%s': %w", item.ValidatorName, err) + } + } + } + return nil +} + +// NewValidator creates a new validator instance with a given translator. +// This is the most flexible way to create a validator, allowing for any locale. +func NewValidator(trans ut.Translator, opts ...Option) (Validate, error) { + v := validator.New() + + if err := entranslations.RegisterDefaultTranslations(v, trans); err != nil { + return nil, fmt.Errorf("failed to register default translations: %w", err) + } + + v.RegisterTagNameFunc(func(fld reflect.StructField) string { + name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0] + if name == "-" { + return "" + } + return name + }) + + validatorInstance := &Validator{ + V: v, + Trans: trans, + } + + if err := validatorInstance.BindToValidator(opts...); err != nil { + return nil, fmt.Errorf("failed to bind validator options: %w", err) + } + + return validatorInstance, nil +} + +// NewWithDefaultEN creates a new validator instance with English as the default language. +// It's a convenience wrapper around NewValidator. +func NewWithDefaultEN(opts ...Option) (Validate, error) { + enLocale := en.New() + uni := ut.New(enLocale, enLocale) + trans, found := uni.GetTranslator("en") + if !found { + return nil, errors.New("failed to find 'en' translator") + } + return NewValidator(trans, opts...) +} diff --git a/internal/library/validate/validate_test.go b/internal/library/validate/validate_test.go new file mode 100644 index 0000000..8855db6 --- /dev/null +++ b/internal/library/validate/validate_test.go @@ -0,0 +1,112 @@ +package validate + +import ( + "errors" + "fmt" + ut "github.com/go-playground/universal-translator" + "github.com/go-playground/validator/v10" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +// Test struct for validation scenarios +type User struct { + Username string `json:"username" validate:"required,min=4"` + Email string `json:"email" validate:"required,email"` + Bio string `json:"bio" validate:"is-awesome"` +} + +// Custom validation function: must be "awesome" +func IsAwesomeValidator(fl validator.FieldLevel) bool { + return fl.Field().String() == "awesome" +} + +func TestValidateAll_Success(t *testing.T) { + // A custom option for the "is-awesome" tag + awesomeOption := Option{ + ValidatorName: "is-awesome", + ValidatorFunc: IsAwesomeValidator, + } + + v, err := NewWithDefaultEN(awesomeOption) + require.NoError(t, err) + + user := User{ + Username: "tester", + Email: "test@example.com", + Bio: "awesome", + } + + err = v.ValidateAll(user) + assert.NoError(t, err) +} + +func TestValidateAll_DefaultTranslationFailure(t *testing.T) { + v, err := NewWithDefaultEN() + require.NoError(t, err) + + user := struct { + Name string `json:"name" validate:"required"` + Email string `json:"email_address" validate:"email"` + }{ + Name: "", + Email: "not-an-email", + } + + err = v.ValidateAll(user) + require.Error(t, err) + + var validationErrs ValidationErrors + require.True(t, errors.As(err, &validationErrs), "Error should be of type ValidationErrors") + require.Len(t, validationErrs, 2) + + // Check errors (order might vary) + errMap := make(map[string]string) + for _, ve := range validationErrs { + errMap[ve.Field] = ve.Message + } + + assert.Equal(t, "name is a required field", errMap["name"]) + assert.Equal(t, "email_address must be a valid email address", errMap["email_address"]) +} + +func TestValidateAll_CustomValidationWithTranslation(t *testing.T) { + // A custom option for a "status" validator with translation + statusOption := Option{ + ValidatorName: "status", + ValidatorFunc: func(fl validator.FieldLevel) bool { + status := fl.Field().String() + return status == "active" || status == "inactive" + }, + // Function to register the translation + RegisterTranslationFunc: func(ut ut.Translator) error { + return ut.Add("status", "{0} must be 'active' or 'inactive'", true) + }, + // Function that defines how the translation message is generated + TranslationFunc: func(ut ut.Translator, fe validator.FieldError) string { + t, _ := ut.T("status", fe.Field()) + return t + }, + } + + v, err := NewWithDefaultEN(statusOption) + require.NoError(t, err) + + payload := struct { + Status string `json:"user_status" validate:"status"` + }{ + Status: "pending", + } + + err = v.ValidateAll(payload) + require.Error(t, err) + + var validationErrs ValidationErrors + require.True(t, errors.As(err, &validationErrs)) + require.Len(t, validationErrs, 1) + + assert.Equal(t, "user_status", validationErrs[0].Field) + assert.Equal(t, "user_status must be 'active' or 'inactive'", validationErrs[0].Message) + fmt.Println(validationErrs.Error()) // For visual confirmation +} diff --git a/internal/response/README.md b/internal/response/README.md index eeb4e27..4d24f70 100644 --- a/internal/response/README.md +++ b/internal/response/README.md @@ -10,7 +10,7 @@ ## Handler 範例 -有 request 的 API,模板會自動生成 **參數綁定 + 驗證**(`httpx.Parse`): +有 request 的 API,模板會自動生成 **參數綁定 + 驗證**(`httpx.Parse` → `svcCtx.Validator.ValidateAll`): ```go var req types.GetUserReq @@ -18,11 +18,17 @@ if err := httpx.Parse(r, &req); err != nil { response.Write(r.Context(), w, nil, response.WrapRequestError(err)) // → 400 InputInvalidFormat return } +if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) // validate:"..." 失敗 + return +} data, err := l.GetUser(&req) response.Write(r.Context(), w, data, err) ``` -無 request 的 API(如 `GET /health`)不會生成 Parse 區塊,屬正常行為。 +在 `.api` 的 request 欄位加 `validate:"required,email"` 等 tag,`make gen-api` 後會寫入 `internal/types`,無需在 Logic 再手動驗證。 + +無 request 的 API(如 `GET /health`)不會生成 Parse / ValidateAll 區塊,屬正常行為。 在 `main` 可設定驗證錯誤使用的 scope: @@ -32,6 +38,8 @@ response.RequestErrScope = code.Facade `.api` 的 request struct 可加 `validate:` tag 或實作 `Validate() error`,由 go-zero `httpx.Parse` 觸發。 +進階驗證可使用 `svcCtx.Validator.ValidateAll(&req)`(`gateway/internal/library/validate`)。回傳的 `validate.ValidationErrors` 經 `response.WrapRequestError` 會映射為 **400** `InputInvalidFormat`。 + ## Logic 範例 ```go diff --git a/internal/response/request.go b/internal/response/request.go index 545e43d..10a2c52 100644 --- a/internal/response/request.go +++ b/internal/response/request.go @@ -3,6 +3,7 @@ package response import ( errs "gateway/internal/library/errors" "gateway/internal/library/errors/code" + "gateway/internal/library/validate" ) // RequestErrScope is used when mapping httpx.Parse / validation errors to business codes. @@ -18,6 +19,9 @@ func WrapRequestError(err error) error { if errs.FromError(err) != nil { return err } + if ve, ok := validate.AsErrors(err); ok { + return ve.ToBusinessError(RequestErrScope) + } return errs.For(RequestErrScope).InputInvalidFormat(err.Error()) } diff --git a/internal/response/request_test.go b/internal/response/request_test.go index a17bde0..5a837b5 100644 --- a/internal/response/request_test.go +++ b/internal/response/request_test.go @@ -7,6 +7,7 @@ import ( errs "gateway/internal/library/errors" "gateway/internal/library/errors/code" + "gateway/internal/library/validate" "gateway/internal/response" ) @@ -28,6 +29,27 @@ func TestWrapRequestError(t *testing.T) { } } +func TestWrapRequestErrorValidationErrors(t *testing.T) { + t.Parallel() + + response.RequestErrScope = code.Facade + + ve := validate.ValidationErrors{ + {Field: "email", Message: "email must be a valid email address"}, + } + e := response.WrapRequestError(ve) + be := errs.FromError(e) + if be == nil { + t.Fatal("expected business error") + } + if be.Category() != code.InputInvalidFormat { + t.Fatalf("category = %d, want %d", be.Category(), code.InputInvalidFormat) + } + if be.HTTPStatus() != http.StatusBadRequest { + t.Fatalf("http = %d, want 400", be.HTTPStatus()) + } +} + func TestWrapRequestErrorPreservesBusinessError(t *testing.T) { t.Parallel() diff --git a/internal/response/response_test.go b/internal/response/response_test.go index 2ef9d70..bd3c887 100644 --- a/internal/response/response_test.go +++ b/internal/response/response_test.go @@ -29,7 +29,7 @@ func TestWriteSuccess(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatal(err) } - if body.Code != 0 || body.Message != code.DefaultSuccessMessage { + if body.Code != code.DefaultSuccessFullCodeInt || body.Message != code.DefaultSuccessMessage { t.Fatalf("envelope = %+v", body) } if body.Error != nil { diff --git a/internal/svc/service_context.go b/internal/svc/service_context.go index 5aaded5..de64b8a 100644 --- a/internal/svc/service_context.go +++ b/internal/svc/service_context.go @@ -5,14 +5,22 @@ package svc import ( "gateway/internal/config" + "gateway/internal/library/validate" ) type ServiceContext struct { - Config config.Config + Config config.Config + Validator validate.Validate } func NewServiceContext(c config.Config) *ServiceContext { + v, err := validate.NewWithDefaultEN() + if err != nil { + panic(err) + } + return &ServiceContext{ - Config: c, + Config: c, + Validator: v, } } diff --git a/internal/types/response.go b/internal/types/response.go index 9085d2d..3d6ceee 100644 --- a/internal/types/response.go +++ b/internal/types/response.go @@ -8,11 +8,3 @@ type Status struct { Data any `json:"data,omitempty"` Error any `json:"error,omitempty"` } - -// ErrorDetail is the structured payload for Status.Error on failure. -type ErrorDetail struct { - BizCode string `json:"biz_code"` - Scope uint32 `json:"scope,omitempty"` - Category uint32 `json:"category,omitempty"` - Detail uint32 `json:"detail,omitempty"` -}