diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..6352f2e --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,145 @@ +# golangci-lint v2 — Gateway 專案 +# 參考:https://golangci-lint.run/docs/configuration/ +# 預設集「standard」= errcheck + govet + ineffassign + staticcheck + unused(取代已棄用的 golint) +# 再啟用社群常見的品質 / 安全 / 風格 linter,並關閉過度吵雜的規則。 + +version: "2" + +run: + timeout: 5m + tests: true + modules-download-mode: readonly + go: "1.26" + +output: + formats: + text: + print-linter-name: true + colors: true + +linters: + default: standard + + enable: + # 錯誤與 context + - bodyclose + - contextcheck + - copyloopvar + - errname + - errorlint + - noctx + # 安全 + - gosec + # 風格(revive 為 golint 的現代替代) + - revive + - misspell + - unconvert + - whitespace + - gocritic + - goconst + - predeclared + # 測試 + - testifylint + - thelper + # HTTP / API 常見 + - rowserrcheck + - sloglint + + disable: + - exhaustruct + - varnamelen + - wrapcheck + - nlreturn + - wsl + - wsl_v5 + - dupl + - depguard + - tagliatelle + - ireturn + - funlen + - gochecknoglobals + - gochecknoinits + + settings: + gosec: + excludes: + - G104 + gocritic: + enabled-tags: + - diagnostic + - style + - performance + disabled-checks: + - hugeParam + - rangeValCopy + - whyNoLint + revive: + severity: warning + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-naming + - name: exported + arguments: + - disableStutteringCheck + - name: increment-decrement + - name: indent-error-flow + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: var-declaration + - name: var-naming + disabled: true + - name: package-comments + disabled: true + - name: comment-spacings + disabled: true + errcheck: + check-type-assertions: true + check-blank: true + staticcheck: + checks: + - all + - -ST1000 + - -ST1003 + - -ST1016 + + exclusions: + generated: lax + warn-unused: true + presets: + - comments + - common-false-positives + - std-error-handling + - legacy + rules: + - path: _test\.go + linters: + - gosec + - errcheck + - funlen + - gocyclo + - cyclop + - path: internal/types/types\.go + linters: + - revive + - gocritic + paths: + - generate/doc-generate + - docs/openapi + +formatters: + enable: + - goimports + exclusions: + generated: lax + paths: + - generate/doc-generate + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/Makefile b/Makefile index 7667bad..77989ee 100644 --- a/Makefile +++ b/Makefile @@ -1,27 +1,71 @@ # go-zero 生成風格 -GO_ZERO_STYLE=go_zero +GO_ZERO_STYLE := go_zero GO ?= go -GOFMT ?= gofmt "-s" -GOFILES := $(shell find . -name "*.go") -LDFLAGS := -s -w -VERSION="v0.0.1" -DOCKER_REP="reg.wang/app-cloudep-member-service" -GIT_COMMIT ?= $(shell git rev-parse --short HEAD) - +GOFMT ?= gofmt +GOFILES := $(shell find . -name '*.go' -not -path './generate/doc-generate/*') GO_DOC_DIR := generate/doc-generate GO_DOC_BIN := $(GO_DOC_DIR)/bin/go-doc API_ENTRY := ./generate/api/gateway.api DOC_OUT := ./docs/openapi -.PHONY: gen-api build-go-doc gen-doc -gen-api: # 使用專案 handler 模板(response.Write) - goctl api go -api $(API_ENTRY) -dir . -style go_zero -home generate/goctl +GOCTL ?= goctl +GOCTL_PKG := github.com/zeromicro/go-zero/tools/goctl@latest +GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 + +.DEFAULT_GOAL := help + +.PHONY: help tools gen-api build-go-doc gen-doc test fmt lint lint-fix fix check run + +help: ## 顯示可用指令 + @echo "Gateway Makefile" + @echo "" + @echo "首次開發:" + @echo " make tools 安裝 goctl、goimports、golangci-lint(寫入 \$$GOPATH/bin)" + @echo " go mod download" + @echo "" + @echo "常用:" + @grep -E '^[a-zA-Z0-9_-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "}; {printf " make %-14s %s\n", $$1, $$2}' + +tools: ## 安裝 goctl、goimports、golangci-lint(需 Go,且 GOPATH/bin 在 PATH) + @command -v $(GOCTL) >/dev/null 2>&1 || (echo ">> installing goctl" && $(GO) install $(GOCTL_PKG)) + @command -v goimports >/dev/null 2>&1 || (echo ">> installing goimports" && $(GO) install golang.org/x/tools/cmd/goimports@latest) + @command -v golangci-lint >/dev/null 2>&1 || (echo ">> installing golangci-lint" && $(GO) install $(GOLANGCI_PKG)) + @echo "tools OK" + @echo " goctl: $$(goctl --version 2>/dev/null || echo missing)" + @echo " golangci-lint: $$(golangci-lint version 2>/dev/null | head -1 || echo missing)" + +gen-api: tools ## 由 .api 生成 handler / logic / types(自訂 handler 模板) + $(GOCTL) api go -api $(API_ENTRY) -dir . -style $(GO_ZERO_STYLE) -home generate/goctl build-go-doc: ## 編譯 go-doc(OpenAPI 文件生成器) - cd $(GO_DOC_DIR) && GOTOOLCHAIN=go1.26.1 go build -o bin/go-doc ./cmd/go-doc + @echo ">> building $(GO_DOC_BIN)" + @mkdir -p $(GO_DOC_DIR)/bin + @cd $(GO_DOC_DIR) && $(GO) build -o bin/go-doc ./cmd/go-doc gen-doc: build-go-doc ## 從 .api 生成 OpenAPI 3.0 YAML @mkdir -p $(DOC_OUT) $(GO_DOC_BIN) -a $(API_ENTRY) -d $(DOC_OUT) -f gateway -s openapi3.0 -y - @echo "Generated: $(DOC_OUT)/gateway.yaml" \ No newline at end of file + @echo "Generated: $(DOC_OUT)/gateway.yaml" + +test: ## 執行測試 + $(GO) test ./... + +fmt: ## gofmt + goimports(不含 lint) + $(GOFMT) -s -w $(GOFILES) + @command -v goimports >/dev/null 2>&1 && goimports -w . || (echo "goimports not found; run: make tools" && exit 1) + +lint: ## golangci-lint 靜態檢查(先 make tools) + @command -v golangci-lint >/dev/null 2>&1 || (echo "golangci-lint not found; run: make tools" && exit 1) + golangci-lint run ./... + +lint-fix: ## 自動修正可修的 lint / formatter 問題(見 .golangci.yml) + @command -v golangci-lint >/dev/null 2>&1 || (echo "golangci-lint not found; run: make tools" && exit 1) + golangci-lint run --fix ./... + +fix: fmt lint-fix lint ## 格式化 + 自動修 lint + 再檢查(提交前建議) + +check: fix test ## 提交 / PR 前完整檢查(fmt、lint、test) + +run: ## 啟動 Gateway(etc/gateway.yaml) + $(GO) run gateway.go -f etc/gateway.yaml diff --git a/README.md b/README.md index faadbda..0d1c381 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -y94# Portal API Gateway (PGW) +# Portal API Gateway (PGW) 基於 [go-zero](https://github.com/zeromicro/go-zero) 的 API Gateway,提供統一 HTTP JSON 回應、8 碼業務錯誤碼,以及由 `.api` 定義驅動的程式碼與 OpenAPI 3.0 文件生成。 @@ -12,32 +12,40 @@ y94# Portal API Gateway (PGW) ## 環境需求 -| 工具 | 說明 | -|------|------| -| Go | ≥ 1.26.1(見 `go.mod`) | -| [goctl](https://go-zero.dev/docs/tasks/installation/goctl) | go-zero 程式碼生成 | -| Make | 執行 `gen-api` / `gen-doc`(可選) | +| 工具 | 必要 | 說明 | +|------|:----:|------| +| Go | ✓ | ≥ 1.26.1(見 `go.mod`) | +| Make | ✓ | 執行 `gen-api` / `gen-doc` 等 | +| [goctl](https://go-zero.dev/docs/tasks/installation/goctl) | ✓ | `make gen-api` 用;可 `make tools` 安裝 | +| goimports | 建議 | `make fmt` 用;`make tools` 安裝 | +| golangci-lint | 建議 | `make lint` 用;`make tools` 安裝(v2,見 `.golangci.yml`) | +| jq | 選用 | README 範例 `curl \| jq` | -安裝 goctl(若尚未安裝): +請確認 **`$(go env GOPATH)/bin` 在 PATH**,否則 `go install` 的 `goctl` 找不到。 + +## 快速開始(首次) ```bash -go install github.com/zeromicro/go-zero/tools/goctl@latest -``` - -## 快速開始 - -```bash -# 克隆後進入專案目錄 cd gateway -# 下載依賴 +# 1. 工具(goctl、goimports) +make tools + +# 2. 依賴 go mod download -# 依 .api 生成 handler / logic / types(若已生成可跳過) +# 3. 依 .api 生成程式碼(可選,repo 內通常已有) make gen-api -# 啟動服務 -go run gateway.go -f etc/gateway.yaml +# 4. 啟動 +make run +# 或:go run gateway.go -f etc/gateway.yaml +``` + +產生 OpenAPI(會先編譯 `generate/doc-generate` 內的 go-doc): + +```bash +make gen-doc # 輸出 docs/openapi/gateway.yaml ``` 健康檢查: @@ -58,12 +66,20 @@ curl -s http://127.0.0.1:8888/api/v1/health | jq ## 常用指令 +執行 `make` 或 `make help` 可看完整列表。 + | 指令 | 說明 | |------|------| -| `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 ./...` | 執行測試 | +| `make tools` | 首次安裝 goctl、goimports | +| `make gen-api` | 由 `gateway.api` 生成 Go(自訂 `generate/goctl` 模板) | +| `make gen-doc` | 編譯 go-doc 並生成 `docs/openapi/gateway.yaml` | +| `make test` | 執行測試 | +| `make fmt` | gofmt + goimports | +| `make lint` | golangci-lint 靜態檢查(必須 0 issues) | +| `make lint-fix` | 自動修正可修的 lint / import 問題 | +| `make fix` | `fmt` + `lint-fix` + `lint`(提交前建議) | +| `make check` | `fix` + `test`(PR / AI 完成前必跑) | +| `make run` | 啟動 Gateway | ## 專案結構 @@ -257,7 +273,20 @@ if err != nil { **禁止:** repository 回傳裸 `fmt.Errorf` 給上層;logic 再包一層無語意錯誤;logic 直接操作 Mongo / repository 實作。 -### 5. 請求驗證(`library/validate`) +### 5. Lint 與自動修正 + +設定檔:`.golangci.yml`(golangci-lint v2,`standard` + revive / gosec / errorlint 等)。 + +```bash +make lint-fix # 自動修(goimports、部分 linter auto-fix) +make lint # 僅檢查 +make fix # fmt + lint-fix + lint +make check # fix + test(建議每次改 Go 後執行) +``` + +無法自動修的須手改;AI / 協作者應以 `make check` 通過為完成條件(見 `.cursor/rules/golangci-lint.mdc`)。 + +### 6. 請求驗證(`library/validate`) - `svc.ServiceContext.Validator`:啟動時以 `validate.NewWithDefaultEN()` 建立,可傳入 `validate.Option` 註冊自訂 tag。 - **Handler 模板**(`generate/goctl/api/handler.tpl`)在 `httpx.Parse` 之後自動 `ValidateAll(&req)`,失敗經 `WrapRequestError` → **400** `InputInvalidFormat`;Logic 通常不必再驗證。 @@ -270,7 +299,7 @@ if err := l.svcCtx.Validator.ValidateAll(req); err != nil { } ``` -### 6. 新增業務模組檢查清單 +### 7. 新增業務模組檢查清單 1. 建立 `internal/model/{module}/`,依上方目錄放置 `entity`、`enum`、`repository`、`usecase`。 2. 在 `internal/svc/service_context.go` 組裝 repository → usecase,掛上介面欄位。 @@ -280,7 +309,7 @@ if err := l.svcCtx.Validator.ValidateAll(req); err != nil { 完整步驟見 [docs/model.md](docs/model.md) 第 11 節。 -### 7. 錯誤碼 API 速查 +### 8. 錯誤碼 API 速查 ```go import ( @@ -296,7 +325,7 @@ return nil, errb.InputMissingRequired("email").WithCause(err) Category 與 HTTP 對照見 [internal/library/errors/README.md](internal/library/errors/README.md)。 -### 8. API 文件(`@` 註解) +### 9. API 文件(`@` 註解) 在 `.api` 中: @@ -306,7 +335,7 @@ Category 與 HTTP 對照見 [internal/library/errors/README.md](internal/library 範例見 `generate/api/normal.api`。 -### 9. OpenAPI 產物與 Git +### 10. OpenAPI 產物與 Git 預設 `.gitignore` 會忽略 `docs/openapi/*.yaml`。若需提交給前端,請在 `.gitignore` 註解相關規則後執行 `make gen-doc` 並加入版本控制。 diff --git a/generate/doc-generate/internal/swagger/command.go b/generate/doc-generate/internal/swagger/command.go index 170453a..21b7c81 100644 --- a/generate/doc-generate/internal/swagger/command.go +++ b/generate/doc-generate/internal/swagger/command.go @@ -7,9 +7,10 @@ import ( "path/filepath" "strings" + "go-doc/internal/util" + "github.com/spf13/cobra" "github.com/zeromicro/go-zero/tools/goctl/pkg/parser/api/parser" - "go-doc/internal/util" "gopkg.in/yaml.v2" ) diff --git a/generate/doc-generate/internal/swagger/options.go b/generate/doc-generate/internal/swagger/options.go index 0cb13cc..a6648c9 100644 --- a/generate/doc-generate/internal/swagger/options.go +++ b/generate/doc-generate/internal/swagger/options.go @@ -4,8 +4,9 @@ import ( "strconv" "strings" - "github.com/zeromicro/go-zero/tools/goctl/api/spec" "go-doc/internal/util" + + "github.com/zeromicro/go-zero/tools/goctl/api/spec" ) func rangeValueFromOptions(options []string) (minimum *float64, maximum *float64, exclusiveMinimum bool, exclusiveMaximum bool) { diff --git a/generate/doc-generate/internal/swagger/path.go b/generate/doc-generate/internal/swagger/path.go index 506e047..e5b1947 100644 --- a/generate/doc-generate/internal/swagger/path.go +++ b/generate/doc-generate/internal/swagger/path.go @@ -5,9 +5,10 @@ import ( "path" "strings" + "go-doc/internal/util" + "github.com/go-openapi/spec" apiSpec "github.com/zeromicro/go-zero/tools/goctl/api/spec" - "go-doc/internal/util" ) func spec2Paths(ctx Context, srv apiSpec.Service) *spec.Paths { diff --git a/internal/library/errors/convert.go b/internal/library/errors/convert.go index 7e9731b..9dee3d2 100644 --- a/internal/library/errors/convert.go +++ b/internal/library/errors/convert.go @@ -7,6 +7,7 @@ import ( "strconv" "gateway/internal/library/errors/code" + "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) diff --git a/internal/library/errors/errors_test.go b/internal/library/errors/errors_test.go index d3c4a94..9df9217 100644 --- a/internal/library/errors/errors_test.go +++ b/internal/library/errors/errors_test.go @@ -7,6 +7,7 @@ import ( errs "gateway/internal/library/errors" "gateway/internal/library/errors/code" + "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) diff --git a/internal/library/errors/grpc.go b/internal/library/errors/grpc.go index d142e38..3606c7a 100644 --- a/internal/library/errors/grpc.go +++ b/internal/library/errors/grpc.go @@ -4,6 +4,7 @@ import ( "fmt" "gateway/internal/library/errors/code" + "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) diff --git a/internal/library/errors/http_grpc.go b/internal/library/errors/http_grpc.go index f646259..0b6e421 100644 --- a/internal/library/errors/http_grpc.go +++ b/internal/library/errors/http_grpc.go @@ -4,6 +4,7 @@ import ( "net/http" "gateway/internal/library/errors/code" + "google.golang.org/grpc/codes" ) diff --git a/internal/library/validate/errors.go b/internal/library/validate/errors.go index cd176cd..fd482be 100644 --- a/internal/library/validate/errors.go +++ b/internal/library/validate/errors.go @@ -28,7 +28,7 @@ func (ve ValidationErrors) Error() string { 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)) + fmt.Fprintf(&sb, " field='%s', message='%s'", e.Field, e.Message) if i < len(ve)-1 { sb.WriteString(";") } @@ -57,9 +57,9 @@ func (ve ValidationErrors) ToBusinessError(scope code.Scope) *errs.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 { +func translateValidationErrors(valErrs validator.ValidationErrors, trans ut.Translator) ValidationErrors { + customErrors := make(ValidationErrors, 0, len(valErrs)) + for _, err := range valErrs { customErrors = append(customErrors, ValidationError{ Field: err.Field(), // Already transformed by RegisterTagNameFunc Message: err.Translate(trans), diff --git a/internal/library/validate/validate.go b/internal/library/validate/validate.go index 4d2d608..dc149f9 100644 --- a/internal/library/validate/validate.go +++ b/internal/library/validate/validate.go @@ -12,7 +12,7 @@ import ( entranslations "github.com/go-playground/validator/v10/translations/en" ) -// Validate ... +// Validate validates structs using go-playground/validator tags and custom options. type Validate interface { ValidateAll(obj any) error // 驗證整個結構體 BindToValidator(opts ...Option) error // 綁定客製化驗證規則 diff --git a/internal/library/validate/validate_test.go b/internal/library/validate/validate_test.go index 8855db6..70c9cb4 100644 --- a/internal/library/validate/validate_test.go +++ b/internal/library/validate/validate_test.go @@ -1,13 +1,13 @@ package validate import ( - "errors" "fmt" + "testing" + 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 @@ -58,7 +58,7 @@ func TestValidateAll_DefaultTranslationFailure(t *testing.T) { require.Error(t, err) var validationErrs ValidationErrors - require.True(t, errors.As(err, &validationErrs), "Error should be of type ValidationErrors") + require.ErrorAs(t, err, &validationErrs) require.Len(t, validationErrs, 2) // Check errors (order might vary) @@ -103,7 +103,7 @@ func TestValidateAll_CustomValidationWithTranslation(t *testing.T) { require.Error(t, err) var validationErrs ValidationErrors - require.True(t, errors.As(err, &validationErrs)) + require.ErrorAs(t, err, &validationErrs) require.Len(t, validationErrs, 1) assert.Equal(t, "user_status", validationErrs[0].Field) diff --git a/internal/response/request_test.go b/internal/response/request_test.go index 5a837b5..c5d4352 100644 --- a/internal/response/request_test.go +++ b/internal/response/request_test.go @@ -54,7 +54,7 @@ func TestWrapRequestErrorPreservesBusinessError(t *testing.T) { t.Parallel() orig := errs.For(code.Facade).ResNotFound("x") - if response.WrapRequestError(orig) != orig { + if wrapped := response.WrapRequestError(orig); !errors.Is(wrapped, orig) { t.Fatal("business error should not be wrapped") } }