add lib mongo

This commit is contained in:
王性驊 2026-05-19 21:33:04 +08:00
parent fb5ac4b09f
commit 04077a0fcb
14 changed files with 878 additions and 5 deletions

View File

@ -100,6 +100,7 @@ gateway/
│ ├── config/ # go-zero RestConf 等服務設定 │ ├── config/ # go-zero RestConf 等服務設定
│ ├── library/errors/ # 全專案唯一結構化錯誤8 碼 SSCCCDDD │ ├── library/errors/ # 全專案唯一結構化錯誤8 碼 SSCCCDDD
│ ├── library/validate/ # struct 驗證go-playground/validator + 翻譯) │ ├── library/validate/ # struct 驗證go-playground/validator + 翻譯)
│ ├── library/mongo/ # DocumentDB + Redis cachemongo-driver v2
│ ├── library/errlog/ # 可選 slog 日誌輔助 │ ├── library/errlog/ # 可選 slog 日誌輔助
│ └── model/ # 業務模型根目錄(見下方) │ └── model/ # 業務模型根目錄(見下方)
│ └── {module}/ # 例如 member/、order/ │ └── {module}/ # 例如 member/、order/
@ -157,6 +158,7 @@ HTTP Request
- [generate/api/README.md](generate/api/README.md) — `.api``@respdoc` 約定 - [generate/api/README.md](generate/api/README.md) — `.api``@respdoc` 約定
- [internal/response/README.md](internal/response/README.md) — Handler / Logic 分工 - [internal/response/README.md](internal/response/README.md) — Handler / Logic 分工
- [internal/library/errors/README.md](internal/library/errors/README.md) — 錯誤碼與 HTTP 對照 - [internal/library/errors/README.md](internal/library/errors/README.md) — 錯誤碼與 HTTP 對照
- [internal/library/mongo/README.md](internal/library/mongo/README.md) — MongoDB / Redis cache 流程與用法
- [docs/model.md](docs/model.md) — `internal/model/{module}` 分層entity / repository / usecase - [docs/model.md](docs/model.md) — `internal/model/{module}` 分層entity / repository / usecase
## 開發約定 ## 開發約定

View File

@ -119,7 +119,7 @@ func (p Platform) ToString() string { /* map lookup */ }
- 方法第一個參數固定為 `context.Context` - 方法第一個參數固定為 `context.Context`
- 參數 / 回傳值使用 `entity` 型別,不暴露 driver 細節(除 index migration 等必要場景)。 - 參數 / 回傳值使用 `entity` 型別,不暴露 driver 細節(除 index migration 等必要場景)。
- Index migration 以獨立 interface 嵌入,命名 `{Entity}IndexUP`,方法名含版本號,如 `Index20241226001UP` - Index migration 以獨立 interface 嵌入,命名 `{Entity}IndexUP`,方法名含版本號,如 `Index20241226001UP`
- 介面檔案不含實作、不含 import 基礎設施 package`mon`、`mgo` 等僅在實作出現)。 - 介面檔案不含實作、不含 import 基礎設施 package`mon`、`mongo` 實作層等僅在 repository 實作出現)。
**範例:** **範例:**
@ -152,7 +152,7 @@ type AccountIndexUP interface {
- struct 名稱 `{Entity}Repository`,建構子 `New{Entity}Repository(param {Entity}RepositoryParam)` - struct 名稱 `{Entity}Repository`,建構子 `New{Entity}Repository(param {Entity}RepositoryParam)`
- Param struct 集中注入 `Conf`、`CacheConf`、`DBOpts`、`CacheOpts`。 - Param struct 集中注入 `Conf`、`CacheConf`、`DBOpts`、`CacheOpts`。
- 建構時以 entity 的 `CollectionName()` 初始化 DocumentDB失敗時 `panic`(啟動期錯誤)。 - 建構時以 entity 的 `CollectionName()` 初始化 DocumentDB失敗時 `panic`(啟動期錯誤)。
- CRUD 透過 `mgo.DocumentDBWithCacheUseCase` 操作,搭配模組 `redis.go` 的 key helper。 - CRUD 透過 `mongo.DocumentDBWithCacheUseCase``gateway/internal/library/mongo`操作,搭配模組 `redis.go` 的 key helper。
- `Insert`ID 為 zero 時自動產生 ObjectID 並寫入 `CreateAt` / `UpdateAt` - `Insert`ID 為 zero 時自動產生 ObjectID 並寫入 `CreateAt` / `UpdateAt`
- `Update`:自動更新 `UpdateAt` - `Update`:自動更新 `UpdateAt`
- `FindOne` / `Delete`:無效 ObjectID → `*errs.Error``ResInvalidMeasureID`)或模組 `ErrInvalidObjectID`;查無資料 → 模組 `ErrNotFound`(見第 7 節錯誤)。 - `FindOne` / `Delete`:無效 ObjectID → `*errs.Error``ResInvalidMeasureID`)或模組 `ErrInvalidObjectID`;查無資料 → 模組 `ErrNotFound`(見第 7 節錯誤)。
@ -162,19 +162,19 @@ type AccountIndexUP interface {
```go ```go
type AccountRepositoryParam struct { type AccountRepositoryParam struct {
Conf *mgo.Conf Conf *mongo.Conf
CacheConf cache.CacheConf CacheConf cache.CacheConf
DBOpts []mon.Option DBOpts []mon.Option
CacheOpts []cache.Option CacheOpts []cache.Option
} }
type accountRepository struct { type accountRepository struct {
DB mgo.DocumentDBWithCacheUseCase DB mongo.DocumentDBWithCacheUseCase
} }
func NewAccountRepository(param AccountRepositoryParam) AccountRepository { func NewAccountRepository(param AccountRepositoryParam) AccountRepository {
e := entity.Account{} e := entity.Account{}
documentDB, err := mgo.MustDocumentDBWithCache( documentDB, err := mongo.MustDocumentDBWithCache(
param.Conf, e.CollectionName(), param.CacheConf, param.DBOpts, param.CacheOpts, param.Conf, e.CollectionName(), param.CacheConf, param.DBOpts, param.CacheOpts,
) )
if err != nil { if err != nil {

11
go.mod
View File

@ -6,8 +6,10 @@ require (
github.com/go-playground/locales v0.14.1 github.com/go-playground/locales v0.14.1
github.com/go-playground/universal-translator v0.18.1 github.com/go-playground/universal-translator v0.18.1
github.com/go-playground/validator/v10 v10.30.2 github.com/go-playground/validator/v10 v10.30.2
github.com/shopspring/decimal v1.4.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/zeromicro/go-zero v1.10.1 github.com/zeromicro/go-zero v1.10.1
go.mongodb.org/mongo-driver/v2 v2.5.0
google.golang.org/grpc v1.79.3 google.golang.org/grpc v1.79.3
) )
@ -16,6 +18,7 @@ require (
github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fatih/color v1.18.0 // indirect github.com/fatih/color v1.18.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
@ -37,8 +40,13 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/titanous/json5 v1.0.0 // indirect github.com/titanous/json5 v1.0.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
@ -50,10 +58,13 @@ require (
go.opentelemetry.io/otel/sdk v1.40.0 // indirect go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.49.0 // indirect golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.51.0 // indirect golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.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/api v0.0.0-20260128011058-8636f8732409 // indirect

58
go.sum
View File

@ -1,11 +1,19 @@
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 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/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 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
@ -41,6 +49,8 @@ github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslC
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@ -72,10 +82,14 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGGF0= github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGGF0=
github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY= github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
@ -84,8 +98,23 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/titanous/json5 v1.0.0 h1:hJf8Su1d9NuI/ffpxgxQfxh/UiBFZX7bMPid0rIL/7s= github.com/titanous/json5 v1.0.0 h1:hJf8Su1d9NuI/ffpxgxQfxh/UiBFZX7bMPid0rIL/7s=
github.com/titanous/json5 v1.0.0/go.mod h1:7JH1M8/LHKc6cyP5o5g3CSaRj+mBrIimTxzpvmckH8c= github.com/titanous/json5 v1.0.0/go.mod h1:7JH1M8/LHKc6cyP5o5g3CSaRj+mBrIimTxzpvmckH8c=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
github.com/zeromicro/go-zero v1.10.1 h1:1nM3ilvYx97GUqyaNH2IQPtfNyK7tp5JvN63c7m6QKU= github.com/zeromicro/go-zero v1.10.1 h1:1nM3ilvYx97GUqyaNH2IQPtfNyK7tp5JvN63c7m6QKU=
github.com/zeromicro/go-zero v1.10.1/go.mod h1:z41DXmO6gx/Se7Ow5UIwPxcUmpVj3ebhoNCcZ1gfp5k= github.com/zeromicro/go-zero v1.10.1/go.mod h1:z41DXmO6gx/Se7Ow5UIwPxcUmpVj3ebhoNCcZ1gfp5k=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
@ -110,22 +139,51 @@ go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZY
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= 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= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= 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/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= 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/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= 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/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= 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= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=

View File

@ -0,0 +1,331 @@
# MongoDBDocumentDB + Cache
Gateway 的 **MongoDB 存取層**:在 [go-zero `mon`](https://go-zero.dev/docs/tutorials/mongo/mongo-connections) 之上封裝 collection 操作,並整合 **Redis cache-aside**。業務模組(`internal/model/{module}/repository`)應透過本包存取 DB而不是直接 new driver client。
更上層的分層與 API 流程見:
- [專案 README](../../README.md) — HTTP、錯誤碼、驗證、目錄總覽
- [docs/model.md](../../docs/model.md) — entity / repository / usecase 規範
---
## 流程
### 1. 執行期:一筆 API 怎麼走到 Mongo / Redis
```mermaid
sequenceDiagram
participant C as Client
participant H as handler
participant L as logic
participant UC as usecase
participant R as repository
participant M as library/mongo
participant Redis as Redis
participant Mongo as MongoDB
C->>H: HTTP
H->>H: httpx.Parse + ValidateAll
H->>L: types req
L->>UC: usecase DTO
UC->>R: 業務方法
alt 讀取 FindOne
R->>M: FindOne(ctx, cacheKey, doc, filter)
M->>Redis: TakeCtx 查 cache
alt cache hit
Redis-->>M: doc
else cache miss
M->>Mongo: FindOne
Mongo-->>M: doc
M->>Redis: 寫入 cache
end
M-->>R: err / nil
else 寫入 Update/Insert/Delete
R->>M: Update*/Insert*/Delete*
M->>Mongo: 寫入
M->>Redis: DelCache(key) 失效
end
R-->>UC: entity / error
UC-->>L: DTO / *errs.Error
L-->>H: types data / err
H-->>C: Status JSON
```
### 2. 開發期:新功能從零到上線(建議順序)
```mermaid
flowchart TD
A[1. 定義 entity + CollectionName] --> B[2. redis.go 定義 cache key]
B --> C[3. repository interface]
C --> D[4. NewRepository: MustDocumentDBWithCache]
D --> E[5. 實作 CRUD 呼叫本包]
E --> F[6. PopulateIndex / IndexUP]
F --> G[7. usecase interface + DTO]
G --> H[8. usecase 實作 + 錯誤轉 errs]
H --> I[9. svc 組裝注入 UseCase]
I --> J[10. generate/api + make gen-api]
J --> K[11. logic: types 映射]
K --> L[12. make check]
```
| 步驟 | 做什麼 | 在哪裡 |
|:----:|--------|--------|
| 1 | Document struct、`CollectionName()` | `internal/model/{module}/entity/` |
| 2 | `GetXxxRedisKey()`,與 `FindOne` 的 key 一致 | `internal/model/{module}/redis.go` |
| 3 | `XxxRepository` interface | `internal/model/{module}/repository/` |
| 4 | `mongo.Conf` + `cache.CacheConf` + `mon.Option` | `svc` 或 repository `New*` |
| 5 | `FindOne` / `InsertOne` / … | 同 repository 實作檔 |
| 6 | 建立索引(失敗要處理 error | 啟動或 migration |
| 78 | 業務規則、不直接碰 mongo 包 | `usecase/` |
| 9 | `ServiceContext.MemberUC` 等 | `internal/svc/` |
| 1011 | HTTP 契約與編排 | `generate/api/`、`internal/logic/` |
| 12 | fmt + lint + test | `make check` |
### 3. 啟動期:連線與健康檢查
```mermaid
flowchart LR
A[NewDocumentDB / MustDocumentDBWithCache] --> B[buildConnectionURI]
B --> C[mon.NewModel]
C --> D[InitMongoOptions + 可選 SetCustomDecimalType]
D --> E[Ping Primary]
E --> F{成功?}
F -->|是| G[注入 repository]
F -->|否| H[啟動失敗 error]
```
### 4. Cache 讀寫(本包內部)
```mermaid
flowchart TD
subgraph 讀取 FindOne
R1[TakeCtx] --> R2{Redis 有 key?}
R2 -->|有| R3[回傳快取]
R2 -->|無| R4[Mongo FindOne]
R4 --> R5[寫入 Redis]
end
subgraph 寫入 Insert/Update/Delete
W1[Mongo 寫入] --> W2[DelCache keys]
W2 --> W3{Redis 刪除失敗?}
W3 -->|是| W4[只 log仍回成功]
W3 -->|否| W5[完成]
end
```
---
## 為什麼需要這一層?
| 問題 | 本包做法 |
|------|----------|
| 各服務自己拼 Mongo URI、各自 cache | 統一 `Conf` + `buildConnectionURI`(密碼 URL 編碼、TLS/query |
| go-zero 只提供 `mon.Model`,沒有「依 cache key 讀寫」 | `DocumentDBWithCache`:寫入後失效、讀取走 `TakeCtx` + SingleFlight |
| `shopspring/decimal` 與 BSON Decimal128 不一致 | `SetCustomDecimalType()` 註冊 codec |
| 索引建立散落、失敗只 log | `PopulateIndex*` 回傳 `error`,由啟動或 migration 處理 |
| driver v1 / v2 與 go-zero 1.10 不一致 | 全專案對齊 **mongo-driver v2**(與 go-zero 1.10 `mon` 相同) |
---
## 用到的技術與原因
| 元件 | 用途 | 為什麼選它 |
|------|------|------------|
| **[go-zero `mon`](https://github.com/zeromicro/go-zero/tree/master/core/stores/mon)** | Collection CRUD、breaker、慢查詢 log | 與 Gateway 同框架,連線共用、可觀測 |
| **[mongo-driver v2](https://www.mongodb.com/docs/drivers/go/current/)** | 官方 Go driver`mon` 使用) | 現行維護版本go-zero 1.10 已遷移 v2 |
| **[go-zero `cache`](https://github.com/zeromicro/go-zero/tree/master/core/stores/cache)** | Redis cache-aside、`TakeCtx`、SingleFlight | 與 go-zero 生態一致;`ErrNotFound` 不當成 cache 穿透錯誤 |
| **[shopspring/decimal](https://github.com/shopspring/decimal)** | 業務金額精度 | 避免 `float64`;透過自訂 codec 存成 Decimal128 |
| **`gateway/internal/library/errors`** | 對外 API 錯誤(在 repository 轉換) | 本包只提供 `ErrNotFound`HTTP 8 碼在上一層處理 |
---
## 在整體架構中的位置
與上方 **§1 執行期流程** 相同;本包只出現在 **repository** 層。
**禁止:**
- `logic` / `handler` 直接 import 本包或操作 collection
- repository 繞過 `DocumentDBWithCache` 改 DB 卻不處理 cache
- 在業務 struct 上用 `bson.M` 直接吃未驗證的 HTTP JSONNoSQL operator injection
---
## 目錄與職責
| 檔案 | 職責 |
|------|------|
| `config.go` | `Conf`連線、pool、壓縮、逾時 |
| `uri.go` | 組裝 / 遮蔽連線 URI |
| `option.go` | `InitMongoOptions`、`SetCustomDecimalType``mon.Option` |
| `doc-db.go` | `NewDocumentDB`、Ping、建立索引 |
| `doc-db-with-cache.go` | 帶 cache 的 CRUD 封裝 |
| `usecase.go` | `DocumentDBUseCase` / `DocumentDBWithCacheUseCase` 介面 |
| `custom_mongo_decimal.go` | Decimal ↔ Decimal128 codec |
| `const.go` | `ErrNotFound`、`singleFlight`、cache stat |
| `uri_test.go` | URI 單元測試 |
---
## 開發步驟詳解
以下對應 **§2 開發期流程** 各步驟的程式範例。
### 1. 設定與組裝(`internal/svc`
`ServiceContext` 建立各 collection 的 DB 實例(或 repository 建構時建立):
```go
import (
"gateway/internal/library/mongo"
"github.com/zeromicro/go-zero/core/stores/cache"
"github.com/zeromicro/go-zero/core/stores/mon"
)
mongoConf := &mongo.Conf{
Schema: "mongodb",
Host: "127.0.0.1:27017",
Database: "gateway",
User: "app",
Password: "secret",
AuthSource: "admin",
}
dbOpts := []mon.Option{
mongo.InitMongoOptions(*mongoConf),
mongo.SetCustomDecimalType(), // 有 decimal 欄位才需要
}
accountDB, err := mongo.MustDocumentDBWithCache(
mongoConf,
"account", // collection 名稱,與 entity.CollectionName() 一致
cache.CacheConf{ /* Host, Pass, ... */ },
dbOpts,
nil,
)
```
**為什麼在 svc / repository 建構?** 連線與 cache 屬基礎設施,生命週期應與 process 相同;業務 usecase 只依賴 interface。
### 2. Entity 與 Redis key`internal/model/{module}/`
1. 在 `entity/` 定義 document + `CollectionName()`
2. 在 `redis.go` 定義 cache key例如 `GetAccountRedisKey(id)`
3. key 規則要與 **讀取** `FindOne(ctx, key, ...)``key` 參數一致。
### 3. Repository 實作(`repository/`
1. struct 持有 `mongo.DocumentDBWithCacheUseCase`
2. `NewXxxRepository``MustDocumentDBWithCache`(啟動失敗 `panic`)。
3. CRUD 範例:
```go
func (r *accountRepository) FindOne(ctx context.Context, id string) (*entity.Account, error) {
var doc entity.Account
filter := bson.M{"_id": oid}
err := r.DB.FindOne(ctx, member.GetAccountRedisKey(id), &doc, filter)
if err != nil {
if errors.Is(err, mongo.ErrNotFound) {
return nil, member.ErrNotFound
}
return nil, err
}
return &doc, nil
}
```
4. 錯誤在 usecase 轉成 `gateway/internal/library/errors`(見 [errors README](../errors/README.md))。
5. 啟動或 migration 呼叫 `PopulateIndex` / `IndexUP`(回傳 error 要處理)。
詳見 [docs/model.md](../../docs/model.md) 第 4、11 節。
### 4. UseCase 與 Logic
- UseCase 只依賴 `repository` interface不 import 本包。
- Logic 只呼叫 usecase`types` ↔ DTO 映射。
### 5. 新增對外 API可選
1. `generate/api/*.api``make gen-api`
2. logic 實作 → `make check`
---
## 設定說明(`Conf`
| 欄位 | 說明 |
|------|------|
| `Schema` | `mongodb``mongodb+srv`Atlas |
| `Host` | `host:port` 或 srv 的 host |
| `User` / `Password` | 會經 URL 編碼,勿手拼 URI |
| `Database` | 傳入 `mon.NewModel` 的 db 名稱 |
| `AuthSource` | 查詢參數 `authSource` |
| `ReplicaName` | 查詢參數 `replicaSet` |
| `TLS` | 查詢參數 `tls=true` |
| `MaxPoolSize` / `MinPoolSize` / `MaxConnIdleTime` | client pool |
| `Compressors` | 預設 `zstd`、`snappy` |
| `ConnectTimeoutMs` | 啟動 Ping 逾時(預設 10s |
尚未接到 `etc/gateway.yaml` 時,可在 `ServiceContext` 從環境變數或本地 yaml 填入 `Conf`
---
## Cache 語意(必讀)
| 方法 | 行為 |
|------|------|
| `FindOne` | `cache.TakeCtx`miss 時查 Mongo並寫入 cache |
| `InsertOne` / `Update*` / `DeleteOne` | DB 成功後 **刪除** 傳入的 cache key(s) |
| `FindOneAndDelete` / `FindOneAndReplace` | DB 成功後刪除 key |
**寫入後刪 cache 失敗**:只記 error log**不回傳錯誤**(避免 DB 已寫入卻對外報錯);可能短暫讀到舊快取,需接受或改為強一致策略。
**未封裝的操作**`Find`、列表、aggregate 沒有 cache若直接 `GetClient()` 改 DBcache **不會**自動失效。
---
## 介面一覽
- `DocumentDBUseCase`:連線、索引、`GetClient() *mon.Model`(進階或繞過 cache 時慎用)
- `DocumentDBWithCacheUseCase`:業務 repository 應使用此介面
- `CacheUseCase``DelCache` / `GetCache` / `SetCache`(皆帶 `context`
`ErrNotFound``mon.ErrNotFound` / `mongo.ErrNoDocuments` 相同,供 repository 判斷「無資料」。
---
## 測試
| 類型 | 內容 | 何時跑 |
|------|------|--------|
| 單元 | `uri_test.go`URI 編碼、遮蔽) | `make test` / `make check` |
| 建議補 | `custom_mongo_decimal` 往返測試 | 有金額欄位時 |
| 整合 | testcontainers + miniredis測 repository CRUD + cache | 第一個 repository 完成時;可標 `//go:build integration` |
本包 **不強制** testcontainers**業務 repository 的整合測試** 才是上線前主要門禁。
---
## 維運注意
- 啟動時 Ping **Primary**;讀取若要走 secondary 請在 URI / client 設定 read preference。
- 多 collection = 多次 `MustDocumentDBWithCache`(每個 collection 一個實例)。
- 程序關閉:目前未封裝 `Disconnect`;依 go-zero client manager 生命週期,必要時可在 shutdown hook 補強。
- Health API 建議另做 Mongo Ping給 K8s probe與啟動 Ping 分開。
---
## 常用指令
```bash
# 在專案根目錄
make test ./internal/library/mongo/...
make check # 改 Go 後建議全跑
```
---
## 版本與遷移備註
- 2023 舊版曾使用獨立 module `library-go/mongo` + driver **v1**;已併入 `gateway` 並升級 **v2**
- `SetCustomDecimalType` 使用 go-zero 內建 `mon.WithTypeCodec`,勿再維護重複的 registry 註冊邏輯。
- Repository 介面若仍 import `go.mongodb.org/mongo-driver/mongo`v1請改為 **v2** 路徑:`go.mongodb.org/mongo-driver/v2/mongo`。

View File

@ -0,0 +1,20 @@
package mongo
import "time"
// Conf is MongoDB client configuration for DocumentDB helpers.
type Conf struct {
Schema string
User string
Password string
Host string
Database string
AuthSource string
ReplicaName string
TLS bool
MaxPoolSize uint64
MinPoolSize uint64
MaxConnIdleTime time.Duration
Compressors []string
ConnectTimeoutMs int64
}

View File

@ -0,0 +1,15 @@
package mongo
import (
"github.com/zeromicro/go-zero/core/stores/cache"
"github.com/zeromicro/go-zero/core/stores/mon"
"github.com/zeromicro/go-zero/core/syncx"
)
var (
// ErrNotFound matches go-zero mon / mongo driver "no documents".
ErrNotFound = mon.ErrNotFound
singleFlight = syncx.NewSingleFlight()
stats = cache.NewStat("mongoc")
)

View File

@ -0,0 +1,45 @@
package mongo
import (
"fmt"
"reflect"
"github.com/shopspring/decimal"
"go.mongodb.org/mongo-driver/v2/bson"
)
type MgoDecimal struct{}
var (
_ bson.ValueEncoder = &MgoDecimal{}
_ bson.ValueDecoder = &MgoDecimal{}
)
func (dc *MgoDecimal) EncodeValue(_ bson.EncodeContext, vw bson.ValueWriter, value reflect.Value) error {
dec, ok := value.Interface().(decimal.Decimal)
if !ok {
return fmt.Errorf("value %v is not decimal.Decimal", value.Interface())
}
primDec, err := bson.ParseDecimal128(dec.String())
if err != nil {
return fmt.Errorf("decimal %v to Decimal128: %w", dec, err)
}
return vw.WriteDecimal128(primDec)
}
func (dc *MgoDecimal) DecodeValue(_ bson.DecodeContext, vr bson.ValueReader, value reflect.Value) error {
primDec, err := vr.ReadDecimal128()
if err != nil {
return fmt.Errorf("read Decimal128: %w", err)
}
dec, err := decimal.NewFromString(primDec.String())
if err != nil {
return fmt.Errorf("Decimal128 %v to decimal: %w", primDec, err)
}
value.Set(reflect.ValueOf(dec))
return nil
}

View File

@ -0,0 +1,126 @@
package mongo
import (
"context"
"fmt"
"github.com/zeromicro/go-zero/core/logx"
"github.com/zeromicro/go-zero/core/stores/cache"
"github.com/zeromicro/go-zero/core/stores/mon"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
type DocumentDBWithCache struct {
DocumentDBUseCase
Cache cache.Cache
}
func MustDocumentDBWithCache(
conf *Conf,
collection string,
cacheConf cache.CacheConf,
dbOpts []mon.Option,
cacheOpts []cache.Option,
) (DocumentDBWithCacheUseCase, error) {
documentDB, err := NewDocumentDB(conf, collection, dbOpts...)
if err != nil {
return nil, fmt.Errorf("mongo: document db: %w", err)
}
return &DocumentDBWithCache{
DocumentDBUseCase: documentDB,
Cache: MustModelCache(cacheConf, cacheOpts...),
}, nil
}
func (dc *DocumentDBWithCache) DelCache(ctx context.Context, keys ...string) error {
return dc.Cache.DelCtx(ctx, keys...)
}
func (dc *DocumentDBWithCache) GetCache(ctx context.Context, key string, v any) error {
return dc.Cache.GetCtx(ctx, key, v)
}
func (dc *DocumentDBWithCache) SetCache(ctx context.Context, key string, v any) error {
return dc.Cache.SetCtx(ctx, key, v)
}
func (dc *DocumentDBWithCache) DeleteOne(ctx context.Context, key string, filter any, opts ...options.Lister[options.DeleteOneOptions]) (int64, error) {
val, err := dc.GetClient().DeleteOne(ctx, filter, opts...)
if err != nil {
return 0, err
}
dc.delCacheBestEffort(ctx, key)
return val, nil
}
func (dc *DocumentDBWithCache) FindOne(ctx context.Context, key string, v, filter any, opts ...options.Lister[options.FindOneOptions]) error {
return dc.Cache.TakeCtx(ctx, v, key, func(v any) error {
return dc.GetClient().FindOne(ctx, v, filter, opts...)
})
}
func (dc *DocumentDBWithCache) FindOneAndDelete(ctx context.Context, key string, v, filter any, opts ...options.Lister[options.FindOneAndDeleteOptions]) error {
if err := dc.GetClient().FindOneAndDelete(ctx, v, filter, opts...); err != nil {
return err
}
dc.delCacheBestEffort(ctx, key)
return nil
}
func (dc *DocumentDBWithCache) FindOneAndReplace(ctx context.Context, key string, v, filter, replacement any, opts ...options.Lister[options.FindOneAndReplaceOptions]) error {
if err := dc.GetClient().FindOneAndReplace(ctx, v, filter, replacement, opts...); err != nil {
return err
}
dc.delCacheBestEffort(ctx, key)
return nil
}
func (dc *DocumentDBWithCache) InsertOne(ctx context.Context, key string, document any, opts ...options.Lister[options.InsertOneOptions]) (*mongo.InsertOneResult, error) {
res, err := dc.GetClient().InsertOne(ctx, document, opts...)
if err != nil {
return nil, err
}
dc.delCacheBestEffort(ctx, key)
return res, nil
}
func (dc *DocumentDBWithCache) UpdateByID(ctx context.Context, key string, id, update any, opts ...options.Lister[options.UpdateOneOptions]) (*mongo.UpdateResult, error) {
res, err := dc.GetClient().UpdateByID(ctx, id, update, opts...)
if err != nil {
return nil, err
}
dc.delCacheBestEffort(ctx, key)
return res, nil
}
func (dc *DocumentDBWithCache) UpdateMany(ctx context.Context, keys []string, filter, update any, opts ...options.Lister[options.UpdateManyOptions]) (*mongo.UpdateResult, error) {
res, err := dc.GetClient().UpdateMany(ctx, filter, update, opts...)
if err != nil {
return nil, err
}
dc.delCacheBestEffort(ctx, keys...)
return res, nil
}
func (dc *DocumentDBWithCache) UpdateOne(ctx context.Context, key string, filter, update any, opts ...options.Lister[options.UpdateOneOptions]) (*mongo.UpdateResult, error) {
res, err := dc.GetClient().UpdateOne(ctx, filter, update, opts...)
if err != nil {
return nil, err
}
dc.delCacheBestEffort(ctx, key)
return res, nil
}
func (dc *DocumentDBWithCache) delCacheBestEffort(ctx context.Context, keys ...string) {
if len(keys) == 0 {
return
}
if err := dc.DelCache(ctx, keys...); err != nil {
logx.WithContext(ctx).Errorf("[DocumentDBWithCache] del cache keys=%v: %v", keys, err)
}
}
func MustModelCache(conf cache.CacheConf, opts ...cache.Option) cache.Cache {
return cache.New(conf, singleFlight, stats, ErrNotFound, opts...)
}

106
internal/library/mongo/doc-db.go Executable file
View File

@ -0,0 +1,106 @@
package mongo
import (
"context"
"fmt"
"time"
"github.com/zeromicro/go-zero/core/logx"
"github.com/zeromicro/go-zero/core/stores/mon"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
)
type DocumentDB struct {
Mon *mon.Model
}
func NewDocumentDB(config *Conf, collection string, opts ...mon.Option) (DocumentDBUseCase, error) {
if config == nil {
return nil, fmt.Errorf("mongo: config is nil")
}
if collection == "" {
return nil, fmt.Errorf("mongo: collection is required")
}
connectionURI, err := buildConnectionURI(*config)
if err != nil {
return nil, err
}
opts = append(opts, InitMongoOptions(*config))
logx.Infof("[DocumentDB] connecting to %s db=%s coll=%s", redactConnectionURI(connectionURI), config.Database, collection)
client, err := mon.NewModel(connectionURI, config.Database, collection, opts...)
if err != nil {
return nil, fmt.Errorf("mongo: new model: %w", err)
}
timeout := time.Duration(config.ConnectTimeoutMs) * time.Millisecond
if timeout <= 0 {
timeout = 10 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if err := client.Collection.Database().Client().Ping(ctx, readpref.Primary()); err != nil {
return nil, fmt.Errorf("mongo: ping primary: %w", err)
}
logx.Infof("[DocumentDB] connected")
return &DocumentDB{Mon: client}, nil
}
func (document *DocumentDB) PopulateIndex(ctx context.Context, key string, sort int32, unique bool) error {
return document.createIndex(ctx, []string{key}, []int32{sort}, unique, nil)
}
func (document *DocumentDB) PopulateTTLIndex(ctx context.Context, key string, sort int32, unique bool, ttl int32) error {
return document.createIndex(ctx, []string{key}, []int32{sort}, unique, options.Index().SetExpireAfterSeconds(ttl))
}
func (document *DocumentDB) PopulateMultiIndex(ctx context.Context, keys []string, sorts []int32, unique bool) error {
if len(keys) != len(sorts) {
return fmt.Errorf("mongo: keys and sorts length mismatch")
}
return document.createIndex(ctx, keys, sorts, unique, nil)
}
func (document *DocumentDB) createIndex(
ctx context.Context,
keys []string,
sorts []int32,
unique bool,
indexOpt *options.IndexOptionsBuilder,
) error {
c := document.Mon.Collection
index := yieldIndexModel(keys, sorts, unique, indexOpt)
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
_, err := c.Indexes().CreateOne(ctx, index)
if err != nil {
return fmt.Errorf("mongo: create index: %w", err)
}
return nil
}
func (document *DocumentDB) GetClient() *mon.Model {
return document.Mon
}
func yieldIndexModel(keys []string, sorts []int32, unique bool, indexOpt *options.IndexOptionsBuilder) mongo.IndexModel {
setKeysDoc := bson.D{}
for i, key := range keys {
setKeysDoc = append(setKeysDoc, bson.E{Key: key, Value: sorts[i]})
}
if indexOpt == nil {
indexOpt = options.Index()
}
indexOpt.SetUnique(unique)
return mongo.IndexModel{
Keys: setKeysDoc,
Options: indexOpt,
}
}

View File

@ -0,0 +1,34 @@
package mongo
import (
"reflect"
"github.com/shopspring/decimal"
"github.com/zeromicro/go-zero/core/stores/mon"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// SetCustomDecimalType registers decimal.Decimal <-> BSON Decimal128 conversion.
func SetCustomDecimalType() mon.Option {
return mon.WithTypeCodec(mon.TypeCodec{
ValueType: reflect.TypeOf(decimal.Decimal{}),
Encoder: &MgoDecimal{},
Decoder: &MgoDecimal{},
})
}
// InitMongoOptions applies pool / compressor settings from Conf.
func InitMongoOptions(cfg Conf) mon.Option {
return func(opts *options.ClientOptions) {
if cfg.MaxPoolSize > 0 {
opts.SetMaxPoolSize(cfg.MaxPoolSize)
}
if cfg.MinPoolSize > 0 {
opts.SetMinPoolSize(cfg.MinPoolSize)
}
if cfg.MaxConnIdleTime > 0 {
opts.SetMaxConnIdleTime(cfg.MaxConnIdleTime)
}
opts.SetCompressors(defaultCompressors(cfg))
}
}

View File

@ -0,0 +1,59 @@
package mongo
import (
"fmt"
"net/url"
)
func buildConnectionURI(c Conf) (string, error) {
scheme := c.Schema
if scheme == "" {
scheme = "mongodb"
}
if c.Host == "" {
return "", fmt.Errorf("mongo: host is required")
}
u := &url.URL{
Scheme: scheme,
Host: c.Host,
}
if c.User != "" {
u.User = url.UserPassword(c.User, c.Password)
}
q := url.Values{}
if c.AuthSource != "" {
q.Set("authSource", c.AuthSource)
}
if c.ReplicaName != "" {
q.Set("replicaSet", c.ReplicaName)
}
if c.TLS {
q.Set("tls", "true")
}
if len(q) > 0 {
u.RawQuery = q.Encode()
}
return u.String(), nil
}
func redactConnectionURI(uri string) string {
u, err := url.Parse(uri)
if err != nil || u.User == nil {
return uri
}
if _, hasPassword := u.User.Password(); !hasPassword {
return uri
}
u.User = url.UserPassword(u.User.Username(), "*****")
return u.String()
}
func defaultCompressors(c Conf) []string {
if len(c.Compressors) > 0 {
return c.Compressors
}
return []string{"zstd", "snappy"}
}

View File

@ -0,0 +1,31 @@
package mongo
import "testing"
func TestBuildConnectionURI_escapesPassword(t *testing.T) {
t.Parallel()
uri, err := buildConnectionURI(Conf{
Schema: "mongodb",
Host: "127.0.0.1:27017",
User: "user",
Password: "p@ss:word",
AuthSource: "admin",
})
if err != nil {
t.Fatal(err)
}
if uri != "mongodb://user:p%40ss%3Aword@127.0.0.1:27017?authSource=admin" {
t.Fatalf("uri = %q", uri)
}
}
func TestRedactConnectionURI(t *testing.T) {
t.Parallel()
raw := "mongodb://user:secret@127.0.0.1:27017"
got := redactConnectionURI(raw)
if got == raw {
t.Fatalf("expected redacted uri, got %q", got)
}
}

View File

@ -0,0 +1,35 @@
package mongo
import (
"context"
"github.com/zeromicro/go-zero/core/stores/mon"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
type DocumentDBUseCase interface {
PopulateIndex(ctx context.Context, key string, sort int32, unique bool) error
PopulateTTLIndex(ctx context.Context, key string, sort int32, unique bool, ttl int32) error
PopulateMultiIndex(ctx context.Context, keys []string, sorts []int32, unique bool) error
GetClient() *mon.Model
}
type DocumentDBWithCacheUseCase interface {
DocumentDBUseCase
CacheUseCase
DeleteOne(ctx context.Context, key string, filter any, opts ...options.Lister[options.DeleteOneOptions]) (int64, error)
FindOne(ctx context.Context, key string, v, filter any, opts ...options.Lister[options.FindOneOptions]) error
FindOneAndDelete(ctx context.Context, key string, v, filter any, opts ...options.Lister[options.FindOneAndDeleteOptions]) error
FindOneAndReplace(ctx context.Context, key string, v, filter, replacement any, opts ...options.Lister[options.FindOneAndReplaceOptions]) error
InsertOne(ctx context.Context, key string, document any, opts ...options.Lister[options.InsertOneOptions]) (*mongo.InsertOneResult, error)
UpdateByID(ctx context.Context, key string, id, update any, opts ...options.Lister[options.UpdateOneOptions]) (*mongo.UpdateResult, error)
UpdateMany(ctx context.Context, keys []string, filter, update any, opts ...options.Lister[options.UpdateManyOptions]) (*mongo.UpdateResult, error)
UpdateOne(ctx context.Context, key string, filter, update any, opts ...options.Lister[options.UpdateOneOptions]) (*mongo.UpdateResult, error)
}
type CacheUseCase interface {
DelCache(ctx context.Context, keys ...string) error
GetCache(ctx context.Context, key string, v any) error
SetCache(ctx context.Context, key string, v any) error
}