add validate

This commit is contained in:
王性驊 2026-05-19 20:56:32 +08:00
parent ea4f45f949
commit 79c12702ec
18 changed files with 1144 additions and 24 deletions

333
README.md Normal file
View File

@ -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 handlergoctl 生成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 documentAccount、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
→ handlerresponse.Write
→ logictypes ↔ 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 ./...
```
## 授權
內部專案,授權依組織規範為準。

370
docs/model.md Normal file
View File

@ -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 # 模組 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`
- 介面檔案不含實作、不含 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 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。

View File

@ -21,6 +21,7 @@ make gen-doc # 生成 docs/openapi/gateway.yamlOpenAPI 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 對齊

View File

@ -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}})

22
go.mod
View File

@ -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
)

26
go.sum
View File

@ -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=

View File

@ -0,0 +1,9 @@
# 自訂驗證規則
在此目錄新增 Gateway 專用的 `validate.Option`,並於 `svc.NewServiceContext` 傳入:
```go
validate.NewWithDefaultEN(custom.YourOption)
```
範例實作見 `validate_test.go``status` / `is-awesome` 選項。

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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
}

View File

@ -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...)
}

View File

@ -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
}

View File

@ -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

View File

@ -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())
}

View File

@ -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()

View File

@ -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 {

View File

@ -5,14 +5,22 @@ package svc
import (
"gateway/internal/config"
"gateway/internal/library/validate"
)
type ServiceContext struct {
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,
Validator: v,
}
}

View File

@ -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"`
}