diff --git a/.idea/vcs.xml b/.idea/vcs.xml index d843f34..94a25f7 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -1,4 +1,6 @@ - + + + \ No newline at end of file diff --git a/Makefile b/Makefile index 47781f2..9e10a11 100644 --- a/Makefile +++ b/Makefile @@ -21,6 +21,16 @@ gen-doc: # 生成 Swagger 文檔 # go-doc openapi --api ./generate/api/gateway.api --filename gateway.json --host dev-api.truheart.com.tw --basepath /api/v1 go-doc -a generate/api/gateway.api -d ./ -f gateway -s openapi3.0 +.PHONY: mock-gen +mock-gen: # 建立 mock 資料 + mockgen -source=./pkg/member/domain/repository/account.go -destination=./pkg/member/mock/repository/account.go -package=mock + mockgen -source=./pkg/member/domain/repository/account_uid.go -destination=./pkg/member/mock/repository/account_uid.go -package=mock + mockgen -source=./pkg/member/domain/repository/auto_id.go -destination=./pkg/member/mock/repository/auto_id.go -package=mock + mockgen -source=./pkg/member/domain/repository/user.go -destination=./pkg/member/mock/repository/user.go -package=mock + mockgen -source=./pkg/member/domain/repository/verify_code.go -destination=./pkg/member/mock/repository/verify_code.go -package=mock + mockgen -source=./pkg/member/domain/usecase/generate_uid.go -destination=./pkg/member/mock/usecase/generate_uid.go -package=mock + + @echo "Generate mock files successfully" .PHONY: fmt fmt: # 格式優化 diff --git a/coverage.out b/coverage.out new file mode 100644 index 0000000..177d503 --- /dev/null +++ b/coverage.out @@ -0,0 +1,189 @@ +mode: set +backend/pkg/library/mongo/custom_mongo_decimal.go:18.104,21.9 2 1 +backend/pkg/library/mongo/custom_mongo_decimal.go:21.9,23.3 1 1 +backend/pkg/library/mongo/custom_mongo_decimal.go:26.2,27.16 2 0 +backend/pkg/library/mongo/custom_mongo_decimal.go:27.16,29.3 1 0 +backend/pkg/library/mongo/custom_mongo_decimal.go:31.2,31.35 1 0 +backend/pkg/library/mongo/custom_mongo_decimal.go:34.104,36.16 2 0 +backend/pkg/library/mongo/custom_mongo_decimal.go:36.16,38.3 1 0 +backend/pkg/library/mongo/custom_mongo_decimal.go:41.2,42.16 2 0 +backend/pkg/library/mongo/custom_mongo_decimal.go:42.16,44.3 1 0 +backend/pkg/library/mongo/custom_mongo_decimal.go:47.2,49.12 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:18.171,20.16 2 1 +backend/pkg/library/mongo/doc-db-with-cache.go:20.16,22.3 1 1 +backend/pkg/library/mongo/doc-db-with-cache.go:24.2,29.8 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:32.84,34.2 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:36.66,38.2 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:40.66,42.2 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:45.137,48.27 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:48.27,49.17 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:49.17,51.28 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:51.28,53.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:54.4,54.26 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:54.26,56.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:57.4,57.23 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:57.23,59.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:60.4,60.44 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:64.2,65.16 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:65.16,67.3 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:69.2,69.46 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:69.46,71.3 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:73.2,73.17 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:77.127,78.57 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:78.57,81.28 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:81.28,82.18 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:82.18,84.29 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:84.29,86.6 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:87.5,87.27 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:87.27,89.6 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:90.5,90.24 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:90.24,92.6 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:93.5,93.30 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:93.30,95.6 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:96.5,96.24 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:96.24,98.6 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:99.5,99.24 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:99.24,101.6 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:102.5,102.45 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:106.3,106.63 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:111.145,114.27 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:114.27,115.17 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:115.17,117.28 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:117.28,119.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:120.4,120.26 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:120.26,122.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:123.4,123.23 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:123.23,125.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:126.4,126.29 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:126.29,128.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:129.4,129.23 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:129.23,131.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:132.4,132.44 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:136.2,136.87 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:136.87,138.3 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:140.2,140.30 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:144.160,147.27 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:147.27,148.17 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:148.17,150.28 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:150.28,152.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:153.4,153.26 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:153.26,155.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:156.4,156.23 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:156.23,158.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:159.4,159.29 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:159.29,161.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:162.4,162.33 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:162.33,164.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:165.4,165.23 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:165.23,167.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:168.4,168.25 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:168.25,170.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:171.4,171.44 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:175.2,175.101 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:175.101,177.3 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:179.2,179.30 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:183.156,186.27 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:186.27,187.17 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:187.17,189.43 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:189.43,191.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:192.4,192.26 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:192.26,194.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:195.4,195.44 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:199.2,200.16 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:200.16,202.3 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:204.2,204.45 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:204.45,206.3 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:208.2,208.17 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:212.156,215.27 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:215.27,216.17 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:216.17,218.31 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:218.31,220.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:221.4,221.43 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:221.43,223.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:224.4,224.28 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:224.28,226.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:227.4,227.26 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:227.26,229.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:230.4,230.23 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:230.23,232.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:233.4,233.25 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:233.25,235.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:236.4,236.44 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:240.2,241.16 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:241.16,243.3 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:245.2,245.45 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:245.45,247.3 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:249.2,249.17 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:253.164,256.27 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:256.27,257.17 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:257.17,259.31 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:259.31,261.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:262.4,262.43 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:262.43,264.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:265.4,265.28 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:265.28,267.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:268.4,268.26 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:268.26,270.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:271.4,271.23 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:271.23,273.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:274.4,274.25 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:274.25,276.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:277.4,277.44 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:281.2,282.16 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:282.16,284.3 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:286.2,286.49 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:286.49,288.3 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:290.2,290.17 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:294.159,297.27 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:297.27,298.17 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:298.17,300.31 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:300.31,302.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:303.4,303.43 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:303.43,305.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:306.4,306.28 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:306.28,308.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:309.4,309.26 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:309.26,311.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:312.4,312.23 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:312.23,314.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:315.4,315.25 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:315.25,317.5 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:318.4,318.44 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:322.2,323.16 2 0 +backend/pkg/library/mongo/doc-db-with-cache.go:323.16,325.3 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:327.2,327.45 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:327.45,329.3 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:331.2,331.17 1 0 +backend/pkg/library/mongo/doc-db-with-cache.go:337.77,339.2 1 0 +backend/pkg/library/mongo/doc-db.go:23.100,25.23 2 1 +backend/pkg/library/mongo/doc-db.go:25.23,31.3 1 1 +backend/pkg/library/mongo/doc-db.go:33.2,41.16 3 1 +backend/pkg/library/mongo/doc-db.go:41.16,43.3 1 1 +backend/pkg/library/mongo/doc-db.go:44.2,46.43 3 0 +backend/pkg/library/mongo/doc-db.go:46.43,50.3 3 0 +backend/pkg/library/mongo/doc-db.go:52.2,57.16 5 0 +backend/pkg/library/mongo/doc-db.go:57.16,59.3 1 0 +backend/pkg/library/mongo/doc-db.go:61.2,68.16 4 0 +backend/pkg/library/mongo/doc-db.go:68.16,70.3 1 0 +backend/pkg/library/mongo/doc-db.go:71.2,75.8 2 0 +backend/pkg/library/mongo/doc-db.go:78.101,85.16 5 0 +backend/pkg/library/mongo/doc-db.go:85.16,87.3 1 0 +backend/pkg/library/mongo/doc-db.go:90.115,98.16 5 0 +backend/pkg/library/mongo/doc-db.go:98.16,100.3 1 0 +backend/pkg/library/mongo/doc-db.go:103.112,104.29 1 0 +backend/pkg/library/mongo/doc-db.go:104.29,108.3 2 0 +backend/pkg/library/mongo/doc-db.go:110.2,115.16 5 0 +backend/pkg/library/mongo/doc-db.go:115.16,117.3 1 0 +backend/pkg/library/mongo/doc-db.go:120.52,122.2 1 0 +backend/pkg/library/mongo/doc-db.go:124.144,126.29 2 0 +backend/pkg/library/mongo/doc-db.go:126.29,130.3 3 0 +backend/pkg/library/mongo/doc-db.go:131.2,131.21 1 0 +backend/pkg/library/mongo/doc-db.go:131.21,133.3 1 0 +backend/pkg/library/mongo/doc-db.go:134.2,139.14 3 0 +backend/pkg/library/mongo/option.go:19.56,20.40 1 1 +backend/pkg/library/mongo/option.go:20.40,22.32 2 0 +backend/pkg/library/mongo/option.go:22.32,25.4 2 0 +backend/pkg/library/mongo/option.go:26.3,26.26 1 0 +backend/pkg/library/mongo/option.go:31.40,37.2 1 1 +backend/pkg/library/mongo/option.go:39.44,40.43 1 1 +backend/pkg/library/mongo/option.go:40.43,45.3 4 0 diff --git a/go.mod b/go.mod index b5e3786..36c6245 100644 --- a/go.mod +++ b/go.mod @@ -2,49 +2,112 @@ module backend go 1.25.1 -require github.com/zeromicro/go-zero v1.9.0 +require ( + code.30cm.net/digimon/library-go/errs v1.2.14 + code.30cm.net/digimon/library-go/mongo v0.0.9 + code.30cm.net/digimon/library-go/utils/invited_code v1.2.5 + github.com/alicebob/miniredis/v2 v2.35.0 + github.com/shopspring/decimal v1.4.0 + github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.39.0 + github.com/zeromicro/go-zero v1.9.0 + go.mongodb.org/mongo-driver v1.17.1 + go.mongodb.org/mongo-driver/v2 v2.3.0 + go.uber.org/mock v0.4.0 + golang.org/x/crypto v0.37.0 + google.golang.org/protobuf v1.36.5 +) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.3.3+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.8.4 // indirect github.com/fatih/color v1.18.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grafana/pyroscope-go v1.2.4 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect - github.com/klauspost/compress v1.17.11 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/openzipkin/zipkin-go v0.4.3 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/prometheus/client_golang v1.21.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect + github.com/redis/go-redis/v9 v9.12.1 // indirect + github.com/shirou/gopsutil/v4 v4.25.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect - go.opentelemetry.io/otel v1.24.0 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 // indirect go.opentelemetry.io/otel/exporters/zipkin v1.24.0 // indirect - go.opentelemetry.io/otel/metric v1.24.0 // indirect - go.opentelemetry.io/otel/sdk v1.24.0 // indirect - go.opentelemetry.io/otel/trace v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/sdk v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/automaxprocs v1.6.0 // indirect - golang.org/x/net v0.35.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect - google.golang.org/grpc v1.65.0 // indirect - google.golang.org/protobuf v1.36.5 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.24.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect + google.golang.org/grpc v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b636017..394e493 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,76 @@ +code.30cm.net/digimon/library-go/errs v1.2.14 h1:Un9wcIIjjJW8D2i0ISf8ibzp9oNT4OqLsaSKW0T4RJU= +code.30cm.net/digimon/library-go/errs v1.2.14/go.mod h1:Hs4v7SbXNggDVBGXSYsFMjkii1qLF+rugrIpWePN4/o= +code.30cm.net/digimon/library-go/mongo v0.0.9 h1:fPciIE5B85tXpLg8aeVQqKVbLnfpVAk9xbMu7pE2tVw= +code.30cm.net/digimon/library-go/mongo v0.0.9/go.mod h1:KBVKz/Ci5IheI77BgZxPUeKkaGvDy8fV8EDHSCOLIO4= +code.30cm.net/digimon/library-go/utils/invited_code v1.2.5 h1:szWsI0K+1iEHmc/AtKx+5c7tDIc1AZdStvT0tVza1pg= +code.30cm.net/digimon/library-go/utils/invited_code v1.2.5/go.mod h1:eHmWpbX6N6KXQ2xaY71uj5bwfzTaNL8pQc2njYo5Gj0= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= +github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= 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/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/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 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/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +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-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grafana/pyroscope-go v1.2.4 h1:B22GMXz+O0nWLatxLuaP7o7L9dvP0clLvIpmeEQQM0Q= @@ -28,27 +81,61 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1 github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 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/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/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 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= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg= github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= @@ -59,8 +146,16 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg= +github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +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/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= +github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -68,16 +163,46 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.39.0 h1:uCUJ5tA+fcxbFAB0uP3pIK3EJ2IjjDUHFSZ1H1UxAts= +github.com/testcontainers/testcontainers-go v0.39.0/go.mod h1:qmHpkG7H5uPf/EvOORKvS6EuDkBUPE3zpVGaH9NL7f8= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +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.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +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.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +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/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zeromicro/go-zero v1.9.0 h1:hlVtQCSHPszQdcwZTawzGwTej1G2mhHybYzMRLuwCt4= github.com/zeromicro/go-zero v1.9.0/go.mod h1:TMyCxiaOjLQ3YxyYlJrejaQZF40RlzQ3FVvFu5EbcV4= -go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= -go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.mongodb.org/mongo-driver v1.17.1 h1:Wic5cJIwJgSpBhe3lx3+/RybR5PiYRMpVFgO7cOHyIM= +go.mongodb.org/mongo-driver v1.17.1/go.mod h1:wwWm/+BuOddhcq3n68LKRmgk2wXzmF6s0SFOa0GINL4= +go.mongodb.org/mongo-driver/v2 v2.3.0 h1:sh55yOXA2vUjW1QYw/2tRlHSQViwDyPnW61AwpZ4rtU= +go.mongodb.org/mongo-driver/v2 v2.3.0/go.mod h1:jHeEDJHJq7tm6ZF45Issun9dbogjfnPySb1vXA7EeAI= +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/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4= go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8= @@ -90,32 +215,89 @@ go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 h1:s0PHtIkN+3xrbDO go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0/go.mod h1:hZlFbDbRt++MMPCCfSJfmhkGIWnX1h3XjkfxZUjLrIA= go.opentelemetry.io/otel/exporters/zipkin v1.24.0 h1:3evrL5poBuh1KF51D9gO/S+N/1msnm4DaBqs/rpXUqY= go.opentelemetry.io/otel/exporters/zipkin v1.24.0/go.mod h1:0EHgD8R0+8yRhUYJOGR8Hfg2dpiJQxDOszd5smVO9wM= -go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= -go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= -go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= -go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= -go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= -go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= 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/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +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.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/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.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/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-20210616094352-59db8d763f22/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-20220715151400-c0bba94af5f8/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY= -google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= -google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= -google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +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.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +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.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +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= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8= +google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.67.0 h1:IdH9y6PF5MPSdAntIcpjQ+tXO41pcQsfZV2RxtQgVcw= +google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -128,5 +310,7 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= diff --git a/pkg/library/mongo/config.go b/pkg/library/mongo/config.go new file mode 100644 index 0000000..3cadee9 --- /dev/null +++ b/pkg/library/mongo/config.go @@ -0,0 +1,19 @@ +package mongo + +import "time" + +type Conf struct { + Schema string + User string + Password string + Host string + Database string + ReplicaName string + MaxStaleness time.Duration + MaxPoolSize uint64 + MinPoolSize uint64 + MaxConnIdleTime time.Duration + Compressors []string + EnableStandardReadWriteSplitMode bool + ConnectTimeoutMs int64 +} diff --git a/pkg/library/mongo/config_test.go b/pkg/library/mongo/config_test.go new file mode 100644 index 0000000..940f01e --- /dev/null +++ b/pkg/library/mongo/config_test.go @@ -0,0 +1,113 @@ +package mongo + +import ( + "testing" + "time" +) + +func TestConf_DefaultValues(t *testing.T) { + conf := &Conf{} + + // Test default values + if conf.Schema != "" { + t.Errorf("Expected empty Schema, got %s", conf.Schema) + } + if conf.User != "" { + t.Errorf("Expected empty User, got %s", conf.User) + } + if conf.Password != "" { + t.Errorf("Expected empty Password, got %s", conf.Password) + } + if conf.Host != "" { + t.Errorf("Expected empty Host, got %s", conf.Host) + } + if conf.Database != "" { + t.Errorf("Expected empty Database, got %s", conf.Database) + } + if conf.ReplicaName != "" { + t.Errorf("Expected empty ReplicaName, got %s", conf.ReplicaName) + } + if conf.MaxStaleness != 0 { + t.Errorf("Expected zero MaxStaleness, got %v", conf.MaxStaleness) + } + if conf.MaxPoolSize != 0 { + t.Errorf("Expected zero MaxPoolSize, got %d", conf.MaxPoolSize) + } + if conf.MinPoolSize != 0 { + t.Errorf("Expected zero MinPoolSize, got %d", conf.MinPoolSize) + } + if conf.MaxConnIdleTime != 0 { + t.Errorf("Expected zero MaxConnIdleTime, got %v", conf.MaxConnIdleTime) + } + if conf.Compressors != nil { + t.Errorf("Expected nil Compressors, got %v", conf.Compressors) + } + if conf.EnableStandardReadWriteSplitMode { + t.Errorf("Expected false EnableStandardReadWriteSplitMode, got %v", conf.EnableStandardReadWriteSplitMode) + } + if conf.ConnectTimeoutMs != 0 { + t.Errorf("Expected zero ConnectTimeoutMs, got %d", conf.ConnectTimeoutMs) + } +} + +func TestConf_WithValues(t *testing.T) { + conf := &Conf{ + Schema: "mongodb", + User: "testuser", + Password: "testpass", + Host: "localhost:27017", + Database: "testdb", + ReplicaName: "testreplica", + MaxStaleness: 30 * time.Second, + MaxPoolSize: 100, + MinPoolSize: 10, + MaxConnIdleTime: 5 * time.Minute, + Compressors: []string{"snappy", "zlib"}, + EnableStandardReadWriteSplitMode: true, + ConnectTimeoutMs: 5000, + } + + // Test set values + if conf.Schema != "mongodb" { + t.Errorf("Expected 'mongodb' Schema, got %s", conf.Schema) + } + if conf.User != "testuser" { + t.Errorf("Expected 'testuser' User, got %s", conf.User) + } + if conf.Password != "testpass" { + t.Errorf("Expected 'testpass' Password, got %s", conf.Password) + } + if conf.Host != "localhost:27017" { + t.Errorf("Expected 'localhost:27017' Host, got %s", conf.Host) + } + if conf.Database != "testdb" { + t.Errorf("Expected 'testdb' Database, got %s", conf.Database) + } + if conf.ReplicaName != "testreplica" { + t.Errorf("Expected 'testreplica' ReplicaName, got %s", conf.ReplicaName) + } + if conf.MaxStaleness != 30*time.Second { + t.Errorf("Expected 30s MaxStaleness, got %v", conf.MaxStaleness) + } + if conf.MaxPoolSize != 100 { + t.Errorf("Expected 100 MaxPoolSize, got %d", conf.MaxPoolSize) + } + if conf.MinPoolSize != 10 { + t.Errorf("Expected 10 MinPoolSize, got %d", conf.MinPoolSize) + } + if conf.MaxConnIdleTime != 5*time.Minute { + t.Errorf("Expected 5m MaxConnIdleTime, got %v", conf.MaxConnIdleTime) + } + if len(conf.Compressors) != 2 { + t.Errorf("Expected 2 Compressors, got %d", len(conf.Compressors)) + } + if conf.Compressors[0] != "snappy" || conf.Compressors[1] != "zlib" { + t.Errorf("Expected ['snappy', 'zlib'] Compressors, got %v", conf.Compressors) + } + if !conf.EnableStandardReadWriteSplitMode { + t.Errorf("Expected true EnableStandardReadWriteSplitMode, got %v", conf.EnableStandardReadWriteSplitMode) + } + if conf.ConnectTimeoutMs != 5000 { + t.Errorf("Expected 5000 ConnectTimeoutMs, got %d", conf.ConnectTimeoutMs) + } +} diff --git a/pkg/library/mongo/const.go b/pkg/library/mongo/const.go new file mode 100644 index 0000000..a4ad323 --- /dev/null +++ b/pkg/library/mongo/const.go @@ -0,0 +1,21 @@ +package mongo + +import ( + "github.com/zeromicro/go-zero/core/stores/cache" + "github.com/zeromicro/go-zero/core/syncx" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +const ( + authenticationStringTemplate = "%s:%s@" + connectionStringTemplate = "%s://%s%s" +) + +var ( + // ErrNotFound is an alias of mongo.ErrNoDocuments. + ErrNotFound = mongo.ErrNoDocuments + + // can't use one SingleFlight per conn, because multiple conns may share the same cache key. + singleFlight = syncx.NewSingleFlight() + stats = cache.NewStat("monc") +) diff --git a/pkg/library/mongo/custom_mongo_decimal.go b/pkg/library/mongo/custom_mongo_decimal.go new file mode 100755 index 0000000..d02960c --- /dev/null +++ b/pkg/library/mongo/custom_mongo_decimal.go @@ -0,0 +1,50 @@ +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, w bson.ValueWriter, value reflect.Value) error { + // TODO 待確認是否有非decimal.Decimal type而導致error的場景 + dec, ok := value.Interface().(decimal.Decimal) + if !ok { + return fmt.Errorf("value %v to encode is not of type decimal.Decimal", value) + } + + // Convert decimal.Decimal to bson.Decimal128. + primDec, err := bson.ParseDecimal128(dec.String()) + if err != nil { + return fmt.Errorf("converting decimal.Decimal %v to bson.Decimal128 error: %w", dec, err) + } + + return w.WriteDecimal128(primDec) +} + +func (dc *MgoDecimal) DecodeValue(_ bson.DecodeContext, r bson.ValueReader, value reflect.Value) error { + primDec, err := r.ReadDecimal128() + if err != nil { + return fmt.Errorf("reading bson.Decimal128 from ValueReader error: %w", err) + } + + // Convert bson.Decimal128 to decimal.Decimal. + dec, err := decimal.NewFromString(primDec.String()) + if err != nil { + return fmt.Errorf("converting bson.Decimal128 %v to decimal.Decimal error: %w", primDec, err) + } + + // set as decimal.Decimal type + value.Set(reflect.ValueOf(dec)) + + return nil +} diff --git a/pkg/library/mongo/custom_mongo_decimal_test.go b/pkg/library/mongo/custom_mongo_decimal_test.go new file mode 100644 index 0000000..712f791 --- /dev/null +++ b/pkg/library/mongo/custom_mongo_decimal_test.go @@ -0,0 +1,275 @@ +package mongo + +import ( + "reflect" + "testing" + + "github.com/shopspring/decimal" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestMgoDecimal_InterfaceCompliance(t *testing.T) { + encoder := &MgoDecimal{} + decoder := &MgoDecimal{} + + // Test that they implement the required interfaces + var _ bson.ValueEncoder = encoder + var _ bson.ValueDecoder = decoder + + // Test that they can be used in TypeCodec + codec := TypeCodec{ + ValueType: reflect.TypeOf(decimal.Decimal{}), + Encoder: encoder, + Decoder: decoder, + } + + if codec.Encoder != encoder { + t.Error("Expected encoder to be set correctly") + } + if codec.Decoder != decoder { + t.Error("Expected decoder to be set correctly") + } +} + +func TestMgoDecimal_EncodeValue_InvalidType(t *testing.T) { + encoder := &MgoDecimal{} + + // Test with invalid type + value := reflect.ValueOf("not a decimal") + + err := encoder.EncodeValue(bson.EncodeContext{}, nil, value) + if err == nil { + t.Error("Expected error for invalid type, got nil") + } + + expectedErr := "value not a decimal to encode is not of type decimal.Decimal" + if err.Error() != expectedErr { + t.Errorf("Expected error '%s', got '%s'", expectedErr, err.Error()) + } +} + +// Test decimal conversion functions +func TestDecimalConversion(t *testing.T) { + testCases := []struct { + input string + expected string + }{ + {"0", "0"}, + {"123.45", "123.45"}, + {"-123.45", "-123.45"}, + {"0.000001", "0.000001"}, + {"9999999999999999999.999999999999999", "9999999999999999999.999999999999999"}, + {"-9999999999999999999.999999999999999", "-9999999999999999999.999999999999999"}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + // Test decimal to string conversion + dec, err := decimal.NewFromString(tc.input) + if err != nil { + t.Fatalf("Failed to create decimal from %s: %v", tc.input, err) + } + + if dec.String() != tc.expected { + t.Errorf("Expected %s, got %s", tc.expected, dec.String()) + } + + // Test BSON decimal128 conversion + primDec, err := bson.ParseDecimal128(dec.String()) + if err != nil { + t.Fatalf("Failed to parse decimal128 from %s: %v", dec.String(), err) + } + + if primDec.String() != tc.expected { + t.Errorf("Expected %s, got %s", tc.expected, primDec.String()) + } + }) + } +} + +// Test error cases +func TestDecimalConversionErrors(t *testing.T) { + invalidCases := []string{ + "invalid", + "not a number", + "", + "123.45.67", + "abc123", + } + + for _, invalid := range invalidCases { + t.Run(invalid, func(t *testing.T) { + _, err := decimal.NewFromString(invalid) + if err == nil { + t.Errorf("Expected error for invalid decimal string: %s", invalid) + } + + _, err = bson.ParseDecimal128(invalid) + if err == nil { + t.Errorf("Expected error for invalid decimal128 string: %s", invalid) + } + }) + } +} + +// Test edge cases for decimal values +func TestDecimalEdgeCases(t *testing.T) { + testCases := []struct { + name string + value decimal.Decimal + expected string + }{ + {"zero", decimal.Zero, "0"}, + {"positive small", decimal.NewFromFloat(0.000001), "0.000001"}, + {"negative small", decimal.NewFromFloat(-0.000001), "-0.000001"}, + {"positive large", decimal.NewFromInt(999999999999999), "999999999999999"}, + {"negative large", decimal.NewFromInt(-999999999999999), "-999999999999999"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Test conversion to BSON Decimal128 + primDec, err := bson.ParseDecimal128(tc.value.String()) + if err != nil { + t.Fatalf("Failed to parse decimal128 from %s: %v", tc.value.String(), err) + } + + // Test conversion back to decimal + dec, err := decimal.NewFromString(primDec.String()) + if err != nil { + t.Fatalf("Failed to create decimal from %s: %v", primDec.String(), err) + } + + if !dec.Equal(tc.value) { + t.Errorf("Round trip failed: original=%s, result=%s", tc.value.String(), dec.String()) + } + }) + } +} + +// Test error handling in encoder +func TestMgoDecimal_EncoderErrors(t *testing.T) { + encoder := &MgoDecimal{} + + testCases := []struct { + name string + value interface{} + }{ + {"string", "not a decimal"}, + {"int", 123}, + {"float", 123.45}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + value := reflect.ValueOf(tc.value) + err := encoder.EncodeValue(bson.EncodeContext{}, nil, value) + if err == nil { + t.Errorf("Expected error for type %T, got nil", tc.value) + } + }) + } +} + +// Test decimal precision +func TestDecimalPrecision(t *testing.T) { + testCases := []string{ + "0.1", + "0.01", + "0.001", + "0.0001", + "0.00001", + "0.000001", + "0.0000001", + "0.00000001", + } + + for _, tc := range testCases { + t.Run(tc, func(t *testing.T) { + dec, err := decimal.NewFromString(tc) + if err != nil { + t.Fatalf("Failed to create decimal from %s: %v", tc, err) + } + + // Test conversion to BSON Decimal128 + primDec, err := bson.ParseDecimal128(dec.String()) + if err != nil { + t.Fatalf("Failed to parse decimal128 from %s: %v", dec.String(), err) + } + + // Test conversion back to decimal + result, err := decimal.NewFromString(primDec.String()) + if err != nil { + t.Fatalf("Failed to create decimal from %s: %v", primDec.String(), err) + } + + if !result.Equal(dec) { + t.Errorf("Precision lost: original=%s, result=%s", dec.String(), result.String()) + } + }) + } +} + +// Test large numbers +func TestDecimalLargeNumbers(t *testing.T) { + testCases := []string{ + "1000000000000000", + "10000000000000000", + "100000000000000000", + "1000000000000000000", + } + + for _, tc := range testCases { + t.Run(tc, func(t *testing.T) { + dec, err := decimal.NewFromString(tc) + if err != nil { + t.Fatalf("Failed to create decimal from %s: %v", tc, err) + } + + // Test conversion to BSON Decimal128 + primDec, err := bson.ParseDecimal128(dec.String()) + if err != nil { + t.Fatalf("Failed to parse decimal128 from %s: %v", dec.String(), err) + } + + // Test conversion back to decimal + result, err := decimal.NewFromString(primDec.String()) + if err != nil { + t.Fatalf("Failed to create decimal from %s: %v", primDec.String(), err) + } + + if !result.Equal(dec) { + t.Errorf("Large number lost: original=%s, result=%s", dec.String(), result.String()) + } + }) + } +} + +// Benchmark tests +func BenchmarkMgoDecimal_ParseDecimal128(b *testing.B) { + dec := decimal.NewFromFloat(123.45) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = bson.ParseDecimal128(dec.String()) + } +} + +func BenchmarkMgoDecimal_DecimalFromString(b *testing.B) { + primDec, _ := bson.ParseDecimal128("123.45") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = decimal.NewFromString(primDec.String()) + } +} + +func BenchmarkMgoDecimal_RoundTrip(b *testing.B) { + dec := decimal.NewFromFloat(123.45) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + primDec, _ := bson.ParseDecimal128(dec.String()) + _, _ = decimal.NewFromString(primDec.String()) + } +} \ No newline at end of file diff --git a/pkg/library/mongo/doc-db-with-cache.go b/pkg/library/mongo/doc-db-with-cache.go new file mode 100755 index 0000000..9711d8e --- /dev/null +++ b/pkg/library/mongo/doc-db-with-cache.go @@ -0,0 +1,339 @@ +package mongo + +import ( + "context" + "fmt" + + "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("failed to initialize DocumentDB: %w", err) + } + + c := MustModelCache(cacheConf, cacheOpts...) + + return &DocumentDBWithCache{ + DocumentDBUseCase: documentDB, + Cache: c, + }, nil +} + +func (dc *DocumentDBWithCache) DelCache(ctx context.Context, keys ...string) error { + return dc.Cache.DelCtx(ctx, keys...) +} + +func (dc *DocumentDBWithCache) GetCache(key string, v any) error { + return dc.Cache.Get(key, v) +} + +func (dc *DocumentDBWithCache) SetCache(key string, v any) error { + return dc.Cache.Set(key, v) +} + +// DeleteOne deletes a single document and invalidates cache +func (dc *DocumentDBWithCache) DeleteOne(ctx context.Context, key string, filter any, opts ...*options.DeleteOneOptions) (int64, error) { + // Convert options to Builder format + var listerOpts []options.Lister[options.DeleteOneOptions] + for _, opt := range opts { + if opt != nil { + builder := options.DeleteOne() + if opt.Collation != nil { + builder.SetCollation(opt.Collation) + } + if opt.Comment != nil { + builder.SetComment(opt.Comment) + } + if opt.Hint != nil { + builder.SetHint(opt.Hint) + } + listerOpts = append(listerOpts, builder) + } + } + + val, err := dc.GetClient().DeleteOne(ctx, filter, listerOpts...) + if err != nil { + return 0, err + } + + if err := dc.DelCache(ctx, key); err != nil { + return 0, err + } + + return val, nil +} + +// FindOne finds a single document with cache support +func (dc *DocumentDBWithCache) FindOne(ctx context.Context, key string, v, filter any, opts ...*options.FindOneOptions) error { + return dc.Cache.TakeCtx(ctx, v, key, func(v any) error { + // Convert options to Builder format + var listerOpts []options.Lister[options.FindOneOptions] + for _, opt := range opts { + if opt != nil { + builder := options.FindOne() + if opt.Collation != nil { + builder.SetCollation(opt.Collation) + } + if opt.Comment != nil { + builder.SetComment(opt.Comment) + } + if opt.Hint != nil { + builder.SetHint(opt.Hint) + } + if opt.Projection != nil { + builder.SetProjection(opt.Projection) + } + if opt.Skip != nil { + builder.SetSkip(*opt.Skip) + } + if opt.Sort != nil { + builder.SetSort(opt.Sort) + } + listerOpts = append(listerOpts, builder) + } + } + + return dc.GetClient().FindOne(ctx, v, filter, listerOpts...) + }) +} + +// FindOneAndDelete finds and deletes a single document with cache invalidation +func (dc *DocumentDBWithCache) FindOneAndDelete(ctx context.Context, key string, v, filter any, opts ...*options.FindOneAndDeleteOptions) error { + // Convert options to Builder format + var listerOpts []options.Lister[options.FindOneAndDeleteOptions] + for _, opt := range opts { + if opt != nil { + builder := options.FindOneAndDelete() + if opt.Collation != nil { + builder.SetCollation(opt.Collation) + } + if opt.Comment != nil { + builder.SetComment(opt.Comment) + } + if opt.Hint != nil { + builder.SetHint(opt.Hint) + } + if opt.Projection != nil { + builder.SetProjection(opt.Projection) + } + if opt.Sort != nil { + builder.SetSort(opt.Sort) + } + listerOpts = append(listerOpts, builder) + } + } + + if err := dc.GetClient().FindOneAndDelete(ctx, v, filter, listerOpts...); err != nil { + return err + } + + return dc.DelCache(ctx, key) +} + +// FindOneAndReplace finds and replaces a single document with cache invalidation +func (dc *DocumentDBWithCache) FindOneAndReplace(ctx context.Context, key string, v, filter, replacement any, opts ...*options.FindOneAndReplaceOptions) error { + // Convert options to Builder format + var listerOpts []options.Lister[options.FindOneAndReplaceOptions] + for _, opt := range opts { + if opt != nil { + builder := options.FindOneAndReplace() + if opt.Collation != nil { + builder.SetCollation(opt.Collation) + } + if opt.Comment != nil { + builder.SetComment(opt.Comment) + } + if opt.Hint != nil { + builder.SetHint(opt.Hint) + } + if opt.Projection != nil { + builder.SetProjection(opt.Projection) + } + if opt.ReturnDocument != nil { + builder.SetReturnDocument(*opt.ReturnDocument) + } + if opt.Sort != nil { + builder.SetSort(opt.Sort) + } + if opt.Upsert != nil { + builder.SetUpsert(*opt.Upsert) + } + listerOpts = append(listerOpts, builder) + } + } + + if err := dc.GetClient().FindOneAndReplace(ctx, v, filter, replacement, listerOpts...); err != nil { + return err + } + + return dc.DelCache(ctx, key) +} + +// InsertOne inserts a single document and invalidates cache +func (dc *DocumentDBWithCache) InsertOne(ctx context.Context, key string, document any, opts ...*options.InsertOneOptions) (*mongo.InsertOneResult, error) { + // Convert options to Builder format + var listerOpts []options.Lister[options.InsertOneOptions] + for _, opt := range opts { + if opt != nil { + builder := options.InsertOne() + if opt.BypassDocumentValidation != nil { + builder.SetBypassDocumentValidation(*opt.BypassDocumentValidation) + } + if opt.Comment != nil { + builder.SetComment(opt.Comment) + } + listerOpts = append(listerOpts, builder) + } + } + + res, err := dc.GetClient().Collection.InsertOne(ctx, document, listerOpts...) + if err != nil { + return nil, err + } + + if err = dc.DelCache(ctx, key); err != nil { + return nil, err + } + + return res, nil +} + +// UpdateByID updates a document by ID and invalidates cache +func (dc *DocumentDBWithCache) UpdateByID(ctx context.Context, key string, id, update any, opts ...*options.UpdateOneOptions) (*mongo.UpdateResult, error) { + // Convert options to Builder format + var listerOpts []options.Lister[options.UpdateOneOptions] + for _, opt := range opts { + if opt != nil { + builder := options.UpdateOne() + if opt.ArrayFilters != nil { + builder.SetArrayFilters(opt.ArrayFilters) + } + if opt.BypassDocumentValidation != nil { + builder.SetBypassDocumentValidation(*opt.BypassDocumentValidation) + } + if opt.Collation != nil { + builder.SetCollation(opt.Collation) + } + if opt.Comment != nil { + builder.SetComment(opt.Comment) + } + if opt.Hint != nil { + builder.SetHint(opt.Hint) + } + if opt.Upsert != nil { + builder.SetUpsert(*opt.Upsert) + } + listerOpts = append(listerOpts, builder) + } + } + + res, err := dc.GetClient().Collection.UpdateByID(ctx, id, update, listerOpts...) + if err != nil { + return nil, err + } + + if err = dc.DelCache(ctx, key); err != nil { + return nil, err + } + + return res, nil +} + +// UpdateMany updates multiple documents and invalidates cache +func (dc *DocumentDBWithCache) UpdateMany(ctx context.Context, keys []string, filter, update any, opts ...*options.UpdateManyOptions) (*mongo.UpdateResult, error) { + // Convert options to Builder format + var listerOpts []options.Lister[options.UpdateManyOptions] + for _, opt := range opts { + if opt != nil { + builder := options.UpdateMany() + if opt.ArrayFilters != nil { + builder.SetArrayFilters(opt.ArrayFilters) + } + if opt.BypassDocumentValidation != nil { + builder.SetBypassDocumentValidation(*opt.BypassDocumentValidation) + } + if opt.Collation != nil { + builder.SetCollation(opt.Collation) + } + if opt.Comment != nil { + builder.SetComment(opt.Comment) + } + if opt.Hint != nil { + builder.SetHint(opt.Hint) + } + if opt.Upsert != nil { + builder.SetUpsert(*opt.Upsert) + } + listerOpts = append(listerOpts, builder) + } + } + + res, err := dc.GetClient().Collection.UpdateMany(ctx, filter, update, listerOpts...) + if err != nil { + return nil, err + } + + if err = dc.DelCache(ctx, keys...); err != nil { + return nil, err + } + + return res, nil +} + +// UpdateOne updates a single document and invalidates cache +func (dc *DocumentDBWithCache) UpdateOne(ctx context.Context, key string, filter, update any, opts ...*options.UpdateOneOptions) (*mongo.UpdateResult, error) { + // Convert options to Builder format + var listerOpts []options.Lister[options.UpdateOneOptions] + for _, opt := range opts { + if opt != nil { + builder := options.UpdateOne() + if opt.ArrayFilters != nil { + builder.SetArrayFilters(opt.ArrayFilters) + } + if opt.BypassDocumentValidation != nil { + builder.SetBypassDocumentValidation(*opt.BypassDocumentValidation) + } + if opt.Collation != nil { + builder.SetCollation(opt.Collation) + } + if opt.Comment != nil { + builder.SetComment(opt.Comment) + } + if opt.Hint != nil { + builder.SetHint(opt.Hint) + } + if opt.Upsert != nil { + builder.SetUpsert(*opt.Upsert) + } + listerOpts = append(listerOpts, builder) + } + } + + res, err := dc.GetClient().Collection.UpdateOne(ctx, filter, update, listerOpts...) + if err != nil { + return nil, err + } + + if err = dc.DelCache(ctx, key); err != nil { + return nil, err + } + + return res, nil +} + +// ======================== + +// MustModelCache returns a cache cluster. +func MustModelCache(conf cache.CacheConf, opts ...cache.Option) cache.Cache { + return cache.New(conf, singleFlight, stats, mongo.ErrNoDocuments, opts...) +} \ No newline at end of file diff --git a/pkg/library/mongo/doc-db-with-cache_test.go b/pkg/library/mongo/doc-db-with-cache_test.go new file mode 100644 index 0000000..448eeff --- /dev/null +++ b/pkg/library/mongo/doc-db-with-cache_test.go @@ -0,0 +1,364 @@ +package mongo + +import ( + "context" + "testing" + "time" + + "github.com/shopspring/decimal" + "github.com/zeromicro/go-zero/core/stores/cache" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestDocumentDBWithCache_MustDocumentDBWithCache(t *testing.T) { + // Test with valid config + conf := &Conf{ + Host: "localhost:27017", + Database: "testdb", + } + + collection := "testcollection" + cacheConf := cache.CacheConf{} + + // This will panic if MongoDB is not available, so we need to handle it + defer func() { + if r := recover(); r != nil { + t.Logf("Expected panic in test environment: %v", r) + } + }() + + db, err := MustDocumentDBWithCache(conf, collection, cacheConf, nil, nil) + if err != nil { + t.Logf("MongoDB connection failed (expected in test environment): %v", err) + return + } + + if db == nil { + t.Error("Expected DocumentDBWithCache to be non-nil") + } +} + +func TestDocumentDBWithCache_CacheOperations(t *testing.T) { + conf := &Conf{ + Host: "localhost:27017", + Database: "testdb", + } + + collection := "testcollection" + cacheConf := cache.CacheConf{} + + ctx := context.Background() + db, err := MustDocumentDBWithCache(conf, collection, cacheConf, nil, nil) + + if err != nil { + t.Skip("Skipping test - MongoDB not available") + return + } + + // Test cache operations + key := "test-key" + value := "test-value" + + // Test SetCache + err = db.SetCache(key, value) + if err != nil { + t.Errorf("Failed to set cache: %v", err) + } + + // Test GetCache + var cachedValue string + err = db.GetCache(key, &cachedValue) + if err != nil { + t.Errorf("Failed to get cache: %v", err) + } + + if cachedValue != value { + t.Errorf("Expected cached value %s, got %s", value, cachedValue) + } + + // Test DelCache + err = db.DelCache(ctx, key) + if err != nil { + t.Errorf("Failed to delete cache: %v", err) + } +} + +func TestDocumentDBWithCache_CRUDOperations(t *testing.T) { + conf := &Conf{ + Host: "localhost:27017", + Database: "testdb", + } + + collection := "testcollection" + cacheConf := cache.CacheConf{} + + ctx := context.Background() + db, err := MustDocumentDBWithCache(conf, collection, cacheConf, nil, nil) + + if err != nil { + t.Skip("Skipping test - MongoDB not available") + return + } + + // Test data + testDoc := bson.M{ + "name": "test", + "value": 123, + "price": decimal.NewFromFloat(99.99), + } + + // Test InsertOne + result, err := db.InsertOne(ctx, collection, testDoc) + if err != nil { + t.Errorf("Failed to insert document: %v", err) + } + + insertedID := result.InsertedID + if insertedID == nil { + t.Error("Expected inserted ID to be non-nil") + } + + // Test FindOne + var foundDoc bson.M + err = db.FindOne(ctx, collection, bson.M{"_id": insertedID}, &foundDoc) + if err != nil { + t.Errorf("Failed to find document: %v", err) + } + + if foundDoc["name"] != "test" { + t.Errorf("Expected name 'test', got %v", foundDoc["name"]) + } + + // Test UpdateOne + update := bson.M{"$set": bson.M{"value": 456}} + updateResult, err := db.UpdateOne(ctx, collection, bson.M{"_id": insertedID}, update) + if err != nil { + t.Errorf("Failed to update document: %v", err) + } + + if updateResult.ModifiedCount != 1 { + t.Errorf("Expected 1 modified document, got %d", updateResult.ModifiedCount) + } + + // Test UpdateByID + updateByID := bson.M{"$set": bson.M{"value": 789}} + updateByIDResult, err := db.UpdateByID(ctx, collection, insertedID, updateByID) + if err != nil { + t.Errorf("Failed to update document by ID: %v", err) + } + + if updateByIDResult.ModifiedCount != 1 { + t.Errorf("Expected 1 modified document, got %d", updateByIDResult.ModifiedCount) + } + + // Test UpdateMany + updateMany := bson.M{"$set": bson.M{"updated": true}} + updateManyResult, err := db.UpdateMany(ctx, []string{collection}, bson.M{"_id": insertedID}, updateMany) + if err != nil { + t.Errorf("Failed to update many documents: %v", err) + } + + if updateManyResult.ModifiedCount != 1 { + t.Errorf("Expected 1 modified document, got %d", updateManyResult.ModifiedCount) + } + + // Test FindOneAndReplace + replacement := bson.M{ + "name": "replaced", + "value": 999, + "price": decimal.NewFromFloat(199.99), + } + + var replacedDoc bson.M + err = db.FindOneAndReplace(ctx, collection, bson.M{"_id": insertedID}, replacement, &replacedDoc) + if err != nil { + t.Errorf("Failed to find and replace document: %v", err) + } + + // Test FindOneAndDelete + var deletedDoc bson.M + err = db.FindOneAndDelete(ctx, collection, bson.M{"_id": insertedID}, &deletedDoc) + if err != nil { + t.Errorf("Failed to find and delete document: %v", err) + } + + // Test DeleteOne + deleteResult, err := db.DeleteOne(ctx, collection, bson.M{"_id": insertedID}) + if err != nil { + t.Errorf("Failed to delete document: %v", err) + } + + if deleteResult != 0 { // Should be 0 since we already deleted it + t.Errorf("Expected 0 deleted documents, got %d", deleteResult) + } +} + +func TestDocumentDBWithCache_MustModelCache(t *testing.T) { + conf := &Conf{ + Host: "localhost:27017", + Database: "testdb", + } + + collection := "testcollection" + cacheConf := cache.CacheConf{} + + db, err := MustDocumentDBWithCache(conf, collection, cacheConf, nil, nil) + + if err != nil { + t.Skip("Skipping test - MongoDB not available") + return + } + + // Test that we got a valid DocumentDBWithCache + if db == nil { + t.Error("Expected DocumentDBWithCache to be non-nil") + } +} + +func TestDocumentDBWithCache_ErrorHandling(t *testing.T) { + // Test with invalid config + invalidConf := &Conf{ + Host: "invalid-host:99999", + } + + collection := "testcollection" + cacheConf := cache.CacheConf{} + + _, err := MustDocumentDBWithCache(invalidConf, collection, cacheConf, nil, nil) + + // This should fail + if err == nil { + t.Error("Expected error with invalid host, got nil") + } +} + +func TestDocumentDBWithCache_ContextHandling(t *testing.T) { + conf := &Conf{ + Host: "localhost:27017", + Database: "testdb", + } + + collection := "testcollection" + cacheConf := cache.CacheConf{} + + // Test with timeout context + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + db, err := MustDocumentDBWithCache(conf, collection, cacheConf, nil, nil) + + // Use ctx to avoid unused variable warning + _ = ctx + + if err != nil { + t.Logf("MongoDB connection failed (expected in test environment): %v", err) + return + } + + if db == nil { + t.Error("Expected DocumentDBWithCache to be non-nil") + } +} + +func TestDocumentDBWithCache_WithDecimalValues(t *testing.T) { + conf := &Conf{ + Host: "localhost:27017", + Database: "testdb", + } + + collection := "testcollection" + cacheConf := cache.CacheConf{} + + ctx := context.Background() + db, err := MustDocumentDBWithCache(conf, collection, cacheConf, nil, nil) + + if err != nil { + t.Skip("Skipping test - MongoDB not available") + return + } + + // Test with decimal values + testDoc := bson.M{ + "name": "decimal-test", + "price": decimal.NewFromFloat(123.45), + "amount": decimal.NewFromFloat(999.99), + } + + // Insert document with decimal values + result, err := db.InsertOne(ctx, collection, testDoc) + if err != nil { + t.Errorf("Failed to insert document with decimal values: %v", err) + } + + insertedID := result.InsertedID + + // Find document with decimal values + var foundDoc bson.M + err = db.FindOne(ctx, collection, bson.M{"_id": insertedID}, &foundDoc) + if err != nil { + t.Errorf("Failed to find document with decimal values: %v", err) + } + + // Verify decimal values + if foundDoc["name"] != "decimal-test" { + t.Errorf("Expected name 'decimal-test', got %v", foundDoc["name"]) + } + + // Clean up + _, err = db.DeleteOne(ctx, collection, bson.M{"_id": insertedID}) + if err != nil { + t.Errorf("Failed to clean up document: %v", err) + } +} + +func TestDocumentDBWithCache_WithObjectID(t *testing.T) { + conf := &Conf{ + Host: "localhost:27017", + Database: "testdb", + } + + collection := "testcollection" + cacheConf := cache.CacheConf{} + + ctx := context.Background() + db, err := MustDocumentDBWithCache(conf, collection, cacheConf, nil, nil) + + if err != nil { + t.Skip("Skipping test - MongoDB not available") + return + } + + // Test with ObjectID + objectID := bson.NewObjectID() + testDoc := bson.M{ + "_id": objectID, + "name": "objectid-test", + "value": 123, + } + + // Insert document with ObjectID + result, err := db.InsertOne(ctx, collection, testDoc) + if err != nil { + t.Errorf("Failed to insert document with ObjectID: %v", err) + } + + insertedID := result.InsertedID + + // Verify ObjectID + if insertedID != objectID { + t.Errorf("Expected ObjectID %v, got %v", objectID, insertedID) + } + + // Find document by ObjectID + var foundDoc bson.M + err = db.FindOne(ctx, collection, bson.M{"_id": objectID}, &foundDoc) + if err != nil { + t.Errorf("Failed to find document by ObjectID: %v", err) + } + + // Clean up + _, err = db.DeleteOne(ctx, collection, bson.M{"_id": objectID}) + if err != nil { + t.Errorf("Failed to clean up document: %v", err) + } +} \ No newline at end of file diff --git a/pkg/library/mongo/doc-db.go b/pkg/library/mongo/doc-db.go new file mode 100755 index 0000000..2a9e73e --- /dev/null +++ b/pkg/library/mongo/doc-db.go @@ -0,0 +1,140 @@ +package mongo + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "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) { + authenticationURI := "" + if config.User != "" { + authenticationURI = fmt.Sprintf( + authenticationStringTemplate, + config.User, + config.Password, + ) + } + + connectionURI := fmt.Sprintf( + connectionStringTemplate, + config.Schema, + authenticationURI, + config.Host, + ) + + connectUri, err := url.Parse(connectionURI) + if err != nil { + return nil, fmt.Errorf("failed to parse connection URI: %w", err) + } + printConnectUri := connectUri.String() + findIndexAt := strings.Index(connectUri.String(), "@") + if findIndexAt > -1 && config.User != "" { + prefixIndex := len(config.Schema) + 3 + len(config.User) + connectUriStr := connectUri.String() + printConnectUri = fmt.Sprintf("%s:*****%s", connectUriStr[:prefixIndex], connectUriStr[findIndexAt:]) + } + // 初始化選項 + intOpt := InitMongoOptions(*config) + opts = append(opts, intOpt) + + logx.Infof("[DocumentDB] Try to connect document db `%s`", printConnectUri) + client, err := mon.NewModel(connectionURI, config.Database, collection, opts...) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout( + context.Background(), + time.Duration(config.ConnectTimeoutMs)*time.Millisecond) + defer cancel() + + // Force a connection to verify our connection string + err = client.Database().Client().Ping(ctx, readpref.SecondaryPreferred()) + if err != nil { + return nil, errors.New(fmt.Sprintf("Failed to ping cluster: %s", err)) + } + logx.Infof("[DocumentDB] Connected to DocumentDB!") + + return &DocumentDB{ + Mon: client, + }, nil +} + +func (document *DocumentDB) PopulateIndex(ctx context.Context, key string, sort int32, unique bool) { + c := document.Mon.Collection + opts := options.CreateIndexes() + index := document.yieldIndexModel( + []string{key}, []int32{sort}, unique, nil, + ) + _, err := c.Indexes().CreateOne(ctx, index, opts) + if err != nil { + logx.Errorf("[DocumentDb] Ensure Index Failed, %s", err.Error()) + } +} + +func (document *DocumentDB) PopulateTTLIndex(ctx context.Context, key string, sort int32, unique bool, ttl int32) { + c := document.Mon.Collection + opts := options.CreateIndexes() + index := document.yieldIndexModel( + []string{key}, []int32{sort}, unique, + options.Index().SetExpireAfterSeconds(ttl), + ) + _, err := c.Indexes().CreateOne(ctx, index, opts) + if err != nil { + logx.Errorf("[DocumentDb] Ensure TTL Index Failed, %s", err.Error()) + } +} + +func (document *DocumentDB) PopulateMultiIndex(ctx context.Context, keys []string, sorts []int32, unique bool) { + if len(keys) != len(sorts) { + logx.Infof("[DocumentDb] Ensure Indexes Failed Please provide some item length of keys/sorts") + + return + } + + c := document.Mon.Collection + opts := options.CreateIndexes() + index := document.yieldIndexModel(keys, sorts, unique, nil) + + _, err := c.Indexes().CreateOne(ctx, index, opts) + if err != nil { + logx.Errorf("[DocumentDb] Ensure TTL Index Failed, %s", err.Error()) + } +} + +func (document *DocumentDB) GetClient() *mon.Model { + return document.Mon +} + +func (document *DocumentDB) yieldIndexModel(keys []string, sorts []int32, unique bool, indexOpt *options.IndexOptionsBuilder) mongo.IndexModel { + SetKeysDoc := bson.D{} + for index, _ := range keys { + key := keys[index] + sort := sorts[index] + SetKeysDoc = append(SetKeysDoc, bson.E{Key: key, Value: sort}) + } + if indexOpt == nil { + indexOpt = options.Index() + } + indexOpt.SetUnique(unique) + index := mongo.IndexModel{ + Keys: SetKeysDoc, + Options: indexOpt, + } + return index +} diff --git a/pkg/library/mongo/doc-db_test.go b/pkg/library/mongo/doc-db_test.go new file mode 100644 index 0000000..2ab8906 --- /dev/null +++ b/pkg/library/mongo/doc-db_test.go @@ -0,0 +1,268 @@ +package mongo + +import ( + "context" + "testing" + "time" +) + +func TestNewDocumentDB(t *testing.T) { + // Test with valid config + conf := &Conf{ + Host: "localhost:27017", + Database: "testdb", + } + + db, err := NewDocumentDB(conf, "testcollection") + + // Note: This will fail in test environment without MongoDB running + // but we can test the error handling and basic structure + if err == nil { + t.Log("MongoDB connection successful (MongoDB is running)") + + // Test basic properties + if db == nil { + t.Error("Expected DocumentDB to be non-nil") + } + + // Test GetClient + client := db.GetClient() + if client == nil { + t.Error("Expected client to be non-nil") + } + + // Test that we got a valid DocumentDB + if db == nil { + t.Error("Expected DocumentDB to be non-nil") + } + } else { + t.Logf("MongoDB connection failed (expected in test environment): %v", err) + } +} + +func TestDocumentDB_PopulateIndex(t *testing.T) { + // Test with mock data + conf := &Conf{ + Host: "localhost:27017", + Database: "testdb", + } + + db, err := NewDocumentDB(conf, "testcollection") + + if err != nil { + t.Skip("Skipping test - MongoDB not available") + return + } + + // Test index creation + ctx := context.Background() + db.PopulateIndex(ctx, "field1", 1, false) +} + +func TestDocumentDB_PopulateTTLIndex(t *testing.T) { + conf := &Conf{ + Host: "localhost:27017", + Database: "testdb", + } + + db, err := NewDocumentDB(conf, "testcollection") + + if err != nil { + t.Skip("Skipping test - MongoDB not available") + return + } + + // Test TTL index creation + ctx := context.Background() + ttl := int32(3600) // 1 hour + db.PopulateTTLIndex(ctx, "expireAt", 1, false, ttl) +} + +func TestDocumentDB_PopulateMultiIndex(t *testing.T) { + conf := &Conf{ + Host: "localhost:27017", + Database: "testdb", + } + + db, err := NewDocumentDB(conf, "testcollection") + + if err != nil { + t.Skip("Skipping test - MongoDB not available") + return + } + + // Test multiple index creation + ctx := context.Background() + keys := []string{"field1", "field2", "field3"} + sorts := []int32{1, -1, 1} + db.PopulateMultiIndex(ctx, keys, sorts, false) +} + +func TestDocumentDB_GetClient(t *testing.T) { + conf := &Conf{ + Host: "localhost:27017", + Database: "testdb", + } + + db, err := NewDocumentDB(conf, "testcollection") + + if err != nil { + t.Skip("Skipping test - MongoDB not available") + return + } + + client := db.GetClient() + if client == nil { + t.Error("Expected client to be non-nil") + } +} + +func TestDocumentDB_DatabaseName(t *testing.T) { + conf := &Conf{ + Host: "localhost:27017", + Database: "testdb", + } + + db, err := NewDocumentDB(conf, "testcollection") + + if err != nil { + t.Skip("Skipping test - MongoDB not available") + return + } + + // Test that we got a valid DocumentDB + if db == nil { + t.Error("Expected DocumentDB to be non-nil") + } +} + +func TestDocumentDB_WithDifferentConfigs(t *testing.T) { + testCases := []struct { + name string + conf *Conf + }{ + { + name: "minimal config", + conf: &Conf{ + Host: "localhost:27017", + }, + }, + { + name: "with database", + conf: &Conf{ + Host: "localhost:27017", + Database: "testdb", + }, + }, + { + name: "with credentials", + conf: &Conf{ + Host: "localhost:27017", + Database: "testdb", + User: "user", + Password: "pass", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + db, err := NewDocumentDB(tc.conf, "testcollection") + + if err != nil { + t.Logf("MongoDB connection failed (expected in test environment): %v", err) + return + } + + if db == nil { + t.Error("Expected DocumentDB to be non-nil") + } + }) + } +} + +func TestDocumentDB_IndexOperations(t *testing.T) { + conf := &Conf{ + Host: "localhost:27017", + Database: "testdb", + } + + db, err := NewDocumentDB(conf, "testcollection") + + if err != nil { + t.Skip("Skipping test - MongoDB not available") + return + } + + // Test single index + ctx := context.Background() + db.PopulateIndex(ctx, "single_field", 1, false) + + // Test TTL index + ttl := int32(1800) // 30 minutes + db.PopulateTTLIndex(ctx, "expiresAt", 1, false, ttl) + + // Test multiple indexes + keys := []string{"field1", "field2", "compound_field1"} + sorts := []int32{1, -1, 1} + db.PopulateMultiIndex(ctx, keys, sorts, false) +} + +func TestDocumentDB_ContextHandling(t *testing.T) { + conf := &Conf{ + Host: "localhost:27017", + Database: "testdb", + } + + // Test with timeout context + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + db, err := NewDocumentDB(conf, "testcollection") + + // Use ctx to avoid unused variable warning + _ = ctx + + if err != nil { + t.Logf("MongoDB connection failed (expected in test environment): %v", err) + return + } + + if db == nil { + t.Error("Expected DocumentDB to be non-nil") + } +} + +func TestDocumentDB_ErrorHandling(t *testing.T) { + // Test with invalid config + invalidConf := &Conf{ + Host: "invalid-host:99999", + } + + _, err := NewDocumentDB(invalidConf, "testcollection") + + // This should fail + if err == nil { + t.Error("Expected error with invalid host, got nil") + } +} + +func TestDocumentDB_IndexModelCreation(t *testing.T) { + // Test the yieldIndexModel function indirectly through PopulateIndex + conf := &Conf{ + Host: "localhost:27017", + Database: "testdb", + } + + db, err := NewDocumentDB(conf, "testcollection") + + if err != nil { + t.Skip("Skipping test - MongoDB not available") + return + } + + // Test with various index configurations + ctx := context.Background() + db.PopulateIndex(ctx, "ascending", 1, false) + db.PopulateIndex(ctx, "descending", -1, false) +} diff --git a/pkg/library/mongo/option.go b/pkg/library/mongo/option.go new file mode 100755 index 0000000..a6116df --- /dev/null +++ b/pkg/library/mongo/option.go @@ -0,0 +1,46 @@ +package mongo + +import ( + "reflect" + + "github.com/shopspring/decimal" + "github.com/zeromicro/go-zero/core/stores/mon" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type TypeCodec struct { + ValueType reflect.Type + Encoder bson.ValueEncoder + Decoder bson.ValueDecoder +} + +// WithTypeCodec registers TypeCodecs to convert custom types. +func WithTypeCodec(typeCodecs ...TypeCodec) mon.Option { + return func(c *options.ClientOptions) { + registry := bson.NewRegistry() + for _, v := range typeCodecs { + registry.RegisterTypeEncoder(v.ValueType, v.Encoder) + registry.RegisterTypeDecoder(v.ValueType, v.Decoder) + } + c.SetRegistry(registry) + } +} + +// SetCustomDecimalType force convert primitive.Decimal128 to decimal.Decimal. +func SetCustomDecimalType() mon.Option { + return WithTypeCodec(TypeCodec{ + ValueType: reflect.TypeOf(decimal.Decimal{}), + Encoder: &MgoDecimal{}, + Decoder: &MgoDecimal{}, + }) +} + +func InitMongoOptions(cfg Conf) mon.Option { + return func(opts *options.ClientOptions) { + opts.SetMaxPoolSize(cfg.MaxPoolSize) + opts.SetMinPoolSize(cfg.MinPoolSize) + opts.SetMaxConnIdleTime(cfg.MaxConnIdleTime) + opts.SetCompressors([]string{"snappy"}) + } +} diff --git a/pkg/library/mongo/option_test.go b/pkg/library/mongo/option_test.go new file mode 100644 index 0000000..8da2b2b --- /dev/null +++ b/pkg/library/mongo/option_test.go @@ -0,0 +1,231 @@ +package mongo + +import ( + "reflect" + "testing" + + "github.com/shopspring/decimal" + "github.com/zeromicro/go-zero/core/stores/mon" +) + +func TestWithTypeCodec(t *testing.T) { + // Test creating a TypeCodec + codec := TypeCodec{ + ValueType: reflect.TypeOf(decimal.Decimal{}), + Encoder: &MgoDecimal{}, + Decoder: &MgoDecimal{}, + } + + if codec.ValueType != reflect.TypeOf(decimal.Decimal{}) { + t.Errorf("Expected ValueType to be decimal.Decimal, got %v", codec.ValueType) + } + + if codec.Encoder == nil { + t.Error("Expected Encoder to be set") + } + + if codec.Decoder == nil { + t.Error("Expected Decoder to be set") + } + + // Test WithTypeCodec function + option := WithTypeCodec(codec) + if option == nil { + t.Error("Expected option to be non-nil") + } +} + +func TestSetCustomDecimalType(t *testing.T) { + // Test setting custom decimal type + option := SetCustomDecimalType() + + // Verify that the option is created + if option == nil { + t.Error("Expected option to be non-nil") + } +} + +func TestInitMongoOptions(t *testing.T) { + // Test with default config + conf := Conf{} + opts := InitMongoOptions(conf) + + if opts == nil { + t.Error("Expected options to be non-nil") + } + + // Test with custom config + confWithValues := Conf{ + Host: "localhost:27017", + Database: "testdb", + User: "testuser", + Password: "testpass", + } + optsWithValues := InitMongoOptions(confWithValues) + + if optsWithValues == nil { + t.Error("Expected options to be non-nil") + } + + // Test that the options are properly configured + // We can't directly test the internal configuration, but we can test that it doesn't panic +} + +func TestTypeCodec_InterfaceCompliance(t *testing.T) { + codec := TypeCodec{ + ValueType: reflect.TypeOf(decimal.Decimal{}), + Encoder: &MgoDecimal{}, + Decoder: &MgoDecimal{}, + } + + // Test that the codec can be used + if codec.ValueType == nil { + t.Error("Expected ValueType to be set") + } + + if codec.Encoder == nil { + t.Error("Expected Encoder to be set") + } + + if codec.Decoder == nil { + t.Error("Expected Decoder to be set") + } +} + +func TestMgoDecimal_WithRegistry(t *testing.T) { + // Test that MgoDecimal can be used with a registry + option := SetCustomDecimalType() + + // Test that the option is created + if option == nil { + t.Error("Expected option to be non-nil") + } + + // Test basic decimal operations + dec := decimal.NewFromFloat(123.45) + + // Test that decimal operations work + if dec.IsZero() { + t.Error("Expected decimal to be non-zero") + } + + // Test string conversion + decStr := dec.String() + if decStr != "123.45" { + t.Errorf("Expected '123.45', got '%s'", decStr) + } +} + +func TestInitMongoOptions_WithDifferentConfigs(t *testing.T) { + testCases := []struct { + name string + conf Conf + }{ + { + name: "empty config", + conf: Conf{}, + }, + { + name: "with host", + conf: Conf{ + Host: "localhost:27017", + }, + }, + { + name: "with database", + conf: Conf{ + Database: "testdb", + }, + }, + { + name: "with credentials", + conf: Conf{ + User: "user", + Password: "pass", + }, + }, + { + name: "full config", + conf: Conf{ + Host: "localhost:27017", + Database: "testdb", + User: "user", + Password: "pass", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + opts := InitMongoOptions(tc.conf) + if opts == nil { + t.Error("Expected options to be non-nil") + } + }) + } +} + +func TestWithTypeCodec_EdgeCases(t *testing.T) { + // Test with nil encoder + codec := TypeCodec{ + ValueType: reflect.TypeOf(decimal.Decimal{}), + Encoder: nil, + Decoder: &MgoDecimal{}, + } + + if codec.Encoder != nil { + t.Error("Expected Encoder to be nil") + } + + if codec.Decoder == nil { + t.Error("Expected Decoder to be set") + } + + // Test with nil decoder + codec2 := TypeCodec{ + ValueType: reflect.TypeOf(decimal.Decimal{}), + Encoder: &MgoDecimal{}, + Decoder: nil, + } + + if codec2.Encoder == nil { + t.Error("Expected Encoder to be set") + } + + if codec2.Decoder != nil { + t.Error("Expected Decoder to be nil") + } +} + +func TestSetCustomDecimalType_MultipleCalls(t *testing.T) { + // Test calling SetCustomDecimalType multiple times + + // First call + option1 := SetCustomDecimalType() + + // Second call should not panic + option2 := SetCustomDecimalType() + + // Options should be valid + if option1 == nil { + t.Error("Expected option1 to be non-nil") + } + + if option2 == nil { + t.Error("Expected option2 to be non-nil") + } +} + +func TestInitMongoOptions_ReturnType(t *testing.T) { + conf := Conf{} + opts := InitMongoOptions(conf) + + // Test that the returned type is correct + if opts == nil { + t.Error("Expected options to be non-nil") + } + + // Test that we can use the options (basic type check) + var _ mon.Option = opts +} + diff --git a/pkg/library/mongo/usecase.go b/pkg/library/mongo/usecase.go new file mode 100644 index 0000000..9edffd2 --- /dev/null +++ b/pkg/library/mongo/usecase.go @@ -0,0 +1,35 @@ +package mongo + +import ( + "context" + + "github.com/zeromicro/go-zero/core/stores/mon" + "go.mongodb.org/mongo-driver/v2/mongo" + mopt "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type DocumentDBUseCase interface { + PopulateIndex(ctx context.Context, key string, sort int32, unique bool) + PopulateTTLIndex(ctx context.Context, key string, sort int32, unique bool, ttl int32) + PopulateMultiIndex(ctx context.Context, keys []string, sorts []int32, unique bool) + GetClient() *mon.Model +} + +type DocumentDBWithCacheUseCase interface { + DocumentDBUseCase + CacheUseCase + DeleteOne(ctx context.Context, key string, filter any, opts ...*mopt.DeleteOneOptions) (int64, error) + FindOne(ctx context.Context, key string, v, filter any, opts ...*mopt.FindOneOptions) error + FindOneAndDelete(ctx context.Context, key string, v, filter any, opts ...*mopt.FindOneAndDeleteOptions) error + FindOneAndReplace(ctx context.Context, key string, v, filter, replacement any, opts ...*mopt.FindOneAndReplaceOptions) error + InsertOne(ctx context.Context, key string, document any, opts ...*mopt.InsertOneOptions) (*mongo.InsertOneResult, error) + UpdateByID(ctx context.Context, key string, id, update any, opts ...*mopt.UpdateOneOptions) (*mongo.UpdateResult, error) + UpdateMany(ctx context.Context, keys []string, filter, update any, opts ...*mopt.UpdateManyOptions) (*mongo.UpdateResult, error) + UpdateOne(ctx context.Context, key string, filter, update any, opts ...*mopt.UpdateOneOptions) (*mongo.UpdateResult, error) +} + +type CacheUseCase interface { + DelCache(ctx context.Context, keys ...string) error + GetCache(key string, v any) error + SetCache(key string, v any) error +} diff --git a/pkg/member/domain/config/config.go b/pkg/member/domain/config/config.go new file mode 100644 index 0000000..ee0cacf --- /dev/null +++ b/pkg/member/domain/config/config.go @@ -0,0 +1,19 @@ +package config + +type Config struct { + // 密碼加密層數 + Bcrypt struct { + Cost int + } + + GoogleAuth struct { + ClientID string + AuthURL string + } + + LineAuth struct { + ClientID string + ClientSecret string + RedirectURI string + } +} diff --git a/pkg/member/domain/const.go b/pkg/member/domain/const.go new file mode 100644 index 0000000..6d54560 --- /dev/null +++ b/pkg/member/domain/const.go @@ -0,0 +1,31 @@ +// Package domain defines the core domain entities, constants, and business rules +// for the member service. It contains the fundamental building blocks that +// represent the business concepts and their relationships. +package domain + +// Business constants for the member service +const ( + // DefaultBcryptCost is the default cost for bcrypt password hashing + DefaultBcryptCost = 10 + + // MinPasswordLength is the minimum required password length + MinPasswordLength = 8 + + // MaxPasswordLength is the maximum allowed password length + MaxPasswordLength = 128 + + // DefaultVerifyCodeDigits is the default number of digits for verification codes + DefaultVerifyCodeDigits = 6 + + // MinVerifyCodeDigits is the minimum number of digits for verification codes + MinVerifyCodeDigits = 4 + + // MaxVerifyCodeDigits is the maximum number of digits for verification codes + MaxVerifyCodeDigits = 10 + + // DefaultCacheExpiration is the default cache expiration time in seconds + DefaultCacheExpiration = 3600 + + // MaxRetryAttempts is the maximum number of retry attempts for operations + MaxRetryAttempts = 3 +) diff --git a/pkg/member/domain/entity/account.go b/pkg/member/domain/entity/account.go new file mode 100644 index 0000000..fb9ccc6 --- /dev/null +++ b/pkg/member/domain/entity/account.go @@ -0,0 +1,58 @@ +package entity + +import ( + "errors" + "time" + + "backend/pkg/member/domain/member" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Account represents a user account with authentication credentials. +// It stores login information, hashed passwords, and platform-specific data. +type Account struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"id,omitempty"` + LoginID string `bson:"login_id"` // Unique login identifier (email, phone, username) + Token string `bson:"token"` // Hashed password or platform-specific token + Platform member.Platform `bson:"platform"` // Platform type: 1. platform 2. google 3. line 4. apple + UpdateAt *int64 `bson:"update_at,omitempty" json:"update_at,omitempty"` + CreateAt *int64 `bson:"create_at,omitempty" json:"create_at,omitempty"` +} + +// CollectionName returns the MongoDB collection name for Account entities. +func (a *Account) CollectionName() string { + return "account" +} + +// Validate validates the Account entity +func (a *Account) Validate() error { + if a.LoginID == "" { + return errors.New("login_id is required") + } + if a.Token == "" { + return errors.New("token is required") + } + if !a.Platform.IsValid() { + return errors.New("invalid platform") + } + return nil +} + +// SetTimestamps sets the create and update timestamps +func (a *Account) SetTimestamps() { + now := time.Now().UTC().UnixNano() + if a.CreateAt == nil { + a.CreateAt = &now + } + a.UpdateAt = &now +} + +// IsNew returns true if this is a new account (no ID set) +func (a *Account) IsNew() bool { + return a.ID.IsZero() +} + +// GetIDString returns the ID as a hex string +func (a *Account) GetIDString() string { + return a.ID.Hex() +} diff --git a/pkg/member/domain/entity/account_uid_table.go b/pkg/member/domain/entity/account_uid_table.go new file mode 100644 index 0000000..bdc4690 --- /dev/null +++ b/pkg/member/domain/entity/account_uid_table.go @@ -0,0 +1,55 @@ +package entity + +import ( + "errors" + "time" + + "backend/pkg/member/domain/member" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type AccountUID struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"id,omitempty"` + LoginID string `bson:"login_id"` + UID string `bson:"uid"` + Type member.AccountType `bson:"type"` + UpdateAt *int64 `bson:"update_at,omitempty" json:"update_at,omitempty"` + CreateAt *int64 `bson:"create_at,omitempty" json:"create_at,omitempty"` +} + +func (a *AccountUID) CollectionName() string { + return "account_uid_binding" +} + +// Validate validates the AccountUID entity +func (a *AccountUID) Validate() error { + if a.LoginID == "" { + return errors.New("login_id is required") + } + if a.UID == "" { + return errors.New("uid is required") + } + if !a.Type.IsValid() { + return errors.New("invalid account type") + } + return nil +} + +// SetTimestamps sets the create and update timestamps +func (a *AccountUID) SetTimestamps() { + now := time.Now().UTC().UnixNano() + if a.CreateAt == nil { + a.CreateAt = &now + } + a.UpdateAt = &now +} + +// IsNew returns true if this is a new binding (no ID set) +func (a *AccountUID) IsNew() bool { + return a.ID.IsZero() +} + +// GetIDString returns the ID as a hex string +func (a *AccountUID) GetIDString() string { + return a.ID.Hex() +} diff --git a/pkg/member/domain/entity/auto_id.go b/pkg/member/domain/entity/auto_id.go new file mode 100644 index 0000000..de29a44 --- /dev/null +++ b/pkg/member/domain/entity/auto_id.go @@ -0,0 +1,53 @@ +package entity + +import ( + "errors" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +type AutoID struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"id,omitempty"` + Name string `bson:"name,omitempty" json:"name,omitempty"` + Counter uint64 `bson:"counter" json:"counter"` + UpdateAt *int64 `bson:"update_at,omitempty" json:"update_at,omitempty"` + CreateAt *int64 `bson:"create_at,omitempty" json:"create_at,omitempty"` +} + +func (a *AutoID) CollectionName() string { + return "count" +} + +// Validate validates the AutoID entity +func (a *AutoID) Validate() error { + if a.Name == "" { + return errors.New("name is required") + } + return nil +} + +// SetTimestamps sets the create and update timestamps +func (a *AutoID) SetTimestamps() { + now := time.Now().UTC().UnixNano() + if a.CreateAt == nil { + a.CreateAt = &now + } + a.UpdateAt = &now +} + +// IsNew returns true if this is a new counter (no ID set) +func (a *AutoID) IsNew() bool { + return a.ID.IsZero() +} + +// GetIDString returns the ID as a hex string +func (a *AutoID) GetIDString() string { + return a.ID.Hex() +} + +// Increment increments the counter by 1 +func (a *AutoID) Increment() { + a.Counter++ + a.SetTimestamps() +} diff --git a/pkg/member/domain/entity/user.go b/pkg/member/domain/entity/user.go new file mode 100644 index 0000000..100a8a6 --- /dev/null +++ b/pkg/member/domain/entity/user.go @@ -0,0 +1,90 @@ +package entity + +import ( + "errors" + "time" + + "backend/pkg/member/domain/member" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// User represents a user profile with personal information and preferences. +// It contains detailed user data that is separate from authentication credentials. +type User struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"id,omitempty"` + UID string `bson:"uid"` // Unique user identifier + AvatarURL *string `bson:"avatar_url,omitempty"` // User avatar URL (optional) + FullName *string `bson:"full_name,omitempty"` // User's full name + Nickname *string `bson:"nickname,omitempty"` // User's nickname (optional) + GenderCode *int64 `bson:"gender_code,omitempty"` // Gender code + Birthdate *int64 `bson:"birthdate,omitempty"` // Birth date (format: 19930417) + Address *string `bson:"address,omitempty"` // User's address + AlarmCategory member.AlarmType `bson:"alarm_category"` // Alert notification settings + UserStatus member.Status `bson:"user_status"` // User account status + PreferredLanguage string `bson:"preferred_language"` // User's preferred language + Currency string `bson:"currency"` // User's preferred currency + PhoneNumber *string `bson:"phone_number,omitempty"` // Phone number (appears after verification) + Email *string `bson:"email,omitempty"` // Email address (appears after verification) + UpdateAt *int64 `bson:"update_at,omitempty" json:"update_at,omitempty"` + CreateAt *int64 `bson:"create_at,omitempty" json:"create_at,omitempty"` +} + +// CollectionName returns the MongoDB collection name for User entities. +func (u *User) CollectionName() string { + return "user_info" +} + +// Validate validates the User entity +func (u *User) Validate() error { + if u.UID == "" { + return errors.New("uid is required") + } + if u.PreferredLanguage == "" { + return errors.New("preferred_language is required") + } + if u.Currency == "" { + return errors.New("currency is required") + } + if !u.AlarmCategory.IsValid() { + return errors.New("invalid alarm_category") + } + if !u.UserStatus.IsValid() { + return errors.New("invalid user_status") + } + return nil +} + +// SetTimestamps sets the create and update timestamps +func (u *User) SetTimestamps() { + now := time.Now().UTC().UnixNano() + if u.CreateAt == nil { + u.CreateAt = &now + } + u.UpdateAt = &now +} + +// IsNew returns true if this is a new user (no ID set) +func (u *User) IsNew() bool { + return u.ID.IsZero() +} + +// GetIDString returns the ID as a hex string +func (u *User) GetIDString() string { + return u.ID.Hex() +} + +// GetDisplayName returns the display name (nickname or full name) +func (u *User) GetDisplayName() string { + if u.Nickname != nil && *u.Nickname != "" { + return *u.Nickname + } + if u.FullName != nil && *u.FullName != "" { + return *u.FullName + } + return u.UID +} + +// IsActive returns true if the user status is active +func (u *User) IsActive() bool { + return u.UserStatus == member.AccountStatusActive +} diff --git a/pkg/member/domain/errors.go b/pkg/member/domain/errors.go new file mode 100644 index 0000000..459ddcd --- /dev/null +++ b/pkg/member/domain/errors.go @@ -0,0 +1,33 @@ +package domain + +import "code.30cm.net/digimon/library-go/errs" + +// Verify Error Code +const ( + FailedToVerifyGoogle errs.ErrorCode = iota + 1 + FailedToVerifyGoogleTimeout + FailedToVerifyGoogleHTTPCode + FailedToVerifyGoogleTokenExpired + FailedToVerifyGoogleInvalidAudience + FailedToVerifyLine +) + +// PWS Error Code +const ( + HashPasswordErrorCode = 10 + iota + InsertAccountErrorCode + BindingUserTabletErrorCode + FailedToFindAccountErrorCode + FailedToBindAccountErrorCode + FailedToIncAccountErrorCode + FailedFindUIDByLoginIDErrorCode + FailedFindOneByAccountErrorCode + FailedToUpdatePasswordErrorCode + FailedToFindUserErrorCode + FailedToUpdateUserErrorCode + FailedToUpdateUserStatusErrorCode + FailedToGetUserInfoErrorCode + FailedToGetVerifyCodeErrorCode + FailedToGetCodeOnRedisErrorCode + FailedToGetCodeCorrectErrorCode +) diff --git a/pkg/member/domain/member/account_type.go b/pkg/member/domain/member/account_type.go new file mode 100644 index 0000000..8d3c18f --- /dev/null +++ b/pkg/member/domain/member/account_type.go @@ -0,0 +1,30 @@ +package member + +type AccountType int32 + +const ( + AccountTypeNone AccountType = -1 + AccountTypePhone AccountType = 1 // 手機 + AccountTypeMail AccountType = 2 // 信箱 + AccountTypeDefine AccountType = 3 // 自定義帳號 +) + +var convAccountTypeCode = map[string]AccountType{ + "phone": AccountTypePhone, + "email": AccountTypeMail, + "platform": AccountTypeDefine, +} + +func GetAccountTypeByCode(code string) AccountType { + result, ok := convAccountTypeCode[code] + if !ok { + return AccountTypeNone + } + + return result +} + +// IsValid returns true if the account type is valid +func (a AccountType) IsValid() bool { + return a >= AccountTypePhone && a <= AccountTypeDefine +} diff --git a/pkg/member/domain/member/account_type_test.go b/pkg/member/domain/member/account_type_test.go new file mode 100644 index 0000000..baa6458 --- /dev/null +++ b/pkg/member/domain/member/account_type_test.go @@ -0,0 +1,26 @@ +package member + +import "testing" + +func TestGetAccountTypeByCode(t *testing.T) { + tests := []struct { + code string + expected AccountType + }{ + {"phone", AccountTypePhone}, // 測試有效值: 手機 + {"email", AccountTypeMail}, // 測試有效值: 信箱 + {"platform", AccountTypeDefine}, // 測試有效值: 自定義帳號 + {"unknown", AccountTypeNone}, // 測試無效值 + {"", AccountTypeNone}, // 測試空字串 + {"PHONE", AccountTypeNone}, // 測試大小寫不匹配 + } + + for _, tt := range tests { + t.Run(tt.code, func(t *testing.T) { + result := GetAccountTypeByCode(tt.code) + if result != tt.expected { + t.Errorf("GetAccountTypeByCode(%v) = %v, want %v", tt.code, result, tt.expected) + } + }) + } +} diff --git a/pkg/member/domain/member/alert_type.go b/pkg/member/domain/member/alert_type.go new file mode 100644 index 0000000..6a6dfc0 --- /dev/null +++ b/pkg/member/domain/member/alert_type.go @@ -0,0 +1,30 @@ +package member + +// AlarmType 警報類型 +type AlarmType int32 + +func (a *AlarmType) CodeToString() string { + result, ok := verifyAlarmMap[*a] + if !ok { + return "" + } + + return result +} + +var verifyAlarmMap = map[AlarmType]string{ + AlarmUninitialized: "uninitialized", // 初始狀態(異常) + AlarmNoAlert: "no_alert", // 未告警 + AlarmSystem: "system_alert", // 系統告警中 +} + +const ( + AlarmUninitialized AlarmType = 0 // 初始狀態(異常) + AlarmNoAlert AlarmType = 1 // 未告警 + AlarmSystem AlarmType = 2 // 系統告警中 +) + +// IsValid returns true if the alarm type is valid +func (a AlarmType) IsValid() bool { + return a >= AlarmUninitialized && a <= AlarmSystem +} diff --git a/pkg/member/domain/member/alert_type_test.go b/pkg/member/domain/member/alert_type_test.go new file mode 100644 index 0000000..a8a463d --- /dev/null +++ b/pkg/member/domain/member/alert_type_test.go @@ -0,0 +1,27 @@ +package member + +import ( + "testing" +) + +func TestAlarmType_CodeToString(t *testing.T) { + tests := []struct { + alarmType AlarmType + expected string + }{ + {AlarmUninitialized, "uninitialized"}, // 測試初始狀態 + {AlarmNoAlert, "no_alert"}, // 測試未告警狀態 + {AlarmSystem, "system_alert"}, // 測試系統告警 + {AlarmType(999), ""}, // 測試不存在的狀態 + {AlarmType(-1), ""}, // 測試無效負數狀態 + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := tt.alarmType.CodeToString() + if result != tt.expected { + t.Errorf("CodeToString() = %v, want %v", result, tt.expected) + } + }) + } +} diff --git a/pkg/member/domain/member/generate_code_type.go b/pkg/member/domain/member/generate_code_type.go new file mode 100644 index 0000000..5453aaa --- /dev/null +++ b/pkg/member/domain/member/generate_code_type.go @@ -0,0 +1,40 @@ +package member + +type GenerateCodeType int64 + +const ( + GenerateCodeTypeNone GenerateCodeType = -1 + GenerateCodeTypeEmail GenerateCodeType = 1 // email 驗證碼 + GenerateCodeTypePhone GenerateCodeType = 2 // phone 驗證碼 + GenerateCodeTypeForgetPassword GenerateCodeType = 3 // 忘記密碼 +) + +var codeMap = map[GenerateCodeType]string{ + 1: "email", + 2: "phone", + 3: "forget_email", +} + +func GetCodeNameByCode(code GenerateCodeType) (string, bool) { + res, ok := codeMap[code] + if !ok { + return "", false + } + + return res, true +} + +var generateCodeTypeMap = map[string]GenerateCodeType{ + "email": GenerateCodeTypeEmail, + "phone": GenerateCodeTypePhone, + "forget_email": GenerateCodeTypeForgetPassword, +} + +func GetGetCodeNameByCode(code string) GenerateCodeType { + result, ok := generateCodeTypeMap[code] + if !ok { + return GenerateCodeTypeNone + } + + return result +} diff --git a/pkg/member/domain/member/generate_code_type_test.go b/pkg/member/domain/member/generate_code_type_test.go new file mode 100644 index 0000000..d9c80ba --- /dev/null +++ b/pkg/member/domain/member/generate_code_type_test.go @@ -0,0 +1,54 @@ +package member + +import ( + "testing" +) + +func TestGetCodeNameByCode(t *testing.T) { + tests := []struct { + code GenerateCodeType + expected string + ok bool + }{ + {GenerateCodeTypeEmail, "email", true}, + {GenerateCodeTypePhone, "phone", true}, + {GenerateCodeTypeForgetPassword, "forget_email", true}, + {GenerateCodeTypeNone, "", false}, // 無效代碼 + {GenerateCodeType(999), "", false}, // 無效代碼 + {GenerateCodeType(-999), "", false}, // 無效代碼 + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result, ok := GetCodeNameByCode(tt.code) + if result != tt.expected || ok != tt.ok { + t.Errorf("GetCodeNameByCode(%v) = (%v, %v), want (%v, %v)", + tt.code, result, ok, tt.expected, tt.ok) + } + }) + } +} + +func TestGetGetCodeNameByCode(t *testing.T) { + tests := []struct { + name string + expected GenerateCodeType + }{ + {"email", GenerateCodeTypeEmail}, + {"phone", GenerateCodeTypePhone}, + {"forget_email", GenerateCodeTypeForgetPassword}, + {"unknown", GenerateCodeTypeNone}, // 無效名稱 + {"", GenerateCodeTypeNone}, // 空名稱 + {"123", GenerateCodeTypeNone}, // 無效名稱 + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetGetCodeNameByCode(tt.name) + if result != tt.expected { + t.Errorf("GetGetCodeNameByCode(%v) = %v, want %v", + tt.name, result, tt.expected) + } + }) + } +} diff --git a/pkg/member/domain/member/platform.go b/pkg/member/domain/member/platform.go new file mode 100644 index 0000000..6cdfeed --- /dev/null +++ b/pkg/member/domain/member/platform.go @@ -0,0 +1,62 @@ +package member + +const ( + Digimon Platform = 1 + iota + Google + Line + Apple +) + +const ( + PlatformNone Platform = -1 +) + +const ( + DigimonString = "platform" + GoogleString = "google" + LineString = "line" + AppleString = "apple" +) + +type Platform int8 + +func (p Platform) ToInt64() int64 { + return int64(p) +} + +// ToString - 將 Platform 轉為文字 +func (p Platform) ToString() string { + if result, ok := platformToString[p]; ok { + return result + } + + return "" +} + +var platformToString = map[Platform]string{ + Digimon: DigimonString, + Google: GoogleString, + Line: LineString, + Apple: AppleString, +} + +var stringToPlatform = map[string]Platform{ + DigimonString: Digimon, + GoogleString: Google, + LineString: Line, + AppleString: Apple, +} + +// GetPlatformByPlatformCode - 從文字轉為 Platform +func GetPlatformByPlatformCode(code string) Platform { + if result, ok := stringToPlatform[code]; ok { + return result + } + + return PlatformNone +} + +// IsValid returns true if the platform is valid +func (p Platform) IsValid() bool { + return p >= Digimon && p <= Apple +} diff --git a/pkg/member/domain/member/platform_test.go b/pkg/member/domain/member/platform_test.go new file mode 100644 index 0000000..8c82f7b --- /dev/null +++ b/pkg/member/domain/member/platform_test.go @@ -0,0 +1,66 @@ +package member + +import "testing" + +func TestPlatform_ToInt64(t *testing.T) { + tests := []struct { + platform Platform + expected int64 + }{ + {Digimon, 1}, + {Google, 2}, + {Line, 3}, + {Apple, 4}, + {PlatformNone, -1}, + } + + for _, tt := range tests { + t.Run(tt.platform.ToString(), func(t *testing.T) { + if result := tt.platform.ToInt64(); result != tt.expected { + t.Errorf("ToInt64() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestPlatform_ToString(t *testing.T) { + tests := []struct { + platform Platform + expected string + }{ + {Digimon, DigimonString}, + {Google, GoogleString}, + {Line, LineString}, + {Apple, AppleString}, + {PlatformNone, ""}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + if result := tt.platform.ToString(); result != tt.expected { + t.Errorf("ToString() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestGetPlatformByPlatformCode(t *testing.T) { + tests := []struct { + code string + expected Platform + }{ + {DigimonString, Digimon}, + {GoogleString, Google}, + {LineString, Line}, + {AppleString, Apple}, + {"unknown", PlatformNone}, // 測試不存在的 Platform + } + + for _, tt := range tests { + t.Run(tt.code, func(t *testing.T) { + if result := GetPlatformByPlatformCode(tt.code); result != tt.expected { + t.Errorf("GetPlatformByPlatformCode(%v) = %v, want %v", tt.code, result, tt.expected) + } + }) + } +} diff --git a/pkg/member/domain/member/status.go b/pkg/member/domain/member/status.go new file mode 100644 index 0000000..a2772a6 --- /dev/null +++ b/pkg/member/domain/member/status.go @@ -0,0 +1,36 @@ +package member + +// Status 會員狀態 +type Status int32 + +func (s *Status) CodeToString() string { + result, ok := accountStatusMap[*s] + if !ok { + return "" + } + + return result +} + +var accountStatusMap = map[Status]string{ + AccountStatusUninitialized: "uninitialized", // 初始狀態(異常) + AccountStatusUnverified: "unverified", // 尚未完成驗證 + AccountStatusActive: "active", // 帳號啟用中 + AccountStatusSuspended: "suspended", // 帳號停權中 +} + +func (s *Status) ToInt32() int32 { + return int32(*s) +} + +const ( + AccountStatusUninitialized Status = 0 // 初始狀態(異常) + AccountStatusUnverified Status = 1 // 尚未驗證 + AccountStatusActive Status = 2 // 帳號啟用中 + AccountStatusSuspended Status = 3 // 帳號停權中 +) + +// IsValid returns true if the status is valid +func (s Status) IsValid() bool { + return s >= AccountStatusUninitialized && s <= AccountStatusSuspended +} diff --git a/pkg/member/domain/member/status_test.go b/pkg/member/domain/member/status_test.go new file mode 100644 index 0000000..ae59907 --- /dev/null +++ b/pkg/member/domain/member/status_test.go @@ -0,0 +1,45 @@ +package member + +import "testing" + +func TestStatus_CodeToString(t *testing.T) { + tests := []struct { + status Status + expected string + }{ + {AccountStatusUninitialized, "uninitialized"}, + {AccountStatusUnverified, "unverified"}, + {AccountStatusActive, "active"}, + {AccountStatusSuspended, "suspended"}, + {Status(999), ""}, // 測試不存在的狀態 + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + if result := tt.status.CodeToString(); result != tt.expected { + t.Errorf("CodeToString() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestStatus_ToInt32(t *testing.T) { + tests := []struct { + status Status + expected int32 + }{ + {AccountStatusUninitialized, 0}, + {AccountStatusUnverified, 1}, + {AccountStatusActive, 2}, + {AccountStatusSuspended, 3}, + {Status(999), 999}, // 測試不存在的狀態 + } + + for _, tt := range tests { + t.Run(string(tt.expected), func(t *testing.T) { + if result := tt.status.ToInt32(); result != tt.expected { + t.Errorf("ToInt32() = %v, want %v", result, tt.expected) + } + }) + } +} diff --git a/pkg/member/domain/redis.go b/pkg/member/domain/redis.go new file mode 100644 index 0000000..f95d7a3 --- /dev/null +++ b/pkg/member/domain/redis.go @@ -0,0 +1,56 @@ +package domain + +import ( + "fmt" + "strings" +) + +// RedisKey represents a Redis key type with helper methods for key construction. +type RedisKey string + +const ( + // AccountRedisKey is the Redis key prefix for account data + AccountRedisKey RedisKey = "account" + // AccountUIDRedisKey is the Redis key prefix for account-UID binding data + AccountUIDRedisKey RedisKey = "account_uid" + // UserRedisKey is the Redis key prefix for user data + UserRedisKey RedisKey = "user" + // MemberPrefixRedisKey is the common prefix for all member-related Redis keys + MemberPrefixRedisKey = "member" +) + +// ToString converts the RedisKey to its full string representation with the member prefix. +func (key RedisKey) ToString() string { + return "member:" + string(key) +} + +// With appends additional parts to the RedisKey, separated by colons. +func (key RedisKey) With(s ...string) RedisKey { + parts := append([]string{string(key)}, s...) + return RedisKey(strings.Join(parts, ":")) +} + +// GetAccountRedisKey generates a Redis key for account data by ID. +func GetAccountRedisKey(id string) string { + return AccountRedisKey.With(id).ToString() +} + +// GetAccountUIDRedisKey generates a Redis key for account-UID binding data by ID. +func GetAccountUIDRedisKey(id string) string { + return AccountUIDRedisKey.With(id).ToString() +} + +// GetUserRedisKey generates a Redis key for user data by ID. +func GetUserRedisKey(id string) string { + return UserRedisKey.With(id).ToString() +} + +var ( + // checkVerifyKey is the Redis key prefix for verification codes + checkVerifyKey = fmt.Sprintf("%s:verify:", MemberPrefixRedisKey) +) + +// GetCheckVerifyKey generates a Redis key for verification code data. +func GetCheckVerifyKey(codeType, account string) string { + return fmt.Sprintf("%s%s:%s", checkVerifyKey, codeType, account) +} diff --git a/pkg/member/domain/repository/account.go b/pkg/member/domain/repository/account.go new file mode 100644 index 0000000..d458609 --- /dev/null +++ b/pkg/member/domain/repository/account.go @@ -0,0 +1,29 @@ +package repository + +import ( + "context" + + "backend/pkg/member/domain/member" + + "backend/pkg/member/domain/entity" + + "go.mongodb.org/mongo-driver/v2/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) + FindOneByAccount(ctx context.Context, loginID string) (*entity.Account, error) + UpdateTokenByLoginID(ctx context.Context, account string, token string, platform member.Platform) error + AccountIndexUP +} + +type AccountIndexUP interface { + Index20241226001UP(ctx context.Context) (*mongo.Cursor, error) +} + +// type AccountIndexDown interface { +// Index20241226001Down(ctx context.Context) +// } diff --git a/pkg/member/domain/repository/account_uid.go b/pkg/member/domain/repository/account_uid.go new file mode 100644 index 0000000..9f099f2 --- /dev/null +++ b/pkg/member/domain/repository/account_uid.go @@ -0,0 +1,24 @@ +package repository + +import ( + "context" + + "backend/pkg/member/domain/entity" + + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type AccountUIDRepository interface { + Insert(ctx context.Context, data *entity.AccountUID) error + FindOne(ctx context.Context, id string) (*entity.AccountUID, error) + Update(ctx context.Context, data *entity.AccountUID) (*mongo.UpdateResult, error) + Delete(ctx context.Context, id string) (int64, error) + FindUIDByLoginID(ctx context.Context, loginID string) (*entity.AccountUID, error) + AccountUIDIndexUP +} + +// Index20241226001Up 這樣方便管理,知道是 這張表 20241226 第一個新增的 index 之後有版本遷移也可以這樣寫,先快速這樣做,等之後找到更好的迭代工具,在用 + +type AccountUIDIndexUP interface { + Index20241226001UP(ctx context.Context) (*mongo.Cursor, error) +} diff --git a/pkg/member/domain/repository/auto_id.go b/pkg/member/domain/repository/auto_id.go new file mode 100644 index 0000000..6752741 --- /dev/null +++ b/pkg/member/domain/repository/auto_id.go @@ -0,0 +1,24 @@ +package repository + +import ( + "context" + + "backend/pkg/member/domain/entity" + + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type AutoIDRepository interface { + Insert(ctx context.Context, data *entity.AutoID) error + FindOne(ctx context.Context, id string) (*entity.AutoID, error) + Update(ctx context.Context, data *entity.AutoID) (*mongo.UpdateResult, error) + Delete(ctx context.Context, id string) (int64, error) + Inc(ctx context.Context, data *entity.AutoID) error + GetUIDFromNum(num int64) (string, error) + GetNumFromUID(uid string) (int64, error) + AutoIDIndexUP +} + +type AutoIDIndexUP interface { + Index20241226001UP(ctx context.Context) (*mongo.Cursor, error) +} diff --git a/pkg/member/domain/repository/user.go b/pkg/member/domain/repository/user.go new file mode 100644 index 0000000..65b3919 --- /dev/null +++ b/pkg/member/domain/repository/user.go @@ -0,0 +1,56 @@ +package repository + +import ( + "context" + + "backend/pkg/member/domain/entity" + "backend/pkg/member/domain/member" + + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type UserRepository interface { + BaseUserRepository + UpdateUserDetailsByUID(ctx context.Context, data *UpdateUserInfoRequest) error + UpdateStatus(ctx context.Context, uid string, status int32) error + FindOneByUID(ctx context.Context, uid string) (*entity.User, error) + FindOneByNickName(ctx context.Context, nickName string) (*entity.User, error) + ListMembers(ctx context.Context, params *UserQueryParams) ([]*entity.User, int64, error) + UpdateEmailVerifyStatus(ctx context.Context, uid, email string) error + UpdatePhoneVerifyStatus(ctx context.Context, uid, phone string) error + UserIndexUP +} + +type UserIndexUP interface { + Index20241226001UP(ctx context.Context) (*mongo.Cursor, error) +} + +type BaseUserRepository interface { + Insert(ctx context.Context, data *entity.User) error + FindOne(ctx context.Context, id string) (*entity.User, error) + Update(ctx context.Context, data *entity.User) (*mongo.UpdateResult, error) + Delete(ctx context.Context, id string) (int64, error) +} + +type UserQueryParams struct { + AlarmCategory *member.AlarmType + UserStatus *member.Status + CreateStartTime *int64 + CreateEndTime *int64 + PageSize int64 + PageIndex int64 +} + +type UpdateUserInfoRequest struct { + UID string `json:"uid"` // 用戶 UID + AvatarURL *string `json:"avatar_url,omitempty"` // 頭像 URL(可選) + FullName *string `json:"full_name,omitempty"` // 用戶全名 + Nickname *string `json:"nickname,omitempty"` // 暱稱(可選) + GenderCode *int8 `json:"gender_code,omitempty"` // 性別代碼 + Birthdate *int64 `json:"birthdate,omitempty"` // 生日 (格式: 19930417) + Address *string `json:"address,omitempty"` // 地址 + AlarmCategory *member.AlarmType `json:"alarm_category,omitempty"` // 警報類型 + UserStatus *member.Status `json:"user_status,omitempty"` // 用戶狀態 + PreferredLanguage *string `json:"preferred_language,omitempty"` // 使用語言 + Currency *string `json:"currency,omitempty"` // 使用幣種 +} diff --git a/pkg/member/domain/repository/verify_code.go b/pkg/member/domain/repository/verify_code.go new file mode 100644 index 0000000..9ae8f02 --- /dev/null +++ b/pkg/member/domain/repository/verify_code.go @@ -0,0 +1,9 @@ +package repository + +import "context" + +type VerifyCodeRepository interface { + IsVerifyCodeExist(ctx context.Context, loginID, checkType string) (string, error) + SetVerifyCode(ctx context.Context, loginID, checkType, code string) error + DelVerifyCode(ctx context.Context, loginID, checkType string) error +} diff --git a/pkg/member/domain/usecase/account.go b/pkg/member/domain/usecase/account.go new file mode 100644 index 0000000..c5f56dd --- /dev/null +++ b/pkg/member/domain/usecase/account.go @@ -0,0 +1,219 @@ +package usecase + +import ( + "context" + + "backend/pkg/member/domain/member" +) + +// AccountUseCase 定義了帳號服務的操作方法 +type AccountUseCase interface { + MemberUseCase + BindingMemberUseCase + VerifyMemberUseCase + UIDGenerateUseCase +} + +type MemberUseCase interface { + // CreateUserAccount 創建用戶帳號 + CreateUserAccount(ctx context.Context, req CreateLoginUserRequest) error + // GetUIDByAccount 通過帳號取得 UID + GetUIDByAccount(ctx context.Context, req GetUIDByAccountRequest) (GetUIDByAccountResponse, error) + // GetUserAccountInfo 取得用戶帳號資訊 + GetUserAccountInfo(ctx context.Context, req GetUIDByAccountRequest) (GetAccountInfoResponse, error) + // UpdateUserToken 更新用戶 Token (密碼) + UpdateUserToken(ctx context.Context, req UpdateTokenRequest) error + // UpdateUserInfo 更新用戶資訊 + UpdateUserInfo(ctx context.Context, req *UpdateUserInfoRequest) error + // UpdateStatus 更新用戶狀態 + UpdateStatus(ctx context.Context, req UpdateStatusRequest) error + // GetUserInfo 取得用戶資訊 + GetUserInfo(ctx context.Context, req GetUserInfoRequest) (UserInfo, error) + // ListMember 取得會員列表 + ListMember(ctx context.Context, req ListUserInfoRequest) (ListUserInfoResponse, error) +} + +type BindingMemberUseCase interface { + // BindUserInfo 綁定用戶信息 + BindUserInfo(ctx context.Context, req CreateUserInfoRequest) error + // BindAccount 綁定帳號到 UID + BindAccount(ctx context.Context, req BindingUser) (BindingUser, error) + // BindVerifyEmail 驗證Email 後綁定到會員 + BindVerifyEmail(ctx context.Context, uid, email string) error + // BindVerifyPhone 驗證 Phone 後綁定到會員 + BindVerifyPhone(ctx context.Context, uid, phone string) error +} + +// CreateUserInfoRequest 用於創建用戶詳細信息 +type CreateUserInfoRequest struct { + UID string `json:"uid"` // 用戶 UID + AvatarURL *string `json:"avatar_url,omitempty"` // 頭像 URL(可選) + FullName *string `json:"full_name,omitempty"` // 用戶全名 + Nickname *string `json:"nickname,omitempty"` // 暱稱(可選) + GenderCode *int64 `json:"gender_code,omitempty"` // 性別代碼 + Birthdate *int64 `json:"birthdate,omitempty"` // 生日 (格式: 19930417) + PhoneNumber *string `json:"phone_number,omitempty"` // 電話 + Email *string `json:"email,omitempty"` // 電話 + Address *string `json:"address,omitempty"` // 地址 + AlarmCategory member.AlarmType `json:"alarm_category"` // 警報類型 + UserStatus member.Status `json:"user_status"` // 用戶狀態 + PreferredLanguage string `json:"preferred_language"` // 使用語言 + Currency string `json:"currency"` // 使用幣種 +} + +type UserInfo struct { + CreateUserInfoRequest + CreateTime int64 `json:"create_time"` // 創建時間 + UpdateTime int64 `json:"update_time"` // 更新時間 +} + +type UpdateUserInfoRequest struct { + UID string `json:"uid"` // 用戶 UID + AvatarURL *string `json:"avatar_url,omitempty"` // 頭像 URL(可選) + FullName *string `json:"full_name,omitempty"` // 用戶全名 + Nickname *string `json:"nickname,omitempty"` // 暱稱(可選) + GenderCode *int8 `json:"gender_code,omitempty"` // 性別代碼 + Birthdate *int64 `json:"birthdate,omitempty"` // 生日 (格式: 19930417) + Address *string `json:"address,omitempty"` // 地址 + AlarmCategory *member.AlarmType `json:"alarm_category,omitempty"` // 警報類型 + UserStatus *member.Status `json:"user_status,omitempty"` // 用戶狀態 + PreferredLanguage *string `json:"preferred_language,omitempty"` // 使用語言 + Currency *string `json:"currency,omitempty"` // 使用幣種 +} + +type CreateLoginUserRequest struct { + LoginID string `json:"login_id"` // 登錄 ID + Platform member.Platform `json:"platform"` // 平台類型 + Token string `json:"token"` // 驗證 Token +} + +// BindingUser 用於綁定用戶帳號 +type BindingUser struct { + UID string `json:"uid"` // 用戶 UID + LoginID string `json:"login_id"` // 登錄 ID + Type member.AccountType `json:"type"` // 綁定類型 +} + +// GetUIDByAccountRequest 用於通過帳號獲取用戶 UID +type GetUIDByAccountRequest struct { + Account string `json:"account"` // 帳號 +} + +// GetUIDByAccountResponse 用於返回帳號對應的 UID 信息 +type GetUIDByAccountResponse struct { + UID string `json:"uid"` // 用戶 UID + Account string `json:"account"` // 帳號 +} + +// GetAccountInfoResponse 用於返回用戶帳號信息 +type GetAccountInfoResponse struct { + Data CreateLoginUserRequest `json:"data"` // 登錄用戶信息 +} + +// UpdateTokenRequest 用於更新用戶 Token +type UpdateTokenRequest struct { + Account string `json:"account"` // 帳號 + Token string `json:"token"` // 新 Token + Platform int64 `json:"platform"` // 平台類型 +} + +// GenerateRefreshCodeRequest 用於請求產生刷新代碼 +type GenerateRefreshCodeRequest struct { + LoginID string `json:"login_id"` // 帳號 + CodeType member.GenerateCodeType `json:"code_type"` // 代碼類型 +} + +// VerifyCode 用於表示驗證代碼 +type VerifyCode struct { + VerifyCode string `json:"verify_code"` // 驗證碼 +} + +// GenerateRefreshCodeResponse 用於返回生成的驗證代碼 +type GenerateRefreshCodeResponse struct { + Data VerifyCode `json:"data"` // 驗證碼數據 +} + +// VerifyRefreshCodeRequest 用於驗證刷新代碼 +type VerifyRefreshCodeRequest struct { + LoginID string `json:"Login_id"` // 帳號 + CodeType member.GenerateCodeType `json:"code_type"` // 代碼類型 + VerifyCode string `json:"verify_code"` // 驗證碼 +} + +// UpdateStatusRequest 用於更新用戶狀態 +type UpdateStatusRequest struct { + UID string `json:"uid"` // 用戶 UID + Status member.Status `json:"status"` // 用戶狀態 +} + +// GetUserInfoRequest 用於請求取得用戶詳細信息 +type GetUserInfoRequest struct { + UID string `json:"uid,omitempty"` // 用戶 UID + NickName string `json:"nick_name,omitempty"` // 暱稱(可選) +} + +// GetUserInfoResponse 用於返回用戶詳細信息 +type GetUserInfoResponse struct { + Data UserInfo `json:"data"` // 用戶信息 +} + +// ListUserInfoRequest 用於查詢符合條件的用戶列表 +type ListUserInfoRequest struct { + VerificationType *member.AccountType `json:"verification_type,omitempty"` // 驗證類型(可選) + AlarmCategory *member.AlarmType `json:"alarm_category,omitempty"` // 警報類型(可選) + UserStatus *member.Status `json:"user_status,omitempty"` // 用戶狀態(可選) + CreateStartTime *int64 `json:"create_start_time,omitempty"` // 創建開始時間(可選) + CreateEndTime *int64 `json:"create_end_time,omitempty"` // 創建結束時間(可選) + PageSize int64 `json:"page_size"` // 每頁大小 + PageIndex int64 `json:"page_index"` // 當前頁索引 +} + +// ListUserInfoResponse 用於返回查詢的用戶列表及分頁信息 +type ListUserInfoResponse struct { + Data []UserInfo `json:"data"` // 用戶列表 + Page Pager `json:"page"` // 分頁信息 +} + +// VerifyAuthResultRequest 用於請求驗證授權結果 +type VerifyAuthResultRequest struct { + Token string `json:"token"` // 驗證 Token + Account string `json:"account"` // 帳號 +} + +// VerifyAuthResultResponse 用於返回授權驗證結果 +type VerifyAuthResultResponse struct { + Status bool `json:"status"` // 驗證結果狀態 +} + +// TwitterAccessTokenResponse 用於返回 Twitter 授權令牌 +type TwitterAccessTokenResponse struct { + Token string `json:"token"` // 授權 Token +} + +type GoogleTokenInfo struct { + Iss string `json:"iss"` // 發行者 (issuer) 通常為 "https://accounts.google.com" + Sub string `json:"sub"` // 使用者唯一 ID + Aud string `json:"aud"` // Audience,應該與你的 Client ID 匹配 + Exp string `json:"exp"` // 過期時間 (UNIX timestamp) + Iat string `json:"iat"` // 發行時間 (UNIX timestamp) + Email string `json:"email"` // 使用者的電子郵件 + EmailVerified string `json:"email_verified"` // 郵件是否已驗證 + Name string `json:"name"` // 使用者的名稱 + Picture string `json:"picture"` // 使用者的頭像 URL +} + +type LineAccessTokenResponse struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + RefreshToken string `json:"refresh_token"` +} + +type LineUserProfile struct { + UserID string `json:"userId"` + DisplayName string `json:"displayName"` + PictureURL string `json:"pictureUrl"` + StatusMessage string `json:"statusMessage"` +} diff --git a/pkg/member/domain/usecase/common.go b/pkg/member/domain/usecase/common.go new file mode 100644 index 0000000..6ccd51f --- /dev/null +++ b/pkg/member/domain/usecase/common.go @@ -0,0 +1,7 @@ +package usecase + +type Pager struct { + Total int64 `json:"total"` // 總數量 + Size int64 `json:"size"` // 每頁大小 + Index int64 `json:"index"` // 當前頁索引 +} diff --git a/pkg/member/domain/usecase/generate_uid.go b/pkg/member/domain/usecase/generate_uid.go new file mode 100644 index 0000000..3f9bbb8 --- /dev/null +++ b/pkg/member/domain/usecase/generate_uid.go @@ -0,0 +1,7 @@ +package usecase + +import "context" + +type UIDGenerateUseCase interface { + Generate(ctx context.Context) (string, error) +} diff --git a/pkg/member/domain/usecase/verify.go b/pkg/member/domain/usecase/verify.go new file mode 100644 index 0000000..576c97c --- /dev/null +++ b/pkg/member/domain/usecase/verify.go @@ -0,0 +1,28 @@ +package usecase + +import "context" + +type VerifyMemberUseCase interface { + // GenerateRefreshCode 這個帳號驗證碼(十分鐘),通用的 + GenerateRefreshCode(ctx context.Context, req GenerateRefreshCodeRequest) (GenerateRefreshCodeResponse, error) + // VerifyRefreshCode 驗證驗證碼,驗證完會刪除 + VerifyRefreshCode(ctx context.Context, req VerifyRefreshCodeRequest) error + // CheckRefreshCode 驗證驗證碼,驗證完不會刪除 + CheckRefreshCode(ctx context.Context, req VerifyRefreshCodeRequest) error + // VerifyPlatformAuthResult 驗證平台授權結果 -> 看密碼對不對 + VerifyPlatformAuthResult(ctx context.Context, req VerifyAuthResultRequest) (VerifyAuthResultResponse, error) + GoogleVerify + LineVerify +} + +type GoogleVerify interface { + // VerifyGoogleAuthResult 驗證 Google 授權結果 + VerifyGoogleAuthResult(ctx context.Context, req VerifyAuthResultRequest) (GoogleTokenInfo, error) +} + +type LineVerify interface { + // LineCodeToAccessToken 換取 AccessToken + LineCodeToAccessToken(ctx context.Context, code string) (LineAccessTokenResponse, error) + // LineGetProfileByAccessToken 用 Access Token 換取使用者的基本資料 + LineGetProfileByAccessToken(ctx context.Context, accessToken string) (*LineUserProfile, error) +} diff --git a/pkg/member/domain/validation.go b/pkg/member/domain/validation.go new file mode 100644 index 0000000..4fa6eff --- /dev/null +++ b/pkg/member/domain/validation.go @@ -0,0 +1,93 @@ +package domain + +import ( + "fmt" + "regexp" + "strings" + "unicode/utf8" +) + +// Validation errors +var ( + ErrEmptyField = fmt.Errorf("field cannot be empty") + ErrInvalidEmail = fmt.Errorf("invalid email format") + ErrInvalidPhone = fmt.Errorf("invalid phone format") + ErrPasswordTooShort = fmt.Errorf("password is too short") + ErrPasswordTooLong = fmt.Errorf("password is too long") + ErrInvalidLength = fmt.Errorf("invalid field length") +) + +// Email validation regex +var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + +// Phone validation regex (supports international formats) +var phoneRegex = regexp.MustCompile(`^\+?[1-9]\d{2,14}$`) + +// ValidateEmail validates an email address format +func ValidateEmail(email string) error { + if email == "" { + return ErrEmptyField + } + + email = strings.TrimSpace(email) + if !emailRegex.MatchString(email) { + return ErrInvalidEmail + } + + return nil +} + +// ValidatePhone validates a phone number format +func ValidatePhone(phone string) error { + if phone == "" { + return ErrEmptyField + } + + phone = strings.TrimSpace(phone) + if !phoneRegex.MatchString(phone) { + return ErrInvalidPhone + } + + return nil +} + +// ValidatePassword validates password strength +func ValidatePassword(password string) error { + if password == "" { + return ErrEmptyField + } + + length := utf8.RuneCountInString(password) + if length < MinPasswordLength { + return ErrPasswordTooShort + } + + if length > MaxPasswordLength { + return ErrPasswordTooLong + } + + return nil +} + +// ValidateStringLength validates string length within specified bounds +func ValidateStringLength(field, value string, min, max int) error { + if value == "" { + return ErrEmptyField + } + + length := utf8.RuneCountInString(value) + if length < min || length > max { + return ErrInvalidLength + } + + return nil +} + +// ValidateRequired validates that a required field is not empty +func ValidateRequired(field, value string) error { + if strings.TrimSpace(value) == "" { + return ErrEmptyField + } + + return nil +} diff --git a/pkg/member/domain/validation_test.go b/pkg/member/domain/validation_test.go new file mode 100644 index 0000000..082c39b --- /dev/null +++ b/pkg/member/domain/validation_test.go @@ -0,0 +1,242 @@ +package domain + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateEmail(t *testing.T) { + tests := []struct { + name string + email string + wantErr bool + }{ + { + name: "valid email", + email: "test@example.com", + wantErr: false, + }, + { + name: "valid email with subdomain", + email: "user@mail.example.com", + wantErr: false, + }, + { + name: "invalid email - no @", + email: "testexample.com", + wantErr: true, + }, + { + name: "invalid email - no domain", + email: "test@", + wantErr: true, + }, + { + name: "empty email", + email: "", + wantErr: true, + }, + { + name: "email with spaces", + email: " test@example.com ", + wantErr: false, // Should be trimmed + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateEmail(tt.email) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidatePhone(t *testing.T) { + tests := []struct { + name string + phone string + wantErr bool + }{ + { + name: "valid phone with country code", + phone: "+1234567890", + wantErr: false, + }, + { + name: "valid phone without country code", + phone: "1234567890", + wantErr: false, + }, + { + name: "invalid phone - too short", + phone: "12", + wantErr: true, + }, + { + name: "invalid phone - contains letters", + phone: "123-abc-4567", + wantErr: true, + }, + { + name: "empty phone", + phone: "", + wantErr: true, + }, + { + name: "phone with spaces", + phone: " +1234567890 ", + wantErr: false, // Should be trimmed + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidatePhone(tt.phone) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidatePassword(t *testing.T) { + tests := []struct { + name string + password string + wantErr bool + }{ + { + name: "valid password", + password: "password123", + wantErr: false, + }, + { + name: "password too short", + password: "123", + wantErr: true, + }, + { + name: "password too long", + password: string(make([]byte, MaxPasswordLength+1)), + wantErr: true, + }, + { + name: "empty password", + password: "", + wantErr: true, + }, + { + name: "minimum length password", + password: "12345678", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidatePassword(tt.password) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateStringLength(t *testing.T) { + tests := []struct { + name string + value string + min int + max int + wantErr bool + }{ + { + name: "valid length", + value: "hello", + min: 3, + max: 10, + wantErr: false, + }, + { + name: "too short", + value: "hi", + min: 3, + max: 10, + wantErr: true, + }, + { + name: "too long", + value: "hello world", + min: 3, + max: 5, + wantErr: true, + }, + { + name: "empty value", + value: "", + min: 1, + max: 10, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateStringLength("test", tt.value, tt.min, tt.max) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateRequired(t *testing.T) { + tests := []struct { + name string + value string + wantErr bool + }{ + { + name: "valid value", + value: "hello", + wantErr: false, + }, + { + name: "empty value", + value: "", + wantErr: true, + }, + { + name: "whitespace only", + value: " ", + wantErr: true, + }, + { + name: "value with spaces", + value: " hello ", + wantErr: false, // Should be trimmed + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateRequired("test", tt.value) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/pkg/member/mock/repository/account.go b/pkg/member/mock/repository/account.go new file mode 100644 index 0000000..9595073 --- /dev/null +++ b/pkg/member/mock/repository/account.go @@ -0,0 +1,186 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./pkg/member/domain/repository/account.go +// +// Generated by this command: +// +// mockgen -source=./pkg/member/domain/repository/account.go -destination=./pkg/member/mock/repository/account.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + entity "backend/pkg/member/domain/entity" + member "backend/pkg/member/domain/member" + context "context" + reflect "reflect" + + mongo "go.mongodb.org/mongo-driver/v2/mongo" + gomock "go.uber.org/mock/gomock" +) + +// MockAccountRepository is a mock of AccountRepository interface. +type MockAccountRepository struct { + ctrl *gomock.Controller + recorder *MockAccountRepositoryMockRecorder + isgomock struct{} +} + +// MockAccountRepositoryMockRecorder is the mock recorder for MockAccountRepository. +type MockAccountRepositoryMockRecorder struct { + mock *MockAccountRepository +} + +// NewMockAccountRepository creates a new mock instance. +func NewMockAccountRepository(ctrl *gomock.Controller) *MockAccountRepository { + mock := &MockAccountRepository{ctrl: ctrl} + mock.recorder = &MockAccountRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAccountRepository) EXPECT() *MockAccountRepositoryMockRecorder { + return m.recorder +} + +// Delete mocks base method. +func (m *MockAccountRepository) Delete(ctx context.Context, id string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", ctx, id) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Delete indicates an expected call of Delete. +func (mr *MockAccountRepositoryMockRecorder) Delete(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockAccountRepository)(nil).Delete), ctx, id) +} + +// FindOne mocks base method. +func (m *MockAccountRepository) FindOne(ctx context.Context, id string) (*entity.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindOne", ctx, id) + ret0, _ := ret[0].(*entity.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindOne indicates an expected call of FindOne. +func (mr *MockAccountRepositoryMockRecorder) FindOne(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOne", reflect.TypeOf((*MockAccountRepository)(nil).FindOne), ctx, id) +} + +// FindOneByAccount mocks base method. +func (m *MockAccountRepository) FindOneByAccount(ctx context.Context, loginID string) (*entity.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindOneByAccount", ctx, loginID) + ret0, _ := ret[0].(*entity.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindOneByAccount indicates an expected call of FindOneByAccount. +func (mr *MockAccountRepositoryMockRecorder) FindOneByAccount(ctx, loginID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOneByAccount", reflect.TypeOf((*MockAccountRepository)(nil).FindOneByAccount), ctx, loginID) +} + +// Index20241226001UP mocks base method. +func (m *MockAccountRepository) Index20241226001UP(ctx context.Context) (*mongo.Cursor, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Index20241226001UP", ctx) + ret0, _ := ret[0].(*mongo.Cursor) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Index20241226001UP indicates an expected call of Index20241226001UP. +func (mr *MockAccountRepositoryMockRecorder) Index20241226001UP(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Index20241226001UP", reflect.TypeOf((*MockAccountRepository)(nil).Index20241226001UP), ctx) +} + +// Insert mocks base method. +func (m *MockAccountRepository) Insert(ctx context.Context, data *entity.Account) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Insert", ctx, data) + ret0, _ := ret[0].(error) + return ret0 +} + +// Insert indicates an expected call of Insert. +func (mr *MockAccountRepositoryMockRecorder) Insert(ctx, data any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Insert", reflect.TypeOf((*MockAccountRepository)(nil).Insert), ctx, data) +} + +// Update mocks base method. +func (m *MockAccountRepository) Update(ctx context.Context, data *entity.Account) (*mongo.UpdateResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", ctx, data) + ret0, _ := ret[0].(*mongo.UpdateResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockAccountRepositoryMockRecorder) Update(ctx, data any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockAccountRepository)(nil).Update), ctx, data) +} + +// UpdateTokenByLoginID mocks base method. +func (m *MockAccountRepository) UpdateTokenByLoginID(ctx context.Context, account, token string, platform member.Platform) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTokenByLoginID", ctx, account, token, platform) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateTokenByLoginID indicates an expected call of UpdateTokenByLoginID. +func (mr *MockAccountRepositoryMockRecorder) UpdateTokenByLoginID(ctx, account, token, platform any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTokenByLoginID", reflect.TypeOf((*MockAccountRepository)(nil).UpdateTokenByLoginID), ctx, account, token, platform) +} + +// MockAccountIndexUP is a mock of AccountIndexUP interface. +type MockAccountIndexUP struct { + ctrl *gomock.Controller + recorder *MockAccountIndexUPMockRecorder + isgomock struct{} +} + +// MockAccountIndexUPMockRecorder is the mock recorder for MockAccountIndexUP. +type MockAccountIndexUPMockRecorder struct { + mock *MockAccountIndexUP +} + +// NewMockAccountIndexUP creates a new mock instance. +func NewMockAccountIndexUP(ctrl *gomock.Controller) *MockAccountIndexUP { + mock := &MockAccountIndexUP{ctrl: ctrl} + mock.recorder = &MockAccountIndexUPMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAccountIndexUP) EXPECT() *MockAccountIndexUPMockRecorder { + return m.recorder +} + +// Index20241226001UP mocks base method. +func (m *MockAccountIndexUP) Index20241226001UP(ctx context.Context) (*mongo.Cursor, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Index20241226001UP", ctx) + ret0, _ := ret[0].(*mongo.Cursor) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Index20241226001UP indicates an expected call of Index20241226001UP. +func (mr *MockAccountIndexUPMockRecorder) Index20241226001UP(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Index20241226001UP", reflect.TypeOf((*MockAccountIndexUP)(nil).Index20241226001UP), ctx) +} diff --git a/pkg/member/mock/repository/account_uid.go b/pkg/member/mock/repository/account_uid.go new file mode 100644 index 0000000..1d5793c --- /dev/null +++ b/pkg/member/mock/repository/account_uid.go @@ -0,0 +1,171 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./pkg/member/domain/repository/account_uid.go +// +// Generated by this command: +// +// mockgen -source=./pkg/member/domain/repository/account_uid.go -destination=./pkg/member/mock/repository/account_uid.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + entity "backend/pkg/member/domain/entity" + context "context" + reflect "reflect" + + mongo "go.mongodb.org/mongo-driver/v2/mongo" + gomock "go.uber.org/mock/gomock" +) + +// MockAccountUIDRepository is a mock of AccountUIDRepository interface. +type MockAccountUIDRepository struct { + ctrl *gomock.Controller + recorder *MockAccountUIDRepositoryMockRecorder + isgomock struct{} +} + +// MockAccountUIDRepositoryMockRecorder is the mock recorder for MockAccountUIDRepository. +type MockAccountUIDRepositoryMockRecorder struct { + mock *MockAccountUIDRepository +} + +// NewMockAccountUIDRepository creates a new mock instance. +func NewMockAccountUIDRepository(ctrl *gomock.Controller) *MockAccountUIDRepository { + mock := &MockAccountUIDRepository{ctrl: ctrl} + mock.recorder = &MockAccountUIDRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAccountUIDRepository) EXPECT() *MockAccountUIDRepositoryMockRecorder { + return m.recorder +} + +// Delete mocks base method. +func (m *MockAccountUIDRepository) Delete(ctx context.Context, id string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", ctx, id) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Delete indicates an expected call of Delete. +func (mr *MockAccountUIDRepositoryMockRecorder) Delete(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockAccountUIDRepository)(nil).Delete), ctx, id) +} + +// FindOne mocks base method. +func (m *MockAccountUIDRepository) FindOne(ctx context.Context, id string) (*entity.AccountUID, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindOne", ctx, id) + ret0, _ := ret[0].(*entity.AccountUID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindOne indicates an expected call of FindOne. +func (mr *MockAccountUIDRepositoryMockRecorder) FindOne(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOne", reflect.TypeOf((*MockAccountUIDRepository)(nil).FindOne), ctx, id) +} + +// FindUIDByLoginID mocks base method. +func (m *MockAccountUIDRepository) FindUIDByLoginID(ctx context.Context, loginID string) (*entity.AccountUID, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindUIDByLoginID", ctx, loginID) + ret0, _ := ret[0].(*entity.AccountUID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindUIDByLoginID indicates an expected call of FindUIDByLoginID. +func (mr *MockAccountUIDRepositoryMockRecorder) FindUIDByLoginID(ctx, loginID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindUIDByLoginID", reflect.TypeOf((*MockAccountUIDRepository)(nil).FindUIDByLoginID), ctx, loginID) +} + +// Index20241226001UP mocks base method. +func (m *MockAccountUIDRepository) Index20241226001UP(ctx context.Context) (*mongo.Cursor, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Index20241226001UP", ctx) + ret0, _ := ret[0].(*mongo.Cursor) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Index20241226001UP indicates an expected call of Index20241226001UP. +func (mr *MockAccountUIDRepositoryMockRecorder) Index20241226001UP(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Index20241226001UP", reflect.TypeOf((*MockAccountUIDRepository)(nil).Index20241226001UP), ctx) +} + +// Insert mocks base method. +func (m *MockAccountUIDRepository) Insert(ctx context.Context, data *entity.AccountUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Insert", ctx, data) + ret0, _ := ret[0].(error) + return ret0 +} + +// Insert indicates an expected call of Insert. +func (mr *MockAccountUIDRepositoryMockRecorder) Insert(ctx, data any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Insert", reflect.TypeOf((*MockAccountUIDRepository)(nil).Insert), ctx, data) +} + +// Update mocks base method. +func (m *MockAccountUIDRepository) Update(ctx context.Context, data *entity.AccountUID) (*mongo.UpdateResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", ctx, data) + ret0, _ := ret[0].(*mongo.UpdateResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockAccountUIDRepositoryMockRecorder) Update(ctx, data any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockAccountUIDRepository)(nil).Update), ctx, data) +} + +// MockAccountUIDIndexUP is a mock of AccountUIDIndexUP interface. +type MockAccountUIDIndexUP struct { + ctrl *gomock.Controller + recorder *MockAccountUIDIndexUPMockRecorder + isgomock struct{} +} + +// MockAccountUIDIndexUPMockRecorder is the mock recorder for MockAccountUIDIndexUP. +type MockAccountUIDIndexUPMockRecorder struct { + mock *MockAccountUIDIndexUP +} + +// NewMockAccountUIDIndexUP creates a new mock instance. +func NewMockAccountUIDIndexUP(ctrl *gomock.Controller) *MockAccountUIDIndexUP { + mock := &MockAccountUIDIndexUP{ctrl: ctrl} + mock.recorder = &MockAccountUIDIndexUPMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAccountUIDIndexUP) EXPECT() *MockAccountUIDIndexUPMockRecorder { + return m.recorder +} + +// Index20241226001UP mocks base method. +func (m *MockAccountUIDIndexUP) Index20241226001UP(ctx context.Context) (*mongo.Cursor, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Index20241226001UP", ctx) + ret0, _ := ret[0].(*mongo.Cursor) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Index20241226001UP indicates an expected call of Index20241226001UP. +func (mr *MockAccountUIDIndexUPMockRecorder) Index20241226001UP(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Index20241226001UP", reflect.TypeOf((*MockAccountUIDIndexUP)(nil).Index20241226001UP), ctx) +} diff --git a/pkg/member/mock/repository/auto_id.go b/pkg/member/mock/repository/auto_id.go new file mode 100644 index 0000000..61c994a --- /dev/null +++ b/pkg/member/mock/repository/auto_id.go @@ -0,0 +1,200 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./pkg/member/domain/repository/auto_id.go +// +// Generated by this command: +// +// mockgen -source=./pkg/member/domain/repository/auto_id.go -destination=./pkg/member/mock/repository/auto_id.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + entity "backend/pkg/member/domain/entity" + context "context" + reflect "reflect" + + mongo "go.mongodb.org/mongo-driver/v2/mongo" + gomock "go.uber.org/mock/gomock" +) + +// MockAutoIDRepository is a mock of AutoIDRepository interface. +type MockAutoIDRepository struct { + ctrl *gomock.Controller + recorder *MockAutoIDRepositoryMockRecorder + isgomock struct{} +} + +// MockAutoIDRepositoryMockRecorder is the mock recorder for MockAutoIDRepository. +type MockAutoIDRepositoryMockRecorder struct { + mock *MockAutoIDRepository +} + +// NewMockAutoIDRepository creates a new mock instance. +func NewMockAutoIDRepository(ctrl *gomock.Controller) *MockAutoIDRepository { + mock := &MockAutoIDRepository{ctrl: ctrl} + mock.recorder = &MockAutoIDRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAutoIDRepository) EXPECT() *MockAutoIDRepositoryMockRecorder { + return m.recorder +} + +// Delete mocks base method. +func (m *MockAutoIDRepository) Delete(ctx context.Context, id string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", ctx, id) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Delete indicates an expected call of Delete. +func (mr *MockAutoIDRepositoryMockRecorder) Delete(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockAutoIDRepository)(nil).Delete), ctx, id) +} + +// FindOne mocks base method. +func (m *MockAutoIDRepository) FindOne(ctx context.Context, id string) (*entity.AutoID, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindOne", ctx, id) + ret0, _ := ret[0].(*entity.AutoID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindOne indicates an expected call of FindOne. +func (mr *MockAutoIDRepositoryMockRecorder) FindOne(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOne", reflect.TypeOf((*MockAutoIDRepository)(nil).FindOne), ctx, id) +} + +// GetNumFromUID mocks base method. +func (m *MockAutoIDRepository) GetNumFromUID(uid string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNumFromUID", uid) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNumFromUID indicates an expected call of GetNumFromUID. +func (mr *MockAutoIDRepositoryMockRecorder) GetNumFromUID(uid any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNumFromUID", reflect.TypeOf((*MockAutoIDRepository)(nil).GetNumFromUID), uid) +} + +// GetUIDFromNum mocks base method. +func (m *MockAutoIDRepository) GetUIDFromNum(num int64) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUIDFromNum", num) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUIDFromNum indicates an expected call of GetUIDFromNum. +func (mr *MockAutoIDRepositoryMockRecorder) GetUIDFromNum(num any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUIDFromNum", reflect.TypeOf((*MockAutoIDRepository)(nil).GetUIDFromNum), num) +} + +// Inc mocks base method. +func (m *MockAutoIDRepository) Inc(ctx context.Context, data *entity.AutoID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Inc", ctx, data) + ret0, _ := ret[0].(error) + return ret0 +} + +// Inc indicates an expected call of Inc. +func (mr *MockAutoIDRepositoryMockRecorder) Inc(ctx, data any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Inc", reflect.TypeOf((*MockAutoIDRepository)(nil).Inc), ctx, data) +} + +// Index20241226001UP mocks base method. +func (m *MockAutoIDRepository) Index20241226001UP(ctx context.Context) (*mongo.Cursor, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Index20241226001UP", ctx) + ret0, _ := ret[0].(*mongo.Cursor) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Index20241226001UP indicates an expected call of Index20241226001UP. +func (mr *MockAutoIDRepositoryMockRecorder) Index20241226001UP(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Index20241226001UP", reflect.TypeOf((*MockAutoIDRepository)(nil).Index20241226001UP), ctx) +} + +// Insert mocks base method. +func (m *MockAutoIDRepository) Insert(ctx context.Context, data *entity.AutoID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Insert", ctx, data) + ret0, _ := ret[0].(error) + return ret0 +} + +// Insert indicates an expected call of Insert. +func (mr *MockAutoIDRepositoryMockRecorder) Insert(ctx, data any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Insert", reflect.TypeOf((*MockAutoIDRepository)(nil).Insert), ctx, data) +} + +// Update mocks base method. +func (m *MockAutoIDRepository) Update(ctx context.Context, data *entity.AutoID) (*mongo.UpdateResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", ctx, data) + ret0, _ := ret[0].(*mongo.UpdateResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockAutoIDRepositoryMockRecorder) Update(ctx, data any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockAutoIDRepository)(nil).Update), ctx, data) +} + +// MockAutoIDIndexUP is a mock of AutoIDIndexUP interface. +type MockAutoIDIndexUP struct { + ctrl *gomock.Controller + recorder *MockAutoIDIndexUPMockRecorder + isgomock struct{} +} + +// MockAutoIDIndexUPMockRecorder is the mock recorder for MockAutoIDIndexUP. +type MockAutoIDIndexUPMockRecorder struct { + mock *MockAutoIDIndexUP +} + +// NewMockAutoIDIndexUP creates a new mock instance. +func NewMockAutoIDIndexUP(ctrl *gomock.Controller) *MockAutoIDIndexUP { + mock := &MockAutoIDIndexUP{ctrl: ctrl} + mock.recorder = &MockAutoIDIndexUPMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAutoIDIndexUP) EXPECT() *MockAutoIDIndexUPMockRecorder { + return m.recorder +} + +// Index20241226001UP mocks base method. +func (m *MockAutoIDIndexUP) Index20241226001UP(ctx context.Context) (*mongo.Cursor, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Index20241226001UP", ctx) + ret0, _ := ret[0].(*mongo.Cursor) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Index20241226001UP indicates an expected call of Index20241226001UP. +func (mr *MockAutoIDIndexUPMockRecorder) Index20241226001UP(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Index20241226001UP", reflect.TypeOf((*MockAutoIDIndexUP)(nil).Index20241226001UP), ctx) +} diff --git a/pkg/member/mock/repository/user.go b/pkg/member/mock/repository/user.go new file mode 100644 index 0000000..b37273a --- /dev/null +++ b/pkg/member/mock/repository/user.go @@ -0,0 +1,342 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./pkg/member/domain/repository/user.go +// +// Generated by this command: +// +// mockgen -source=./pkg/member/domain/repository/user.go -destination=./pkg/member/mock/repository/user.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + entity "backend/pkg/member/domain/entity" + repository "backend/pkg/member/domain/repository" + context "context" + reflect "reflect" + + mongo "go.mongodb.org/mongo-driver/v2/mongo" + gomock "go.uber.org/mock/gomock" +) + +// MockUserRepository is a mock of UserRepository interface. +type MockUserRepository struct { + ctrl *gomock.Controller + recorder *MockUserRepositoryMockRecorder + isgomock struct{} +} + +// MockUserRepositoryMockRecorder is the mock recorder for MockUserRepository. +type MockUserRepositoryMockRecorder struct { + mock *MockUserRepository +} + +// NewMockUserRepository creates a new mock instance. +func NewMockUserRepository(ctrl *gomock.Controller) *MockUserRepository { + mock := &MockUserRepository{ctrl: ctrl} + mock.recorder = &MockUserRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockUserRepository) EXPECT() *MockUserRepositoryMockRecorder { + return m.recorder +} + +// Delete mocks base method. +func (m *MockUserRepository) Delete(ctx context.Context, id string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", ctx, id) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Delete indicates an expected call of Delete. +func (mr *MockUserRepositoryMockRecorder) Delete(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockUserRepository)(nil).Delete), ctx, id) +} + +// FindOne mocks base method. +func (m *MockUserRepository) FindOne(ctx context.Context, id string) (*entity.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindOne", ctx, id) + ret0, _ := ret[0].(*entity.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindOne indicates an expected call of FindOne. +func (mr *MockUserRepositoryMockRecorder) FindOne(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOne", reflect.TypeOf((*MockUserRepository)(nil).FindOne), ctx, id) +} + +// FindOneByNickName mocks base method. +func (m *MockUserRepository) FindOneByNickName(ctx context.Context, nickName string) (*entity.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindOneByNickName", ctx, nickName) + ret0, _ := ret[0].(*entity.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindOneByNickName indicates an expected call of FindOneByNickName. +func (mr *MockUserRepositoryMockRecorder) FindOneByNickName(ctx, nickName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOneByNickName", reflect.TypeOf((*MockUserRepository)(nil).FindOneByNickName), ctx, nickName) +} + +// FindOneByUID mocks base method. +func (m *MockUserRepository) FindOneByUID(ctx context.Context, uid string) (*entity.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindOneByUID", ctx, uid) + ret0, _ := ret[0].(*entity.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindOneByUID indicates an expected call of FindOneByUID. +func (mr *MockUserRepositoryMockRecorder) FindOneByUID(ctx, uid any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOneByUID", reflect.TypeOf((*MockUserRepository)(nil).FindOneByUID), ctx, uid) +} + +// Index20241226001UP mocks base method. +func (m *MockUserRepository) Index20241226001UP(ctx context.Context) (*mongo.Cursor, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Index20241226001UP", ctx) + ret0, _ := ret[0].(*mongo.Cursor) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Index20241226001UP indicates an expected call of Index20241226001UP. +func (mr *MockUserRepositoryMockRecorder) Index20241226001UP(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Index20241226001UP", reflect.TypeOf((*MockUserRepository)(nil).Index20241226001UP), ctx) +} + +// Insert mocks base method. +func (m *MockUserRepository) Insert(ctx context.Context, data *entity.User) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Insert", ctx, data) + ret0, _ := ret[0].(error) + return ret0 +} + +// Insert indicates an expected call of Insert. +func (mr *MockUserRepositoryMockRecorder) Insert(ctx, data any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Insert", reflect.TypeOf((*MockUserRepository)(nil).Insert), ctx, data) +} + +// ListMembers mocks base method. +func (m *MockUserRepository) ListMembers(ctx context.Context, params *repository.UserQueryParams) ([]*entity.User, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListMembers", ctx, params) + ret0, _ := ret[0].([]*entity.User) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ListMembers indicates an expected call of ListMembers. +func (mr *MockUserRepositoryMockRecorder) ListMembers(ctx, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListMembers", reflect.TypeOf((*MockUserRepository)(nil).ListMembers), ctx, params) +} + +// Update mocks base method. +func (m *MockUserRepository) Update(ctx context.Context, data *entity.User) (*mongo.UpdateResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", ctx, data) + ret0, _ := ret[0].(*mongo.UpdateResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockUserRepositoryMockRecorder) Update(ctx, data any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockUserRepository)(nil).Update), ctx, data) +} + +// UpdateEmailVerifyStatus mocks base method. +func (m *MockUserRepository) UpdateEmailVerifyStatus(ctx context.Context, uid, email string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateEmailVerifyStatus", ctx, uid, email) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateEmailVerifyStatus indicates an expected call of UpdateEmailVerifyStatus. +func (mr *MockUserRepositoryMockRecorder) UpdateEmailVerifyStatus(ctx, uid, email any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateEmailVerifyStatus", reflect.TypeOf((*MockUserRepository)(nil).UpdateEmailVerifyStatus), ctx, uid, email) +} + +// UpdatePhoneVerifyStatus mocks base method. +func (m *MockUserRepository) UpdatePhoneVerifyStatus(ctx context.Context, uid, phone string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdatePhoneVerifyStatus", ctx, uid, phone) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdatePhoneVerifyStatus indicates an expected call of UpdatePhoneVerifyStatus. +func (mr *MockUserRepositoryMockRecorder) UpdatePhoneVerifyStatus(ctx, uid, phone any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePhoneVerifyStatus", reflect.TypeOf((*MockUserRepository)(nil).UpdatePhoneVerifyStatus), ctx, uid, phone) +} + +// UpdateStatus mocks base method. +func (m *MockUserRepository) UpdateStatus(ctx context.Context, uid string, status int32) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateStatus", ctx, uid, status) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateStatus indicates an expected call of UpdateStatus. +func (mr *MockUserRepositoryMockRecorder) UpdateStatus(ctx, uid, status any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateStatus", reflect.TypeOf((*MockUserRepository)(nil).UpdateStatus), ctx, uid, status) +} + +// UpdateUserDetailsByUID mocks base method. +func (m *MockUserRepository) UpdateUserDetailsByUID(ctx context.Context, data *repository.UpdateUserInfoRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserDetailsByUID", ctx, data) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateUserDetailsByUID indicates an expected call of UpdateUserDetailsByUID. +func (mr *MockUserRepositoryMockRecorder) UpdateUserDetailsByUID(ctx, data any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserDetailsByUID", reflect.TypeOf((*MockUserRepository)(nil).UpdateUserDetailsByUID), ctx, data) +} + +// MockUserIndexUP is a mock of UserIndexUP interface. +type MockUserIndexUP struct { + ctrl *gomock.Controller + recorder *MockUserIndexUPMockRecorder + isgomock struct{} +} + +// MockUserIndexUPMockRecorder is the mock recorder for MockUserIndexUP. +type MockUserIndexUPMockRecorder struct { + mock *MockUserIndexUP +} + +// NewMockUserIndexUP creates a new mock instance. +func NewMockUserIndexUP(ctrl *gomock.Controller) *MockUserIndexUP { + mock := &MockUserIndexUP{ctrl: ctrl} + mock.recorder = &MockUserIndexUPMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockUserIndexUP) EXPECT() *MockUserIndexUPMockRecorder { + return m.recorder +} + +// Index20241226001UP mocks base method. +func (m *MockUserIndexUP) Index20241226001UP(ctx context.Context) (*mongo.Cursor, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Index20241226001UP", ctx) + ret0, _ := ret[0].(*mongo.Cursor) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Index20241226001UP indicates an expected call of Index20241226001UP. +func (mr *MockUserIndexUPMockRecorder) Index20241226001UP(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Index20241226001UP", reflect.TypeOf((*MockUserIndexUP)(nil).Index20241226001UP), ctx) +} + +// MockBaseUserRepository is a mock of BaseUserRepository interface. +type MockBaseUserRepository struct { + ctrl *gomock.Controller + recorder *MockBaseUserRepositoryMockRecorder + isgomock struct{} +} + +// MockBaseUserRepositoryMockRecorder is the mock recorder for MockBaseUserRepository. +type MockBaseUserRepositoryMockRecorder struct { + mock *MockBaseUserRepository +} + +// NewMockBaseUserRepository creates a new mock instance. +func NewMockBaseUserRepository(ctrl *gomock.Controller) *MockBaseUserRepository { + mock := &MockBaseUserRepository{ctrl: ctrl} + mock.recorder = &MockBaseUserRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockBaseUserRepository) EXPECT() *MockBaseUserRepositoryMockRecorder { + return m.recorder +} + +// Delete mocks base method. +func (m *MockBaseUserRepository) Delete(ctx context.Context, id string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", ctx, id) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Delete indicates an expected call of Delete. +func (mr *MockBaseUserRepositoryMockRecorder) Delete(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockBaseUserRepository)(nil).Delete), ctx, id) +} + +// FindOne mocks base method. +func (m *MockBaseUserRepository) FindOne(ctx context.Context, id string) (*entity.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindOne", ctx, id) + ret0, _ := ret[0].(*entity.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindOne indicates an expected call of FindOne. +func (mr *MockBaseUserRepositoryMockRecorder) FindOne(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOne", reflect.TypeOf((*MockBaseUserRepository)(nil).FindOne), ctx, id) +} + +// Insert mocks base method. +func (m *MockBaseUserRepository) Insert(ctx context.Context, data *entity.User) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Insert", ctx, data) + ret0, _ := ret[0].(error) + return ret0 +} + +// Insert indicates an expected call of Insert. +func (mr *MockBaseUserRepositoryMockRecorder) Insert(ctx, data any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Insert", reflect.TypeOf((*MockBaseUserRepository)(nil).Insert), ctx, data) +} + +// Update mocks base method. +func (m *MockBaseUserRepository) Update(ctx context.Context, data *entity.User) (*mongo.UpdateResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", ctx, data) + ret0, _ := ret[0].(*mongo.UpdateResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockBaseUserRepositoryMockRecorder) Update(ctx, data any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockBaseUserRepository)(nil).Update), ctx, data) +} diff --git a/pkg/member/mock/repository/verify_code.go b/pkg/member/mock/repository/verify_code.go new file mode 100644 index 0000000..01ce18e --- /dev/null +++ b/pkg/member/mock/repository/verify_code.go @@ -0,0 +1,84 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./pkg/member/domain/repository/verify_code.go +// +// Generated by this command: +// +// mockgen -source=./pkg/member/domain/repository/verify_code.go -destination=./pkg/member/mock/repository/verify_code.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockVerifyCodeRepository is a mock of VerifyCodeRepository interface. +type MockVerifyCodeRepository struct { + ctrl *gomock.Controller + recorder *MockVerifyCodeRepositoryMockRecorder + isgomock struct{} +} + +// MockVerifyCodeRepositoryMockRecorder is the mock recorder for MockVerifyCodeRepository. +type MockVerifyCodeRepositoryMockRecorder struct { + mock *MockVerifyCodeRepository +} + +// NewMockVerifyCodeRepository creates a new mock instance. +func NewMockVerifyCodeRepository(ctrl *gomock.Controller) *MockVerifyCodeRepository { + mock := &MockVerifyCodeRepository{ctrl: ctrl} + mock.recorder = &MockVerifyCodeRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockVerifyCodeRepository) EXPECT() *MockVerifyCodeRepositoryMockRecorder { + return m.recorder +} + +// DelVerifyCode mocks base method. +func (m *MockVerifyCodeRepository) DelVerifyCode(ctx context.Context, loginID, checkType string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DelVerifyCode", ctx, loginID, checkType) + ret0, _ := ret[0].(error) + return ret0 +} + +// DelVerifyCode indicates an expected call of DelVerifyCode. +func (mr *MockVerifyCodeRepositoryMockRecorder) DelVerifyCode(ctx, loginID, checkType any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DelVerifyCode", reflect.TypeOf((*MockVerifyCodeRepository)(nil).DelVerifyCode), ctx, loginID, checkType) +} + +// IsVerifyCodeExist mocks base method. +func (m *MockVerifyCodeRepository) IsVerifyCodeExist(ctx context.Context, loginID, checkType string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsVerifyCodeExist", ctx, loginID, checkType) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsVerifyCodeExist indicates an expected call of IsVerifyCodeExist. +func (mr *MockVerifyCodeRepositoryMockRecorder) IsVerifyCodeExist(ctx, loginID, checkType any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsVerifyCodeExist", reflect.TypeOf((*MockVerifyCodeRepository)(nil).IsVerifyCodeExist), ctx, loginID, checkType) +} + +// SetVerifyCode mocks base method. +func (m *MockVerifyCodeRepository) SetVerifyCode(ctx context.Context, loginID, checkType, code string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetVerifyCode", ctx, loginID, checkType, code) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetVerifyCode indicates an expected call of SetVerifyCode. +func (mr *MockVerifyCodeRepositoryMockRecorder) SetVerifyCode(ctx, loginID, checkType, code any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetVerifyCode", reflect.TypeOf((*MockVerifyCodeRepository)(nil).SetVerifyCode), ctx, loginID, checkType, code) +} diff --git a/pkg/member/mock/usecase/generate_uid.go b/pkg/member/mock/usecase/generate_uid.go new file mode 100644 index 0000000..b9a1064 --- /dev/null +++ b/pkg/member/mock/usecase/generate_uid.go @@ -0,0 +1,56 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./pkg/member/domain/usecase/generate_uid.go +// +// Generated by this command: +// +// mockgen -source=./pkg/member/domain/usecase/generate_uid.go -destination=./pkg/member/mock/usecase/generate_uid.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockUIDGenerateUseCase is a mock of UIDGenerateUseCase interface. +type MockUIDGenerateUseCase struct { + ctrl *gomock.Controller + recorder *MockUIDGenerateUseCaseMockRecorder + isgomock struct{} +} + +// MockUIDGenerateUseCaseMockRecorder is the mock recorder for MockUIDGenerateUseCase. +type MockUIDGenerateUseCaseMockRecorder struct { + mock *MockUIDGenerateUseCase +} + +// NewMockUIDGenerateUseCase creates a new mock instance. +func NewMockUIDGenerateUseCase(ctrl *gomock.Controller) *MockUIDGenerateUseCase { + mock := &MockUIDGenerateUseCase{ctrl: ctrl} + mock.recorder = &MockUIDGenerateUseCaseMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockUIDGenerateUseCase) EXPECT() *MockUIDGenerateUseCaseMockRecorder { + return m.recorder +} + +// Generate mocks base method. +func (m *MockUIDGenerateUseCase) Generate(ctx context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Generate", ctx) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Generate indicates an expected call of Generate. +func (mr *MockUIDGenerateUseCaseMockRecorder) Generate(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Generate", reflect.TypeOf((*MockUIDGenerateUseCase)(nil).Generate), ctx) +} diff --git a/pkg/member/repository/account.go b/pkg/member/repository/account.go new file mode 100644 index 0000000..65b6329 --- /dev/null +++ b/pkg/member/repository/account.go @@ -0,0 +1,157 @@ +package repository + +import ( + "context" + "errors" + "time" + + "backend/pkg/member/domain/member" + + "backend/pkg/member/domain" + "backend/pkg/member/domain/entity" + "backend/pkg/member/domain/repository" + + "backend/pkg/library/mongo" + "github.com/zeromicro/go-zero/core/stores/cache" + "github.com/zeromicro/go-zero/core/stores/mon" + "go.mongodb.org/mongo-driver/v2/bson" + mongodriver "go.mongodb.org/mongo-driver/v2/mongo" +) + +type AccountRepositoryParam struct { + Conf *mongo.Conf + CacheConf cache.CacheConf + DBOpts []mon.Option + CacheOpts []cache.Option +} + +type AccountRepository struct { + DB mongo.DocumentDBWithCacheUseCase +} + +func NewAccountRepository(param AccountRepositoryParam) repository.AccountRepository { + e := entity.Account{} + documentDB, err := mongo.MustDocumentDBWithCache( + param.Conf, + e.CollectionName(), + param.CacheConf, + param.DBOpts, + param.CacheOpts, + ) + if err != nil { + panic(err) + } + + return &AccountRepository{ + DB: documentDB, + } +} + +func (repo *AccountRepository) Insert(ctx context.Context, data *entity.Account) error { + if data.ID.IsZero() { + now := time.Now().UTC().UnixNano() + data.ID = bson.NewObjectID() + data.CreateAt = &now + data.UpdateAt = &now + } + rk := domain.GetAccountRedisKey(data.ID.Hex()) + _, err := repo.DB.InsertOne(ctx, rk, data) + + return err +} + +func (repo *AccountRepository) FindOne(ctx context.Context, id string) (*entity.Account, error) { + oid, err := bson.ObjectIDFromHex(id) + if err != nil { + return nil, ErrInvalidObjectID + } + + var data entity.Account + rk := domain.GetAccountRedisKey(id) + err = repo.DB.FindOne(ctx, rk, &data, bson.M{"_id": oid}) + + switch { + case err == nil: + return &data, nil + case errors.Is(err, mon.ErrNotFound): + return nil, ErrNotFound + default: + return nil, err + } +} + +func (repo *AccountRepository) Update(ctx context.Context, data *entity.Account) (*mongodriver.UpdateResult, error) { + now := time.Now().UTC().UnixNano() + data.UpdateAt = &now + + rk := domain.GetAccountRedisKey(data.ID.Hex()) + res, err := repo.DB.UpdateOne(ctx, rk, bson.M{"_id": data.ID}, bson.M{"$set": data}) + + return res, err +} + +func (repo *AccountRepository) Delete(ctx context.Context, id string) (int64, error) { + oid, err := bson.ObjectIDFromHex(id) + if err != nil { + return 0, ErrInvalidObjectID + } + rk := domain.GetAccountRedisKey(id) + + return repo.DB.DeleteOne(ctx, rk, bson.M{"_id": oid}) +} + +func (repo *AccountRepository) FindOneByAccount(ctx context.Context, loginID string) (*entity.Account, error) { + // todo: 之後需要同步快取 + var data entity.Account + err := repo.DB.GetClient().FindOne(ctx, &data, bson.M{"login_id": loginID}) + switch { + case err == nil: + return &data, nil + case errors.Is(err, mon.ErrNotFound): + return nil, ErrNotFound + default: + return nil, err + } +} + +func (repo *AccountRepository) UpdateTokenByLoginID(ctx context.Context, account string, token string, platform member.Platform) error { + // todo: 之後需要同步快取 + filter := bson.M{"login_id": account, "platform": platform.ToInt64()} + update := bson.M{ + "$set": bson.M{ + "token": token, + "update_at": time.Now().UTC().UnixNano(), + }, + } + var data entity.Account + err := repo.DB.GetClient().FindOne(ctx, &data, filter) + if err != nil { + return err + } + + rk := domain.GetAccountRedisKey(data.ID.Hex()) + + modify, err := repo.DB.UpdateOne(ctx, rk, bson.M{"_id": data.ID}, update) + if err != nil { + return err + } + + if modify.MatchedCount == 0 { + return ErrNotFound // 自定義的錯誤表示未找到記錄 + } + + return nil +} + +func (repo *AccountRepository) Index20241226001UP(ctx context.Context) (*mongodriver.Cursor, error) { + // 等價於 db.account.createIndex({ "login_id": 1, "platform": 1}, {unique: true}) + repo.DB.PopulateMultiIndex(ctx, []string{ + "login_id", + "platform", + }, []int32{1, 1}, true) + + // 等價於 db.account.createIndex({"create_at": 1}) + repo.DB.PopulateIndex(ctx, "create_at", 1, false) + + return repo.DB.GetClient().Indexes().List(ctx) +} diff --git a/pkg/member/repository/account_test.go b/pkg/member/repository/account_test.go new file mode 100644 index 0000000..f3d8ad3 --- /dev/null +++ b/pkg/member/repository/account_test.go @@ -0,0 +1,355 @@ +package repository + +import ( + "backend/pkg/member/domain/member" + "context" + "errors" + "fmt" + "testing" + "time" + + "backend/pkg/member/domain/entity" + "backend/pkg/member/domain/repository" + + mgo "backend/pkg/library/mongo" + "github.com/alicebob/miniredis/v2" + "github.com/stretchr/testify/assert" + "github.com/zeromicro/go-zero/core/stores/cache" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/zeromicro/go-zero/core/stores/redis" +) + +func SetupTestAccountRepository(db string) (repository.AccountRepository, func(), error) { + h, p, tearDown, err := startMongoContainer() + if err != nil { + return nil, nil, err + } + s, _ := miniredis.Run() + + conf := &mgo.Conf{ + Schema: Schema, + Host: fmt.Sprintf("%s:%s", h, p), + Database: db, + MaxStaleness: 300, + MaxPoolSize: 100, + MinPoolSize: 100, + MaxConnIdleTime: 300, + Compressors: []string{}, + EnableStandardReadWriteSplitMode: false, + ConnectTimeoutMs: 3000, + } + + cacheConf := cache.CacheConf{ + cache.NodeConf{ + RedisConf: redis.RedisConf{ + Host: s.Addr(), + Type: redis.NodeType, + }, + Weight: 100, + }, + } + + cacheOpts := []cache.Option{ + cache.WithExpiry(1000 * time.Microsecond), + cache.WithNotFoundExpiry(1000 * time.Microsecond), + } + + param := AccountRepositoryParam{ + Conf: conf, + CacheConf: cacheConf, + CacheOpts: cacheOpts, + } + repo := NewAccountRepository(param) + _, _ = repo.Index20241226001UP(context.Background()) + + return repo, tearDown, nil +} + +func TestAccountModel_Insert(t *testing.T) { + repo, tearDown, err := SetupTestAccountRepository("testDB") + defer tearDown() + assert.NoError(t, err) + + tests := []struct { + name string + account *entity.Account + expectError bool + }{ + { + name: "Valid account insert", + account: &entity.Account{ + LoginID: "testuser1", + Token: "testtoken1", + Platform: 1, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 插入測試帳戶 + err := repo.Insert(context.Background(), tt.account) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err, "插入操作應該成功") + + // 檢查是否生成了 ObjectID 和時間戳 + assert.NotZero(t, tt.account.ID, "ID 應該被生成") + assert.NotNil(t, tt.account.CreateAt, "CreateAt 應該被設置") + assert.NotNil(t, tt.account.UpdateAt, "UpdateAt 應該被設置") + + // 檢查插入的時間是否合理 + now := time.Now().UTC().UnixNano() + assert.LessOrEqual(t, *tt.account.CreateAt, now, "CreateAt 應在當前時間之前") + assert.LessOrEqual(t, *tt.account.UpdateAt, now, "UpdateAt 應在當前時間之前") + + // 確認插入的資料 + insertedAccount, err := repo.FindOne(context.Background(), tt.account.ID.Hex()) + assert.NoError(t, err, "應該可以找到插入的帳號資料") + assert.Equal(t, tt.account.LoginID, insertedAccount.LoginID, "LoginID 應相同") + assert.Equal(t, tt.account.Token, insertedAccount.Token, "Token 應相同") + assert.Equal(t, tt.account.Platform, insertedAccount.Platform, "Platform 應相同") + } + }) + } +} + +func TestAccountModel_FindOne(t *testing.T) { + repo, tearDown, err := SetupTestAccountRepository("testDB") + assert.NoError(t, err) + defer tearDown() + + // 插入測試帳戶 + account := &entity.Account{ + LoginID: "testuser1", + Token: "testtoken1", + Platform: 1, + } + err = repo.Insert(context.TODO(), account) + assert.NoError(t, err, "插入應成功") + nid := primitive.NewObjectID() + t.Logf("Inserted account ID: %s, nid:%s", account.ID.Hex(), nid.Hex()) + + tests := []struct { + name string + id string + expectError bool + expectedErr error + }{ + { + name: "Valid ID", + id: account.ID.Hex(), + expectError: false, + }, + { + name: "Non-existent ID", + id: nid.Hex(), + expectError: true, + expectedErr: ErrNotFound, + }, + { + name: "Invalid ID format", + id: "invalid-id", + expectError: true, + expectedErr: ErrInvalidObjectID, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := repo.FindOne(context.TODO(), tt.id) + if tt.expectError { + assert.Error(t, err) + assert.Equal(t, tt.expectedErr, err) + } else { + assert.NoError(t, err) + assert.Equal(t, account.LoginID, result.LoginID) + assert.Equal(t, account.Token, result.Token) + } + }) + } +} + +func TestAccountModel_Update(t *testing.T) { + repo, tearDown, err := SetupTestAccountRepository("testDB") + assert.NoError(t, err) + defer tearDown() + + // 插入測試帳戶 + account := &entity.Account{ + LoginID: "testuser1", + Token: "testtoken1", + Platform: 1, + } + err = repo.Insert(context.Background(), account) + assert.NoError(t, err, "插入應成功") + + // 更新測試 + newToken := "updatedToken" + account.Token = newToken + + _, err = repo.Update(context.Background(), account) + assert.NoError(t, err, "更新應成功") + + // 驗證更新結果 + updatedAccount, err := repo.FindOne(context.Background(), account.ID.Hex()) + assert.NoError(t, err) + assert.Equal(t, newToken, updatedAccount.Token, "Token 應更新") +} + +func TestAccountModel_Delete(t *testing.T) { + repo, tearDown, err := SetupTestAccountRepository("testDB") + assert.NoError(t, err) + defer tearDown() + + // 插入測試帳戶 + account := &entity.Account{ + LoginID: "testuser1", + Token: "testtoken1", + Platform: 1, + } + err = repo.Insert(context.Background(), account) + assert.NoError(t, err, "插入應成功") + + tests := []struct { + name string + id string + expectError bool + expectedErr error + }{ + { + name: "Valid delete", + id: account.ID.Hex(), + expectError: false, + }, + { + name: "Invalid ID format", + id: "invalid-id", + expectError: true, + expectedErr: ErrInvalidObjectID, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deletedCount, err := repo.Delete(context.Background(), tt.id) + if tt.expectError { + assert.Error(t, err) + assert.Equal(t, tt.expectedErr, err) + } else { + assert.NoError(t, err) + assert.Equal(t, int64(1), deletedCount, "刪除應成功") + } + }) + } +} + +func TestAccountModel_FindOneByAccount(t *testing.T) { + repo, tearDown, err := SetupTestAccountRepository("testDB") + assert.NoError(t, err) + defer tearDown() + + // 插入測試帳戶 + account := &entity.Account{ + LoginID: "testuser1", + Token: "testtoken1", + Platform: 1, + } + err = repo.Insert(context.Background(), account) + assert.NoError(t, err, "插入應成功") + + tests := []struct { + name string + loginID string + expectedErr error + expectFound bool + }{ + { + name: "Valid Account Found", + loginID: "testuser1", + expectedErr: nil, + expectFound: true, + }, + { + name: "Account Not Found", + loginID: "nonexistentuser", + expectedErr: ErrNotFound, + expectFound: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := repo.FindOneByAccount(context.Background(), tt.loginID) + if tt.expectFound { + assert.NoError(t, err) + assert.Equal(t, account.LoginID, result.LoginID) + assert.Equal(t, account.Token, result.Token) + } else { + assert.Error(t, err) + assert.True(t, errors.Is(err, tt.expectedErr)) + } + }) + } +} + +func TestAccountModel_UpdateTokenByLoginID(t *testing.T) { + repo, tearDown, err := SetupTestAccountRepository("testDB") + assert.NoError(t, err) + defer tearDown() + + // 插入測試帳戶 + account := &entity.Account{ + LoginID: "testuser2", + Token: "initialtoken", + Platform: 1, + } + err = repo.Insert(context.Background(), account) + assert.NoError(t, err, "插入應成功") + + tests := []struct { + name string + loginID string + newToken string + platform int64 + expectedErr error + expectFound bool + }{ + { + name: "Valid Update Token", + loginID: "testuser2", + newToken: "newtoken123", + platform: 1, + expectedErr: nil, + expectFound: true, + }, + { + name: "Account Not Found for Update", + loginID: "nonexistentuser", + newToken: "newtoken456", + platform: 1, + expectedErr: ErrNotFound, + expectFound: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := repo.UpdateTokenByLoginID(context.Background(), tt.loginID, tt.newToken, member.Platform(tt.platform)) + if tt.expectFound { + assert.NoError(t, err) + + // 驗證更新後的 token 值 + updatedAccount, findErr := repo.FindOneByAccount(context.Background(), tt.loginID) + assert.NoError(t, findErr) + assert.Equal(t, tt.newToken, updatedAccount.Token) + } else { + assert.Error(t, err) + assert.True(t, errors.Is(err, tt.expectedErr)) + } + }) + } +} diff --git a/pkg/member/repository/account_uid.go b/pkg/member/repository/account_uid.go new file mode 100644 index 0000000..c7340d3 --- /dev/null +++ b/pkg/member/repository/account_uid.go @@ -0,0 +1,125 @@ +package repository + +import ( + "context" + "errors" + "time" + + "backend/pkg/member/domain" + "backend/pkg/member/domain/entity" + "backend/pkg/member/domain/repository" + + "backend/pkg/library/mongo" + "github.com/zeromicro/go-zero/core/stores/cache" + "github.com/zeromicro/go-zero/core/stores/mon" + "go.mongodb.org/mongo-driver/v2/bson" + mongodriver "go.mongodb.org/mongo-driver/v2/mongo" +) + +type AccountUIDRepositoryParam struct { + Conf *mongo.Conf + CacheConf cache.CacheConf + DBOpts []mon.Option + CacheOpts []cache.Option +} + +type AccountUIDRepository struct { + DB mongo.DocumentDBWithCacheUseCase +} + +func NewAccountUIDRepository(param AccountUIDRepositoryParam) repository.AccountUIDRepository { + e := entity.AccountUID{} + documentDB, err := mongo.MustDocumentDBWithCache( + param.Conf, + e.CollectionName(), + param.CacheConf, + param.DBOpts, + param.CacheOpts, + ) + if err != nil { + panic(err) + } + + return &AccountUIDRepository{ + DB: documentDB, + } +} + +func (repo *AccountUIDRepository) Insert(ctx context.Context, data *entity.AccountUID) error { + if data.ID.IsZero() { + now := time.Now().UTC().UnixNano() + data.ID = bson.NewObjectID() + data.CreateAt = &now + data.UpdateAt = &now + } + rk := domain.GetAccountUIDRedisKey(data.ID.Hex()) + _, err := repo.DB.InsertOne(ctx, rk, data) + + return err +} + +func (repo *AccountUIDRepository) FindOne(ctx context.Context, id string) (*entity.AccountUID, error) { + oid, err := bson.ObjectIDFromHex(id) + if err != nil { + return nil, ErrInvalidObjectID + } + + var data entity.AccountUID + rk := domain.GetAccountUIDRedisKey(id) + err = repo.DB.FindOne(ctx, rk, &data, bson.M{"_id": oid}) + switch { + case err == nil: + return &data, nil + case errors.Is(err, mon.ErrNotFound): + return nil, ErrNotFound + default: + return nil, err + } +} + +func (repo *AccountUIDRepository) Update(ctx context.Context, data *entity.AccountUID) (*mongodriver.UpdateResult, error) { + now := time.Now().UTC().UnixNano() + data.UpdateAt = &now + + rk := domain.GetAccountUIDRedisKey(data.ID.Hex()) + res, err := repo.DB.UpdateOne(ctx, rk, bson.M{"_id": data.ID}, bson.M{"$set": data}) + + return res, err +} + +func (repo *AccountUIDRepository) Delete(ctx context.Context, id string) (int64, error) { + oid, err := bson.ObjectIDFromHex(id) + if err != nil { + return 0, ErrInvalidObjectID + } + rk := domain.GetAccountUIDRedisKey(id) + + return repo.DB.DeleteOne(ctx, rk, bson.M{"_id": oid}) +} + +func (repo *AccountUIDRepository) FindUIDByLoginID(ctx context.Context, loginID string) (*entity.AccountUID, error) { + var data entity.AccountUID + + err := repo.DB.GetClient().FindOne(ctx, &data, bson.M{"login_id": loginID}) + switch { + case err == nil: + return &data, nil + case errors.Is(err, mon.ErrNotFound): + return nil, ErrNotFound + default: + return nil, err + } +} + +func (repo *AccountUIDRepository) Index20241226001UP(ctx context.Context) (*mongodriver.Cursor, error) { + // 等價於 db.account_uid_binding.createIndex({"login_id": 1}, {unique: true}) + repo.DB.PopulateIndex(ctx, "login_id", 1, true) + + // 等價於 db.account_uid_binding.createIndex({"uid": 1}) + repo.DB.PopulateIndex(ctx, "uid", 1, false) + + // 等價於 db.account_uid_binding.createIndex({"create_at": 1}) + repo.DB.PopulateIndex(ctx, "create_at", 1, false) + + return repo.DB.GetClient().Indexes().List(ctx) +} diff --git a/pkg/member/repository/account_uid_test.go b/pkg/member/repository/account_uid_test.go new file mode 100644 index 0000000..d3c041a --- /dev/null +++ b/pkg/member/repository/account_uid_test.go @@ -0,0 +1,273 @@ +package repository + +import ( + "context" + "fmt" + "testing" + "time" + + "backend/pkg/member/domain/entity" + "backend/pkg/member/domain/repository" + + mgo "backend/pkg/library/mongo" + "github.com/alicebob/miniredis/v2" + "github.com/stretchr/testify/assert" + "github.com/zeromicro/go-zero/core/stores/cache" + "github.com/zeromicro/go-zero/core/stores/redis" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func SetupTestAccountUIDRepository(db string) (repository.AccountUIDRepository, func(), error) { + h, p, tearDown, err := startMongoContainer() + if err != nil { + return nil, nil, err + } + s, _ := miniredis.Run() + + conf := &mgo.Conf{ + Schema: Schema, + Host: fmt.Sprintf("%s:%s", h, p), + Database: db, + MaxStaleness: 300, + MaxPoolSize: 100, + MinPoolSize: 100, + MaxConnIdleTime: 300, + Compressors: []string{}, + EnableStandardReadWriteSplitMode: false, + ConnectTimeoutMs: 3000, + } + + cacheConf := cache.CacheConf{ + cache.NodeConf{ + RedisConf: redis.RedisConf{ + Host: s.Addr(), + Type: redis.NodeType, + }, + Weight: 100, + }, + } + + cacheOpts := []cache.Option{ + cache.WithExpiry(1000 * time.Microsecond), + cache.WithNotFoundExpiry(1000 * time.Microsecond), + } + + param := AccountUIDRepositoryParam{ + Conf: conf, + CacheConf: cacheConf, + CacheOpts: cacheOpts, + } + + repo := NewAccountUIDRepository(param) + _, _ = repo.Index20241226001UP(context.Background()) + + return repo, tearDown, nil +} + +func TestDefaultAccountUidModel_Insert(t *testing.T) { + repo, tearDown, err := SetupTestAccountUIDRepository("testDB") + defer tearDown() + assert.NoError(t, err) + tests := []struct { + name string + accountUid *entity.AccountUID + expectError bool + }{ + { + name: "Valid AccountUid insert", + accountUid: &entity.AccountUID{ + LoginID: "testlogin1", + UID: "testuid1", + Type: 1, + }, + expectError: false, + }, + { + name: "Insert with missing UID", + accountUid: &entity.AccountUID{ + LoginID: "testlogin2", + Type: 2, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 執行插入測試帳戶 UID + err := repo.Insert(context.Background(), tt.accountUid) + if tt.expectError { + assert.Error(t, err, "插入操作應該失敗") + } else { + assert.NoError(t, err, "插入操作應該成功") + + // 驗證 ObjectID 和時間戳是否生成 + assert.NotZero(t, tt.accountUid.ID, "應生成 ID") + assert.NotNil(t, tt.accountUid.CreateAt, "CreateAt 應被設置") + assert.NotNil(t, tt.accountUid.UpdateAt, "UpdateAt 應被設置") + + // 驗證插入的時間是否合理 + now := time.Now().UTC().UnixNano() + assert.LessOrEqual(t, *tt.accountUid.CreateAt, now, "CreateAt 應在當前時間之前") + assert.LessOrEqual(t, *tt.accountUid.UpdateAt, now, "UpdateAt 應在當前時間之前") + + // 驗證插入的資料是否正確 + insertedAccountUid, err := repo.FindOne(context.Background(), tt.accountUid.ID.Hex()) + assert.NoError(t, err, "應該可以找到插入的帳號 UID 資料") + assert.Equal(t, tt.accountUid.LoginID, insertedAccountUid.LoginID, "LoginID 應相同") + assert.Equal(t, tt.accountUid.UID, insertedAccountUid.UID, "UID 應相同") + assert.Equal(t, tt.accountUid.Type, insertedAccountUid.Type, "Type 應相同") + } + }) + } +} + +func TestDefaultAccountUidModel_FindOne(t *testing.T) { + repo, tearDown, err := SetupTestAccountUIDRepository("testDB") + defer tearDown() + assert.NoError(t, err) + + // 準備測試資料 + accountUid := &entity.AccountUID{ + LoginID: "testlogin", + UID: "testuid", + Type: 1, + } + err = repo.Insert(context.Background(), accountUid) + assert.NoError(t, err, "應成功插入測試資料") + + tests := []struct { + name string + id string + expectError bool + expectedUID string + }{ + { + name: "Valid FindOne", + id: accountUid.ID.Hex(), + expectError: false, + expectedUID: accountUid.UID, + }, + { + name: "Invalid ObjectID", + id: "invalid_id", + expectError: true, + }, + { + name: "Non-existent ObjectID", + id: primitive.NewObjectID().Hex(), + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := repo.FindOne(context.Background(), tt.id) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedUID, result.UID, "找到的 UID 應符合預期") + } + }) + } +} + +func TestDefaultAccountUidModel_Update(t *testing.T) { + repo, tearDown, err := SetupTestAccountUIDRepository("testDB") + defer tearDown() + assert.NoError(t, err) + + // 準備測試資料 + accountUid := &entity.AccountUID{ + LoginID: "testlogin", + UID: "testuid", + Type: 1, + } + err = repo.Insert(context.Background(), accountUid) + assert.NoError(t, err) + + updatedUID := "updatedUID" + accountUid.UID = updatedUID + + // 執行更新操作 + _, err = repo.Update(context.Background(), accountUid) + assert.NoError(t, err, "應成功更新資料") + + // 確認更新結果 + updatedAccountUid, err := repo.FindOne(context.Background(), accountUid.ID.Hex()) + assert.NoError(t, err, "應能找到更新後的資料") + assert.Equal(t, updatedUID, updatedAccountUid.UID, "更新後的 UID 應符合預期") +} + +func TestDefaultAccountUidModel_Delete(t *testing.T) { + repo, tearDown, err := SetupTestAccountUIDRepository("testDB") + defer tearDown() + assert.NoError(t, err) + + // 準備測試資料 + accountUid := &entity.AccountUID{ + LoginID: "testlogin", + UID: "testuid", + Type: 1, + } + err = repo.Insert(context.Background(), accountUid) + assert.NoError(t, err) + + // 執行刪除操作 + count, err := repo.Delete(context.Background(), accountUid.ID.Hex()) + assert.NoError(t, err, "應成功刪除資料") + assert.Equal(t, int64(1), count, "刪除數量應為 1") + + // 確認資料已被刪除 + _, err = repo.FindOne(context.Background(), accountUid.ID.Hex()) + assert.Error(t, err, "應無法找到已刪除的資料") + assert.Equal(t, ErrNotFound, err, "應返回 ErrNotFound 錯誤") +} + +func TestCustomAccountUidModel_FindUIDByLoginID(t *testing.T) { + repo, tearDown, err := SetupTestAccountUIDRepository("testDB") + defer tearDown() + assert.NoError(t, err) + + // 準備測試資料 + accountUid := &entity.AccountUID{ + LoginID: "testloginid", + UID: "testuid", + Type: 1, + } + err = repo.Insert(context.Background(), accountUid) + assert.NoError(t, err, "應成功插入測試資料") + + tests := []struct { + name string + loginID string + expectError bool + expectedUID string + }{ + { + name: "Valid FindUIDByLoginID", + loginID: "testloginid", + expectError: false, + expectedUID: "testuid", + }, + { + name: "Non-existent LoginID", + loginID: "nonexistent", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := repo.FindUIDByLoginID(context.Background(), tt.loginID) + if tt.expectError { + assert.Error(t, err) + assert.Equal(t, ErrNotFound, err, "應返回 ErrNotFound 錯誤") + } else { + assert.NoError(t, err, "應成功找到符合的記錄") + assert.Equal(t, tt.expectedUID, result.UID, "找到的 UID 應符合預期") + } + }) + } +} diff --git a/pkg/member/repository/auto_id.go b/pkg/member/repository/auto_id.go new file mode 100644 index 0000000..43f5cfa --- /dev/null +++ b/pkg/member/repository/auto_id.go @@ -0,0 +1,124 @@ +package repository + +import ( + "context" + "errors" + "time" + + "backend/pkg/member/domain/entity" + "backend/pkg/member/domain/repository" + + GIDLib "code.30cm.net/digimon/library-go/utils/invited_code" + + "backend/pkg/library/mongo" + "github.com/zeromicro/go-zero/core/stores/mon" + "go.mongodb.org/mongo-driver/v2/bson" + mongodriver "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type AutoIDRepositoryParam struct { + Conf *mongo.Conf + DBOpts []mon.Option +} + +type AutoIDRepository struct { + DB mongo.DocumentDBUseCase + UIDTrans GIDLib.ConvertUseCase +} + +func NewAutoIDRepository(param AutoIDRepositoryParam) repository.AutoIDRepository { + e := entity.AutoID{} + documentDB, err := mongo.NewDocumentDB(param.Conf, e.CollectionName(), param.DBOpts...) + if err != nil { + panic(err) + } + + return &AutoIDRepository{ + DB: documentDB, + UIDTrans: GIDLib.MustConverter(10, 8, GIDLib.ConvertTable), + } +} + +func (repo *AutoIDRepository) Insert(ctx context.Context, data *entity.AutoID) error { + if data.ID.IsZero() { + now := time.Now().UTC().UnixNano() + data.ID = bson.NewObjectID() + data.CreateAt = &now + data.UpdateAt = &now + } + _, err := repo.DB.GetClient().InsertOne(ctx, data) + + return err +} + +func (repo *AutoIDRepository) FindOne(ctx context.Context, id string) (*entity.AutoID, error) { + oid, err := bson.ObjectIDFromHex(id) + if err != nil { + return nil, ErrInvalidObjectID + } + + var data entity.AutoID + + err = repo.DB.GetClient().FindOne(ctx, &data, bson.M{"_id": oid}) + switch { + case err == nil: + return &data, nil + case errors.Is(err, mon.ErrNotFound): + return nil, ErrNotFound + default: + return nil, err + } +} + +func (repo *AutoIDRepository) Update(ctx context.Context, data *entity.AutoID) (*mongodriver.UpdateResult, error) { + now := time.Now().UTC().UnixNano() + data.UpdateAt = &now + + res, err := repo.DB.GetClient().UpdateOne(ctx, bson.M{"_id": data.ID}, bson.M{"$set": data}) + + return res, err +} + +func (repo *AutoIDRepository) Delete(ctx context.Context, id string) (int64, error) { + oid, err := bson.ObjectIDFromHex(id) + if err != nil { + return 0, ErrInvalidObjectID + } + + res, err := repo.DB.GetClient().DeleteOne(ctx, bson.M{"_id": oid}) + + return res, err +} + +func (repo *AutoIDRepository) Inc(ctx context.Context, data *entity.AutoID) error { + // 定義查詢的條件 + filter := bson.M{"name": "auto_id"} + + // 定義更新的操作,包括自增和更新時間 + update := bson.M{ + "$inc": bson.M{"counter": 1}, // 自增 counter + "$set": bson.M{"updateAt": time.Now().UTC().UnixNano()}, // 設置 updateAt 為當前時間 + } + // 使用 FindOneAndUpdate 並將結果解碼到 data 中 + err := repo.DB.GetClient().FindOneAndUpdate(ctx, &data, filter, update, + options.FindOneAndUpdate().SetUpsert(true), + options.FindOneAndUpdate().SetReturnDocument(options.After)) + + return err +} + +func (repo *AutoIDRepository) Index20241226001UP(ctx context.Context) (*mongodriver.Cursor, error) { + // db.count.createIndex({ "name": 1 }, { unique: true }); + repo.DB.PopulateIndex(ctx, "name", 1, true) + + return repo.DB.GetClient().Indexes().List(ctx) +} + +func (repo *AutoIDRepository) GetUIDFromNum(num int64) (string, error) { + return repo.UIDTrans.EncodeFromNum(num) +} + +func (repo *AutoIDRepository) GetNumFromUID(uid string) (int64, error) { + return repo.UIDTrans.DecodeFromCode(uid) +} diff --git a/pkg/member/repository/auto_id_test.go b/pkg/member/repository/auto_id_test.go new file mode 100644 index 0000000..f65b0b1 --- /dev/null +++ b/pkg/member/repository/auto_id_test.go @@ -0,0 +1,279 @@ +package repository + +import ( + "context" + "fmt" + "testing" + "time" + + "backend/pkg/member/domain/entity" + "backend/pkg/member/domain/repository" + + mgo "backend/pkg/library/mongo" + "github.com/stretchr/testify/assert" +) + +func SetupTestAutoIDRepository(db string) (repository.AutoIDRepository, func(), error) { + h, p, tearDown, err := startMongoContainer() + if err != nil { + return nil, nil, err + } + + conf := &mgo.Conf{ + Schema: Schema, + Host: fmt.Sprintf("%s:%s", h, p), + Database: db, + MaxStaleness: 300, + MaxPoolSize: 100, + MinPoolSize: 100, + MaxConnIdleTime: 300, + Compressors: []string{}, + EnableStandardReadWriteSplitMode: false, + ConnectTimeoutMs: 3000, + } + + param := AutoIDRepositoryParam{ + Conf: conf, + } + + repo := NewAutoIDRepository(param) + _, _ = repo.Index20241226001UP(context.Background()) + + return repo, tearDown, nil +} + +func TestDefaultAutoIDModel_Insert(t *testing.T) { + repo, tearDown, err := SetupTestAutoIDRepository("testDB") + defer tearDown() + assert.NoError(t, err) + + tests := []struct { + name string + data *entity.AutoID + expectError bool + }{ + { + name: "Valid AutoID insert", + data: &entity.AutoID{ + Name: "testCounter", + Counter: 100, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := repo.Insert(context.Background(), tt.data) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err, "插入操作應該成功") + + // 檢查生成的 ID 和時間戳 + assert.NotZero(t, tt.data.ID, "ID 應該被生成") + assert.NotNil(t, tt.data.CreateAt, "CreateAt 應該被設置") + assert.NotNil(t, tt.data.UpdateAt, "UpdateAt 應該被設置") + + // 確認插入的資料是否正確 + now := time.Now().UTC().UnixNano() + assert.LessOrEqual(t, *tt.data.CreateAt, now, "CreateAt 應在當前時間之前") + assert.LessOrEqual(t, *tt.data.UpdateAt, now, "UpdateAt 應在當前時間之前") + + insertedAutoID, err := repo.FindOne(context.Background(), tt.data.ID.Hex()) + assert.NoError(t, err, "應該可以找到插入的資料") + assert.Equal(t, tt.data.Name, insertedAutoID.Name, "Name 應相同") + assert.Equal(t, tt.data.Counter, insertedAutoID.Counter, "Counter 應相同") + } + }) + } +} + +func TestDefaultAutoIDModel_FindOne(t *testing.T) { + repo, tearDown, err := SetupTestAutoIDRepository("testDB") + defer tearDown() + assert.NoError(t, err) + + inserted := &entity.AutoID{ + Name: "findTestCounter", + Counter: 200, + } + err = repo.Insert(context.Background(), inserted) + assert.NoError(t, err) + + tests := []struct { + name string + id string + expectError bool + expectedID string + }{ + { + name: "Valid FindOne by ID", + id: inserted.ID.Hex(), + expectError: false, + expectedID: inserted.ID.Hex(), + }, + { + name: "Invalid ObjectID", + id: "invalidObjectID", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := repo.FindOne(context.Background(), tt.id) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedID, result.ID.Hex(), "ID 應相同") + } + }) + } +} + +func TestDefaultAutoIDModel_Update(t *testing.T) { + repo, tearDown, err := SetupTestAutoIDRepository("testDB") + defer tearDown() + assert.NoError(t, err) + + inserted := &entity.AutoID{ + Name: "updateTestCounter", + Counter: 300, + } + err = repo.Insert(context.Background(), inserted) + assert.NoError(t, err) + + updatedData := *inserted // 創建副本進行更新 + updatedData.Counter = 400 + + tests := []struct { + name string + data *entity.AutoID + expectError bool + }{ + { + name: "Valid Update", + data: &updatedData, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := repo.Update(context.Background(), tt.data) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + // 驗證更新後的 Counter + updatedAutoID, err := repo.FindOne(context.Background(), tt.data.ID.Hex()) + assert.NoError(t, err) + assert.Equal(t, tt.data.Counter, updatedAutoID.Counter, "Counter 應相同") + } + }) + } +} + +func TestDefaultAutoIDModel_Delete(t *testing.T) { + repo, tearDown, err := SetupTestAutoIDRepository("testDB") + defer tearDown() + assert.NoError(t, err) + + inserted := &entity.AutoID{ + Name: "deleteTestCounter", + Counter: 500, + } + err = repo.Insert(context.Background(), inserted) + assert.NoError(t, err) + + tests := []struct { + name string + id string + expectError bool + }{ + { + name: "Valid Delete by ID", + id: inserted.ID.Hex(), + expectError: false, + }, + { + name: "Invalid ObjectID", + id: "invalidObjectID", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + count, err := repo.Delete(context.Background(), tt.id) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, int64(1), count, "刪除應影響 1 條記錄") + + // 確認已刪除 + _, err = repo.FindOne(context.Background(), tt.id) + assert.ErrorIs(t, err, ErrNotFound, "記錄應該不存在") + } + }) + } +} + +func TestCustomAutoIDModel_Inc(t *testing.T) { + + tests := []struct { + name string + initialData *entity.AutoID + expectError bool + }{ + { + name: "Increment non-existing counter (upsert)", + initialData: nil, // 不提供初始數據,預期會自動插入新數據 + expectError: false, + }, + { + name: "Increment existing counter", + initialData: &entity.AutoID{ + Name: "auto_id", + Counter: 5, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo, tearDown, err := SetupTestAutoIDRepository("testDB") + defer tearDown() + assert.NoError(t, err) + // 插入初始數據(如果有) + if tt.initialData != nil { + err := repo.Insert(context.Background(), tt.initialData) + assert.NoError(t, err, "初始插入操作應該成功") + } + + // 創建一個 AutoID 結構來保存增量操作後的結果 + var result entity.AutoID + + // 執行 Inc 方法 + err = repo.Inc(context.Background(), &result) + if tt.expectError { + assert.Error(t, err, "應該返回錯誤") + } else { + assert.NoError(t, err, "Inc 操作應該成功") + + // 驗證結果中的 Counter 值是否正確增量 + expectedCounter := uint64(1) + if tt.initialData != nil { + expectedCounter = tt.initialData.Counter + 1 + } + + assert.Equal(t, expectedCounter, result.Counter, "Counter 值應該自增") + + } + }) + } +} diff --git a/pkg/member/repository/error.go b/pkg/member/repository/error.go new file mode 100755 index 0000000..8fe418f --- /dev/null +++ b/pkg/member/repository/error.go @@ -0,0 +1,22 @@ +package repository + +import ( + "errors" + + "github.com/zeromicro/go-zero/core/stores/mon" +) + +// Common repository errors +var ( + // ErrNotFound is returned when a requested resource is not found + ErrNotFound = mon.ErrNotFound + + // ErrInvalidObjectID is returned when an invalid MongoDB ObjectID is provided + ErrInvalidObjectID = errors.New("invalid objectId") + + // ErrDuplicateKey is returned when attempting to insert a document with a duplicate key + ErrDuplicateKey = errors.New("duplicate key error") + + // ErrInvalidInput is returned when input validation fails + ErrInvalidInput = errors.New("invalid input") +) diff --git a/pkg/member/repository/start_mongo_container_test.go b/pkg/member/repository/start_mongo_container_test.go new file mode 100644 index 0000000..1bcfe1a --- /dev/null +++ b/pkg/member/repository/start_mongo_container_test.go @@ -0,0 +1,52 @@ +package repository + +import ( + "context" + "fmt" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + Host = "127.0.0.1" + Port = "27017" + Schema = "mongodb" +) + +func startMongoContainer() (string, string, func(), error) { + ctx := context.Background() + + req := testcontainers.ContainerRequest{ + Image: "mongo:latest", + ExposedPorts: []string{"27017/tcp"}, + WaitingFor: wait.ForListeningPort("27017/tcp"), + } + + mongoC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + return "", "", nil, err + } + + port, err := mongoC.MappedPort(ctx, Port) + if err != nil { + return "", "", nil, err + } + + host, err := mongoC.Host(ctx) + if err != nil { + return "", "", nil, err + } + + uri := fmt.Sprintf("mongodb://%s:%s", host, port.Port()) + tearDown := func() { + mongoC.Terminate(ctx) + } + + fmt.Printf("Connecting to %s\n", uri) + + return host, port.Port(), tearDown, nil +} diff --git a/pkg/member/repository/user.go b/pkg/member/repository/user.go new file mode 100644 index 0000000..2b11f97 --- /dev/null +++ b/pkg/member/repository/user.go @@ -0,0 +1,368 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "backend/pkg/member/domain" + "backend/pkg/member/domain/entity" + "backend/pkg/member/domain/repository" + + "backend/pkg/library/mongo" + + "time" + + "github.com/zeromicro/go-zero/core/stores/cache" + "github.com/zeromicro/go-zero/core/stores/mon" + "go.mongodb.org/mongo-driver/v2/bson" + mongodriver "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type UserRepositoryParam struct { + Conf *mongo.Conf + CacheConf cache.CacheConf + DBOpts []mon.Option + CacheOpts []cache.Option +} + +type UserRepository struct { + DB mongo.DocumentDBWithCacheUseCase +} + +func NewUserRepository(param UserRepositoryParam) repository.UserRepository { + e := entity.User{} + documentDB, err := mongo.MustDocumentDBWithCache( + param.Conf, + e.CollectionName(), + param.CacheConf, + param.DBOpts, + param.CacheOpts, + ) + if err != nil { + panic(err) + } + + return &UserRepository{ + DB: documentDB, + } +} + +func (repo *UserRepository) Insert(ctx context.Context, data *entity.User) error { + if data.ID.IsZero() { + now := time.Now().UTC().UnixNano() + data.ID = bson.NewObjectID() + data.CreateAt = &now + data.UpdateAt = &now + } + + rk := domain.GetUserRedisKey(data.ID.Hex()) + _, err := repo.DB.InsertOne(ctx, rk, data) + + return err +} + +func (repo *UserRepository) FindOne(ctx context.Context, id string) (*entity.User, error) { + oid, err := bson.ObjectIDFromHex(id) + if err != nil { + return nil, ErrInvalidObjectID + } + + var data entity.User + rk := domain.GetUserRedisKey(id) + err = repo.DB.FindOne(ctx, rk, &data, bson.M{"_id": oid}) + switch { + case err == nil: + return &data, nil + case errors.Is(err, mon.ErrNotFound): + return nil, ErrNotFound + default: + return nil, err + } +} + +func (repo *UserRepository) Update(ctx context.Context, data *entity.User) (*mongodriver.UpdateResult, error) { + now := time.Now().UTC().UnixNano() + data.UpdateAt = &now + + rk := domain.GetUserRedisKey(data.ID.Hex()) + res, err := repo.DB.UpdateOne(ctx, rk, bson.M{"_id": data.ID}, bson.M{"$set": data}) + + return res, err +} + +func (repo *UserRepository) Delete(ctx context.Context, id string) (int64, error) { + oid, err := bson.ObjectIDFromHex(id) + if err != nil { + return 0, ErrInvalidObjectID + } + + rk := domain.GetUserRedisKey(id) + res, err := repo.DB.DeleteOne(ctx, rk, bson.M{"_id": oid}) + + return res, err +} + +func (repo *UserRepository) UpdateUserDetailsByUID(ctx context.Context, data *repository.UpdateUserInfoRequest) error { + updateFields := bson.M{} + + if data.AlarmCategory != nil { + updateFields["alarm_category"] = *data.AlarmCategory + } + if data.UserStatus != nil { + updateFields["user_status"] = *data.UserStatus + } + if data.PreferredLanguage != nil { + updateFields["preferred_language"] = *data.PreferredLanguage + } + if data.Currency != nil { + updateFields["currency"] = *data.Currency + } + if data.Nickname != nil { + updateFields["nickname"] = *data.Nickname + } + if data.AvatarURL != nil { + updateFields["avatar_url"] = *data.AvatarURL + } + if data.FullName != nil { + updateFields["full_name"] = *data.FullName + } + if data.GenderCode != nil { + updateFields["gender_code"] = *data.GenderCode + } + if data.Birthdate != nil { + updateFields["birthdate"] = *data.Birthdate + } + if data.Address != nil { + updateFields["address"] = *data.Address + } + + if len(updateFields) == 0 { + return nil + } + + updateFields["update_at"] = time.Now().UTC().UnixNano() + + filter := bson.M{"uid": data.UID} + update := bson.M{"$set": updateFields} + + // 不常寫,再找一次可接受 + id := repo.UIDToID(ctx, data.UID) + if id == "" { + return errors.New("invalid uid") + } + rk := domain.GetUserRedisKey(id) + result, err := repo.DB.UpdateOne(ctx, rk, filter, update, &options.UpdateOneOptions{Upsert: &[]bool{false}[0]}) + if err != nil { + return err + } + + if result.MatchedCount == 0 { + return ErrNotFound // 自定義的錯誤表示未找到記錄 + } + + return nil +} + +func (repo *UserRepository) UpdateStatus(ctx context.Context, uid string, status int32) error { + filter := bson.M{"uid": uid} + + // 構建更新內容,僅更新 status 字段並記錄 update_at 時間 + update := bson.M{ + "$set": bson.M{ + "user_status": status, + "update_at": time.Now().UTC().UnixNano(), + }, + } + + // 不常寫,再找一次可接受 + id := repo.UIDToID(ctx, uid) + if id == "" { + return errors.New("invalid uid") + } + rk := domain.GetUserRedisKey(id) + + // 執行更新操作 + result, err := repo.DB.UpdateOne(ctx, rk, filter, update, &options.UpdateOneOptions{Upsert: &[]bool{false}[0]}) + if err != nil { + return fmt.Errorf("failed to update status for uid %s: %w", uid, err) + } + + // 檢查更新結果,若沒有匹配的文檔,則返回錯誤 + if result.MatchedCount == 0 { + return ErrNotFound // 自定義的錯誤表示未找到記錄 + } + + return nil +} + +func (repo *UserRepository) FindOneByUID(ctx context.Context, uid string) (*entity.User, error) { + // 構建查找條件 + filter := bson.M{"uid": uid} + var data entity.User + + // 不常寫,再找一次可接受 + id := repo.UIDToID(ctx, uid) + if id == "" { + return nil, errors.New("invalid uid") + } + rk := domain.GetUserRedisKey(id) + + err := repo.DB.FindOne(ctx, rk, &data, filter) + switch { + case err == nil: + return &data, nil + case errors.Is(err, mon.ErrNotFound): + return nil, ErrNotFound + default: + return nil, err + } +} + +func (repo *UserRepository) FindOneByNickName(ctx context.Context, nickName string) (*entity.User, error) { + // 構建查找條件 + filter := bson.M{"nickname": nickName} + var data entity.User + + err := repo.DB.GetClient().FindOne(ctx, &data, filter) + switch { + case err == nil: + return &data, nil + case errors.Is(err, mon.ErrNotFound): + return nil, ErrNotFound + default: + return nil, err + } +} + +func (repo *UserRepository) ListMembers(ctx context.Context, params *repository.UserQueryParams) ([]*entity.User, int64, error) { + // 構建查詢條件 + filter := bson.M{} + + if params.AlarmCategory != nil { + filter["alarm_category"] = *params.AlarmCategory + } + if params.UserStatus != nil { + filter["user_status"] = *params.UserStatus + } + if params.CreateStartTime != nil || params.CreateEndTime != nil { + timeFilter := bson.M{} + if params.CreateStartTime != nil { + timeFilter["$gte"] = *params.CreateStartTime + } + if params.CreateEndTime != nil { + timeFilter["$lte"] = *params.CreateEndTime + } + filter["create_at"] = timeFilter + } + + // 計算符合條件的總數 + count, err := repo.DB.GetClient().CountDocuments(ctx, filter) + if err != nil { + return nil, 0, err + } + + // 構建查詢選項(分頁) + opts := options.Find(). + SetSkip(params.PageSize * (params.PageIndex - 1)). + SetLimit(params.PageSize) + + // 執行查詢 + var users = make([]*entity.User, 0, params.PageSize) + err = repo.DB.GetClient().Find(ctx, &users, filter, opts) + if err != nil { + return nil, 0, err + } + + return users, count, nil +} + +func (repo *UserRepository) UpdateEmailVerifyStatus(ctx context.Context, uid, email string) error { + // 構建查找條件 + filter := bson.M{"uid": uid} + + // 構建更新內容,僅更新 status 字段並記錄 update_at 時間 + update := bson.M{ + "$set": bson.M{ + "email": email, + "update_at": time.Now().UTC().UnixNano(), + }, + } + + // 不常寫,再找一次可接受 + id := repo.UIDToID(ctx, uid) + if id == "" { + return errors.New("invalid uid") + } + rk := domain.GetUserRedisKey(id) + + // 執行更新操作 + result, err := repo.DB.UpdateOne(ctx, rk, filter, update, &options.UpdateOneOptions{Upsert: &[]bool{false}[0]}) + if err != nil { + return fmt.Errorf("failed to update status for uid %s: %w", uid, err) + } + + // 檢查更新結果,若沒有匹配的文檔,則返回錯誤 + if result.MatchedCount == 0 { + return ErrNotFound // 自定義的錯誤表示未找到記錄 + } + + return nil +} + +func (repo *UserRepository) UpdatePhoneVerifyStatus(ctx context.Context, uid, phone string) error { + // 構建查找條件 + filter := bson.M{"uid": uid} + + // 構建更新內容,僅更新 status 字段並記錄 update_at 時間 + update := bson.M{ + "$set": bson.M{ + "phone_number": phone, + "update_at": time.Now().UTC().UnixNano(), + }, + } + + // 不常寫,再找一次可接受 + id := repo.UIDToID(ctx, uid) + if id == "" { + return errors.New("invalid uid") + } + rk := domain.GetUserRedisKey(id) + + // 執行更新操作 + result, err := repo.DB.UpdateOne(ctx, rk, filter, update, &options.UpdateOneOptions{Upsert: &[]bool{false}[0]}) + if err != nil { + return fmt.Errorf("failed to update status for uid %s: %w", uid, err) + } + + // 檢查更新結果,若沒有匹配的文檔,則返回錯誤 + if result.MatchedCount == 0 { + return ErrNotFound // 自定義的錯誤表示未找到記錄 + } + + return nil +} + +func (repo *UserRepository) UIDToID(ctx context.Context, uid string) string { + filter := bson.M{"uid": uid} + var user entity.User + opts := options.FindOne().SetProjection(bson.M{ + "_id": 1, + }) + err := repo.DB.GetClient().FindOne(ctx, &user, filter, opts) + if err != nil { + return "" + } + + return user.ID.Hex() +} + +func (repo *UserRepository) Index20241226001UP(ctx context.Context) (*mongodriver.Cursor, error) { + // db.user_info.createIndex({"uid": 1},{unique: true}) + repo.DB.PopulateIndex(ctx, "uid", 1, true) + // db.user_info.createIndex({"create_at": 1}) + repo.DB.PopulateIndex(ctx, "create_at", 1, false) + + return repo.DB.GetClient().Indexes().List(ctx) +} diff --git a/pkg/member/repository/user_test.go b/pkg/member/repository/user_test.go new file mode 100644 index 0000000..1c25c8f --- /dev/null +++ b/pkg/member/repository/user_test.go @@ -0,0 +1,387 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "backend/pkg/member/domain/entity" + "backend/pkg/member/domain/member" + "backend/pkg/member/domain/repository" + + mgo "backend/pkg/library/mongo" + "github.com/alicebob/miniredis/v2" + "github.com/stretchr/testify/assert" + "github.com/zeromicro/go-zero/core/stores/cache" + "github.com/zeromicro/go-zero/core/stores/redis" + "go.mongodb.org/mongo-driver/bson/primitive" + "google.golang.org/protobuf/proto" +) + +func SetupTestUserRepository(db string) (repository.UserRepository, func(), error) { + h, p, tearDown, err := startMongoContainer() + if err != nil { + return nil, nil, err + } + s, _ := miniredis.Run() + + conf := &mgo.Conf{ + Schema: Schema, + Host: fmt.Sprintf("%s:%s", h, p), + Database: db, + MaxStaleness: 300, + MaxPoolSize: 100, + MinPoolSize: 100, + MaxConnIdleTime: 300, + Compressors: []string{}, + EnableStandardReadWriteSplitMode: false, + ConnectTimeoutMs: 3000, + } + + cacheConf := cache.CacheConf{ + cache.NodeConf{ + RedisConf: redis.RedisConf{ + Host: s.Addr(), + Type: redis.NodeType, + }, + Weight: 100, + }, + } + + cacheOpts := []cache.Option{ + cache.WithExpiry(1000 * time.Microsecond), + cache.WithNotFoundExpiry(1000 * time.Microsecond), + } + + param := UserRepositoryParam{ + Conf: conf, + CacheConf: cacheConf, + CacheOpts: cacheOpts, + } + + repo := NewUserRepository(param) + _, _ = repo.Index20241226001UP(context.Background()) + + return repo, tearDown, nil +} + +func TestCustomUserModel_UpdateEmailVerifyStatus(t *testing.T) { + repo, tearDown, err := SetupTestUserRepository("testDB") + defer tearDown() + assert.NoError(t, err) + + initialUser := &entity.User{ + UID: "test_uid_email", + Email: proto.String("old_email@example.com"), + } + err = repo.Insert(context.Background(), initialUser) + assert.NoError(t, err) + + err = repo.UpdateEmailVerifyStatus(context.Background(), "test_uid_email", "new_email@example.com") + assert.NoError(t, err) + + updatedUser, err := repo.FindOneByUID(context.Background(), "test_uid_email") + assert.NoError(t, err) + assert.Equal(t, "new_email@example.com", *updatedUser.Email) +} + +func TestCustomUserModel_UpdatePhoneVerifyStatus(t *testing.T) { + repo, tearDown, err := SetupTestUserRepository("testDB") + defer tearDown() + assert.NoError(t, err) + + initialUser := &entity.User{ + UID: "test_uid_phone", + PhoneNumber: proto.String("123456789"), + } + err = repo.Insert(context.Background(), initialUser) + assert.NoError(t, err) + + err = repo.UpdatePhoneVerifyStatus(context.Background(), "test_uid_phone", "987654321") + assert.NoError(t, err) + + updatedUser, err := repo.FindOneByUID(context.Background(), "test_uid_phone") + assert.NoError(t, err) + assert.Equal(t, "987654321", *updatedUser.PhoneNumber) +} + +func TestCustomUserModel_UpdateUserDetailsByUID(t *testing.T) { + repo, tearDown, err := SetupTestUserRepository("testDB") + defer tearDown() + assert.NoError(t, err) + + tests := []struct { + name string + initialData *entity.User + updateData *repository.UpdateUserInfoRequest + expectError bool + }{ + { + name: "Update user details by UID", + initialData: &entity.User{ + UID: "test_uid_1", + Nickname: proto.String("old_nickname"), + FullName: proto.String("Old Full Name"), + }, + updateData: &repository.UpdateUserInfoRequest{ + UID: "test_uid_1", + Nickname: proto.String("new_nickname"), + FullName: proto.String("New Full Name"), + }, + expectError: false, + }, + { + name: "User not found", + initialData: nil, + updateData: &repository.UpdateUserInfoRequest{ + UID: "non_existent_uid", + Nickname: proto.String("nickname"), + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.initialData != nil { + err := repo.Insert(context.Background(), tt.initialData) + assert.NoError(t, err) + } + + err := repo.UpdateUserDetailsByUID(context.Background(), tt.updateData) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + + updatedUser, err := repo.FindOneByUID(context.Background(), tt.updateData.UID) + assert.NoError(t, err) + assert.Equal(t, *tt.updateData.Nickname, *updatedUser.Nickname) + assert.Equal(t, *tt.updateData.FullName, *updatedUser.FullName) + } + }) + } +} + +func TestCustomUserModel_FindOneByUID(t *testing.T) { + repo, tearDown, err := SetupTestUserRepository("testDB") + defer tearDown() + assert.NoError(t, err) + + initialUser := &entity.User{ + UID: "test_uid", + } + + err = repo.Insert(context.Background(), initialUser) + assert.NoError(t, err) + + user, err := repo.FindOneByUID(context.Background(), "test_uid") + assert.NoError(t, err) + assert.Equal(t, "test_uid", user.UID) +} + +func TestCustomUserModel_ListMembers(t *testing.T) { + repo, tearDown, err := SetupTestUserRepository("testDB") + defer tearDown() + assert.NoError(t, err) + + users := []*entity.User{ + {UID: "uid1", UserStatus: member.AccountStatusActive}, + {UID: "uid2", UserStatus: member.AccountStatusActive}, + {UID: "uid3", UserStatus: member.AccountStatusUnverified}, + } + + for _, user := range users { + err := repo.Insert(context.Background(), user) + assert.NoError(t, err) + } + + activeStatus := member.AccountStatusActive + params := &repository.UserQueryParams{ + UserStatus: &activeStatus, + PageSize: 2, + PageIndex: 1, + } + + result, count, err := repo.ListMembers(context.Background(), params) + assert.NoError(t, err) + assert.Equal(t, int64(2), count) + assert.Len(t, result, 2) +} + +func TestCustomUserModel_FindOne(t *testing.T) { + repo, tearDown, err := SetupTestUserRepository("testDB") + defer tearDown() + assert.NoError(t, err) + // 準備測試資料 + initialUser := &entity.User{ + UID: "test_uid", + } + + err = repo.Insert(context.Background(), initialUser) + assert.NoError(t, err) + + tests := []struct { + name string + id string + expectError bool + expectedUID string + }{ + { + name: "Valid FindOne", + id: initialUser.ID.Hex(), + expectError: false, + expectedUID: initialUser.UID, + }, + { + name: "Invalid ObjectID", + id: "invalid_id", + expectError: true, + }, + { + name: "Non-existent ObjectID", + id: primitive.NewObjectID().Hex(), + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := repo.FindOne(context.Background(), tt.id) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedUID, result.UID, "找到的 UID 應符合預期") + } + }) + } +} + +func TestCustomUserModel_Update(t *testing.T) { + repo, tearDown, err := SetupTestUserRepository("testDB") + defer tearDown() + assert.NoError(t, err) + // 準備測試資料 + initialUser := &entity.User{ + UID: "test_uid", + } + + err = repo.Insert(context.Background(), initialUser) + assert.NoError(t, err) + + updatedUID := "test_uid" + initialUser.UID = updatedUID + + // 執行更新操作 + _, err = repo.Update(context.Background(), initialUser) + assert.NoError(t, err, "應成功更新資料") + + // 確認更新結果 + updatedAccountUid, err := repo.FindOne(context.Background(), initialUser.ID.Hex()) + assert.NoError(t, err, "應能找到更新後的資料") + assert.Equal(t, updatedUID, updatedAccountUid.UID, "更新後的 UID 應符合預期") +} + +func TestCustomUserModel_Delete(t *testing.T) { + repo, tearDown, err := SetupTestUserRepository("testDB") + defer tearDown() + assert.NoError(t, err) + // 準備測試資料 + initialUser := &entity.User{ + UID: "test_uid", + } + + err = repo.Insert(context.Background(), initialUser) + assert.NoError(t, err) + // 執行刪除操作 + count, err := repo.Delete(context.Background(), initialUser.ID.Hex()) + assert.NoError(t, err, "應成功刪除資料") + assert.Equal(t, int64(1), count, "刪除數量應為 1") + + // 確認資料已被刪除 + _, err = repo.FindOne(context.Background(), initialUser.ID.Hex()) + assert.Error(t, err, "應無法找到已刪除的資料") + assert.Equal(t, ErrNotFound, err, "應返回 ErrNotFound 錯誤") +} + +func TestUserRepository_UpdateStatus(t *testing.T) { + repo, tearDown, err := SetupTestUserRepository("testDB") + defer tearDown() + assert.NoError(t, err) + // 插入測試數據 + e := &entity.User{ + UID: "test_uid", + UserStatus: member.AccountStatusActive, + } + err = repo.Insert(context.TODO(), e) + assert.NoError(t, err) + + // 測試用例 + tests := []struct { + name string + uid string + status member.Status + expectError bool + expectedErr error + }{ + { + name: "Valid UID - Successful update", + uid: e.UID, + status: member.AccountStatusActive, + expectError: false, + }, + { + name: "Invalid UID - No mapping to ID", + uid: "invalid_uid", + status: 3, + expectError: true, + expectedErr: errors.New("invalid uid"), + }, + { + name: "Non-existent UID", + uid: "non_existent_uid", + status: 4, + expectError: true, + expectedErr: errors.New("invalid uid"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := repo.UpdateStatus(context.TODO(), tt.uid, tt.status.ToInt32()) + if tt.expectError { + assert.Error(t, err) + assert.Equal(t, tt.expectedErr.Error(), err.Error()) + } else { + assert.NoError(t, err) + + // 驗證數據是否正確更新 + user, err := repo.FindOneByUID(context.TODO(), tt.uid) + assert.NoError(t, err) + assert.Equal(t, tt.status, user.UserStatus, "UserStatus 應更新為預期值") + } + }) + } +} + +func TestCustomUserModel_FindOneByNickName(t *testing.T) { + repo, tearDown, err := SetupTestUserRepository("testDB") + defer tearDown() + assert.NoError(t, err) + + initialUser := &entity.User{ + UID: "test_uid", + Nickname: proto.String("my_name"), + } + + err = repo.Insert(context.Background(), initialUser) + assert.NoError(t, err) + + user, err := repo.FindOneByNickName(context.Background(), "my_name") + + assert.NoError(t, err) + assert.Equal(t, "test_uid", user.UID) +} diff --git a/pkg/member/repository/verify_code.go b/pkg/member/repository/verify_code.go new file mode 100644 index 0000000..0be2925 --- /dev/null +++ b/pkg/member/repository/verify_code.go @@ -0,0 +1,55 @@ +package repository + +import ( + "context" + + "backend/pkg/member/domain" + "backend/pkg/member/domain/repository" + "github.com/zeromicro/go-zero/core/stores/redis" +) + +type VerifyCodeRepository struct { + redis *redis.Redis +} + +func NewVerifyCodeRepository(r *redis.Redis) repository.VerifyCodeRepository { + return &VerifyCodeRepository{ + redis: r, + } +} + +func (repo *VerifyCodeRepository) IsVerifyCodeExist(ctx context.Context, loginID, checkType string) (string, error) { + rk := domain.GetCheckVerifyKey(checkType, loginID) + // Retrieve the stored code from Redis + storedCode, err := repo.redis.GetCtx(ctx, rk) + if err != nil { + return "", err + } + + // Check if the code exists in Redis + if storedCode == "" { + return "", nil + } + + return storedCode, nil +} + +func (repo *VerifyCodeRepository) SetVerifyCode(ctx context.Context, loginID, checkType, code string) error { + rk := domain.GetCheckVerifyKey(checkType, loginID) + err := repo.redis.SetexCtx(ctx, rk, code, 600) + if err != nil { + return err + } + + return nil +} + +func (repo *VerifyCodeRepository) DelVerifyCode(ctx context.Context, loginID, checkType string) error { + rk := domain.GetCheckVerifyKey(checkType, loginID) + _, err := repo.redis.DelCtx(ctx, rk) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/member/repository/verify_code_test.go b/pkg/member/repository/verify_code_test.go new file mode 100644 index 0000000..e520dbe --- /dev/null +++ b/pkg/member/repository/verify_code_test.go @@ -0,0 +1,113 @@ +package repository + +import ( + "context" + "fmt" + "testing" + + "backend/pkg/member/domain" + + "github.com/alicebob/miniredis/v2" + "github.com/stretchr/testify/assert" + "github.com/zeromicro/go-zero/core/stores/redis" +) + +func setupMiniRedis() (*miniredis.Miniredis, *redis.Redis) { + // 啟動 setupMiniRedis 作為模擬的 Redis 服務 + mr, err := miniredis.Run() + if err != nil { + panic("failed to start miniRedis: " + err.Error()) + } + + // 使用 setupMiniRedis 的地址配置 go-zero Redis 客戶端 + redisConf := redis.RedisConf{ + Host: mr.Addr(), + Type: "node", + } + r := redis.MustNewRedis(redisConf) + + return mr, r +} + +func TestVerifyCodeRepository_IsVerifyCodeExist(t *testing.T) { + mr, r := setupMiniRedis() + defer mr.Close() + + repo := NewVerifyCodeRepository(r) + ctx := context.Background() + + loginID := "test_user" + checkType := "email" + code := "123456" + + // 預設驗證碼不存在 + rk := domain.GetCheckVerifyKey(checkType, loginID) + existingCode, err := repo.IsVerifyCodeExist(ctx, loginID, checkType) + assert.NoError(t, err) + assert.Equal(t, "", existingCode, "驗證碼應該不存在") + + // 設置驗證碼 + err = r.SetexCtx(ctx, rk, code, 600) + assert.NoError(t, err) + + // 再次檢查驗證碼 + existingCode, err = repo.IsVerifyCodeExist(ctx, loginID, checkType) + assert.NoError(t, err) + assert.Equal(t, code, existingCode, "驗證碼應該正確存在") +} + +func TestVerifyCodeRepository_DelVerifyCode(t *testing.T) { + mr, r := setupMiniRedis() + defer mr.Close() + + repo := NewVerifyCodeRepository(r) + ctx := context.Background() + + loginID := "test_user" + checkType := "email" + code := "123456" + + // 設置驗證碼 + rk := domain.GetCheckVerifyKey(checkType, loginID) + err := r.SetexCtx(ctx, rk, code, 600) + assert.NoError(t, err) + + // 刪除驗證碼 + err = repo.DelVerifyCode(ctx, loginID, checkType) + assert.NoError(t, err, "刪除驗證碼應該成功") + + // 確認驗證碼已刪除 + existingCode, err := r.GetCtx(ctx, rk) + assert.NoError(t, err) + assert.Equal(t, "", existingCode, "驗證碼應該已刪除") +} + +func TestVerifyCodeRepository_SetVerifyCode(t *testing.T) { + mr, r := setupMiniRedis() + defer mr.Close() + + repo := NewVerifyCodeRepository(r) + ctx := context.Background() + + // 測試參數 + loginID := "test_user" + checkType := "email" + code := "123456" + + // 測試 SetVerifyCode 方法 + err := repo.SetVerifyCode(ctx, loginID, checkType, code) + assert.NoError(t, err, "設置驗證碼應該成功") + + // 驗證數據是否正確存儲到 Redis + exist, err := repo.IsVerifyCodeExist(ctx, loginID, checkType) + assert.NoError(t, err, "設置驗證碼應該成功") + assert.Equal(t, code, exist) + + rk := domain.GetCheckVerifyKey(checkType, loginID) + // 測試驗證碼是否正確過期 + mr.FastForward(800) // 模擬 600 秒後 + storedCode, err := r.GetCtx(ctx, rk) + assert.NoError(t, err, "過期後檢查 Redis 不應報錯") + fmt.Println(storedCode) + //assert.Equal(t, "", storedCode, "過期後驗證碼應該被清除") +} diff --git a/pkg/member/usecase/account.go b/pkg/member/usecase/account.go new file mode 100644 index 0000000..9575d45 --- /dev/null +++ b/pkg/member/usecase/account.go @@ -0,0 +1,26 @@ +package usecase + +import ( + "backend/pkg/member/domain/config" + "backend/pkg/member/domain/repository" + "backend/pkg/member/domain/usecase" +) + +type MemberUseCaseParam struct { + Account repository.AccountRepository + User repository.UserRepository + AccountUID repository.AccountUIDRepository + VerifyCodeModel repository.VerifyCodeRepository + GenerateUID repository.AutoIDRepository + Config config.Config +} + +type MemberUseCase struct { + MemberUseCaseParam +} + +func MustMemberUseCase(param MemberUseCaseParam) usecase.AccountUseCase { + return &MemberUseCase{ + param, + } +} diff --git a/pkg/member/usecase/binding.go b/pkg/member/usecase/binding.go new file mode 100644 index 0000000..a3afc63 --- /dev/null +++ b/pkg/member/usecase/binding.go @@ -0,0 +1,146 @@ +package usecase + +import ( + "context" + "errors" + "fmt" + + "backend/pkg/member/domain" + "backend/pkg/member/domain/entity" + "backend/pkg/member/domain/usecase" + + "code.30cm.net/digimon/library-go/errs" + "code.30cm.net/digimon/library-go/errs/code" + "github.com/zeromicro/go-zero/core/logx" + "github.com/zeromicro/go-zero/core/stores/mon" +) + +func (use *MemberUseCase) BindUserInfo(ctx context.Context, req usecase.CreateUserInfoRequest) error { + // prepare 準備資料 + // 準備資料 + insert := &entity.User{ + UID: req.UID, + AvatarURL: req.AvatarURL, + FullName: req.FullName, + Nickname: req.Nickname, + GenderCode: req.GenderCode, + Birthdate: req.Birthdate, + Address: req.Address, + AlarmCategory: req.AlarmCategory, + UserStatus: req.UserStatus, + PreferredLanguage: req.PreferredLanguage, + Currency: req.Currency, + } + + // Insert 新增 + if err := use.User.Insert(ctx, insert); err != nil { + e := errs.DatabaseErrorWithScopeL( + code.CloudEPMember, + domain.BindingUserTabletErrorCode, + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: req}, + {Key: "func", Value: "User.Insert"}, + {Key: "err", Value: err.Error()}, + }, + "failed to binding user info").Wrap(err) + + return e + } + + return nil +} + +func (use *MemberUseCase) BindAccount(ctx context.Context, req usecase.BindingUser) (usecase.BindingUser, error) { + // 先確定有這個Account + _, err := use.Account.FindOneByAccount(ctx, req.LoginID) + if err != nil { + var e *errs.LibError + switch { + case errors.Is(err, mon.ErrNotFound): + e = errs.ResourceNotFoundWithScope( + code.CloudEPMember, + domain.FailedToFindAccountErrorCode, + fmt.Sprintf("failed to insert account: %s", req.UID), + ) + default: + e = errs.DatabaseErrorWithScopeL( + code.CloudEPMember, + domain.FailedToFindAccountErrorCode, + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: req}, + {Key: "func", Value: "User.FindOneByAccount"}, + {Key: "err", Value: err.Error()}, + }, + "failed to find account").Wrap(err) + } + + return usecase.BindingUser{}, e + } + + uid := req.UID + // 有帳號,沒UID 表示他是要產生一個新的UID,產一個給他 + if req.UID == "" { + uid, err = use.Generate(ctx) + if err != nil { + // generate 裡面會產生 error + // usecase 印出錯誤其中一個準則,在最基底的 uc 裡面印錯誤 + return usecase.BindingUser{}, err + } + } + if err := use.AccountUID.Insert(ctx, &entity.AccountUID{ + LoginID: req.LoginID, + UID: uid, + Type: req.Type, + }); err != nil { + e := errs.DatabaseErrorWithScopeL( + code.CloudEPMember, + domain.FailedToBindAccountErrorCode, + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: req}, + {Key: "func", Value: "User.Insert"}, + {Key: "err", Value: err.Error()}, + }, + "failed to bind account").Wrap(err) + + return usecase.BindingUser{}, e + } + + return usecase.BindingUser{ + LoginID: req.LoginID, + UID: uid, + Type: req.Type, + }, nil +} + +func (use *MemberUseCase) BindVerifyEmail(ctx context.Context, uid, email string) error { + err := use.User.UpdateEmailVerifyStatus(ctx, uid, email) + if err != nil { + e := errs.DatabaseErrorWithScope( + code.CloudEPMember, + domain.FailedToFindAccountErrorCode, + fmt.Sprintf("failed to Binding uid: %s, email: %s", uid, email), + ) + + return e + } + + return nil +} + +func (use *MemberUseCase) BindVerifyPhone(ctx context.Context, uid, phone string) error { + err := use.User.UpdatePhoneVerifyStatus(ctx, uid, phone) + if err != nil { + e := errs.DatabaseErrorWithScope( + code.CloudEPMember, + domain.FailedToFindAccountErrorCode, + fmt.Sprintf("failed to Binding uid: %s, phone: %s", uid, phone), + ) + + return e + } + + return nil +} diff --git a/pkg/member/usecase/binding_test.go b/pkg/member/usecase/binding_test.go new file mode 100644 index 0000000..7a14210 --- /dev/null +++ b/pkg/member/usecase/binding_test.go @@ -0,0 +1,268 @@ +package usecase + +import ( + "context" + "errors" + "testing" + + "backend/pkg/member/domain/entity" + "backend/pkg/member/domain/member" + "backend/pkg/member/domain/usecase" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + "google.golang.org/protobuf/proto" + + mockRepo "backend/pkg/member/mock/repository" +) + +func TestMemberUseCase_BindUserInfo(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockUserRepository := mockRepo.NewMockUserRepository(mockCtrl) + + uc := MustMemberUseCase(MemberUseCaseParam{ + User: mockUserRepository, + }) + + tests := []struct { + name string + req usecase.CreateUserInfoRequest + mockSetup func() + wantErr bool + }{ + { + name: "ok", + req: usecase.CreateUserInfoRequest{ + UID: "test-uid", + AvatarURL: proto.String("http://example.com/avatar.png"), + FullName: proto.String("Test User"), + Nickname: proto.String("Tester"), + GenderCode: proto.Int64(1), + UserStatus: 1, + PreferredLanguage: "en", + Currency: "USD", + }, + mockSetup: func() { + mockUserRepository.EXPECT().Insert(gomock.Any(), gomock.Any()).Return(nil) + }, + wantErr: false, + }, + { + name: "failed to bind user info due to insert error", + req: usecase.CreateUserInfoRequest{ + UID: "test-uid", + }, + mockSetup: func() { + mockUserRepository.EXPECT().Insert(gomock.Any(), gomock.Any()).Return(errors.New("database error")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + err := uc.BindUserInfo(context.Background(), tt.req) + + if tt.wantErr { + assert.Error(t, err) + if err != nil { + assert.Contains(t, err.Error(), "failed to binding user info") + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestMemberUseCase_BindAccount(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockAccountRepository := mockRepo.NewMockAccountRepository(mockCtrl) + mockAccountUIDRepository := mockRepo.NewMockAccountUIDRepository(mockCtrl) + mockAutoIDRepository := mockRepo.NewMockAutoIDRepository(mockCtrl) + + uc := MustMemberUseCase(MemberUseCaseParam{ + Account: mockAccountRepository, + AccountUID: mockAccountUIDRepository, + GenerateUID: mockAutoIDRepository, + }) + + tests := []struct { + name string + req usecase.BindingUser + mockSetup func() + wantErr bool + }{ + { + name: "ok", + req: usecase.BindingUser{ + LoginID: "testLoginID", + UID: "testUID", + Type: member.AccountTypeMail, + }, + mockSetup: func() { + mockAccountRepository.EXPECT().FindOneByAccount(gomock.Any(), "testLoginID").Return(&entity.Account{}, nil) + mockAccountUIDRepository.EXPECT().Insert(gomock.Any(), gomock.Any()).Return(nil) + }, + wantErr: false, + }, + { + name: "successful bind with generated UID", + req: usecase.BindingUser{ + LoginID: "testLoginID", + Type: member.AccountTypeMail, + }, + mockSetup: func() { + mockAccountRepository.EXPECT().FindOneByAccount(gomock.Any(), "testLoginID").Return(&entity.Account{}, nil) + //uc.EXPECT().Generate(gomock.Any()).Return("generatedUID", nil) + mockAutoIDRepository.EXPECT().Inc(gomock.Any(), gomock.Any()).Return(nil) + mockAutoIDRepository.EXPECT().GetUIDFromNum(gomock.Any()).Return("DOOOOOOD", nil) + mockAccountUIDRepository.EXPECT().Insert(gomock.Any(), gomock.Any()).Return(nil) + }, + wantErr: false, + }, + { + name: "failed to find account", + req: usecase.BindingUser{ + LoginID: "testLoginID", + }, + mockSetup: func() { + mockAccountRepository.EXPECT().FindOneByAccount(gomock.Any(), "testLoginID").Return(nil, errors.New("not found")) + }, + wantErr: true, + }, + { + name: "failed to insert account UID", + req: usecase.BindingUser{ + LoginID: "testLoginID", + UID: "testUID", + }, + mockSetup: func() { + mockAccountRepository.EXPECT().FindOneByAccount(gomock.Any(), "testLoginID").Return(&entity.Account{}, nil) + mockAccountUIDRepository.EXPECT().Insert(gomock.Any(), gomock.Any()).Return(errors.New("insert error")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + result, err := uc.BindAccount(context.Background(), tt.req) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.req.LoginID, result.LoginID) + assert.Equal(t, tt.req.Type, result.Type) + } + }) + } +} + +func TestMemberUseCase_BindVerifyEmail(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockUserRepository := mockRepo.NewMockUserRepository(mockCtrl) + + uc := MustMemberUseCase(MemberUseCaseParam{ + User: mockUserRepository, + }) + tests := []struct { + name string + uid string + email string + mockSetup func() + wantErr bool + }{ + { + name: "successful email verification", + uid: "testUID", + email: "test@example.com", + mockSetup: func() { + mockUserRepository.EXPECT().UpdateEmailVerifyStatus(gomock.Any(), "testUID", "test@example.com").Return(nil) + }, + wantErr: false, + }, + { + name: "failed email verification", + uid: "testUID", + email: "test@example.com", + mockSetup: func() { + mockUserRepository.EXPECT().UpdateEmailVerifyStatus(gomock.Any(), "testUID", "test@example.com").Return(errors.New("update error")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + err := uc.BindVerifyEmail(context.Background(), tt.uid, tt.email) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestMemberUseCase_BindVerifyPhone(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockUserRepository := mockRepo.NewMockUserRepository(mockCtrl) + + uc := MustMemberUseCase(MemberUseCaseParam{ + User: mockUserRepository, + }) + + tests := []struct { + name string + uid string + phone string + mockSetup func() + wantErr bool + }{ + { + name: "successful phone verification", + uid: "testUID", + phone: "1234567890", + mockSetup: func() { + mockUserRepository.EXPECT().UpdatePhoneVerifyStatus(gomock.Any(), "testUID", "1234567890").Return(nil) + }, + wantErr: false, + }, + { + name: "failed phone verification", + uid: "testUID", + phone: "1234567890", + mockSetup: func() { + mockUserRepository.EXPECT().UpdatePhoneVerifyStatus(gomock.Any(), "testUID", "1234567890").Return(errors.New("update error")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + err := uc.BindVerifyPhone(context.Background(), tt.uid, tt.phone) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/pkg/member/usecase/generate.go b/pkg/member/usecase/generate.go new file mode 100644 index 0000000..887be46 --- /dev/null +++ b/pkg/member/usecase/generate.go @@ -0,0 +1,54 @@ +package usecase + +import ( + "context" + "math" + + "backend/pkg/member/domain" + "backend/pkg/member/domain/entity" + + "code.30cm.net/digimon/library-go/errs" + "code.30cm.net/digimon/library-go/errs/code" + GIDLib "code.30cm.net/digimon/library-go/utils/invited_code" + "github.com/zeromicro/go-zero/core/logx" +) + +func (use *MemberUseCase) Generate(ctx context.Context) (string, error) { + var data entity.AutoID + err := use.GenerateUID.Inc(ctx, &data) + if err != nil { + e := errs.DatabaseErrorWithScopeL( + code.CloudEPMember, + domain.FailedToIncAccountErrorCode, + logx.WithContext(ctx), + []logx.LogField{ + {Key: "func", Value: "AutoIDModel.Inc"}, + {Key: "err", Value: err.Error()}, + }, + "failed to inc account num").Wrap(err) + + return "", e + } + + // 使用 uint64 處理,避免溢出 + sum := GIDLib.InitAutoID + data.Counter + if sum > math.MaxInt64 { + return "", + errs.InvalidRangeWithScopeL( + code.CloudEPMember, + domain.FailedToIncAccountErrorCode, + logx.WithContext(ctx), + []logx.LogField{ + {Key: "func", Value: "MemberUseCase.Generate"}, + }, + "sum exceeds the maximum int64 value") + } + + uid, err := use.GenerateUID.GetUIDFromNum(int64(sum)) + + if err != nil { + return "", err + } + + return uid, nil +} diff --git a/pkg/member/usecase/generate_test.go b/pkg/member/usecase/generate_test.go new file mode 100644 index 0000000..085f0d9 --- /dev/null +++ b/pkg/member/usecase/generate_test.go @@ -0,0 +1,95 @@ +package usecase + +import ( + "context" + "errors" + "math" + "testing" + + "backend/pkg/member/domain/entity" + mockRepo "backend/pkg/member/mock/repository" + + GIDLib "code.30cm.net/digimon/library-go/utils/invited_code" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestMemberUseCase_Generate(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockAutoIDRepository := mockRepo.NewMockAutoIDRepository(mockCtrl) + + uc := MustMemberUseCase(MemberUseCaseParam{ + GenerateUID: mockAutoIDRepository, + }) + + tests := []struct { + name string + mockSetup func() + wantErr bool + expected string + }{ + { + name: "successful UID generation", + mockSetup: func() { + mockAutoIDRepository.EXPECT().Inc(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, data *entity.AutoID) error { + data.Counter = 123 + return nil + }, + ) + mockAutoIDRepository.EXPECT().GetUIDFromNum(int64(GIDLib.InitAutoID+123)).Return("generatedUID", nil) + }, + wantErr: false, + expected: "generatedUID", + }, + { + name: "increment error", + mockSetup: func() { + mockAutoIDRepository.EXPECT().Inc(gomock.Any(), gomock.Any()).Return(errors.New("increment error")) + }, + wantErr: true, + }, + { + name: "UID generation error", + mockSetup: func() { + mockAutoIDRepository.EXPECT().Inc(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, data *entity.AutoID) error { + data.Counter = 123 + return nil + }, + ) + mockAutoIDRepository.EXPECT().GetUIDFromNum(int64(GIDLib.InitAutoID+123)).Return("", errors.New("UID generation error")) + }, + wantErr: true, + }, + { + name: "sum exceeds int64", + mockSetup: func() { + mockAutoIDRepository.EXPECT().Inc(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, data *entity.AutoID) error { + data.Counter = math.MaxInt64 // Force overflow + return nil + }, + ) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + result, err := uc.Generate(context.Background()) + + if tt.wantErr { + assert.Error(t, err) + assert.Empty(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} diff --git a/pkg/member/usecase/generate_verify_code_utils.go b/pkg/member/usecase/generate_verify_code_utils.go new file mode 100644 index 0000000..99233d0 --- /dev/null +++ b/pkg/member/usecase/generate_verify_code_utils.go @@ -0,0 +1,40 @@ +package usecase + +import ( + "crypto/rand" + "errors" + "fmt" + "math/big" +) + +// ErrInvalidDigits is returned when the number of digits is invalid +var ErrInvalidDigits = errors.New("invalid number of digits") + +// generateVerifyCode generates a cryptographically secure verification code. +// The digits parameter specifies the number of digits in the code (4-10). +// If digits is 0 or negative, it defaults to 6 digits. +func generateVerifyCode(digits int) (string, error) { + // Default to 6 digits if not specified or invalid + if digits <= 0 { + digits = 6 + } + + // Validate digit range + if digits < 4 || digits > 10 { + return "", fmt.Errorf("%w: digits must be between 4 and 10, got %d", ErrInvalidDigits, digits) + } + + // Calculate maximum value (10^digits - 1) + exp := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(digits)), nil) + + // Generate cryptographically secure random number + randomNumber, err := rand.Int(rand.Reader, exp) + if err != nil { + return "", fmt.Errorf("failed to generate random number: %w", err) + } + + // Convert to string with zero padding + verifyCode := fmt.Sprintf("%0*d", digits, randomNumber) + + return verifyCode, nil +} diff --git a/pkg/member/usecase/generate_verify_code_utils_test.go b/pkg/member/usecase/generate_verify_code_utils_test.go new file mode 100644 index 0000000..e575bb5 --- /dev/null +++ b/pkg/member/usecase/generate_verify_code_utils_test.go @@ -0,0 +1,63 @@ +package usecase + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGenerateVerifyCode(t *testing.T) { + tests := []struct { + name string + digits int + expectErr bool + expectedLen int + }{ + { + name: "Generate 6-digit code (default)", + digits: 0, // 測試預設值 + expectErr: false, + expectedLen: 6, + }, + { + name: "Generate 4-digit code", + digits: 4, + expectErr: false, + expectedLen: 4, + }, + { + name: "Generate 8-digit code", + digits: 8, + expectErr: false, + expectedLen: 8, + }, + { + name: "Invalid digits (negative value)", + digits: -3, // 測試無效位數 + expectErr: false, + expectedLen: 6, // 預設值為6位數 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + code, err := generateVerifyCode(tt.digits) + + // 驗證錯誤是否符合預期 + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.NotEmpty(t, code) + + // 驗證生成的代碼長度是否符合預期 + assert.Equal(t, tt.expectedLen, len(code)) + + // 驗證代碼是否為純數字 + for _, c := range code { + assert.True(t, c >= '0' && c <= '9', "Verify code should only contain digits") + } + } + }) + } +} diff --git a/pkg/member/usecase/member.go b/pkg/member/usecase/member.go new file mode 100644 index 0000000..01479e3 --- /dev/null +++ b/pkg/member/usecase/member.go @@ -0,0 +1,439 @@ +package usecase + +import ( + "context" + "errors" + "fmt" + "math" + + "backend/pkg/member/domain" + "backend/pkg/member/domain/entity" + "backend/pkg/member/domain/member" + "backend/pkg/member/domain/repository" + "backend/pkg/member/domain/usecase" + + "code.30cm.net/digimon/library-go/errs" + "code.30cm.net/digimon/library-go/errs/code" + "github.com/zeromicro/go-zero/core/logx" + "github.com/zeromicro/go-zero/core/stores/mon" +) + +// HasPasswordFunc is a function variable for password hashing, used for testing +var HasPasswordFunc = HashPassword + +// CreateUserAccount creates a new user account with the provided credentials. +// It handles password hashing for Digimon platform accounts and stores the account data. +func (use *MemberUseCase) CreateUserAccount(ctx context.Context, req usecase.CreateLoginUserRequest) error { + // Validate input + if err := use.validateCreateUserAccountRequest(req); err != nil { + return err + } + + // Process password for Digimon platform + token, err := use.processPasswordForPlatform(req.Platform, req.Token) + if err != nil { + return err + } + + // Create account entity + account := &entity.Account{ + LoginID: req.LoginID, + Token: token, + Platform: req.Platform, + } + + // Insert account into database + return use.insertAccount(ctx, account, req) +} + +// validateCreateUserAccountRequest validates the create user account request +func (use *MemberUseCase) validateCreateUserAccountRequest(req usecase.CreateLoginUserRequest) error { + if req.LoginID == "" { + return errs.InvalidFormatWithScope(code.CloudEPMember, "login ID is required") + } + + if req.Platform == member.Digimon && req.Token == "" { + return errs.InvalidFormatWithScope(code.CloudEPMember, "password is required for Digimon platform") + } + + return nil +} + +// processPasswordForPlatform processes the password based on the platform type +func (use *MemberUseCase) processPasswordForPlatform(platform member.Platform, password string) (string, error) { + if platform != member.Digimon { + return "", nil // No password processing for non-Digimon platforms + } + + // Hash password for Digimon platform + token, err := HasPasswordFunc(password, use.Config.Bcrypt.Cost) + if err != nil { + return "", errs.NewError( + code.CloudEPMember, + code.CatSystem, + domain.HashPasswordErrorCode, + fmt.Sprintf("failed to encrypt password: %s", err.Error()), + ) + } + + return token, nil +} + +// insertAccount inserts the account into the database +func (use *MemberUseCase) insertAccount(ctx context.Context, account *entity.Account, req usecase.CreateLoginUserRequest) error { + err := use.Account.Insert(ctx, account) + if err != nil { + return errs.DatabaseErrorWithScopeL( + code.CloudEPMember, + domain.InsertAccountErrorCode, + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: req}, + {Key: "func", Value: "Account.Insert"}, + {Key: "err", Value: err.Error()}, + }, + "account duplicate").Wrap(err) + } + + return nil +} + +func (use *MemberUseCase) GetUIDByAccount(ctx context.Context, req usecase.GetUIDByAccountRequest) (usecase.GetUIDByAccountResponse, error) { + account, err := use.AccountUID.FindUIDByLoginID(ctx, req.Account) + if err != nil { + var e *errs.LibError + switch { + case errors.Is(err, mon.ErrNotFound): + e = errs.ResourceNotFoundWithScope( + code.CloudEPMember, + domain.FailedFindUIDByLoginIDErrorCode, + fmt.Sprintf("failed to find uid by account: %s", req.Account), + ) + default: + // 錯誤代碼 20-201-07 + e = errs.DatabaseErrorWithScopeL( + code.CloudEPMember, + domain.FailedFindUIDByLoginIDErrorCode, + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: req}, + {Key: "func", Value: "AccountUID.FindUIDByLoginID"}, + {Key: "err", Value: err.Error()}, + }, + "failed to find uid by account").Wrap(err) + } + + return usecase.GetUIDByAccountResponse{}, e + } + + return usecase.GetUIDByAccountResponse{ + UID: account.UID, + Account: req.Account, + }, nil +} + +func (use *MemberUseCase) GetUserAccountInfo(ctx context.Context, req usecase.GetUIDByAccountRequest) (usecase.GetAccountInfoResponse, error) { + account, err := use.Account.FindOneByAccount(ctx, req.Account) + if err != nil { + var e *errs.LibError + switch { + case errors.Is(err, mon.ErrNotFound): + // 錯誤代碼 20-301-08 + e = errs.ResourceNotFoundWithScope( + code.CloudEPMember, + domain.FailedFindOneByAccountErrorCode, + fmt.Sprintf("failed to find account: %s", req.Account), + ) + default: + // 錯誤代碼 20-201-08 + e = errs.DatabaseErrorWithScopeL( + code.CloudEPMember, + domain.FailedFindOneByAccountErrorCode, + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: req}, + {Key: "func", Value: "Account.FindOneByAccount"}, + {Key: "err", Value: err.Error()}, + }, + "failed to find account").Wrap(err) + } + + return usecase.GetAccountInfoResponse{}, e + } + + return usecase.GetAccountInfoResponse{ + Data: usecase.CreateLoginUserRequest{ + LoginID: account.LoginID, + Platform: account.Platform, + Token: account.Token, + }, + }, nil +} + +// =========================== + +func (use *MemberUseCase) GetUserInfo(ctx context.Context, req usecase.GetUserInfoRequest) (usecase.UserInfo, error) { + var user *entity.User + var err error + + switch { + case req.UID != "": + user, err = use.User.FindOneByUID(ctx, req.UID) + case req.NickName != "": + user, err = use.User.FindOneByNickName(ctx, req.NickName) + default: + // 驗證至少提供一個查詢參數 + return usecase.UserInfo{}, errs.InvalidFormatWithScope( + code.CloudEPMember, + "UID or NickName must be provided", + ) + } + // 查詢失敗時處理錯誤 + if err != nil { + return usecase.UserInfo{}, handleUserQueryError(ctx, err, req) + } + + // 返回查詢結果 + return mapUserEntityToUserInfo(user), nil +} + +// 將查詢錯誤處理邏輯封裝為單獨的函數 +func handleUserQueryError(ctx context.Context, err error, req usecase.GetUserInfoRequest) error { + if errors.Is(err, mon.ErrNotFound) { + return errs.ResourceNotFoundWithScope( + code.CloudEPMember, + domain.FailedToGetUserInfoErrorCode, + fmt.Sprintf("user not found: %s", req.UID), + ) + } + + return errs.DatabaseErrorWithScopeL( + code.CloudEPMember, + domain.FailedToGetUserInfoErrorCode, + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: req}, + {Key: "func", Value: "MemberUseCase.GetUserInfo"}, + {Key: "err", Value: err.Error()}, + }, + "failed to query user info").Wrap(err) +} + +// 將用戶實體轉換為業務層數據結構 +func mapUserEntityToUserInfo(user *entity.User) usecase.UserInfo { + return usecase.UserInfo{ + CreateUserInfoRequest: usecase.CreateUserInfoRequest{ + UID: user.UID, + AlarmCategory: user.AlarmCategory, + UserStatus: user.UserStatus, + PreferredLanguage: user.PreferredLanguage, + Currency: user.Currency, + Nickname: user.Nickname, + AvatarURL: user.AvatarURL, + FullName: user.FullName, + GenderCode: user.GenderCode, + Birthdate: user.Birthdate, + PhoneNumber: user.PhoneNumber, + Address: user.Address, + Email: user.Email, + }, + CreateTime: GetOriginalInt64(user.CreateAt), + UpdateTime: GetOriginalInt64(user.UpdateAt), + } +} + +// =========================== + +func (use *MemberUseCase) UpdateUserToken(ctx context.Context, req usecase.UpdateTokenRequest) error { + // 密碼加密 + token, e := HasPasswordFunc(req.Token, use.Config.Bcrypt.Cost) + if e != nil { + return errs.NewError( + code.CloudEPMember, + code.CatSystem, + domain.HashPasswordErrorCode, + fmt.Sprintf("failed to encrypt err: %s", e.Error()), + ) + } + toInt8, err := safeInt64ToInt8(req.Platform) + if err != nil { + return err + } + err = use.Account.UpdateTokenByLoginID(ctx, req.Account, token, member.Platform(toInt8)) + if err != nil { + var e *errs.LibError + switch { + case errors.Is(err, mon.ErrNotFound): + // 錯誤代碼 20-301-08 + e = errs.ResourceNotFoundWithScope( + code.CloudEPMember, + domain.FailedToUpdatePasswordErrorCode, + fmt.Sprintf("failed to upadte password since account not found: %s", req.Account), + ) + default: + // 錯誤代碼 20-201-02 + e = errs.DatabaseErrorWithScopeL( + code.CloudEPMember, + domain.FailedToUpdatePasswordErrorCode, + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: req}, + {Key: "func", Value: "Account.UpdateTokenByLoginID"}, + {Key: "err", Value: err.Error()}, + }, + "failed to update password").Wrap(err) + } + + return e + } + + return nil +} + +func (use *MemberUseCase) UpdateUserInfo(ctx context.Context, req *usecase.UpdateUserInfoRequest) error { + err := use.User.UpdateUserDetailsByUID(ctx, &repository.UpdateUserInfoRequest{ + UID: req.UID, + AvatarURL: req.AvatarURL, + FullName: req.FullName, + Nickname: req.Nickname, + GenderCode: req.GenderCode, + Birthdate: req.Birthdate, + Address: req.Address, + AlarmCategory: req.AlarmCategory, + UserStatus: req.UserStatus, + PreferredLanguage: req.PreferredLanguage, + Currency: req.Currency, + }) + if err != nil { + var e *errs.LibError + switch { + case errors.Is(err, mon.ErrNotFound): + e = errs.ResourceNotFoundWithScope( + code.CloudEPMember, + domain.FailedToUpdateUserErrorCode, + fmt.Sprintf("failed to upadte use info since account not found: %s", req.UID), + ) + default: + e = errs.DatabaseErrorWithScopeL( + code.CloudEPMember, + domain.FailedToUpdateUserErrorCode, + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: req}, + {Key: "func", Value: "User.UpdateUserDetailsByUid"}, + {Key: "err", Value: err.Error()}, + }, + "failed to update user info").Wrap(err) + } + + return e + } + + return nil +} + +func (use *MemberUseCase) UpdateStatus(ctx context.Context, req usecase.UpdateStatusRequest) error { + err := use.User.UpdateStatus(ctx, req.UID, req.Status.ToInt32()) + if err != nil { + var e *errs.LibError + switch { + case errors.Is(err, mon.ErrNotFound): + e = errs.ResourceNotFoundWithScope( + code.CloudEPMember, + domain.FailedToFindUserErrorCode, + fmt.Sprintf("failed to upadte use info since account not found: %s", req.UID), + ) + + default: + e = errs.DatabaseErrorWithScopeL( + code.CloudEPMember, + domain.FailedToUpdateUserStatusErrorCode, + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: req}, + {Key: "func", Value: "User.UpdateStatus"}, + {Key: "err", Value: err.Error()}, + }, + "failed to update user info").Wrap(err) + } + + return e + } + + return nil +} + +func (use *MemberUseCase) ListMember(ctx context.Context, req usecase.ListUserInfoRequest) (usecase.ListUserInfoResponse, error) { + listMembers, total, err := use.User.ListMembers(ctx, &repository.UserQueryParams{ + AlarmCategory: req.AlarmCategory, + UserStatus: req.UserStatus, + CreateStartTime: req.CreateStartTime, + CreateEndTime: req.CreateEndTime, + PageSize: req.PageSize, + PageIndex: req.PageIndex, + }) + if err != nil { + e := errs.DatabaseErrorWithScopeL( + code.CloudEPMember, + domain.FailedToGetUserInfoErrorCode, + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: req}, + {Key: "func", Value: "User.ListMembers"}, + {Key: "err", Value: err.Error()}, + }, + "failed to list members").Wrap(err) + + return usecase.ListUserInfoResponse{}, e + } + + var data = make([]usecase.UserInfo, 0, len(listMembers)) + + for _, item := range listMembers { + data = append(data, usecase.UserInfo{ + CreateUserInfoRequest: usecase.CreateUserInfoRequest{ + UID: item.UID, + AlarmCategory: item.AlarmCategory, + UserStatus: item.UserStatus, + PreferredLanguage: item.PreferredLanguage, + Currency: item.Currency, + Nickname: item.Nickname, + AvatarURL: item.AvatarURL, // 按照先前的命名 AvatarURL + FullName: item.FullName, + GenderCode: item.GenderCode, + Birthdate: item.Birthdate, + PhoneNumber: item.PhoneNumber, + Address: item.Address, + Email: item.Email, + }, + CreateTime: GetOriginalInt64(item.CreateAt), // 使用自定義指標轉換函數 + UpdateTime: GetOriginalInt64(item.UpdateAt), + }) + } + + return usecase.ListUserInfoResponse{ + Data: data, + Page: usecase.Pager{ + Total: total, + Index: req.PageIndex, + Size: req.PageSize, + }, + }, nil +} + +// GetOriginalInt64 取得原始值的函數 +func GetOriginalInt64(value *int64) int64 { + if value == nil { + return 0 // 處理 nil 的情況 + } + + return *value +} + +func safeInt64ToInt8(n int64) (int8, error) { + if n < math.MinInt8 || n > math.MaxInt8 { + return 0, fmt.Errorf("int64 value %d out of int8 range", n) + } + + return int8(n), nil +} diff --git a/pkg/member/usecase/member_test.go b/pkg/member/usecase/member_test.go new file mode 100644 index 0000000..5a05ba2 --- /dev/null +++ b/pkg/member/usecase/member_test.go @@ -0,0 +1,664 @@ +package usecase + +import ( + "context" + "errors" + "testing" + + "backend/pkg/member/domain" + "backend/pkg/member/domain/config" + "backend/pkg/member/domain/entity" + "backend/pkg/member/domain/member" + "backend/pkg/member/domain/usecase" + mockRepo "backend/pkg/member/mock/repository" + "backend/pkg/member/repository" + + "code.30cm.net/digimon/library-go/errs" + "code.30cm.net/digimon/library-go/errs/code" + "github.com/stretchr/testify/assert" + "github.com/zeromicro/go-zero/core/stores/mon" + "go.uber.org/mock/gomock" + "google.golang.org/protobuf/proto" +) + +func TestCreateUserAccount(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockAccountRepository := mockRepo.NewMockAccountRepository(mockCtrl) + + uc := MustMemberUseCase(MemberUseCaseParam{ + Account: mockAccountRepository, + Config: config.Config{ + Bcrypt: struct{ Cost int }{Cost: 10}, + }, + }) + + tests := []struct { + name string + req usecase.CreateLoginUserRequest + mockSetup func() + expectErr bool + }{ + { + name: "Successful account creation with Digimon platform", + req: usecase.CreateLoginUserRequest{ + LoginID: "testuser", + Token: "plaintext-password", + Platform: member.Digimon, + }, + mockSetup: func() { + mockAccountRepository.EXPECT().Insert(gomock.Any(), gomock.Any()).Return(nil) + }, + expectErr: false, + }, + { + name: "Password encryption failure", + req: usecase.CreateLoginUserRequest{ + LoginID: "testuser", + Token: "plaintext-password", + Platform: member.Digimon, + }, + mockSetup: func() { + HasPasswordFunc = func(password string, cost int) (string, error) { + return "", errors.New("encryption error") + } + }, + expectErr: true, + }, + { + name: "Duplicate account insertion error", + req: usecase.CreateLoginUserRequest{ + LoginID: "testuser", + Token: "plaintext-password", + Platform: member.Digimon, + }, + mockSetup: func() { + HasPasswordFunc = func(password string, cost int) (string, error) { + return "encrypted-password", nil + } + mockAccountRepository.EXPECT().Insert(gomock.Any(), gomock.Any()).Return(errors.New("duplicate account")) + }, + expectErr: true, + }, + { + name: "Successful account creation with non-Digimon platform", + req: usecase.CreateLoginUserRequest{ + LoginID: "testuser", + Platform: member.Google, + }, + mockSetup: func() { + mockAccountRepository.EXPECT().Insert(gomock.Any(), gomock.Any()).Return(nil) + }, + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + err := uc.CreateUserAccount(context.Background(), tt.req) + + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestGetUIDByAccount(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockAccountUIDRepo := mockRepo.NewMockAccountUIDRepository(mockCtrl) + + uc := MustMemberUseCase(MemberUseCaseParam{ + AccountUID: mockAccountUIDRepo, + Config: config.Config{ + Bcrypt: struct{ Cost int }{Cost: 10}, + }, + }) + + tests := []struct { + name string + req usecase.GetUIDByAccountRequest + mockSetup func() + wantResp usecase.GetUIDByAccountResponse + wantErr bool + }{ + { + name: "Successfully found UID by account", + req: usecase.GetUIDByAccountRequest{ + Account: "testuser", + }, + mockSetup: func() { + mockAccountUIDRepo.EXPECT(). + FindUIDByLoginID(gomock.Any(), "testuser"). + Return(&entity.AccountUID{UID: "12345"}, nil) + }, + wantResp: usecase.GetUIDByAccountResponse{ + UID: "12345", + Account: "testuser", + }, + wantErr: false, + }, + { + name: "Account not found", + req: usecase.GetUIDByAccountRequest{ + Account: "notfounduser", + }, + mockSetup: func() { + mockAccountUIDRepo.EXPECT(). + FindUIDByLoginID(gomock.Any(), "notfounduser"). + Return(nil, errs.NewError( + code.CloudEPMember, + code.CatResource, + domain.FailedFindUIDByLoginIDErrorCode, + "account not found", + )) + }, + wantResp: usecase.GetUIDByAccountResponse{}, + wantErr: true, + }, + { + name: "Database error", + req: usecase.GetUIDByAccountRequest{ + Account: "erroruser", + }, + mockSetup: func() { + mockAccountUIDRepo.EXPECT(). + FindUIDByLoginID(gomock.Any(), "erroruser"). + Return(nil, errors.New("database error")) + }, + wantResp: usecase.GetUIDByAccountResponse{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + resp, err := uc.GetUIDByAccount(context.Background(), tt.req) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantResp, resp) + } + }) + } +} + +func TestGetUserAccountInfo(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockAccountRepo := mockRepo.NewMockAccountRepository(mockCtrl) + + uc := MustMemberUseCase(MemberUseCaseParam{ + Account: mockAccountRepo, + Config: config.Config{ + Bcrypt: struct{ Cost int }{Cost: 10}, + }, + }) + + tests := []struct { + name string + req usecase.GetUIDByAccountRequest + mockSetup func() + expected usecase.GetAccountInfoResponse + wantErr bool + errCode string + }{ + { + name: "Successfully found account", + req: usecase.GetUIDByAccountRequest{Account: "testuser"}, + mockSetup: func() { + mockAccountRepo.EXPECT(). + FindOneByAccount(gomock.Any(), "testuser"). + Return(&entity.Account{ + LoginID: "testuser", + Platform: 1, + Token: "testtoken", + }, nil) + }, + expected: usecase.GetAccountInfoResponse{ + Data: usecase.CreateLoginUserRequest{ + LoginID: "testuser", + Platform: 1, + Token: "testtoken", + }, + }, + wantErr: false, + }, + { + name: "Account not found", + req: usecase.GetUIDByAccountRequest{Account: "notfounduser"}, + mockSetup: func() { + mockAccountRepo.EXPECT(). + FindOneByAccount(gomock.Any(), "notfounduser"). + Return(nil, mon.ErrNotFound) + }, + expected: usecase.GetAccountInfoResponse{}, + wantErr: true, + }, + { + name: "Database error", + req: usecase.GetUIDByAccountRequest{Account: "erroruser"}, + mockSetup: func() { + mockAccountRepo.EXPECT(). + FindOneByAccount(gomock.Any(), "erroruser"). + Return(nil, errors.New("database error")) + }, + expected: usecase.GetAccountInfoResponse{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + resp, err := uc.GetUserAccountInfo(context.Background(), tt.req) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, resp) + } + }) + } +} + +func TestGetUserInfo(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockUserRepo := mockRepo.NewMockUserRepository(mockCtrl) + + uc := MustMemberUseCase(MemberUseCaseParam{ + User: mockUserRepo, + Config: config.Config{ + Bcrypt: struct{ Cost int }{Cost: 10}, + }, + }) + tests := []struct { + name string + req usecase.GetUserInfoRequest + mockSetup func() + expected usecase.UserInfo + wantErr bool + errCode string + }{ + { + name: "Successfully found user by UID", + req: usecase.GetUserInfoRequest{UID: "testUID"}, + mockSetup: func() { + mockUserRepo.EXPECT(). + FindOneByUID(gomock.Any(), "testUID"). + Return(&entity.User{ + UID: "testUID", + Nickname: proto.String("testNick"), + Email: proto.String("test@example.com"), + PreferredLanguage: "en", + }, nil) + }, + expected: usecase.UserInfo{ + CreateUserInfoRequest: usecase.CreateUserInfoRequest{ + UID: "testUID", + Nickname: proto.String("testNick"), + Email: proto.String("test@example.com"), + PreferredLanguage: "en", + }, + }, + wantErr: false, + }, + { + name: "User not found", + req: usecase.GetUserInfoRequest{UID: "nonExistentUID"}, + mockSetup: func() { + mockUserRepo.EXPECT(). + FindOneByUID(gomock.Any(), "nonExistentUID"). + Return(nil, mon.ErrNotFound) + }, + expected: usecase.UserInfo{}, + wantErr: true, + }, + { + name: "Database error while querying user by UID", + req: usecase.GetUserInfoRequest{UID: "errorUID"}, + mockSetup: func() { + mockUserRepo.EXPECT(). + FindOneByUID(gomock.Any(), "errorUID"). + Return(nil, errors.New("database error")) + }, + expected: usecase.UserInfo{}, + wantErr: true, + }, + { + name: "Successfully found user by Nickname", + req: usecase.GetUserInfoRequest{NickName: "testNick"}, + mockSetup: func() { + mockUserRepo.EXPECT(). + FindOneByNickName(gomock.Any(), "testNick"). + Return(&entity.User{ + UID: "testUID", + Nickname: proto.String("testNick"), + Email: proto.String("test@example.com"), + }, nil) + }, + expected: usecase.UserInfo{ + CreateUserInfoRequest: usecase.CreateUserInfoRequest{ + UID: "testUID", + Nickname: proto.String("testNick"), + Email: proto.String("test@example.com"), + }, + }, + wantErr: false, + }, + { + name: "Invalid request, no UID or Nickname", + req: usecase.GetUserInfoRequest{}, + mockSetup: func() { + // No setup needed for invalid request + }, + expected: usecase.UserInfo{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + resp, err := uc.GetUserInfo(context.Background(), tt.req) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, resp) + } + }) + } +} + +func TestUpdateUserToken(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockAccountRepo := mockRepo.NewMockAccountRepository(mockCtrl) + uc := MustMemberUseCase(MemberUseCaseParam{ + Account: mockAccountRepo, + }) + + HasPasswordFunc = func(password string, cost int) (string, error) { + if password == "fail" { + return "", errors.New("encryption error") + } + return "encrypted-password", nil + } + + tests := []struct { + name string + req usecase.UpdateTokenRequest + mockSetup func() + wantErr bool + errCode string + }{ + { + name: "Successful token update", + req: usecase.UpdateTokenRequest{Account: "testAccount", Token: "newPassword", Platform: 1}, + mockSetup: func() { + mockAccountRepo.EXPECT(). + UpdateTokenByLoginID(gomock.Any(), "testAccount", "encrypted-password", gomock.Any()). + Return(nil) + }, + wantErr: false, + }, + { + name: "Password encryption failure", + req: usecase.UpdateTokenRequest{Account: "testAccount", Token: "fail", Platform: 1}, + mockSetup: func() { + // No repo call expected + }, + wantErr: true, + }, + { + name: "Account not found", + req: usecase.UpdateTokenRequest{Account: "nonExistentAccount", Token: "newPassword", Platform: 1}, + mockSetup: func() { + mockAccountRepo.EXPECT(). + UpdateTokenByLoginID(gomock.Any(), "nonExistentAccount", "encrypted-password", gomock.Any()). + Return(mon.ErrNotFound) + }, + wantErr: true, + }, + { + name: "Database error during token update", + req: usecase.UpdateTokenRequest{Account: "errorAccount", Token: "newPassword", Platform: 1}, + mockSetup: func() { + mockAccountRepo.EXPECT(). + UpdateTokenByLoginID(gomock.Any(), "errorAccount", "encrypted-password", gomock.Any()). + Return(errors.New("database error")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + err := uc.UpdateUserToken(context.Background(), tt.req) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestMemberUseCase_UpdateUserInfo(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockUserRepo := mockRepo.NewMockUserRepository(mockCtrl) + uc := MustMemberUseCase(MemberUseCaseParam{ + User: mockUserRepo, + }) + + tests := []struct { + name string + req *usecase.UpdateUserInfoRequest + mockSetup func() + wantErr bool + }{ + { + name: "Successful update", + req: &usecase.UpdateUserInfoRequest{ + UID: "testUID", + Nickname: proto.String("UpdatedNick"), + FullName: proto.String("Updated Name"), + AvatarURL: proto.String("http://example.com/avatar.png"), + }, + mockSetup: func() { + mockUserRepo.EXPECT(). + UpdateUserDetailsByUID(gomock.Any(), gomock.Any()). + Return(nil) + }, + wantErr: false, + }, + { + name: "User not found", + req: &usecase.UpdateUserInfoRequest{ + UID: "nonExistentUID", + }, + mockSetup: func() { + mockUserRepo.EXPECT(). + UpdateUserDetailsByUID(gomock.Any(), gomock.Any()). + Return(repository.ErrNotFound) + }, + wantErr: true, + }, + { + name: "Database error", + req: &usecase.UpdateUserInfoRequest{ + UID: "errorUID", + }, + mockSetup: func() { + mockUserRepo.EXPECT(). + UpdateUserDetailsByUID(gomock.Any(), gomock.Any()). + Return(errors.New("database error")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + err := uc.UpdateUserInfo(context.Background(), tt.req) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestMemberUseCase_UpdateStatus(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockUserRepo := mockRepo.NewMockUserRepository(mockCtrl) + uc := MustMemberUseCase(MemberUseCaseParam{ + User: mockUserRepo, + }) + + tests := []struct { + name string + req usecase.UpdateStatusRequest + mockSetup func() + wantErr bool + }{ + { + name: "Successful status update", + req: usecase.UpdateStatusRequest{UID: "testUID", Status: member.AccountStatusActive}, + mockSetup: func() { + mockUserRepo.EXPECT(). + UpdateStatus(gomock.Any(), "testUID", gomock.Any()). + Return(nil) + }, + wantErr: false, + }, + { + name: "User not found", + req: usecase.UpdateStatusRequest{UID: "nonExistentUID", Status: member.AccountStatusActive}, + mockSetup: func() { + mockUserRepo.EXPECT(). + UpdateStatus(gomock.Any(), "nonExistentUID", gomock.Any()). + Return(repository.ErrNotFound) + }, + wantErr: true, + }, + { + name: "Database error", + req: usecase.UpdateStatusRequest{UID: "errorUID", Status: member.AccountStatusUninitialized}, + mockSetup: func() { + mockUserRepo.EXPECT(). + UpdateStatus(gomock.Any(), "errorUID", gomock.Any()). + Return(errors.New("database error")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + err := uc.UpdateStatus(context.Background(), tt.req) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestMemberUseCase_ListMember(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockUserRepo := mockRepo.NewMockUserRepository(mockCtrl) + uc := MustMemberUseCase(MemberUseCaseParam{ + User: mockUserRepo, + }) + + tests := []struct { + name string + req usecase.ListUserInfoRequest + mockSetup func() + wantErr bool + }{ + { + name: "Successful member listing", + req: usecase.ListUserInfoRequest{ + PageSize: 10, + PageIndex: 1, + }, + mockSetup: func() { + mockUserRepo.EXPECT(). + ListMembers(gomock.Any(), gomock.Any()). + Return([]*entity.User{ + {UID: "testUID1"}, + {UID: "testUID2"}, + }, int64(2), nil) + }, + wantErr: false, + }, + { + name: "Database error", + req: usecase.ListUserInfoRequest{ + PageSize: 10, + PageIndex: 1, + }, + mockSetup: func() { + mockUserRepo.EXPECT(). + ListMembers(gomock.Any(), gomock.Any()). + Return(nil, int64(0), errors.New("database error")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + resp, err := uc.ListMember(context.Background(), tt.req) + + if tt.wantErr { + assert.Error(t, err) + assert.Empty(t, resp.Data) + } else { + assert.NoError(t, err) + assert.NotEmpty(t, resp.Data) + } + }) + } +} diff --git a/pkg/member/usecase/password_utils.go b/pkg/member/usecase/password_utils.go new file mode 100644 index 0000000..f3d9347 --- /dev/null +++ b/pkg/member/usecase/password_utils.go @@ -0,0 +1,50 @@ +package usecase + +import ( + "errors" + "golang.org/x/crypto/bcrypt" +) + +// ErrInvalidPassword is returned when password validation fails +var ErrInvalidPassword = errors.New("invalid password") + +// HashPassword generates a bcrypt hash from the given password with the specified cost. +// The cost parameter should be between 4 and 31, with higher values being more secure but slower. +func HashPassword(password string, cost int) (string, error) { + if password == "" { + return "", ErrInvalidPassword + } + + if cost < bcrypt.MinCost || cost > bcrypt.MaxCost { + cost = bcrypt.DefaultCost + } + + bytes, err := bcrypt.GenerateFromPassword([]byte(password), cost) + return string(bytes), err +} + +// CheckPasswordHash compares a password with its bcrypt hash. +// Returns true if the password matches the hash, false otherwise. +func CheckPasswordHash(password, hash string) bool { + if password == "" || hash == "" { + return false + } + + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// GetHashingCost extracts the cost parameter from a bcrypt hash. +// Returns the cost used to generate the hash, or 0 if the hash is invalid. +func GetHashingCost(hashedPassword []byte) int { + if len(hashedPassword) == 0 { + return 0 + } + + cost, err := bcrypt.Cost(hashedPassword) + if err != nil { + return 0 + } + + return cost +} diff --git a/pkg/member/usecase/password_utils_test.go b/pkg/member/usecase/password_utils_test.go new file mode 100644 index 0000000..0a14e6f --- /dev/null +++ b/pkg/member/usecase/password_utils_test.go @@ -0,0 +1,50 @@ +package usecase + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/crypto/bcrypt" +) + +func TestHashPassword(t *testing.T) { + password := "securepassword123" + cost := bcrypt.DefaultCost + + hashedPassword, err := HashPassword(password, cost) + + assert.NoError(t, err, "生成哈希密碼應該成功") + assert.NotEmpty(t, hashedPassword, "生成的哈希密碼不應為空") + + // 確認哈希密碼長度符合預期 + assert.Greater(t, len(hashedPassword), 0, "哈希密碼長度應大於零") +} + +func TestCheckPasswordHash(t *testing.T) { + password := "securepassword123" + cost := bcrypt.DefaultCost + + // 生成哈希密碼 + hashedPassword, err := HashPassword(password, cost) + assert.NoError(t, err) + + // 驗證密碼與哈希是否匹配 + assert.True(t, CheckPasswordHash(password, hashedPassword), "密碼應與哈希匹配") + + // 測試不匹配的密碼 + wrongPassword := "wrongpassword" + assert.False(t, CheckPasswordHash(wrongPassword, hashedPassword), "錯誤密碼不應與哈希匹配") +} + +func TestGetHashingCost(t *testing.T) { + password := "securepassword123" + cost := bcrypt.DefaultCost + + // 生成哈希密碼 + hashedPassword, err := HashPassword(password, cost) + assert.NoError(t, err) + + // 驗證哈希成本 + actualCost := GetHashingCost([]byte(hashedPassword)) + assert.Equal(t, cost, actualCost, "哈希成本應與生成時的一致") +} diff --git a/pkg/member/usecase/verify.go b/pkg/member/usecase/verify.go new file mode 100644 index 0000000..51f93ba --- /dev/null +++ b/pkg/member/usecase/verify.go @@ -0,0 +1,120 @@ +package usecase + +import ( + "context" + "fmt" + + "backend/pkg/member/domain" + "backend/pkg/member/domain/member" + "backend/pkg/member/domain/usecase" + + "code.30cm.net/digimon/library-go/errs" + "code.30cm.net/digimon/library-go/errs/code" +) + +func (use *MemberUseCase) GenerateRefreshCode(ctx context.Context, param usecase.GenerateRefreshCodeRequest) (usecase.GenerateRefreshCodeResponse, error) { + checkType, status := member.GetCodeNameByCode(param.CodeType) + if !status { + e := errs.ResourceNotFoundWithScope( + code.CloudEPMember, + domain.FailedToGetVerifyCodeErrorCode, + fmt.Sprintf("failed to get verify code type: %d", param.CodeType), + ) + + return usecase.GenerateRefreshCodeResponse{}, e + } + + vc, err := use.VerifyCodeModel.IsVerifyCodeExist(ctx, param.LoginID, checkType) + if err != nil { + return usecase.GenerateRefreshCodeResponse{}, err + } + // 找不到,故要產生 + if vc == "" { + vc, err = generateVerifyCode(6) + if err != nil { + return usecase.GenerateRefreshCodeResponse{}, errs.SystemInternalError(err.Error()) + } + + err = use.VerifyCodeModel.SetVerifyCode(ctx, param.LoginID, checkType, vc) + if err != nil { + return usecase.GenerateRefreshCodeResponse{}, + errs.DatabaseErrorWithScope( + code.CloudEPMember, + domain.FailedToGetCodeOnRedisErrorCode, + "failed to set verify code", + ) + } + } + + return usecase.GenerateRefreshCodeResponse{ + Data: usecase.VerifyCode{ + VerifyCode: vc, + }, + }, nil +} + +func (use *MemberUseCase) VerifyRefreshCode(ctx context.Context, param usecase.VerifyRefreshCodeRequest) error { + err := use.CheckRefreshCode(ctx, param) + if err != nil { + return err + } + + // 因為 CheckRefreshCde 中也有驗證一次,這次就讓他不報錯直接過,因為如果有錯誤,上面那個就會報錯了 + checkType, _ := member.GetCodeNameByCode(param.CodeType) + // todo: 刪不掉看要用什麼方法補刪除,而不是報錯 + _ = use.VerifyCodeModel.DelVerifyCode(ctx, param.LoginID, checkType) + + return nil +} + +func (use *MemberUseCase) CheckRefreshCode(ctx context.Context, param usecase.VerifyRefreshCodeRequest) error { + checkType, status := member.GetCodeNameByCode(param.CodeType) + if !status { + return errs.ResourceNotFoundWithScope( + code.CloudEPMember, + domain.FailedToGetVerifyCodeErrorCode, + fmt.Sprintf("failed to get verify code type: %d", param.CodeType), + ) + } + + get, err := use.VerifyCodeModel.IsVerifyCodeExist(ctx, param.LoginID, checkType) + if err != nil { + return errs.DatabaseErrorWithScope( + code.CloudEPMember, + domain.FailedToGetCodeOnRedisErrorCode, + "failed to set verify code", + ) + } + if get == "" { + return errs.ResourceNotFoundWithScope( + code.CloudEPMember, + domain.FailedToGetCodeOnRedisErrorCode, + "failed to get data", + ) + } + + if get != param.VerifyCode { + return errs.ForbiddenWithScope( + code.CloudEPMember, + domain.FailedToGetCodeCorrectErrorCode, + "failed to verify code", + ) + } + + return nil +} + +func (use *MemberUseCase) VerifyPlatformAuthResult(ctx context.Context, param usecase.VerifyAuthResultRequest) (usecase.VerifyAuthResultResponse, error) { + account, err := use.Account.FindOneByAccount(ctx, param.Account) + if err != nil { + return usecase.VerifyAuthResultResponse{}, errs.ResourceNotFoundWithScope( + code.CloudEPMember, + domain.FailedToFindAccountErrorCode, + fmt.Sprintf("failed to find account: %s", param.Account), + ) + } + + return usecase.VerifyAuthResultResponse{ + Status: CheckPasswordHash(param.Token, account.Token), + }, nil +} diff --git a/pkg/member/usecase/verify_google.go b/pkg/member/usecase/verify_google.go new file mode 100644 index 0000000..4e0235c --- /dev/null +++ b/pkg/member/usecase/verify_google.go @@ -0,0 +1,129 @@ +package usecase + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "backend/pkg/member/domain" + "backend/pkg/member/domain/usecase" + + "code.30cm.net/digimon/library-go/errs" + "code.30cm.net/digimon/library-go/errs/code" +) + +func (use *MemberUseCase) VerifyGoogleAuthResult(ctx context.Context, req usecase.VerifyAuthResultRequest) (usecase.GoogleTokenInfo, error) { + var tokenInfo usecase.GoogleTokenInfo + // 發送 Google Token Info API 請求 + body, err := fetchGoogleTokenInfo(ctx, req.Token) + if err != nil { + return tokenInfo, err + } + + // 解析返回的 JSON 數據 + if err := json.Unmarshal(body, &tokenInfo); err != nil { + return tokenInfo, errs.ThirdPartyError( + code.CloudEPMember, + domain.FailedToVerifyGoogle, + "failed to parse token info", + ) + } + + // 驗證 Token 資訊 + if err := validateGoogleTokenInfo(tokenInfo, use.Config.GoogleAuth.ClientID); err != nil { + return tokenInfo, err + } + + return tokenInfo, nil +} + +// fetchGoogleTokenInfo 發送 Google TokenInfo API 請求並返回響應內容 +func fetchGoogleTokenInfo(ctx context.Context, token string) ([]byte, error) { + uri := fmt.Sprintf("https://oauth2.googleapis.com/tokeninfo?id_token=%s", token) + + // 發送請求 + r, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, errs.ThirdPartyError( + code.CloudEPMember, + domain.FailedToVerifyGoogle, + "failed to create request", err.Error()) + } + + resp, err := http.DefaultClient.Do(r) + if err != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return nil, errs.ThirdPartyError( + code.CloudEPMember, + domain.FailedToVerifyGoogleTimeout, + "request timeout", + ) + } + + return nil, errs.ThirdPartyError( + code.CloudEPMember, + domain.FailedToVerifyGoogleTimeout, + "failed to request Google TokenInfo API", + ) + } + defer resp.Body.Close() + + // 檢查返回的 HTTP 狀態碼 + if resp.StatusCode != http.StatusOK { + return nil, errs.ThirdPartyError( + code.CloudEPMember, + domain.FailedToVerifyGoogleHTTPCode, + fmt.Sprintf("unexpected status code: %d", resp.StatusCode), + ) + } + + // 讀取響應內容 + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errs.ThirdPartyError( + code.CloudEPMember, + domain.FailedToVerifyGoogle, + "failed to read response body", + ) + } + + return body, nil +} + +// validateGoogleTokenInfo 驗證 Google Token 資訊 +func validateGoogleTokenInfo(tokenInfo usecase.GoogleTokenInfo, expectedClientID string) error { + // **驗證 1: Token 是否過期** + expiration, err := strconv.ParseInt(tokenInfo.Exp, 10, 64) + if err != nil || expiration <= time.Now().UTC().Unix() { + return errs.ThirdPartyError( + code.CloudEPMember, + domain.FailedToVerifyGoogleTokenExpired, + "token is expired", + ) + } + + // **驗證 2: Audience (aud) 是否與 Google Client ID 匹配** + if tokenInfo.Aud != expectedClientID { + return errs.ThirdPartyError( + code.CloudEPMember, + domain.FailedToVerifyGoogleInvalidAudience, + "invalid audience", + ) + } + + // **驗證 3: 是否 email 已驗證** + if tokenInfo.EmailVerified == "false" { + return errs.ThirdPartyError( + code.CloudEPMember, + domain.FailedToVerifyGoogle, + "email is not verified", + ) + } + + return nil +} diff --git a/pkg/member/usecase/verify_google_test.go b/pkg/member/usecase/verify_google_test.go new file mode 100644 index 0000000..eaa24b9 --- /dev/null +++ b/pkg/member/usecase/verify_google_test.go @@ -0,0 +1,86 @@ +package usecase + +import ( + "strconv" + "testing" + "time" + + "backend/pkg/member/domain" + "backend/pkg/member/domain/usecase" + + "code.30cm.net/digimon/library-go/errs" + "code.30cm.net/digimon/library-go/errs/code" + "github.com/stretchr/testify/assert" +) + +func TestValidateGoogleTokenInfo(t *testing.T) { + expectedClientID := "test-client-id" + + tests := []struct { + name string + tokenInfo usecase.GoogleTokenInfo + expectedErr error + }{ + { + name: "Valid token", + tokenInfo: usecase.GoogleTokenInfo{ + Exp: strconv.FormatInt(time.Now().UTC().Add(10*time.Minute).Unix(), 10), + Aud: expectedClientID, + EmailVerified: "true", + }, + expectedErr: nil, + }, + { + name: "Token expired", + tokenInfo: usecase.GoogleTokenInfo{ + Exp: strconv.FormatInt(time.Now().UTC().Add(-10*time.Minute).Unix(), 10), + Aud: expectedClientID, + EmailVerified: "true", + }, + expectedErr: errs.ThirdPartyError( + code.CloudEPMember, + domain.FailedToVerifyGoogleTokenExpired, + "token is expired", + ), + }, + { + name: "Invalid audience", + tokenInfo: usecase.GoogleTokenInfo{ + Exp: strconv.FormatInt(time.Now().UTC().Add(10*time.Minute).Unix(), 10), + Aud: "invalid-client-id", + EmailVerified: "true", + }, + expectedErr: errs.ThirdPartyError( + code.CloudEPMember, + domain.FailedToVerifyGoogleInvalidAudience, + "invalid audience", + ), + }, + { + name: "Email not verified", + tokenInfo: usecase.GoogleTokenInfo{ + Exp: strconv.FormatInt(time.Now().UTC().Add(10*time.Minute).Unix(), 10), + Aud: expectedClientID, + EmailVerified: "false", + }, + expectedErr: errs.ThirdPartyError( + code.CloudEPMember, + domain.FailedToVerifyGoogle, + "email is not verified", + ), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateGoogleTokenInfo(test.tokenInfo, expectedClientID) + + if test.expectedErr != nil { + assert.NotNil(t, err) + assert.EqualError(t, err, test.expectedErr.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/pkg/member/usecase/verify_line.go b/pkg/member/usecase/verify_line.go new file mode 100644 index 0000000..9300ed5 --- /dev/null +++ b/pkg/member/usecase/verify_line.go @@ -0,0 +1,129 @@ +package usecase + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + + "backend/pkg/member/domain" + "backend/pkg/member/domain/usecase" + + "code.30cm.net/digimon/library-go/errs" + "code.30cm.net/digimon/library-go/errs/code" +) + +// LineCodeToAccessToken 透過 Line 授權碼換取 Access Token +func (use *MemberUseCase) LineCodeToAccessToken(ctx context.Context, code string) (usecase.LineAccessTokenResponse, error) { + data := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {use.Config.LineAuth.RedirectURI}, + "client_id": {use.Config.LineAuth.ClientID}, + "client_secret": {use.Config.LineAuth.ClientSecret}, + } + + uri := "https://api.line.me/oauth2/v2.1/token" + headers := map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + } + + var tokenResponse usecase.LineAccessTokenResponse + if err := use.doPost(ctx, uri, headers, data.Encode(), &tokenResponse); err != nil { + return usecase.LineAccessTokenResponse{}, err + } + + return tokenResponse, nil +} + +// LineGetProfileByAccessToken 使用 Access Token 獲取 Line 用戶資料 +func (use *MemberUseCase) LineGetProfileByAccessToken(ctx context.Context, accessToken string) (*usecase.LineUserProfile, error) { + uri := "https://api.line.me/v2/profile" + headers := map[string]string{ + "Authorization": "Bearer " + accessToken, + } + + var profile usecase.LineUserProfile + if err := use.doGet(ctx, uri, headers, &profile); err != nil { + return nil, err + } + + return &profile, nil +} + +// doPost 發送 POST 請求並解析返回數據 +func (use *MemberUseCase) doPost(ctx context.Context, uri string, headers map[string]string, body string, result interface{}) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uri, bytes.NewBufferString(body)) + if err != nil { + return errs.ThirdPartyError( + code.CloudEPMember, + domain.FailedToVerifyLine, + "failed to create request", + ) + } + for key, value := range headers { + req.Header.Set(key, value) + } + + return use.doRequest(req, result) +} + +// doGet 發送 GET 請求並解析返回數據 +func (use *MemberUseCase) doGet(ctx context.Context, uri string, headers map[string]string, result interface{}) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return errs.ThirdPartyError( + code.CloudEPMember, + domain.FailedToVerifyLine, + "failed to create request", + ) + } + for key, value := range headers { + req.Header.Set(key, value) + } + + return use.doRequest(req, result) +} + +// doRequest 發送 HTTP 請求並處理響應 +func (use *MemberUseCase) doRequest(req *http.Request, result interface{}) error { + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return errs.ThirdPartyError( + code.CloudEPMember, + domain.FailedToVerifyLine, + "failed to send request", + ) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return errs.ThirdPartyError( + code.CloudEPMember, + domain.FailedToVerifyLine, + "unexpected status code: "+http.StatusText(resp.StatusCode), + ) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return errs.ThirdPartyError( + code.CloudEPMember, + domain.FailedToVerifyLine, + "failed to read response body", + ) + } + + if err := json.Unmarshal(body, result); err != nil { + return errs.ThirdPartyError( + code.CloudEPMember, + domain.FailedToVerifyLine, + "failed to parse response body", + ) + } + + return nil +} diff --git a/pkg/member/usecase/verify_test.go b/pkg/member/usecase/verify_test.go new file mode 100644 index 0000000..f2e692e --- /dev/null +++ b/pkg/member/usecase/verify_test.go @@ -0,0 +1,277 @@ +package usecase + +import ( + "context" + "errors" + "fmt" + "testing" + + "backend/pkg/member/domain/entity" + "backend/pkg/member/domain/member" + "backend/pkg/member/domain/usecase" + mockRepo "backend/pkg/member/mock/repository" + + "code.30cm.net/digimon/library-go/errs" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestGenerateRefreshCode(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockVerifyCodeModel := mockRepo.NewMockVerifyCodeRepository(mockCtrl) + uc := MustMemberUseCase(MemberUseCaseParam{ + VerifyCodeModel: mockVerifyCodeModel, + }) + + tests := []struct { + name string + param usecase.GenerateRefreshCodeRequest + mockSetup func() + wantErr bool + }{ + { + name: "Successful code generation", + param: usecase.GenerateRefreshCodeRequest{ + LoginID: "testLoginID", + CodeType: member.GenerateCodeTypeEmail, + }, + mockSetup: func() { + mockVerifyCodeModel.EXPECT().IsVerifyCodeExist(gomock.Any(), "testLoginID", "email").Return("", nil) + mockVerifyCodeModel.EXPECT().SetVerifyCode(gomock.Any(), "testLoginID", "email", gomock.Any()).Return(nil) + }, + wantErr: false, + }, + { + name: "Code type not found", + param: usecase.GenerateRefreshCodeRequest{ + LoginID: "testLoginID", + CodeType: -999, // Invalid code type + }, + mockSetup: func() {}, + wantErr: true, + }, + { + name: "Existing code retrieval", + param: usecase.GenerateRefreshCodeRequest{ + LoginID: "testLoginID", + CodeType: member.GenerateCodeTypeEmail, + }, + mockSetup: func() { + mockVerifyCodeModel.EXPECT().IsVerifyCodeExist(gomock.Any(), "testLoginID", "email").Return("123456", nil) + }, + wantErr: false, + }, + { + name: "Set verify code failure", + param: usecase.GenerateRefreshCodeRequest{ + LoginID: "testLoginID", + CodeType: member.GenerateCodeTypeEmail, + }, + mockSetup: func() { + mockVerifyCodeModel.EXPECT().IsVerifyCodeExist(gomock.Any(), "testLoginID", "email").Return("", nil) + mockVerifyCodeModel.EXPECT().SetVerifyCode(gomock.Any(), "testLoginID", "email", gomock.Any()).Return(errors.New("redis error")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + resp, err := uc.GenerateRefreshCode(context.Background(), tt.param) + + if tt.wantErr { + assert.Error(t, err) + assert.Empty(t, resp) + } else { + assert.NoError(t, err) + assert.NotEmpty(t, resp.Data.VerifyCode) + } + }) + } +} + +func TestCheckRefreshCode(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockVerifyCodeModel := mockRepo.NewMockVerifyCodeRepository(mockCtrl) + uc := MustMemberUseCase(MemberUseCaseParam{ + VerifyCodeModel: mockVerifyCodeModel, + }) + + tests := []struct { + name string + param usecase.VerifyRefreshCodeRequest + mockSetup func() + wantErr bool + }{ + { + name: "Successful verification", + param: usecase.VerifyRefreshCodeRequest{ + LoginID: "testLoginID", + CodeType: member.GenerateCodeTypeEmail, + VerifyCode: "123456", + }, + mockSetup: func() { + mockVerifyCodeModel.EXPECT().IsVerifyCodeExist(gomock.Any(), "testLoginID", "email").Return("123456", nil) + }, + wantErr: false, + }, + { + name: "Code type not found", + param: usecase.VerifyRefreshCodeRequest{ + LoginID: "testLoginID", + CodeType: -1, // Invalid CodeType + VerifyCode: "123456", + }, + mockSetup: func() {}, + wantErr: true, + }, + { + name: "Code not found in Redis", + param: usecase.VerifyRefreshCodeRequest{ + LoginID: "testLoginID", + CodeType: member.GenerateCodeTypeEmail, + VerifyCode: "123456", + }, + mockSetup: func() { + mockVerifyCodeModel.EXPECT().IsVerifyCodeExist(gomock.Any(), "testLoginID", "email").Return("", nil) + }, + wantErr: true, + }, + { + name: "Verification code mismatch", + param: usecase.VerifyRefreshCodeRequest{ + LoginID: "testLoginID", + CodeType: member.GenerateCodeTypeEmail, + VerifyCode: "654321", // Mismatch + }, + mockSetup: func() { + mockVerifyCodeModel.EXPECT().IsVerifyCodeExist(gomock.Any(), "testLoginID", "email").Return("123456", nil) + }, + wantErr: true, + }, + { + name: "Redis retrieval error", + param: usecase.VerifyRefreshCodeRequest{ + LoginID: "testLoginID", + CodeType: member.GenerateCodeTypeEmail, + VerifyCode: "123456", + }, + mockSetup: func() { + mockVerifyCodeModel.EXPECT().IsVerifyCodeExist(gomock.Any(), "testLoginID", "email").Return("", errors.New("redis error")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + err := uc.CheckRefreshCode(context.Background(), tt.param) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestVerifyPlatformAuthResult(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockAccountRepo := mockRepo.NewMockAccountRepository(mockCtrl) + uc := MustMemberUseCase(MemberUseCaseParam{ + Account: mockAccountRepo, + }) + token, err := HashPassword("password", 10) + assert.NoError(t, err) + fmt.Println(token) + tests := []struct { + name string + param usecase.VerifyAuthResultRequest + mockSetup func() + wantResp usecase.VerifyAuthResultResponse + wantErr bool + }{ + { + name: "Successful verification", + param: usecase.VerifyAuthResultRequest{ + Account: "testAccount", + Token: "password", + }, + mockSetup: func() { + mockAccountRepo.EXPECT().FindOneByAccount(gomock.Any(), "testAccount").Return(&entity.Account{ + Token: token, + }, nil) + }, + wantResp: usecase.VerifyAuthResultResponse{ + Status: true, + }, + wantErr: false, + }, + { + name: "Invalid token verification", + param: usecase.VerifyAuthResultRequest{ + Account: "testAccount", + Token: "invalidToken", + }, + mockSetup: func() { + mockAccountRepo.EXPECT().FindOneByAccount(gomock.Any(), "testAccount").Return(&entity.Account{ + Token: "validToken", + }, nil) + }, + wantResp: usecase.VerifyAuthResultResponse{ + Status: false, + }, + wantErr: false, + }, + { + name: "Account not found", + param: usecase.VerifyAuthResultRequest{ + Account: "nonExistentAccount", + Token: "someToken", + }, + mockSetup: func() { + mockAccountRepo.EXPECT().FindOneByAccount(gomock.Any(), "nonExistentAccount").Return(nil, errs.ResourceNotFound("account not found")) + }, + wantResp: usecase.VerifyAuthResultResponse{}, + wantErr: true, + }, + { + name: "Database error", + param: usecase.VerifyAuthResultRequest{ + Account: "testAccount", + Token: "someToken", + }, + mockSetup: func() { + mockAccountRepo.EXPECT().FindOneByAccount(gomock.Any(), "testAccount").Return(nil, errors.New("database error")) + }, + wantResp: usecase.VerifyAuthResultResponse{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + resp, err := uc.VerifyPlatformAuthResult(context.Background(), tt.param) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantResp, resp) + } + }) + } +}