diff --git a/.golangci.yaml b/.golangci.yaml index 5518484..c9726ca 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -117,6 +117,14 @@ issues: - gocognit - contextcheck + exclude-dirs: + - internal/model + + exclude-files: + - .*_test.go + + + linters-settings: gci: sections: diff --git a/Makefile b/Makefile index 20dc350..d8e2c07 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ test: # 進行測試 fmt: # 格式優化 $(GOFMT) -w $(GOFILES) goimports -w ./ + golangci-lint run .PHONY: gen-rpc gen-rpc: # 建立 rpc code @@ -51,9 +52,9 @@ gen-mongo-model: # 建立 rpc 資料庫 # 只產生 Model 剩下的要自己撰寫,連欄位名稱也是 goctl model mongo -t post --dir ./internal/model/mongo --style $(GO_ZERO_STYLE) goctl model mongo -t comment --dir ./internal/model/mongo --style $(GO_ZERO_STYLE) -# goctl model mongo -t tags --dir ./internal/model/mongo --style $(GO_ZERO_STYLE) -# goctl model mongo -t post_likes --dir ./internal/model/mongo --style $(GO_ZERO_STYLE) -# goctl model mongo -t comment_likes --dir ./internal/model/mongo --style $(GO_ZERO_STYLE) + goctl model mongo -t tags --dir ./internal/model/mongo --style $(GO_ZERO_STYLE) + goctl model mongo -t post_likes --dir ./internal/model/mongo --style $(GO_ZERO_STYLE) + goctl model mongo -t comment_likes --dir ./internal/model/mongo --style $(GO_ZERO_STYLE) @echo "Generate mongo model files successfully" .PHONY: mock-gen @@ -62,6 +63,8 @@ mock-gen: # 建立 mock 資料 mockgen -source=./internal/model/mongo/post_model.go -destination=./internal/mock/model/post_model.go -package=mock mockgen -source=./internal/model/mongo/comment_model_gen.go -destination=./internal/mock/model/comment_model_gen.go -package=mock mockgen -source=./internal/model/mongo/comment_model.go -destination=./internal/mock/model/comment_model.go -package=mock + mockgen -source=./internal/domain/repository/social_network.go -destination=./internal/mock/repository/social_network.go -package=mock + mockgen -source=./internal/domain/repository/timeline.go -destination=./internal/mock/repository/timeline.go -package=mock @echo "Generate mock files successfully" .PHONY: migrate-database diff --git a/etc/tweeting.yaml b/etc/tweeting.yaml index 279f3cb..8dfae0b 100644 --- a/etc/tweeting.yaml +++ b/etc/tweeting.yaml @@ -11,4 +11,21 @@ Mongo: User: "" Password: "" Port: "27017" - Database: digimon_tweeting \ No newline at end of file + Database: digimon_tweeting + +TimelineSetting: + Expire: 86400 + MaxLength: 1000 + +RedisCluster: + Host: 127.0.0.1:7001 + Type: cluster + +Neo4J: + URI: bolt://localhost:7687 + Username: neo4j + Password: yyyytttt + MaxConnectionPoolSize: 20 + MaxConnectionLifetime: 200s + ConnectionTimeout : 200s + LogLevel : debug \ No newline at end of file diff --git a/generate/database/mongodb/20240829054501_post.up.js b/generate/database/mongodb/20240829054501_post.up.mongo similarity index 100% rename from generate/database/mongodb/20240829054501_post.up.js rename to generate/database/mongodb/20240829054501_post.up.mongo diff --git a/generate/database/mongodb/20240829054502_comment.up.js b/generate/database/mongodb/20240829054502_comment.up.mongo similarity index 53% rename from generate/database/mongodb/20240829054502_comment.up.js rename to generate/database/mongodb/20240829054502_comment.up.mongo index 119f0ad..33382ec 100644 --- a/generate/database/mongodb/20240829054502_comment.up.js +++ b/generate/database/mongodb/20240829054502_comment.up.mongo @@ -1,5 +1,2 @@ use digimon_tweeting; db.comment.createIndex({ "post_id": 1,"createAt":1}); - - -// TODO 看是否有要刪除過多的索引,要在測試一下 \ No newline at end of file diff --git a/generate/database/neo4j/relaction.cypher b/generate/database/neo4j/relaction.cypher new file mode 100644 index 0000000..06c9f90 --- /dev/null +++ b/generate/database/neo4j/relaction.cypher @@ -0,0 +1,5 @@ +// 企業版才能用,社群版只能用預設的 +CREATE DATABASE relation; + +// 創建 User 節點 UID 是唯一鍵 +CREATE CONSTRAINT FOR (u:User) REQUIRE u.uid IS UNIQUE \ No newline at end of file diff --git a/generate/protobuf/tweeting.proto b/generate/protobuf/tweeting.proto index cbc1b2c..ff19d56 100644 --- a/generate/protobuf/tweeting.proto +++ b/generate/protobuf/tweeting.proto @@ -181,4 +181,118 @@ service CommentService rpc DeleteComment(DeleteCommentReq) returns (OKResp); // UpdateComment 更新評論 rpc UpdateComment(UpdateCommentReq) returns (OKResp); +} + +// ========== TimeLineService (個人動態時報) ========== + +message GetTimelineReq +{ + string uid = 1; // 用户ID + int64 pageIndex = 2; // 頁碼 + int64 pageSize = 3; // 每一頁大小 +} + +message FetchTimelineResponse +{ + repeated FetchTimelineItem posts = 1; // 貼文列表 + Pager page = 2; // 分頁訊息 +} + +message FetchTimelineItem +{ + string post_id = 1; + int64 score = 2; +} + +message AddPostToTimelineReq +{ + string uid = 1; // key + repeated PostTimelineItem posts = 3; +} + +// 貼文更新 +message PostTimelineItem +{ + string post_id = 1; // 貼文ID + int64 created_at = 7; // 發佈時間 -> 排序使用 +} + +message DoNoMoreDataReq +{ + string uid = 1; +} + +message HasNoMoreDataResp +{ + bool status = 1; +} + +// TimelineService 業務邏輯在外面組合 +service TimelineService +{ + // AddPost 加入貼文,只管一股腦全塞,這裡會自動判斷 + // 誰要砍誰不砍,處理排序,上限是 1000 筆(超過1000 請他回資料庫拿) + // 只存活躍用戶的,不活躍不浪費快取的空間 + rpc AddPost(AddPostToTimelineReq) returns (OKResp); + // FetchTimeline 取得這個人的動態時報 + rpc FetchTimeline(GetTimelineReq) returns (FetchTimelineResponse); + // SetNoMoreDataFlag 標記時間線已完整,避免繼續查詢資料庫。 + rpc SetNoMoreDataFlag(DoNoMoreDataReq) returns (OKResp); + // HasNoMoreData 檢查時間線是否已完整,決定是否需要查詢資料庫。 + rpc HasNoMoreData(DoNoMoreDataReq) returns (HasNoMoreDataResp); + // ClearNoMoreDataFlag 清除時間線的 "NoMoreData" 標誌。 + rpc ClearNoMoreDataFlag(DoNoMoreDataReq) returns (OKResp); +} + +// ========== Social Network (關係網路) ========== + +message AddUserToNetworkReq +{ + string uid = 1; +} + +message DoFollowerRelationReq +{ + string follower_uid = 1; + string followee_uid = 2; +} + +message FollowReq +{ + string uid = 1; + int64 page_size = 2; + int64 page_index = 3; +} + +message FollowResp +{ + repeated string uid = 1; + Pager page = 2; +} + +message FollowCountReq +{ + string uid = 1; +} + +message FollowCountResp +{ + string uid = 1; + int64 total = 2; +} + +service SocialNetworkService +{ + // MarkFollowRelation 關注 + rpc MarkFollowRelation(DoFollowerRelationReq) returns (OKResp); + // RemoveFollowRelation 取消關注 + rpc RemoveFollowRelation(DoFollowerRelationReq) returns (OKResp); + // GetFollower 取得跟隨者名單 + rpc GetFollower(FollowReq) returns (FollowResp); + // GetFollowee 取得我跟隨的名單 + rpc GetFollowee(FollowReq) returns (FollowResp); + // GetFollowerCount 取得跟隨者數量 + rpc GetFollowerCount(FollowCountReq) returns (FollowCountResp); + // GetFolloweeCount 取得我跟隨的數量 + rpc GetFolloweeCount(FollowCountReq) returns (FollowCountResp); } \ No newline at end of file diff --git a/go.mod b/go.mod index ea41a6a..f1f1499 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,10 @@ go 1.22.3 require ( code.30cm.net/digimon/library-go/errs v1.2.4 code.30cm.net/digimon/library-go/validator v1.0.0 + github.com/alicebob/miniredis/v2 v2.33.0 + github.com/neo4j/neo4j-go-driver/v5 v5.24.0 github.com/stretchr/testify v1.9.0 + github.com/testcontainers/testcontainers-go v0.33.0 github.com/zeromicro/go-zero v1.7.0 go.mongodb.org/mongo-driver v1.16.0 go.uber.org/mock v0.4.0 @@ -14,18 +17,32 @@ require ( ) require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // 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/containerd v1.7.18 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/cpuguy83/dockercfg v0.3.1 // 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 v27.1.1+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/fatih/color v1.17.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.22.4 // indirect @@ -45,29 +62,49 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.8 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.7.7 // 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/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.5.0 // indirect + github.com/moby/sys/user v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // 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.0 // 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.19.1 // indirect github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect github.com/redis/go-redis/v9 v9.6.1 // indirect + github.com/shirou/gopsutil/v3 v3.23.12 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spaolacci/murmur3 v1.1.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-20201027041543-1326539a0a0a // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + github.com/yusufpapurcu/wmi v1.2.3 // indirect go.etcd.io/etcd/api/v3 v3.5.15 // indirect go.etcd.io/etcd/client/pkg/v3 v3.5.15 // indirect go.etcd.io/etcd/client/v3 v3.5.15 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect go.opentelemetry.io/otel v1.24.0 // indirect go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 // indirect diff --git a/internal/config/config.go b/internal/config/config.go index 73cf966..a028a59 100755 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,9 +1,15 @@ package config -import "github.com/zeromicro/go-zero/zrpc" +import ( + "time" + + "github.com/zeromicro/go-zero/core/stores/redis" + "github.com/zeromicro/go-zero/zrpc" +) type Config struct { zrpc.RpcServerConf + Mongo struct { Schema string User string @@ -12,4 +18,23 @@ type Config struct { Port string Database string } + + TimelineSetting struct { + Expire int64 // Second + MaxLength int64 // 暫存筆數 + } + + // Redis Cluster + RedisCluster redis.RedisConf + + // 圖形資料庫 + Neo4J struct { + URI string + Username string + Password string + MaxConnectionPoolSize int + MaxConnectionLifetime time.Duration + ConnectionTimeout time.Duration + LogLevel string + } } diff --git a/internal/domain/const.go b/internal/domain/const.go index 0e7211b..060080a 100644 --- a/internal/domain/const.go +++ b/internal/domain/const.go @@ -11,3 +11,7 @@ const ( AdTypeOnlyAd AdTypeOnlyNotAd ) + +const ( + LastOfTimelineFlag = "NoMoreData" +) diff --git a/internal/domain/errors.go b/internal/domain/errors.go index d16b1ab..2f18b18 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -1,7 +1,6 @@ package domain import ( - "fmt" "strings" ers "code.30cm.net/digimon/library-go/errs" @@ -33,10 +32,25 @@ const ( CommentListErrorCode ) +const ( + AddTimeLineErrorCode ErrorCode = iota + 20 + FetchTimeLineErrorCode + ClearNoMoreDataErrorCode + HasNoMoreDataErrorCode + SetNoMoreDataErrorCode +) + +const ( + MarkRelationErrorCode ErrorCode = iota + 30 + GetFollowerErrorCode + GetFollowerCountErrorCode + GetFolloweeErrorCode + GetFolloweeCountErrorCode + RemoveRelationErrorCode +) + func CommentError(ec ErrorCode, s ...string) *ers.LibError { - return ers.NewError(code.CloudEPTweeting, code.DBError, - ec.ToUint32(), - fmt.Sprintf("%s", strings.Join(s, " "))) + return ers.NewError(code.CloudEPTweeting, code.DBError, ec.ToUint32(), strings.Join(s, " ")) } func CommentErrorL(ec ErrorCode, diff --git a/internal/domain/redis.go b/internal/domain/redis.go new file mode 100644 index 0000000..b8ab2a0 --- /dev/null +++ b/internal/domain/redis.go @@ -0,0 +1,19 @@ +package domain + +import "strings" + +type RedisKey string + +func (key RedisKey) ToString() string { + return string(key) +} + +func (key RedisKey) With(s ...string) RedisKey { + parts := append([]string{string(key)}, s...) + + return RedisKey(strings.Join(parts, ":")) +} + +const ( + TimelineRedisKey RedisKey = "timeline" +) diff --git a/internal/domain/redis_test.go b/internal/domain/redis_test.go new file mode 100644 index 0000000..3df7c50 --- /dev/null +++ b/internal/domain/redis_test.go @@ -0,0 +1,45 @@ +package domain + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestRedisKeyToString 測試 ToString 方法 +func TestRedisKeyToString(t *testing.T) { + key := RedisKey("user:timeline") + expected := "user:timeline" + result := key.ToString() + + assert.Equal(t, expected, result, "ToString should return the correct string representation of RedisKey") +} + +// TestRedisKeyWith 測試 With 方法 +func TestRedisKeyWith(t *testing.T) { + key := RedisKey("user:timeline") + subKey := "12345" + expected := "user:timeline:12345" + result := key.With(subKey) + + assert.Equal(t, RedisKey(expected), result, "With should correctly concatenate the RedisKey with the provided subKey") +} + +// TestRedisKeyWithMultiple 測試 With 方法與多個參數 +func TestRedisKeyWithMultiple(t *testing.T) { + key := RedisKey("user:timeline") + subKeys := []string{"12345", "posts"} + expected := "user:timeline:12345:posts" + result := key.With(subKeys...) + + assert.Equal(t, RedisKey(expected), result, "With should correctly concatenate the RedisKey with multiple provided subKeys") +} + +// TestRedisKeyWithEmpty 測試 With 方法與空參數 +func TestRedisKeyWithEmpty(t *testing.T) { + key := RedisKey("user:timeline") + expected := "user:timeline" + result := key.With() + + assert.Equal(t, RedisKey(expected), result, "With should return the original key when no subKeys are provided") +} diff --git a/internal/domain/repository/social_network.go b/internal/domain/repository/social_network.go new file mode 100644 index 0000000..3775e25 --- /dev/null +++ b/internal/domain/repository/social_network.go @@ -0,0 +1,26 @@ +package repository + +import "context" + +type SocialNetworkRepository interface { + CreateUserNode(ctx context.Context, uid string) error + MarkFollowerRelation(ctx context.Context, fromUID, toUID string) error + RemoveFollowerRelation(ctx context.Context, fromUID, toUID string) error + GetFollower(ctx context.Context, req FollowReq) (FollowResp, error) + GetFollowee(ctx context.Context, req FollowReq) (FollowResp, error) + GetFollowerCount(ctx context.Context, uid string) (int64, error) + GetFolloweeCount(ctx context.Context, uid string) (int64, error) + GetDegreeBetweenUsers(ctx context.Context, uid1, uid2 string) (int64, error) + GetUIDsWithinNDegrees(ctx context.Context, uid string, degrees, pageSize, pageIndex int64) ([]string, int64, error) +} + +type FollowReq struct { + UID string + PageSize int64 + PageIndex int64 +} + +type FollowResp struct { + UIDs []string + Total int64 +} diff --git a/internal/domain/repository/timeline.go b/internal/domain/repository/timeline.go new file mode 100644 index 0000000..6beb3af --- /dev/null +++ b/internal/domain/repository/timeline.go @@ -0,0 +1,57 @@ +package repository + +import ( + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + "context" +) + +/* + ---------------------------------------- + | | | | | + | data A| data B |NO Data | .... | + | | | | | + ---------------------------------------- + + 動態時報在發現這個 Queue 有 No Data 的 Flag 時 + 就不再去 Query 資料庫,防止被狂刷。 + 只是要注意在業務上何時要 加入/刪除 這個 Flag +*/ + +// TimelineRepository 定義時間線的存儲接口,可以根據不同的排序策略實現。 +type TimelineRepository interface { + // AddPost 將貼文添加到動態時報,並根據排序策略進行排序。 + AddPost(ctx context.Context, req AddPostRequest) error + // FetchTimeline 獲取指定用戶的動態時報。 + FetchTimeline(ctx context.Context, req FetchTimelineRequest) (FetchTimelineResponse, error) + // SetNoMoreDataFlag 標記時間線已完整,避免繼續查詢資料庫。 + SetNoMoreDataFlag(ctx context.Context, uid string) error + // HasNoMoreData 檢查時間線是否已完整,決定是否需要查詢資料庫。 + HasNoMoreData(ctx context.Context, uid string) (bool, error) + // ClearNoMoreDataFlag 清除時間線的 "NoMoreData" 標誌。 + ClearNoMoreDataFlag(ctx context.Context, uid string) error +} + +// AddPostRequest 用於將貼文添加到時間線的請求結構體。 +type AddPostRequest struct { + UID string + PostItems []TimelineItem +} + +// TimelineItem 表示時間線中的一個元素,排序依據取決於 Score。 +type TimelineItem struct { + PostID string // 貼文ID + Score int64 // 排序使用的分數,根據具體實現可能代表時間、優先級等 +} + +// FetchTimelineRequest 用於獲取時間線的請求結構體。 +type FetchTimelineRequest struct { + UID string + PageSize int64 + PageIndex int64 +} + +// FetchTimelineResponse 表示獲取時間線的回應結構體。 +type FetchTimelineResponse struct { + Items []TimelineItem + Page tweeting.Pager +} diff --git a/internal/lib/neo4j/config.go b/internal/lib/neo4j/config.go new file mode 100644 index 0000000..a90758b --- /dev/null +++ b/internal/lib/neo4j/config.go @@ -0,0 +1,14 @@ +package neo4j + +import "time" + +// Config holds the configuration for Neo4j connection. +type Config struct { + URI string + Username string + Password string + MaxConnectionPoolSize int + MaxConnectionLifetime time.Duration + ConnectionTimeout time.Duration + LogLevel string +} diff --git a/internal/lib/neo4j/neo4j.go b/internal/lib/neo4j/neo4j.go new file mode 100644 index 0000000..c110b8a --- /dev/null +++ b/internal/lib/neo4j/neo4j.go @@ -0,0 +1,53 @@ +package neo4j + +import ( + "context" + "fmt" + + "github.com/neo4j/neo4j-go-driver/v5/neo4j" + n4Cfg "github.com/neo4j/neo4j-go-driver/v5/neo4j/config" +) + +// NewNeo4J initializes a Neo4jInit using the provided Config and options. +// If opts is not provided, it will initialize Neo4jInit with default configuration. +func NewNeo4J(conf *Config, opts ...Option) *Client { + driverConfig := &n4Cfg.Config{ + MaxConnectionLifetime: conf.MaxConnectionLifetime, + MaxConnectionPoolSize: conf.MaxConnectionPoolSize, + ConnectionAcquisitionTimeout: conf.ConnectionTimeout, + } + + neo4ji := &Client{ + neo4jConf: driverConfig, + serviceConf: Config{ + URI: conf.URI, + Username: conf.Username, + Password: conf.Password, + LogLevel: conf.LogLevel, + }, + } + + for _, opt := range opts { + opt(neo4ji) + } + + return neo4ji +} + +// Conn initiates connection to the database and returns a Neo4j driver instance. +func (c *Client) Conn() (neo4j.DriverWithContext, error) { + auth := neo4j.BasicAuth(c.serviceConf.Username, c.serviceConf.Password, "") + driver, err := neo4j.NewDriverWithContext(c.serviceConf.URI, auth, func(_ *n4Cfg.Config) {}) + if err != nil { + return nil, fmt.Errorf("neo4j driver initialization error: %w", err) + } + + ctx := context.Background() + // Verify the connection to Neo4j. + err = driver.VerifyConnectivity(ctx) + if err != nil { + return nil, fmt.Errorf("neo4j connectivity verification error: %w", err) + } + + return driver, nil +} diff --git a/internal/lib/neo4j/neo4j_test.go b/internal/lib/neo4j/neo4j_test.go new file mode 100644 index 0000000..5618f69 --- /dev/null +++ b/internal/lib/neo4j/neo4j_test.go @@ -0,0 +1,209 @@ +package neo4j + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +func TestNewNeo4J(t *testing.T) { + tests := []struct { + name string + conf *Config + expected *Config + }{ + { + name: "valid configuration", + conf: &Config{ + URI: "neo4j://localhost:7687", + Username: "neo4j", + Password: "password", + LogLevel: "info", + MaxConnectionLifetime: time.Minute * 5, + MaxConnectionPoolSize: 10, + ConnectionTimeout: time.Second * 5, + }, + expected: &Config{ + URI: "neo4j://localhost:7687", + Username: "neo4j", + Password: "password", + LogLevel: "info", + }, + }, + { + name: "empty URI", + conf: &Config{ + URI: "", + Username: "neo4j", + Password: "password", + LogLevel: "info", + MaxConnectionLifetime: time.Minute * 5, + MaxConnectionPoolSize: 10, + ConnectionTimeout: time.Second * 5, + }, + expected: &Config{ + URI: "", + Username: "neo4j", + Password: "password", + LogLevel: "info", + }, + }, + { + name: "empty username and password", + conf: &Config{ + URI: "neo4j://localhost:7687", + Username: "", + Password: "", + LogLevel: "info", + MaxConnectionLifetime: time.Minute * 5, + MaxConnectionPoolSize: 10, + ConnectionTimeout: time.Second * 5, + }, + expected: &Config{ + URI: "neo4j://localhost:7687", + Username: "", + Password: "", + LogLevel: "info", + }, + }, + { + name: "custom log level", + conf: &Config{ + URI: "neo4j://localhost:7687", + Username: "neo4j", + Password: "password", + LogLevel: "debug", + MaxConnectionLifetime: time.Minute * 5, + MaxConnectionPoolSize: 10, + ConnectionTimeout: time.Second * 5, + }, + expected: &Config{ + URI: "neo4j://localhost:7687", + Username: "neo4j", + Password: "password", + LogLevel: "debug", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewNeo4J(tt.conf) + assert.NotNil(t, client) + assert.Equal(t, tt.expected.URI, client.serviceConf.URI) + assert.Equal(t, tt.expected.Username, client.serviceConf.Username) + assert.Equal(t, tt.expected.Password, client.serviceConf.Password) + assert.Equal(t, tt.expected.LogLevel, client.serviceConf.LogLevel) + }) + } +} + +func TestConn(t *testing.T) { + ctx := context.Background() + + neo4jContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "neo4j:latest", + ExposedPorts: []string{"7687/tcp"}, + Env: map[string]string{ + "NEO4J_AUTH": "neo4j/yyyytttt", + }, + WaitingFor: wait.ForLog("Started"), + }, + Started: true, + }) + if err != nil { + t.Fatal(err) + } + defer neo4jContainer.Terminate(ctx) + + host, _ := neo4jContainer.Host(ctx) + port, _ := neo4jContainer.MappedPort(ctx, "7687") + + uri := fmt.Sprintf("bolt://%s:%s", host, port.Port()) + t.Log("Neo4j running at:", uri) + + tests := []struct { + name string + conf *Config + shouldFail bool + }{ + { + name: "successful connection", + conf: &Config{ + URI: uri, + Username: "neo4j", + Password: "yyyytttt", + LogLevel: "info", + MaxConnectionLifetime: time.Minute * 5, + MaxConnectionPoolSize: 10, + ConnectionTimeout: time.Second * 5, + }, + shouldFail: false, + }, + { + name: "failed connection due to invalid URI", + conf: &Config{ + URI: uri, + Username: "neo4j", + Password: "wrongpassword", + LogLevel: "info", + MaxConnectionLifetime: time.Minute * 5, + MaxConnectionPoolSize: 10, + ConnectionTimeout: time.Second * 5, + }, + shouldFail: true, + }, + { + name: "failed connection due to missing URI", + conf: &Config{ + URI: "", + Username: "neo4j", + Password: "password", + LogLevel: "info", + MaxConnectionLifetime: time.Minute * 5, + MaxConnectionPoolSize: 10, + ConnectionTimeout: time.Second * 5, + }, + shouldFail: true, + }, + { + name: "failed connection due to missing username and password", + conf: &Config{ + URI: uri, + Username: "", + Password: "", + LogLevel: "info", + MaxConnectionLifetime: time.Minute * 5, + MaxConnectionPoolSize: 10, + ConnectionTimeout: time.Second * 5, + }, + shouldFail: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewNeo4J(tt.conf) + driver, err := client.Conn() + if tt.shouldFail { + assert.Error(t, err) + assert.Nil(t, driver) + } else { + assert.NoError(t, err) + assert.NotNil(t, driver) + + // Close the driver after test + defer func() { + err := driver.Close(context.Background()) + assert.NoError(t, err) + }() + } + }) + } +} diff --git a/internal/lib/neo4j/option.go b/internal/lib/neo4j/option.go new file mode 100644 index 0000000..a126618 --- /dev/null +++ b/internal/lib/neo4j/option.go @@ -0,0 +1,60 @@ +package neo4j + +import ( + "strings" + "time" + + n4Cfg "github.com/neo4j/neo4j-go-driver/v5/neo4j/config" + "github.com/neo4j/neo4j-go-driver/v5/neo4j/log" +) + +const ( + defaultMaxConnectionLifetime = 5 * time.Minute + defaultMaxConnectionPoolSize = 25 + defaultConnectionTimeout = 5 * time.Second +) + +// Option configures Neo4jInit behaviour. +type Option func(*Client) + +type Client struct { + neo4jConf *n4Cfg.Config + serviceConf Config +} + +// WithLogLevel sets the log level for the Neo4j driver. +func WithLogLevel(level string) Option { + return func(neo4ji *Client) { + var logger log.Logger + + switch strings.ToLower(level) { + case "panic", "fatal", "error": + logger = log.ToConsole(log.ERROR) + case "warn", "warning": + logger = log.ToConsole(log.WARNING) + case "info", "debug", "trace": + logger = log.ToConsole(log.INFO) + default: + logger = log.ToConsole(log.ERROR) + } + + neo4ji.neo4jConf.Log = logger + } +} + +// WithPerformance configures the Neo4j driver for performance by setting connection pool size and lifetime. +func WithPerformance() Option { + return func(neo4ji *Client) { + if neo4ji.serviceConf.MaxConnectionPoolSize > 0 { + neo4ji.neo4jConf.MaxConnectionPoolSize = neo4ji.serviceConf.MaxConnectionPoolSize + } else { + neo4ji.neo4jConf.MaxConnectionPoolSize = defaultMaxConnectionPoolSize + } + + if neo4ji.serviceConf.MaxConnectionLifetime > 0 { + neo4ji.neo4jConf.MaxConnectionLifetime = neo4ji.serviceConf.MaxConnectionLifetime + } else { + neo4ji.neo4jConf.MaxConnectionLifetime = defaultMaxConnectionLifetime + } + } +} diff --git a/internal/logic/commentservice/delete_comment_logic.go b/internal/logic/commentservice/delete_comment_logic.go index 467d027..80ec847 100644 --- a/internal/logic/commentservice/delete_comment_logic.go +++ b/internal/logic/commentservice/delete_comment_logic.go @@ -37,6 +37,7 @@ func (l *DeleteCommentLogic) DeleteComment(in *tweeting.DeleteCommentReq) (*twee {Key: "err", Value: err}, }, "failed to del comment").Wrap(err) + return nil, e } diff --git a/internal/logic/commentservice/get_comments_logic.go b/internal/logic/commentservice/get_comments_logic.go index d22a2cf..d369e1e 100644 --- a/internal/logic/commentservice/get_comments_logic.go +++ b/internal/logic/commentservice/get_comments_logic.go @@ -41,8 +41,8 @@ func convertToCommentDetailItem(item *model.Comment) *tweeting.CommentDetail { Uid: item.UID, Content: item.Content, CreatedAt: item.CreateAt, - LikeCount: int64(item.LikeCount), - DislikeCount: int64(item.DisLikeCount), + LikeCount: item.LikeCount, + DislikeCount: item.DisLikeCount, } } @@ -82,6 +82,7 @@ func (l *GetCommentsLogic) GetComments(in *tweeting.GetCommentsReq) (*tweeting.G {Key: "err", Value: err}, }, "failed to find comment").Wrap(err) + return nil, e } diff --git a/internal/logic/commentservice/update_comment_logic.go b/internal/logic/commentservice/update_comment_logic.go index bc24ef0..5002f72 100644 --- a/internal/logic/commentservice/update_comment_logic.go +++ b/internal/logic/commentservice/update_comment_logic.go @@ -27,16 +27,16 @@ func NewUpdateCommentLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Upd } } -type checkCommentId struct { - CommentId string `validate:"required"` +type checkCommentID struct { + CommentID string `validate:"required"` Content string `json:"content,omitempty" validate:"lte=500"` } // UpdateComment 更新評論 func (l *UpdateCommentLogic) UpdateComment(in *tweeting.UpdateCommentReq) (*tweeting.OKResp, error) { // 驗證資料 - if err := l.svcCtx.Validate.ValidateAll(&checkCommentId{ - CommentId: in.GetCommentId(), + if err := l.svcCtx.Validate.ValidateAll(&checkCommentID{ + CommentID: in.GetCommentId(), Content: in.GetContent(), }); err != nil { // 錯誤代碼 05-011-00 @@ -75,6 +75,7 @@ func (l *UpdateCommentLogic) UpdateComment(in *tweeting.UpdateCommentReq) (*twee {Key: "err", Value: err}, }, "failed to update comment:", in.CommentId).Wrap(err) + return nil, e } diff --git a/internal/logic/postservice/create_post_logic.go b/internal/logic/postservice/create_post_logic.go index e3fbb0d..ec00a3c 100644 --- a/internal/logic/postservice/create_post_logic.go +++ b/internal/logic/postservice/create_post_logic.go @@ -32,7 +32,7 @@ type newTweetingReq struct { UID string `json:"uid" validate:"required"` Content string `json:"content" validate:"required,lte=500"` // 貼文限制 500 字內 Tags []string `json:"tags"` - MediaUrl []string `json:"media_url"` + MediaURL []string `json:"media_url"` IsAd bool `json:"is_ad"` // default false } @@ -85,6 +85,7 @@ func (l *CreatePostLogic) CreatePost(in *tweeting.NewPostReq) (*tweeting.PostRes {Key: "err", Value: err}, }, "failed to add new post").Wrap(err) + return nil, e } diff --git a/internal/logic/postservice/delete_post_logic.go b/internal/logic/postservice/delete_post_logic.go index ffa6bda..60256f6 100644 --- a/internal/logic/postservice/delete_post_logic.go +++ b/internal/logic/postservice/delete_post_logic.go @@ -37,6 +37,7 @@ func (l *DeletePostLogic) DeletePost(in *tweeting.DeletePostsReq) (*tweeting.OKR {Key: "err", Value: err}, }, "failed to del post").Wrap(err) + return nil, e } diff --git a/internal/logic/postservice/list_posts_logic.go b/internal/logic/postservice/list_posts_logic.go index 544327d..568d801 100644 --- a/internal/logic/postservice/list_posts_logic.go +++ b/internal/logic/postservice/list_posts_logic.go @@ -54,8 +54,8 @@ func convertToPostDetailItem(item *model.Post) *tweeting.PostDetailItem { IsAd: item.IsAd, CreatedAt: item.CreateAt, UpdateAt: item.UpdateAt, - LikeCount: int64(item.Like), - DislikeCount: int64(item.DisLike), + LikeCount: item.Like, + DislikeCount: item.DisLike, } } @@ -102,6 +102,7 @@ func (l *ListPostsLogic) ListPosts(in *tweeting.QueryPostsReq) (*tweeting.ListPo {Key: "err", Value: err}, }, "failed to find posts").Wrap(err) + return nil, e } diff --git a/internal/logic/postservice/update_post_logic.go b/internal/logic/postservice/update_post_logic.go index 2275fcc..f028b6c 100644 --- a/internal/logic/postservice/update_post_logic.go +++ b/internal/logic/postservice/update_post_logic.go @@ -28,7 +28,7 @@ func NewUpdatePostLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Update } } -type checkPostId struct { +type checkPostID struct { PostID string `validate:"required"` Content string `json:"content,omitempty" validate:"lte=500"` } @@ -36,7 +36,7 @@ type checkPostId struct { // UpdatePost 更新貼文 func (l *UpdatePostLogic) UpdatePost(in *tweeting.UpdatePostReq) (*tweeting.OKResp, error) { // 驗證資料 - if err := l.svcCtx.Validate.ValidateAll(&checkPostId{ + if err := l.svcCtx.Validate.ValidateAll(&checkPostID{ PostID: in.GetPostId(), Content: in.GetContent(), }); err != nil { @@ -53,7 +53,7 @@ func (l *UpdatePostLogic) UpdatePost(in *tweeting.UpdatePostReq) (*tweeting.OKRe update.ID = oid update.Tags = in.GetTags() // 將 Media 存入 - var media []model.Media + media := make([]model.Media, 0, len(in.GetMedia())) for _, item := range in.GetMedia() { media = append(media, model.Media{ Links: item.Url, @@ -88,6 +88,7 @@ func (l *UpdatePostLogic) UpdatePost(in *tweeting.UpdatePostReq) (*tweeting.OKRe {Key: "err", Value: err}, }, "failed to update post", in.PostId).Wrap(err) + return nil, e } diff --git a/internal/logic/socialnetworkservice/get_followee_count_logic.go b/internal/logic/socialnetworkservice/get_followee_count_logic.go new file mode 100644 index 0000000..d5b4385 --- /dev/null +++ b/internal/logic/socialnetworkservice/get_followee_count_logic.go @@ -0,0 +1,59 @@ +package socialnetworkservicelogic + +import ( + "app-cloudep-tweeting-service/internal/domain" + "context" + + ers "code.30cm.net/digimon/library-go/errs" + + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + "app-cloudep-tweeting-service/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetFolloweeCountLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewGetFolloweeCountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetFolloweeCountLogic { + return &GetFolloweeCountLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +// GetFolloweeCount 取得我跟隨的數量 +func (l *GetFolloweeCountLogic) GetFolloweeCount(in *tweeting.FollowCountReq) (*tweeting.FollowCountResp, error) { + // 驗證資料 + if err := l.svcCtx.Validate.ValidateAll(&getFollowCountReq{ + UID: in.Uid, + }); err != nil { + // 錯誤代碼 05-011-00 + return nil, ers.InvalidFormat(err.Error()) + } + + followeeCount, err := l.svcCtx.SocialNetworkRepository.GetFolloweeCount(l.ctx, in.GetUid()) + if err != nil { + // 錯誤代碼 05-021-34 + e := domain.CommentErrorL( + domain.GetFolloweeCountErrorCode, + logx.WithContext(l.ctx), + []logx.LogField{ + {Key: "req", Value: in}, + {Key: "func", Value: "SocialNetworkRepository.GetFolloweeCount"}, + {Key: "err", Value: err}, + }, + "failed to count follower").Wrap(err) + + return nil, e + } + + return &tweeting.FollowCountResp{ + Uid: in.GetUid(), + Total: followeeCount, + }, nil +} diff --git a/internal/logic/socialnetworkservice/get_followee_count_logic_test.go b/internal/logic/socialnetworkservice/get_followee_count_logic_test.go new file mode 100644 index 0000000..93d9991 --- /dev/null +++ b/internal/logic/socialnetworkservice/get_followee_count_logic_test.go @@ -0,0 +1,88 @@ +package socialnetworkservicelogic + +import ( + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + mocklib "app-cloudep-tweeting-service/internal/mock/lib" + mockRepo "app-cloudep-tweeting-service/internal/mock/repository" + "app-cloudep-tweeting-service/internal/svc" + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestGetFolloweeCountLogic_GetFolloweeCount(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockSocialNetworkRepository := mockRepo.NewMockSocialNetworkRepository(ctrl) + mockValidate := mocklib.NewMockValidate(ctrl) + + svcCtx := &svc.ServiceContext{ + SocialNetworkRepository: mockSocialNetworkRepository, + Validate: mockValidate, + } + + // 测试数据集 + tests := []struct { + name string + input *tweeting.FollowCountReq + prepare func() + expectErr bool + }{ + { + name: "ok", + input: &tweeting.FollowCountReq{ + Uid: "12345", + }, + prepare: func() { + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + mockSocialNetworkRepository.EXPECT().GetFolloweeCount(gomock.Any(), "12345").Return(int64(10), nil).Times(1) + }, + expectErr: false, + }, + { + name: "驗證失敗", + input: &tweeting.FollowCountReq{ + Uid: "", + }, + prepare: func() { + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(errors.New("validation failed")).Times(1) + }, + expectErr: true, + }, + { + name: "取得跟隨數量失敗", + input: &tweeting.FollowCountReq{ + Uid: "12345", + }, + prepare: func() { + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + mockSocialNetworkRepository.EXPECT().GetFolloweeCount(gomock.Any(), "12345").Return(int64(0), errors.New("repository error")).Times(1) + }, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.prepare() + + logic := GetFolloweeCountLogic{ + svcCtx: svcCtx, + ctx: context.TODO(), + } + + got, err := logic.GetFolloweeCount(tt.input) + + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.input.Uid, got.Uid) + } + }) + } +} diff --git a/internal/logic/socialnetworkservice/get_followee_logic.go b/internal/logic/socialnetworkservice/get_followee_logic.go new file mode 100644 index 0000000..73d28d1 --- /dev/null +++ b/internal/logic/socialnetworkservice/get_followee_logic.go @@ -0,0 +1,70 @@ +package socialnetworkservicelogic + +import ( + "app-cloudep-tweeting-service/internal/domain" + "app-cloudep-tweeting-service/internal/domain/repository" + "context" + + ers "code.30cm.net/digimon/library-go/errs" + + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + "app-cloudep-tweeting-service/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetFolloweeLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewGetFolloweeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetFolloweeLogic { + return &GetFolloweeLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +// GetFollowee 取得我跟隨的名單 +func (l *GetFolloweeLogic) GetFollowee(in *tweeting.FollowReq) (*tweeting.FollowResp, error) { + // 驗證資料 + if err := l.svcCtx.Validate.ValidateAll(&getFollowReq{ + UID: in.Uid, + PageSize: in.PageSize, + PageIndex: in.PageIndex, + }); err != nil { + // 錯誤代碼 05-011-00 + return nil, ers.InvalidFormat(err.Error()) + } + + followee, err := l.svcCtx.SocialNetworkRepository.GetFollowee(l.ctx, repository.FollowReq{ + UID: in.GetUid(), + PageIndex: in.GetPageIndex(), + PageSize: in.GetPageSize(), + }) + if err != nil { + // 錯誤代碼 05-021-33 + e := domain.CommentErrorL( + domain.GetFolloweeErrorCode, + logx.WithContext(l.ctx), + []logx.LogField{ + {Key: "req", Value: in}, + {Key: "func", Value: "SocialNetworkRepository.GetFollowee"}, + {Key: "err", Value: err}, + }, + "failed to get relation: ", in.GetUid()).Wrap(err) + + return nil, e + } + + return &tweeting.FollowResp{ + Uid: followee.UIDs, + Page: &tweeting.Pager{ + Total: followee.Total, + Index: in.GetPageIndex(), + Size: in.GetPageSize(), + }, + }, nil +} diff --git a/internal/logic/socialnetworkservice/get_followee_logic_test.go b/internal/logic/socialnetworkservice/get_followee_logic_test.go new file mode 100644 index 0000000..d356767 --- /dev/null +++ b/internal/logic/socialnetworkservice/get_followee_logic_test.go @@ -0,0 +1,130 @@ +package socialnetworkservicelogic + +import ( + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + "app-cloudep-tweeting-service/internal/domain/repository" + mocklib "app-cloudep-tweeting-service/internal/mock/lib" + mockRepo "app-cloudep-tweeting-service/internal/mock/repository" + "app-cloudep-tweeting-service/internal/svc" + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestGetFolloweeLogic_GetFollowee(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // 初始化 mock 依賴 + mockSocialNetworkRepository := mockRepo.NewMockSocialNetworkRepository(ctrl) + mockValidate := mocklib.NewMockValidate(ctrl) + + // 初始化服務上下文 + svcCtx := &svc.ServiceContext{ + SocialNetworkRepository: mockSocialNetworkRepository, + Validate: mockValidate, + } + + // 測試數據集 + tests := []struct { + name string + input *tweeting.FollowReq + prepare func() + expectErr bool + wantResp *tweeting.FollowResp + }{ + { + name: "成功獲取我跟隨的名單", + input: &tweeting.FollowReq{ + Uid: "12345", + PageSize: 10, + PageIndex: 1, + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬 GetFollowee 返回正確的結果 + mockSocialNetworkRepository.EXPECT().GetFollowee(gomock.Any(), repository.FollowReq{ + UID: "12345", + PageSize: 10, + PageIndex: 1, + }).Return(repository.FollowResp{ + UIDs: []string{"user1", "user2"}, + Total: 2, + }, nil).Times(1) + }, + expectErr: false, + wantResp: &tweeting.FollowResp{ + Uid: []string{"user1", "user2"}, + Page: &tweeting.Pager{ + Total: 2, + Index: 1, + Size: 10, + }, + }, + }, + { + name: "驗證失敗", + input: &tweeting.FollowReq{ + Uid: "", + PageSize: 10, + PageIndex: 1, + }, + prepare: func() { + // 模擬驗證失敗 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(errors.New("validation failed")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + { + name: "獲取我跟隨的名單失敗", + input: &tweeting.FollowReq{ + Uid: "12345", + PageSize: 10, + PageIndex: 1, + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬 GetFollowee 返回錯誤 + mockSocialNetworkRepository.EXPECT().GetFollowee(gomock.Any(), repository.FollowReq{ + UID: "12345", + PageSize: 10, + PageIndex: 1, + }).Return(repository.FollowResp{}, errors.New("repository error")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + } + + // 執行測試 + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 設置測試環境 + tt.prepare() + + // 初始化 GetFolloweeLogic + logic := GetFolloweeLogic{ + svcCtx: svcCtx, + ctx: context.TODO(), + } + + // 執行 GetFollowee + got, err := logic.GetFollowee(tt.input) + + // 驗證結果 + if tt.expectErr { + assert.Error(t, err) + assert.Nil(t, got) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantResp, got) + } + }) + } +} diff --git a/internal/logic/socialnetworkservice/get_follower_count_logic.go b/internal/logic/socialnetworkservice/get_follower_count_logic.go new file mode 100644 index 0000000..2cd9bb7 --- /dev/null +++ b/internal/logic/socialnetworkservice/get_follower_count_logic.go @@ -0,0 +1,63 @@ +package socialnetworkservicelogic + +import ( + "app-cloudep-tweeting-service/internal/domain" + "context" + + ers "code.30cm.net/digimon/library-go/errs" + + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + "app-cloudep-tweeting-service/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetFollowerCountLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewGetFollowerCountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetFollowerCountLogic { + return &GetFollowerCountLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +type getFollowCountReq struct { + UID string `validate:"required"` +} + +// GetFollowerCount 取得跟隨者數量 +func (l *GetFollowerCountLogic) GetFollowerCount(in *tweeting.FollowCountReq) (*tweeting.FollowCountResp, error) { + // 驗證資料 + if err := l.svcCtx.Validate.ValidateAll(&getFollowCountReq{ + UID: in.Uid, + }); err != nil { + // 錯誤代碼 05-011-00 + return nil, ers.InvalidFormat(err.Error()) + } + + followerCount, err := l.svcCtx.SocialNetworkRepository.GetFollowerCount(l.ctx, in.GetUid()) + if err != nil { + // 錯誤代碼 05-021-32 + e := domain.CommentErrorL( + domain.GetFollowerCountErrorCode, + logx.WithContext(l.ctx), + []logx.LogField{ + {Key: "req", Value: in}, + {Key: "func", Value: "SocialNetworkRepository.GetFollowerCount"}, + {Key: "err", Value: err}, + }, + "failed to count follower").Wrap(err) + + return nil, e + } + + return &tweeting.FollowCountResp{ + Uid: in.GetUid(), + Total: followerCount, + }, nil +} diff --git a/internal/logic/socialnetworkservice/get_follower_count_logic_test.go b/internal/logic/socialnetworkservice/get_follower_count_logic_test.go new file mode 100644 index 0000000..b6f9924 --- /dev/null +++ b/internal/logic/socialnetworkservice/get_follower_count_logic_test.go @@ -0,0 +1,108 @@ +package socialnetworkservicelogic + +import ( + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + mocklib "app-cloudep-tweeting-service/internal/mock/lib" + mockRepo "app-cloudep-tweeting-service/internal/mock/repository" + "app-cloudep-tweeting-service/internal/svc" + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestGetFollowerCountLogic_GetFollowerCount(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // 初始化 mock 依賴 + mockSocialNetworkRepository := mockRepo.NewMockSocialNetworkRepository(ctrl) + mockValidate := mocklib.NewMockValidate(ctrl) + + // 初始化服務上下文 + svcCtx := &svc.ServiceContext{ + SocialNetworkRepository: mockSocialNetworkRepository, + Validate: mockValidate, + } + + // 測試數據集 + tests := []struct { + name string + input *tweeting.FollowCountReq + prepare func() + expectErr bool + wantResp *tweeting.FollowCountResp + }{ + { + name: "成功獲取跟隨者數量", + input: &tweeting.FollowCountReq{ + Uid: "12345", + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬 GetFollowerCount 返回正確的結果 + mockSocialNetworkRepository.EXPECT().GetFollowerCount(gomock.Any(), "12345").Return(int64(10), nil).Times(1) + }, + expectErr: false, + wantResp: &tweeting.FollowCountResp{ + Uid: "12345", + Total: 10, + }, + }, + { + name: "驗證失敗", + input: &tweeting.FollowCountReq{ + Uid: "", + }, + prepare: func() { + // 模擬驗證失敗 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(errors.New("validation failed")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + { + name: "獲取跟隨者數量失敗", + input: &tweeting.FollowCountReq{ + Uid: "12345", + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬 GetFollowerCount 返回錯誤 + mockSocialNetworkRepository.EXPECT().GetFollowerCount(gomock.Any(), "12345").Return(int64(0), errors.New("repository error")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + } + + // 執行測試 + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 設置測試環境 + tt.prepare() + + // 初始化 GetFollowerCountLogic + logic := GetFollowerCountLogic{ + svcCtx: svcCtx, + ctx: context.TODO(), + } + + // 執行 GetFollowerCount + got, err := logic.GetFollowerCount(tt.input) + + // 驗證結果 + if tt.expectErr { + assert.Error(t, err) + assert.Nil(t, got) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantResp, got) + } + }) + } +} diff --git a/internal/logic/socialnetworkservice/get_follower_logic.go b/internal/logic/socialnetworkservice/get_follower_logic.go new file mode 100644 index 0000000..f6c4234 --- /dev/null +++ b/internal/logic/socialnetworkservice/get_follower_logic.go @@ -0,0 +1,76 @@ +package socialnetworkservicelogic + +import ( + "app-cloudep-tweeting-service/internal/domain" + "app-cloudep-tweeting-service/internal/domain/repository" + "context" + + ers "code.30cm.net/digimon/library-go/errs" + + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + "app-cloudep-tweeting-service/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetFollowerLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewGetFollowerLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetFollowerLogic { + return &GetFollowerLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +type getFollowReq struct { + UID string `validate:"required"` + PageSize int64 `validate:"required"` + PageIndex int64 `validate:"required"` +} + +// GetFollower 取得跟隨者名單 +func (l *GetFollowerLogic) GetFollower(in *tweeting.FollowReq) (*tweeting.FollowResp, error) { + // 驗證資料 + if err := l.svcCtx.Validate.ValidateAll(&getFollowReq{ + UID: in.Uid, + PageSize: in.PageSize, + PageIndex: in.PageIndex, + }); err != nil { + // 錯誤代碼 05-011-00 + return nil, ers.InvalidFormat(err.Error()) + } + + follower, err := l.svcCtx.SocialNetworkRepository.GetFollower(l.ctx, repository.FollowReq{ + UID: in.GetUid(), + PageIndex: in.GetPageIndex(), + PageSize: in.GetPageSize(), + }) + if err != nil { + // 錯誤代碼 05-021-31 + e := domain.CommentErrorL( + domain.GetFollowerErrorCode, + logx.WithContext(l.ctx), + []logx.LogField{ + {Key: "req", Value: in}, + {Key: "func", Value: "SocialNetworkRepository.GetFollower"}, + {Key: "err", Value: err}, + }, + "failed to get relation: ", in.GetUid()).Wrap(err) + + return nil, e + } + + return &tweeting.FollowResp{ + Uid: follower.UIDs, + Page: &tweeting.Pager{ + Total: follower.Total, + Index: in.GetPageIndex(), + Size: in.GetPageSize(), + }, + }, nil +} diff --git a/internal/logic/socialnetworkservice/get_follower_logic_test.go b/internal/logic/socialnetworkservice/get_follower_logic_test.go new file mode 100644 index 0000000..249a7c4 --- /dev/null +++ b/internal/logic/socialnetworkservice/get_follower_logic_test.go @@ -0,0 +1,130 @@ +package socialnetworkservicelogic + +import ( + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + "app-cloudep-tweeting-service/internal/domain/repository" + mocklib "app-cloudep-tweeting-service/internal/mock/lib" + mockRepo "app-cloudep-tweeting-service/internal/mock/repository" + "app-cloudep-tweeting-service/internal/svc" + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestGetFollowerLogic_GetFollower(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // 初始化 mock 依賴 + mockSocialNetworkRepository := mockRepo.NewMockSocialNetworkRepository(ctrl) + mockValidate := mocklib.NewMockValidate(ctrl) + + // 初始化服務上下文 + svcCtx := &svc.ServiceContext{ + SocialNetworkRepository: mockSocialNetworkRepository, + Validate: mockValidate, + } + + // 測試數據集 + tests := []struct { + name string + input *tweeting.FollowReq + prepare func() + expectErr bool + wantResp *tweeting.FollowResp + }{ + { + name: "成功獲取跟隨者名單", + input: &tweeting.FollowReq{ + Uid: "12345", + PageSize: 10, + PageIndex: 1, + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬 GetFollower 返回正確的結果 + mockSocialNetworkRepository.EXPECT().GetFollower(gomock.Any(), repository.FollowReq{ + UID: "12345", + PageSize: 10, + PageIndex: 1, + }).Return(repository.FollowResp{ + UIDs: []string{"user1", "user2"}, + Total: 2, + }, nil).Times(1) + }, + expectErr: false, + wantResp: &tweeting.FollowResp{ + Uid: []string{"user1", "user2"}, + Page: &tweeting.Pager{ + Total: 2, + Index: 1, + Size: 10, + }, + }, + }, + { + name: "驗證失敗", + input: &tweeting.FollowReq{ + Uid: "", + PageSize: 10, + PageIndex: 1, + }, + prepare: func() { + // 模擬驗證失敗 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(errors.New("validation failed")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + { + name: "獲取跟隨者名單失敗", + input: &tweeting.FollowReq{ + Uid: "12345", + PageSize: 10, + PageIndex: 1, + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬 GetFollower 返回錯誤 + mockSocialNetworkRepository.EXPECT().GetFollower(gomock.Any(), repository.FollowReq{ + UID: "12345", + PageSize: 10, + PageIndex: 1, + }).Return(repository.FollowResp{}, errors.New("repository error")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + } + + // 執行測試 + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 設置測試環境 + tt.prepare() + + // 初始化 GetFollowerLogic + logic := GetFollowerLogic{ + svcCtx: svcCtx, + ctx: context.TODO(), + } + + // 執行 GetFollower + got, err := logic.GetFollower(tt.input) + + // 驗證結果 + if tt.expectErr { + assert.Error(t, err) + assert.Nil(t, got) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantResp, got) + } + }) + } +} diff --git a/internal/logic/socialnetworkservice/mark_follow_relation_logic.go b/internal/logic/socialnetworkservice/mark_follow_relation_logic.go new file mode 100644 index 0000000..0e1465c --- /dev/null +++ b/internal/logic/socialnetworkservice/mark_follow_relation_logic.go @@ -0,0 +1,63 @@ +package socialnetworkservicelogic + +import ( + "app-cloudep-tweeting-service/internal/domain" + "context" + + ers "code.30cm.net/digimon/library-go/errs" + + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + "app-cloudep-tweeting-service/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type MarkFollowRelationLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewMarkFollowRelationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MarkFollowRelationLogic { + return &MarkFollowRelationLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +type doFollowReq struct { + FollowerUID string `json:"follower_uid" validate:"required"` // 追隨者,跟隨你的人(別人關注你) + FolloweeUID string `json:"followee_uid" validate:"required"` // 追蹤者,你跟隨的人(你關注別) +} + +// MarkFollowRelation 關注 +func (l *MarkFollowRelationLogic) MarkFollowRelation(in *tweeting.DoFollowerRelationReq) (*tweeting.OKResp, error) { + // 驗證資料 + if err := l.svcCtx.Validate.ValidateAll(&doFollowReq{ + FollowerUID: in.GetFollowerUid(), + FolloweeUID: in.GetFolloweeUid(), + }); err != nil { + // 錯誤代碼 05-011-00 + return nil, ers.InvalidFormat(err.Error()) + } + + // 這裡要幫建立關係, follower 追蹤 -> followee + err := l.svcCtx.SocialNetworkRepository.MarkFollowerRelation(l.ctx, in.GetFollowerUid(), in.GetFolloweeUid()) + if err != nil { + // 錯誤代碼 05-021-30 + e := domain.CommentErrorL( + domain.MarkRelationErrorCode, + logx.WithContext(l.ctx), + []logx.LogField{ + {Key: "req", Value: in}, + {Key: "func", Value: "SocialNetworkRepository.MarkFollowerRelationBetweenUsers"}, + {Key: "err", Value: err}, + }, + "failed to mark relation form -> to", in.GetFollowerUid(), in.GetFolloweeUid()).Wrap(err) + + return nil, e + } + + return &tweeting.OKResp{}, nil +} diff --git a/internal/logic/socialnetworkservice/mark_follow_relation_logic_test.go b/internal/logic/socialnetworkservice/mark_follow_relation_logic_test.go new file mode 100644 index 0000000..ec6163c --- /dev/null +++ b/internal/logic/socialnetworkservice/mark_follow_relation_logic_test.go @@ -0,0 +1,108 @@ +package socialnetworkservicelogic + +import ( + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + mocklib "app-cloudep-tweeting-service/internal/mock/lib" + mockRepo "app-cloudep-tweeting-service/internal/mock/repository" + "app-cloudep-tweeting-service/internal/svc" + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestMarkFollowRelationLogic_MarkFollowRelation(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // 初始化 mock 依賴 + mockSocialNetworkRepository := mockRepo.NewMockSocialNetworkRepository(ctrl) + mockValidate := mocklib.NewMockValidate(ctrl) + + // 初始化服務上下文 + svcCtx := &svc.ServiceContext{ + SocialNetworkRepository: mockSocialNetworkRepository, + Validate: mockValidate, + } + + // 測試數據集 + tests := []struct { + name string + input *tweeting.DoFollowerRelationReq + prepare func() + expectErr bool + wantResp *tweeting.OKResp + }{ + { + name: "成功建立關注關係", + input: &tweeting.DoFollowerRelationReq{ + FollowerUid: "follower123", + FolloweeUid: "followee456", + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬成功建立關注關係 + mockSocialNetworkRepository.EXPECT().MarkFollowerRelation(gomock.Any(), "follower123", "followee456").Return(nil).Times(1) + }, + expectErr: false, + wantResp: &tweeting.OKResp{}, + }, + { + name: "驗證失敗", + input: &tweeting.DoFollowerRelationReq{ + FollowerUid: "", + FolloweeUid: "", + }, + prepare: func() { + // 模擬驗證失敗 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(errors.New("validation failed")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + { + name: "建立關注關係失敗", + input: &tweeting.DoFollowerRelationReq{ + FollowerUid: "follower123", + FolloweeUid: "followee456", + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬建立關注關係失敗 + mockSocialNetworkRepository.EXPECT().MarkFollowerRelation(gomock.Any(), "follower123", "followee456").Return(errors.New("repository error")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + } + + // 執行測試 + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 設置測試環境 + tt.prepare() + + // 初始化 MarkFollowRelationLogic + logic := MarkFollowRelationLogic{ + svcCtx: svcCtx, + ctx: context.TODO(), + } + + // 執行 MarkFollowRelation + got, err := logic.MarkFollowRelation(tt.input) + + // 驗證結果 + if tt.expectErr { + assert.Error(t, err) + assert.Nil(t, got) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantResp, got) + } + }) + } +} diff --git a/internal/logic/socialnetworkservice/remove_follow_relation_logic.go b/internal/logic/socialnetworkservice/remove_follow_relation_logic.go new file mode 100644 index 0000000..eff0d15 --- /dev/null +++ b/internal/logic/socialnetworkservice/remove_follow_relation_logic.go @@ -0,0 +1,58 @@ +package socialnetworkservicelogic + +import ( + "app-cloudep-tweeting-service/internal/domain" + "context" + + ers "code.30cm.net/digimon/library-go/errs" + + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + "app-cloudep-tweeting-service/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RemoveFollowRelationLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewRemoveFollowRelationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RemoveFollowRelationLogic { + return &RemoveFollowRelationLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +// RemoveFollowRelation 取消關注 +func (l *RemoveFollowRelationLogic) RemoveFollowRelation(in *tweeting.DoFollowerRelationReq) (*tweeting.OKResp, error) { + // 驗證資料 + if err := l.svcCtx.Validate.ValidateAll(&doFollowReq{ + FollowerUID: in.GetFollowerUid(), + FolloweeUID: in.GetFolloweeUid(), + }); err != nil { + // 錯誤代碼 05-011-00 + return nil, ers.InvalidFormat(err.Error()) + } + + // 這裡要幫刪除關係, follower 追蹤 -> followee + err := l.svcCtx.SocialNetworkRepository.RemoveFollowerRelation(l.ctx, in.GetFollowerUid(), in.GetFolloweeUid()) + if err != nil { + // 錯誤代碼 05-021-35 + e := domain.CommentErrorL( + domain.RemoveRelationErrorCode, + logx.WithContext(l.ctx), + []logx.LogField{ + {Key: "req", Value: in}, + {Key: "func", Value: "SocialNetworkRepository.RemoveFollowerRelation"}, + {Key: "err", Value: err}, + }, + "failed to remove relation form -> to", in.GetFollowerUid(), in.GetFolloweeUid()).Wrap(err) + + return nil, e + } + + return &tweeting.OKResp{}, nil +} diff --git a/internal/logic/socialnetworkservice/remove_follow_relation_logic_test.go b/internal/logic/socialnetworkservice/remove_follow_relation_logic_test.go new file mode 100644 index 0000000..17b9484 --- /dev/null +++ b/internal/logic/socialnetworkservice/remove_follow_relation_logic_test.go @@ -0,0 +1,108 @@ +package socialnetworkservicelogic + +import ( + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + mocklib "app-cloudep-tweeting-service/internal/mock/lib" + mockRepo "app-cloudep-tweeting-service/internal/mock/repository" + "app-cloudep-tweeting-service/internal/svc" + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestRemoveFollowRelationLogic_RemoveFollowRelation(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // 初始化 mock 依賴 + mockSocialNetworkRepository := mockRepo.NewMockSocialNetworkRepository(ctrl) + mockValidate := mocklib.NewMockValidate(ctrl) + + // 初始化服務上下文 + svcCtx := &svc.ServiceContext{ + SocialNetworkRepository: mockSocialNetworkRepository, + Validate: mockValidate, + } + + // 測試數據集 + tests := []struct { + name string + input *tweeting.DoFollowerRelationReq + prepare func() + expectErr bool + wantResp *tweeting.OKResp + }{ + { + name: "成功取消關注", + input: &tweeting.DoFollowerRelationReq{ + FollowerUid: "follower123", + FolloweeUid: "followee456", + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬成功刪除關注關係 + mockSocialNetworkRepository.EXPECT().RemoveFollowerRelation(gomock.Any(), "follower123", "followee456").Return(nil).Times(1) + }, + expectErr: false, + wantResp: &tweeting.OKResp{}, + }, + { + name: "驗證失敗", + input: &tweeting.DoFollowerRelationReq{ + FollowerUid: "", + FolloweeUid: "", + }, + prepare: func() { + // 模擬驗證失敗 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(errors.New("validation failed")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + { + name: "取消關注失敗", + input: &tweeting.DoFollowerRelationReq{ + FollowerUid: "follower123", + FolloweeUid: "followee456", + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬刪除關注關係失敗 + mockSocialNetworkRepository.EXPECT().RemoveFollowerRelation(gomock.Any(), "follower123", "followee456").Return(errors.New("repository error")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + } + + // 執行測試 + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 設置測試環境 + tt.prepare() + + // 初始化 RemoveFollowRelationLogic + logic := RemoveFollowRelationLogic{ + svcCtx: svcCtx, + ctx: context.TODO(), + } + + // 執行 RemoveFollowRelation + got, err := logic.RemoveFollowRelation(tt.input) + + // 驗證結果 + if tt.expectErr { + assert.Error(t, err) + assert.Nil(t, got) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantResp, got) + } + }) + } +} diff --git a/internal/logic/timelineservice/add_post_logic.go b/internal/logic/timelineservice/add_post_logic.go new file mode 100644 index 0000000..53910e4 --- /dev/null +++ b/internal/logic/timelineservice/add_post_logic.go @@ -0,0 +1,77 @@ +package timelineservicelogic + +import ( + "app-cloudep-tweeting-service/internal/domain" + "app-cloudep-tweeting-service/internal/domain/repository" + "context" + + ers "code.30cm.net/digimon/library-go/errs" + + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + "app-cloudep-tweeting-service/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type AddPostLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewAddPostLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AddPostLogic { + return &AddPostLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +type addPostReq struct { + UID string `json:"uid" validate:"required"` +} + +// AddPost 加入貼文,只管一股腦全塞,這裡會自動判斷 +func (l *AddPostLogic) AddPost(in *tweeting.AddPostToTimelineReq) (*tweeting.OKResp, error) { + // 驗證資料 + if err := l.svcCtx.Validate.ValidateAll(&addPostReq{ + UID: in.GetUid(), + }); err != nil { + // 錯誤代碼 05-011-00 + return nil, ers.InvalidFormat(err.Error()) + } + + if len(in.GetPosts()) == 0 { + // 沒資料,直接 OK + return &tweeting.OKResp{}, nil + } + + post := make([]repository.TimelineItem, 0, len(in.GetPosts())) + for _, item := range in.GetPosts() { + post = append(post, repository.TimelineItem{ + PostID: item.PostId, + Score: item.CreatedAt, + }) + } + + err := l.svcCtx.TimelineRepo.AddPost(l.ctx, repository.AddPostRequest{ + UID: in.GetUid(), + PostItems: post, + }) + if err != nil { + // 錯誤代碼 05-021-20 + e := domain.CommentErrorL( + domain.AddTimeLineErrorCode, + logx.WithContext(l.ctx), + []logx.LogField{ + {Key: "req", Value: in}, + {Key: "func", Value: "TimelineRepo.AddPost"}, + {Key: "err", Value: err}, + }, + "failed to insert timeline repo :", in.GetUid()).Wrap(err) + + return nil, e + } + + return &tweeting.OKResp{}, nil +} diff --git a/internal/logic/timelineservice/add_post_logic_test.go b/internal/logic/timelineservice/add_post_logic_test.go new file mode 100644 index 0000000..7b15e24 --- /dev/null +++ b/internal/logic/timelineservice/add_post_logic_test.go @@ -0,0 +1,137 @@ +package timelineservicelogic + +import ( + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + "app-cloudep-tweeting-service/internal/domain/repository" + mocklib "app-cloudep-tweeting-service/internal/mock/lib" + mockRepo "app-cloudep-tweeting-service/internal/mock/repository" + "app-cloudep-tweeting-service/internal/svc" + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestAddPostLogic_AddPost(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // 初始化 mock 依賴 + mockTimelineRepo := mockRepo.NewMockTimelineRepository(ctrl) + mockValidate := mocklib.NewMockValidate(ctrl) + + // 初始化服務上下文 + svcCtx := &svc.ServiceContext{ + TimelineRepo: mockTimelineRepo, + Validate: mockValidate, + } + + // 測試數據集 + tests := []struct { + name string + input *tweeting.AddPostToTimelineReq + prepare func() + expectErr bool + wantResp *tweeting.OKResp + }{ + { + name: "成功加入貼文", + input: &tweeting.AddPostToTimelineReq{ + Uid: "user123", + Posts: []*tweeting.PostTimelineItem{ + {PostId: "post1", CreatedAt: 1627890123}, + {PostId: "post2", CreatedAt: 1627890124}, + }, + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬成功加入貼文 + mockTimelineRepo.EXPECT().AddPost(gomock.Any(), repository.AddPostRequest{ + UID: "user123", + PostItems: []repository.TimelineItem{ + {PostID: "post1", Score: 1627890123}, + {PostID: "post2", Score: 1627890124}, + }, + }).Return(nil).Times(1) + }, + expectErr: false, + wantResp: &tweeting.OKResp{}, + }, + { + name: "驗證失敗", + input: &tweeting.AddPostToTimelineReq{ + Uid: "", + }, + prepare: func() { + // 模擬驗證失敗 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(errors.New("validation failed")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + { + name: "沒有貼文資料", + input: &tweeting.AddPostToTimelineReq{ + Uid: "user123", + Posts: []*tweeting.PostTimelineItem{}, + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + }, + expectErr: false, + wantResp: &tweeting.OKResp{}, + }, + { + name: "加入貼文失敗", + input: &tweeting.AddPostToTimelineReq{ + Uid: "user123", + Posts: []*tweeting.PostTimelineItem{ + {PostId: "post1", CreatedAt: 1627890123}, + }, + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬加入貼文失敗 + mockTimelineRepo.EXPECT().AddPost(gomock.Any(), repository.AddPostRequest{ + UID: "user123", + PostItems: []repository.TimelineItem{ + {PostID: "post1", Score: 1627890123}, + }, + }).Return(errors.New("repository error")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + } + + // 執行測試 + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 設置測試環境 + tt.prepare() + + // 初始化 AddPostLogic + logic := AddPostLogic{ + svcCtx: svcCtx, + ctx: context.TODO(), + } + + // 執行 AddPost + got, err := logic.AddPost(tt.input) + + // 驗證結果 + if tt.expectErr { + assert.Error(t, err) + assert.Nil(t, got) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantResp, got) + } + }) + } +} diff --git a/internal/logic/timelineservice/clear_no_more_data_flag_logic.go b/internal/logic/timelineservice/clear_no_more_data_flag_logic.go new file mode 100644 index 0000000..ca8e961 --- /dev/null +++ b/internal/logic/timelineservice/clear_no_more_data_flag_logic.go @@ -0,0 +1,60 @@ +package timelineservicelogic + +import ( + "app-cloudep-tweeting-service/internal/domain" + "context" + + ers "code.30cm.net/digimon/library-go/errs" + + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + "app-cloudep-tweeting-service/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ClearNoMoreDataFlagLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewClearNoMoreDataFlagLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ClearNoMoreDataFlagLogic { + return &ClearNoMoreDataFlagLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +type clearNoMoreDataFlagReq struct { + UID string `json:"uid" validate:"required"` +} + +// ClearNoMoreDataFlag 清除時間線的 "NoMoreData" 標誌。 +func (l *ClearNoMoreDataFlagLogic) ClearNoMoreDataFlag(in *tweeting.DoNoMoreDataReq) (*tweeting.OKResp, error) { + // 驗證資料 + if err := l.svcCtx.Validate.ValidateAll(&clearNoMoreDataFlagReq{ + UID: in.GetUid(), + }); err != nil { + // 錯誤代碼 05-011-00 + return nil, ers.InvalidFormat(err.Error()) + } + + err := l.svcCtx.TimelineRepo.ClearNoMoreDataFlag(l.ctx, in.GetUid()) + if err != nil { + // 錯誤代碼 05-021-22 + e := domain.CommentErrorL( + domain.ClearNoMoreDataErrorCode, + logx.WithContext(l.ctx), + []logx.LogField{ + {Key: "req", Value: in}, + {Key: "func", Value: "TimelineRepo.ClearNoMoreDataFlag"}, + {Key: "err", Value: err}, + }, + "failed to clear no more data flag timeline repo :", in.GetUid()).Wrap(err) + + return nil, e + } + + return &tweeting.OKResp{}, nil +} diff --git a/internal/logic/timelineservice/clear_no_more_data_flag_logic_test.go b/internal/logic/timelineservice/clear_no_more_data_flag_logic_test.go new file mode 100644 index 0000000..7e18967 --- /dev/null +++ b/internal/logic/timelineservice/clear_no_more_data_flag_logic_test.go @@ -0,0 +1,105 @@ +package timelineservicelogic + +import ( + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + mocklib "app-cloudep-tweeting-service/internal/mock/lib" + mockRepo "app-cloudep-tweeting-service/internal/mock/repository" + "app-cloudep-tweeting-service/internal/svc" + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestClearNoMoreDataFlagLogic_ClearNoMoreDataFlag(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // 初始化 mock 依賴 + mockTimelineRepo := mockRepo.NewMockTimelineRepository(ctrl) + mockValidate := mocklib.NewMockValidate(ctrl) + + // 初始化服務上下文 + svcCtx := &svc.ServiceContext{ + TimelineRepo: mockTimelineRepo, + Validate: mockValidate, + } + + // 測試數據集 + tests := []struct { + name string + input *tweeting.DoNoMoreDataReq + prepare func() + expectErr bool + wantResp *tweeting.OKResp + }{ + { + name: "成功清除 NoMoreData 標誌", + input: &tweeting.DoNoMoreDataReq{ + Uid: "user123", + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬成功清除 NoMoreData 標誌 + mockTimelineRepo.EXPECT().ClearNoMoreDataFlag(gomock.Any(), "user123").Return(nil).Times(1) + }, + expectErr: false, + wantResp: &tweeting.OKResp{}, + }, + { + name: "驗證失敗", + input: &tweeting.DoNoMoreDataReq{ + Uid: "", + }, + prepare: func() { + // 模擬驗證失敗 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(errors.New("validation failed")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + { + name: "清除 NoMoreData 標誌失敗", + input: &tweeting.DoNoMoreDataReq{ + Uid: "user123", + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬清除 NoMoreData 標誌失敗 + mockTimelineRepo.EXPECT().ClearNoMoreDataFlag(gomock.Any(), "user123").Return(errors.New("repository error")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + } + + // 執行測試 + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 設置測試環境 + tt.prepare() + + // 初始化 ClearNoMoreDataFlagLogic + logic := ClearNoMoreDataFlagLogic{ + svcCtx: svcCtx, + ctx: context.TODO(), + } + + // 執行 ClearNoMoreDataFlag + got, err := logic.ClearNoMoreDataFlag(tt.input) + + // 驗證結果 + if tt.expectErr { + assert.Error(t, err) + assert.Nil(t, got) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantResp, got) + } + }) + } +} diff --git a/internal/logic/timelineservice/fetch_timeline_logic.go b/internal/logic/timelineservice/fetch_timeline_logic.go new file mode 100644 index 0000000..74f89d4 --- /dev/null +++ b/internal/logic/timelineservice/fetch_timeline_logic.go @@ -0,0 +1,84 @@ +package timelineservicelogic + +import ( + "app-cloudep-tweeting-service/internal/domain" + "app-cloudep-tweeting-service/internal/domain/repository" + "context" + + ers "code.30cm.net/digimon/library-go/errs" + + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + "app-cloudep-tweeting-service/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type FetchTimelineLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewFetchTimelineLogic(ctx context.Context, svcCtx *svc.ServiceContext) *FetchTimelineLogic { + return &FetchTimelineLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +type fetchTimelineReq struct { + UID string `json:"uid" validate:"required"` + PageSize int64 `json:"page_size" validate:"required"` + PageIndex int64 `json:"page_index" validate:"required"` +} + +// FetchTimeline 取得這個人的動態時報 +func (l *FetchTimelineLogic) FetchTimeline(in *tweeting.GetTimelineReq) (*tweeting.FetchTimelineResponse, error) { + // 驗證資料 + if err := l.svcCtx.Validate.ValidateAll(&fetchTimelineReq{ + UID: in.GetUid(), + PageSize: in.PageSize, + PageIndex: in.PageIndex, + }); err != nil { + // 錯誤代碼 05-011-00 + return nil, ers.InvalidFormat(err.Error()) + } + + resp, err := l.svcCtx.TimelineRepo.FetchTimeline(l.ctx, repository.FetchTimelineRequest{ + UID: in.GetUid(), + PageIndex: in.GetPageIndex(), + PageSize: in.GetPageSize(), + }) + if err != nil { + // 錯誤代碼 05-021-21 + e := domain.CommentErrorL( + domain.FetchTimeLineErrorCode, + logx.WithContext(l.ctx), + []logx.LogField{ + {Key: "req", Value: in}, + {Key: "func", Value: "TimelineRepo.FetchTimeline"}, + {Key: "err", Value: err}, + }, + "failed to fetch timeline repo :", in.GetUid()).Wrap(err) + + return nil, e + } + + result := make([]*tweeting.FetchTimelineItem, 0, resp.Page.Size) + for _, item := range resp.Items { + result = append(result, &tweeting.FetchTimelineItem{ + PostId: item.PostID, + Score: item.Score, + }) + } + + return &tweeting.FetchTimelineResponse{ + Posts: result, + Page: &tweeting.Pager{ + Total: resp.Page.Total, + Index: resp.Page.Index, + Size: resp.Page.Size, + }, + }, nil +} diff --git a/internal/logic/timelineservice/fetch_timeline_logic_test.go b/internal/logic/timelineservice/fetch_timeline_logic_test.go new file mode 100644 index 0000000..6e83be7 --- /dev/null +++ b/internal/logic/timelineservice/fetch_timeline_logic_test.go @@ -0,0 +1,140 @@ +package timelineservicelogic + +import ( + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + "app-cloudep-tweeting-service/internal/domain/repository" + mocklib "app-cloudep-tweeting-service/internal/mock/lib" + mockRepo "app-cloudep-tweeting-service/internal/mock/repository" + "app-cloudep-tweeting-service/internal/svc" + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestFetchTimelineLogic_FetchTimeline(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // 初始化 mock 依賴 + mockTimelineRepo := mockRepo.NewMockTimelineRepository(ctrl) + mockValidate := mocklib.NewMockValidate(ctrl) + + // 初始化服務上下文 + svcCtx := &svc.ServiceContext{ + TimelineRepo: mockTimelineRepo, + Validate: mockValidate, + } + + // 測試數據集 + tests := []struct { + name string + input *tweeting.GetTimelineReq + prepare func() + expectErr bool + wantResp *tweeting.FetchTimelineResponse + }{ + { + name: "成功獲取動態時報", + input: &tweeting.GetTimelineReq{ + Uid: "user123", + PageSize: 10, + PageIndex: 1, + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬成功獲取動態時報 + mockTimelineRepo.EXPECT().FetchTimeline(gomock.Any(), repository.FetchTimelineRequest{ + UID: "user123", + PageSize: 10, + PageIndex: 1, + }).Return(repository.FetchTimelineResponse{ + Items: []repository.TimelineItem{ + {PostID: "post1", Score: 100}, + {PostID: "post2", Score: 200}, + }, + Page: tweeting.Pager{ + Total: 2, + Size: 10, + Index: 1, + }, + }, nil).Times(1) + }, + expectErr: false, + wantResp: &tweeting.FetchTimelineResponse{ + Posts: []*tweeting.FetchTimelineItem{ + {PostId: "post1", Score: 100}, + {PostId: "post2", Score: 200}, + }, + Page: &tweeting.Pager{ + Total: 2, + Index: 1, + Size: 10, + }, + }, + }, + { + name: "驗證失敗", + input: &tweeting.GetTimelineReq{ + Uid: "", + PageSize: 10, + PageIndex: 1, + }, + prepare: func() { + // 模擬驗證失敗 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(errors.New("validation failed")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + { + name: "獲取動態時報失敗", + input: &tweeting.GetTimelineReq{ + Uid: "user123", + PageSize: 10, + PageIndex: 1, + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬獲取動態時報失敗 + mockTimelineRepo.EXPECT().FetchTimeline(gomock.Any(), repository.FetchTimelineRequest{ + UID: "user123", + PageSize: 10, + PageIndex: 1, + }).Return(repository.FetchTimelineResponse{}, errors.New("repository error")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + } + + // 執行測試 + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 設置測試環境 + tt.prepare() + + // 初始化 FetchTimelineLogic + logic := FetchTimelineLogic{ + svcCtx: svcCtx, + ctx: context.TODO(), + } + + // 執行 FetchTimeline + got, err := logic.FetchTimeline(tt.input) + + // 驗證結果 + if tt.expectErr { + assert.Error(t, err) + assert.Nil(t, got) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantResp, got) + } + }) + } +} diff --git a/internal/logic/timelineservice/has_no_more_data_logic.go b/internal/logic/timelineservice/has_no_more_data_logic.go new file mode 100644 index 0000000..6146ffe --- /dev/null +++ b/internal/logic/timelineservice/has_no_more_data_logic.go @@ -0,0 +1,62 @@ +package timelineservicelogic + +import ( + "app-cloudep-tweeting-service/internal/domain" + "context" + + ers "code.30cm.net/digimon/library-go/errs" + + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + "app-cloudep-tweeting-service/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type HasNoMoreDataLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewHasNoMoreDataLogic(ctx context.Context, svcCtx *svc.ServiceContext) *HasNoMoreDataLogic { + return &HasNoMoreDataLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +type hasNoMoreDataReq struct { + UID string `json:"uid" validate:"required"` +} + +// HasNoMoreData 檢查時間線是否已完整,決定是否需要查詢資料庫。 +func (l *HasNoMoreDataLogic) HasNoMoreData(in *tweeting.DoNoMoreDataReq) (*tweeting.HasNoMoreDataResp, error) { + // 驗證資料 + if err := l.svcCtx.Validate.ValidateAll(&hasNoMoreDataReq{ + UID: in.GetUid(), + }); err != nil { + // 錯誤代碼 05-011-00 + return nil, ers.InvalidFormat(err.Error()) + } + + res, err := l.svcCtx.TimelineRepo.HasNoMoreData(l.ctx, in.GetUid()) + if err != nil { + // 錯誤代碼 05-021-23 + e := domain.CommentErrorL( + domain.HasNoMoreDataErrorCode, + logx.WithContext(l.ctx), + []logx.LogField{ + {Key: "req", Value: in}, + {Key: "func", Value: "TimelineRepo.HasNoMoreData"}, + {Key: "err", Value: err}, + }, + "failed to get no more data flag:", in.GetUid()).Wrap(err) + + return nil, e + } + + return &tweeting.HasNoMoreDataResp{ + Status: res, + }, nil +} diff --git a/internal/logic/timelineservice/has_no_more_data_logic_test.go b/internal/logic/timelineservice/has_no_more_data_logic_test.go new file mode 100644 index 0000000..8f8bed7 --- /dev/null +++ b/internal/logic/timelineservice/has_no_more_data_logic_test.go @@ -0,0 +1,107 @@ +package timelineservicelogic + +import ( + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + mocklib "app-cloudep-tweeting-service/internal/mock/lib" + mockRepo "app-cloudep-tweeting-service/internal/mock/repository" + "app-cloudep-tweeting-service/internal/svc" + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestHasNoMoreDataLogic_HasNoMoreData(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // 初始化 mock 依賴 + mockTimelineRepo := mockRepo.NewMockTimelineRepository(ctrl) + mockValidate := mocklib.NewMockValidate(ctrl) + + // 初始化服務上下文 + svcCtx := &svc.ServiceContext{ + TimelineRepo: mockTimelineRepo, + Validate: mockValidate, + } + + // 測試數據集 + tests := []struct { + name string + input *tweeting.DoNoMoreDataReq + prepare func() + expectErr bool + wantResp *tweeting.HasNoMoreDataResp + }{ + { + name: "成功檢查時間線是否完整", + input: &tweeting.DoNoMoreDataReq{ + Uid: "user123", + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬成功檢查時間線狀態 + mockTimelineRepo.EXPECT().HasNoMoreData(gomock.Any(), "user123").Return(true, nil).Times(1) + }, + expectErr: false, + wantResp: &tweeting.HasNoMoreDataResp{ + Status: true, + }, + }, + { + name: "驗證失敗", + input: &tweeting.DoNoMoreDataReq{ + Uid: "", + }, + prepare: func() { + // 模擬驗證失敗 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(errors.New("validation failed")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + { + name: "檢查時間線狀態失敗", + input: &tweeting.DoNoMoreDataReq{ + Uid: "user123", + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬檢查時間線狀態失敗 + mockTimelineRepo.EXPECT().HasNoMoreData(gomock.Any(), "user123").Return(false, errors.New("repository error")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + } + + // 執行測試 + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 設置測試環境 + tt.prepare() + + // 初始化 HasNoMoreDataLogic + logic := HasNoMoreDataLogic{ + svcCtx: svcCtx, + ctx: context.TODO(), + } + + // 執行 HasNoMoreData + got, err := logic.HasNoMoreData(tt.input) + + // 驗證結果 + if tt.expectErr { + assert.Error(t, err) + assert.Nil(t, got) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantResp, got) + } + }) + } +} diff --git a/internal/logic/timelineservice/set_no_more_data_flag_logic.go b/internal/logic/timelineservice/set_no_more_data_flag_logic.go new file mode 100644 index 0000000..59da4df --- /dev/null +++ b/internal/logic/timelineservice/set_no_more_data_flag_logic.go @@ -0,0 +1,60 @@ +package timelineservicelogic + +import ( + "app-cloudep-tweeting-service/internal/domain" + "context" + + ers "code.30cm.net/digimon/library-go/errs" + + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + "app-cloudep-tweeting-service/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SetNoMoreDataFlagLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewSetNoMoreDataFlagLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SetNoMoreDataFlagLogic { + return &SetNoMoreDataFlagLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +type sasNoMoreDataReq struct { + UID string `json:"uid" validate:"required"` +} + +// SetNoMoreDataFlag 標記時間線已完整,避免繼續查詢資料庫。 +func (l *SetNoMoreDataFlagLogic) SetNoMoreDataFlag(in *tweeting.DoNoMoreDataReq) (*tweeting.OKResp, error) { + // 驗證資料 + if err := l.svcCtx.Validate.ValidateAll(&sasNoMoreDataReq{ + UID: in.GetUid(), + }); err != nil { + // 錯誤代碼 05-011-00 + return nil, ers.InvalidFormat(err.Error()) + } + + err := l.svcCtx.TimelineRepo.SetNoMoreDataFlag(l.ctx, in.GetUid()) + if err != nil { + // 錯誤代碼 05-021-24 + e := domain.CommentErrorL( + domain.SetNoMoreDataErrorCode, + logx.WithContext(l.ctx), + []logx.LogField{ + {Key: "req", Value: in}, + {Key: "func", Value: "TimelineRepo.SetNoMoreDataErrorCode"}, + {Key: "err", Value: err}, + }, + "failed to set no more data flag:", in.GetUid()).Wrap(err) + + return nil, e + } + + return &tweeting.OKResp{}, nil +} diff --git a/internal/logic/timelineservice/set_no_more_data_flag_logic_test.go b/internal/logic/timelineservice/set_no_more_data_flag_logic_test.go new file mode 100644 index 0000000..d18c804 --- /dev/null +++ b/internal/logic/timelineservice/set_no_more_data_flag_logic_test.go @@ -0,0 +1,105 @@ +package timelineservicelogic + +import ( + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + mocklib "app-cloudep-tweeting-service/internal/mock/lib" + mockRepo "app-cloudep-tweeting-service/internal/mock/repository" + "app-cloudep-tweeting-service/internal/svc" + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestSetNoMoreDataFlagLogic_SetNoMoreDataFlag(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // 初始化 mock 依賴 + mockTimelineRepo := mockRepo.NewMockTimelineRepository(ctrl) + mockValidate := mocklib.NewMockValidate(ctrl) + + // 初始化服務上下文 + svcCtx := &svc.ServiceContext{ + TimelineRepo: mockTimelineRepo, + Validate: mockValidate, + } + + // 測試數據集 + tests := []struct { + name string + input *tweeting.DoNoMoreDataReq + prepare func() + expectErr bool + wantResp *tweeting.OKResp + }{ + { + name: "成功標記時間線已完整", + input: &tweeting.DoNoMoreDataReq{ + Uid: "user123", + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬成功標記時間線狀態 + mockTimelineRepo.EXPECT().SetNoMoreDataFlag(gomock.Any(), "user123").Return(nil).Times(1) + }, + expectErr: false, + wantResp: &tweeting.OKResp{}, + }, + { + name: "驗證失敗", + input: &tweeting.DoNoMoreDataReq{ + Uid: "", + }, + prepare: func() { + // 模擬驗證失敗 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(errors.New("validation failed")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + { + name: "標記時間線狀態失敗", + input: &tweeting.DoNoMoreDataReq{ + Uid: "user123", + }, + prepare: func() { + // 模擬驗證通過 + mockValidate.EXPECT().ValidateAll(gomock.Any()).Return(nil).Times(1) + // 模擬標記時間線狀態失敗 + mockTimelineRepo.EXPECT().SetNoMoreDataFlag(gomock.Any(), "user123").Return(errors.New("repository error")).Times(1) + }, + expectErr: true, + wantResp: nil, + }, + } + + // 執行測試 + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 設置測試環境 + tt.prepare() + + // 初始化 SetNoMoreDataFlagLogic + logic := SetNoMoreDataFlagLogic{ + svcCtx: svcCtx, + ctx: context.TODO(), + } + + // 執行 SetNoMoreDataFlag + got, err := logic.SetNoMoreDataFlag(tt.input) + + // 驗證結果 + if tt.expectErr { + assert.Error(t, err) + assert.Nil(t, got) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantResp, got) + } + }) + } +} diff --git a/internal/mock/repository/social_network.go b/internal/mock/repository/social_network.go new file mode 100644 index 0000000..163a03d --- /dev/null +++ b/internal/mock/repository/social_network.go @@ -0,0 +1,174 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./internal/domain/repository/social_network.go +// +// Generated by this command: +// +// mockgen -source=./internal/domain/repository/social_network.go -destination=./internal/mock/repository/social_network.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + repository "app-cloudep-tweeting-service/internal/domain/repository" + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockSocialNetworkRepository is a mock of SocialNetworkRepository interface. +type MockSocialNetworkRepository struct { + ctrl *gomock.Controller + recorder *MockSocialNetworkRepositoryMockRecorder +} + +// MockSocialNetworkRepositoryMockRecorder is the mock recorder for MockSocialNetworkRepository. +type MockSocialNetworkRepositoryMockRecorder struct { + mock *MockSocialNetworkRepository +} + +// NewMockSocialNetworkRepository creates a new mock instance. +func NewMockSocialNetworkRepository(ctrl *gomock.Controller) *MockSocialNetworkRepository { + mock := &MockSocialNetworkRepository{ctrl: ctrl} + mock.recorder = &MockSocialNetworkRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSocialNetworkRepository) EXPECT() *MockSocialNetworkRepositoryMockRecorder { + return m.recorder +} + +// CreateUserNode mocks base method. +func (m *MockSocialNetworkRepository) CreateUserNode(ctx context.Context, uid string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateUserNode", ctx, uid) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateUserNode indicates an expected call of CreateUserNode. +func (mr *MockSocialNetworkRepositoryMockRecorder) CreateUserNode(ctx, uid any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUserNode", reflect.TypeOf((*MockSocialNetworkRepository)(nil).CreateUserNode), ctx, uid) +} + +// GetDegreeBetweenUsers mocks base method. +func (m *MockSocialNetworkRepository) GetDegreeBetweenUsers(ctx context.Context, uid1, uid2 string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDegreeBetweenUsers", ctx, uid1, uid2) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetDegreeBetweenUsers indicates an expected call of GetDegreeBetweenUsers. +func (mr *MockSocialNetworkRepositoryMockRecorder) GetDegreeBetweenUsers(ctx, uid1, uid2 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDegreeBetweenUsers", reflect.TypeOf((*MockSocialNetworkRepository)(nil).GetDegreeBetweenUsers), ctx, uid1, uid2) +} + +// GetFollowee mocks base method. +func (m *MockSocialNetworkRepository) GetFollowee(ctx context.Context, req repository.FollowReq) (repository.FollowResp, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetFollowee", ctx, req) + ret0, _ := ret[0].(repository.FollowResp) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetFollowee indicates an expected call of GetFollowee. +func (mr *MockSocialNetworkRepositoryMockRecorder) GetFollowee(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFollowee", reflect.TypeOf((*MockSocialNetworkRepository)(nil).GetFollowee), ctx, req) +} + +// GetFolloweeCount mocks base method. +func (m *MockSocialNetworkRepository) GetFolloweeCount(ctx context.Context, uid string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetFolloweeCount", ctx, uid) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetFolloweeCount indicates an expected call of GetFolloweeCount. +func (mr *MockSocialNetworkRepositoryMockRecorder) GetFolloweeCount(ctx, uid any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFolloweeCount", reflect.TypeOf((*MockSocialNetworkRepository)(nil).GetFolloweeCount), ctx, uid) +} + +// GetFollower mocks base method. +func (m *MockSocialNetworkRepository) GetFollower(ctx context.Context, req repository.FollowReq) (repository.FollowResp, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetFollower", ctx, req) + ret0, _ := ret[0].(repository.FollowResp) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetFollower indicates an expected call of GetFollower. +func (mr *MockSocialNetworkRepositoryMockRecorder) GetFollower(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFollower", reflect.TypeOf((*MockSocialNetworkRepository)(nil).GetFollower), ctx, req) +} + +// GetFollowerCount mocks base method. +func (m *MockSocialNetworkRepository) GetFollowerCount(ctx context.Context, uid string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetFollowerCount", ctx, uid) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetFollowerCount indicates an expected call of GetFollowerCount. +func (mr *MockSocialNetworkRepositoryMockRecorder) GetFollowerCount(ctx, uid any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFollowerCount", reflect.TypeOf((*MockSocialNetworkRepository)(nil).GetFollowerCount), ctx, uid) +} + +// GetUIDsWithinNDegrees mocks base method. +func (m *MockSocialNetworkRepository) GetUIDsWithinNDegrees(ctx context.Context, uid string, degrees, pageSize, pageIndex int64) ([]string, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUIDsWithinNDegrees", ctx, uid, degrees, pageSize, pageIndex) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetUIDsWithinNDegrees indicates an expected call of GetUIDsWithinNDegrees. +func (mr *MockSocialNetworkRepositoryMockRecorder) GetUIDsWithinNDegrees(ctx, uid, degrees, pageSize, pageIndex any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUIDsWithinNDegrees", reflect.TypeOf((*MockSocialNetworkRepository)(nil).GetUIDsWithinNDegrees), ctx, uid, degrees, pageSize, pageIndex) +} + +// MarkFollowerRelation mocks base method. +func (m *MockSocialNetworkRepository) MarkFollowerRelation(ctx context.Context, fromUID, toUID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkFollowerRelation", ctx, fromUID, toUID) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkFollowerRelation indicates an expected call of MarkFollowerRelation. +func (mr *MockSocialNetworkRepositoryMockRecorder) MarkFollowerRelation(ctx, fromUID, toUID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkFollowerRelation", reflect.TypeOf((*MockSocialNetworkRepository)(nil).MarkFollowerRelation), ctx, fromUID, toUID) +} + +// RemoveFollowerRelation mocks base method. +func (m *MockSocialNetworkRepository) RemoveFollowerRelation(ctx context.Context, fromUID, toUID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveFollowerRelation", ctx, fromUID, toUID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemoveFollowerRelation indicates an expected call of RemoveFollowerRelation. +func (mr *MockSocialNetworkRepositoryMockRecorder) RemoveFollowerRelation(ctx, fromUID, toUID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveFollowerRelation", reflect.TypeOf((*MockSocialNetworkRepository)(nil).RemoveFollowerRelation), ctx, fromUID, toUID) +} diff --git a/internal/mock/repository/timeline.go b/internal/mock/repository/timeline.go new file mode 100644 index 0000000..91325f5 --- /dev/null +++ b/internal/mock/repository/timeline.go @@ -0,0 +1,113 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./internal/domain/repository/timeline.go +// +// Generated by this command: +// +// mockgen -source=./internal/domain/repository/timeline.go -destination=./internal/mock/repository/timeline.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + repository "app-cloudep-tweeting-service/internal/domain/repository" + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockTimelineRepository is a mock of TimelineRepository interface. +type MockTimelineRepository struct { + ctrl *gomock.Controller + recorder *MockTimelineRepositoryMockRecorder +} + +// MockTimelineRepositoryMockRecorder is the mock recorder for MockTimelineRepository. +type MockTimelineRepositoryMockRecorder struct { + mock *MockTimelineRepository +} + +// NewMockTimelineRepository creates a new mock instance. +func NewMockTimelineRepository(ctrl *gomock.Controller) *MockTimelineRepository { + mock := &MockTimelineRepository{ctrl: ctrl} + mock.recorder = &MockTimelineRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTimelineRepository) EXPECT() *MockTimelineRepositoryMockRecorder { + return m.recorder +} + +// AddPost mocks base method. +func (m *MockTimelineRepository) AddPost(ctx context.Context, req repository.AddPostRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddPost", ctx, req) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddPost indicates an expected call of AddPost. +func (mr *MockTimelineRepositoryMockRecorder) AddPost(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPost", reflect.TypeOf((*MockTimelineRepository)(nil).AddPost), ctx, req) +} + +// ClearNoMoreDataFlag mocks base method. +func (m *MockTimelineRepository) ClearNoMoreDataFlag(ctx context.Context, uid string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClearNoMoreDataFlag", ctx, uid) + ret0, _ := ret[0].(error) + return ret0 +} + +// ClearNoMoreDataFlag indicates an expected call of ClearNoMoreDataFlag. +func (mr *MockTimelineRepositoryMockRecorder) ClearNoMoreDataFlag(ctx, uid any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearNoMoreDataFlag", reflect.TypeOf((*MockTimelineRepository)(nil).ClearNoMoreDataFlag), ctx, uid) +} + +// FetchTimeline mocks base method. +func (m *MockTimelineRepository) FetchTimeline(ctx context.Context, req repository.FetchTimelineRequest) (repository.FetchTimelineResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FetchTimeline", ctx, req) + ret0, _ := ret[0].(repository.FetchTimelineResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FetchTimeline indicates an expected call of FetchTimeline. +func (mr *MockTimelineRepositoryMockRecorder) FetchTimeline(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchTimeline", reflect.TypeOf((*MockTimelineRepository)(nil).FetchTimeline), ctx, req) +} + +// HasNoMoreData mocks base method. +func (m *MockTimelineRepository) HasNoMoreData(ctx context.Context, uid string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HasNoMoreData", ctx, uid) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HasNoMoreData indicates an expected call of HasNoMoreData. +func (mr *MockTimelineRepositoryMockRecorder) HasNoMoreData(ctx, uid any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasNoMoreData", reflect.TypeOf((*MockTimelineRepository)(nil).HasNoMoreData), ctx, uid) +} + +// SetNoMoreDataFlag mocks base method. +func (m *MockTimelineRepository) SetNoMoreDataFlag(ctx context.Context, uid string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetNoMoreDataFlag", ctx, uid) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetNoMoreDataFlag indicates an expected call of SetNoMoreDataFlag. +func (mr *MockTimelineRepositoryMockRecorder) SetNoMoreDataFlag(ctx, uid any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetNoMoreDataFlag", reflect.TypeOf((*MockTimelineRepository)(nil).SetNoMoreDataFlag), ctx, uid) +} diff --git a/internal/repository/social_network.go b/internal/repository/social_network.go new file mode 100644 index 0000000..978f670 --- /dev/null +++ b/internal/repository/social_network.go @@ -0,0 +1,439 @@ +package repository + +import ( + "app-cloudep-tweeting-service/internal/config" + "app-cloudep-tweeting-service/internal/domain/repository" + client4J "app-cloudep-tweeting-service/internal/lib/neo4j" + "context" + "fmt" + + "github.com/zeromicro/go-zero/core/logx" + + "github.com/neo4j/neo4j-go-driver/v5/neo4j" +) + +type SocialNetworkParam struct { + Config config.Config + Neo4jClient *client4J.Client +} + +type SocialNetworkRepository struct { + cfg config.Config + neo4jClient *client4J.Client +} + +func MustSocialNetworkRepository(param SocialNetworkParam) repository.SocialNetworkRepository { + return &SocialNetworkRepository{ + cfg: param.Config, + neo4jClient: param.Neo4jClient, + } +} + +func (s *SocialNetworkRepository) CreateUserNode(ctx context.Context, uid string) error { + //nolint:contextcheck + session, err := s.neo4jClient.Conn() + if err != nil { + return err + } + defer session.Close(ctx) + + params := map[string]interface{}{ + "uid": uid, + } + + run, err := session.NewSession(ctx, neo4j.SessionConfig{ + AccessMode: neo4j.AccessModeWrite, + }).Run(ctx, "CREATE (n:User {uid: $uid}) RETURN n", params) + if err != nil { + return err + } + + // 處理結果 + if run.Next(ctx) { + _ = run.Record().AsMap() + } + + return nil +} + +func (s *SocialNetworkRepository) MarkFollowerRelation(ctx context.Context, fromUID, toUID string) error { + //nolint:contextcheck + session, err := s.neo4jClient.Conn() + if err != nil { + return err + } + defer session.Close(ctx) + + params := map[string]interface{}{ + "fromUID": fromUID, + "toUID": toUID, + } + + // 這是有向的關係 form -> to + query := ` + MERGE (from:User {uid: $fromUID}) + MERGE (to:User {uid: $toUID}) + MERGE (from)-[:FRIENDS_WITH]->(to) + RETURN from, to + ` + + run, err := session.NewSession(ctx, neo4j.SessionConfig{ + AccessMode: neo4j.AccessModeWrite, + }).Run(ctx, query, params) + if err != nil { + return err + } + + // 處理結果 + if run.Next(ctx) { + _ = run.Record().AsMap() + } + + return nil +} + +func (s *SocialNetworkRepository) GetFollower(ctx context.Context, req repository.FollowReq) (repository.FollowResp, error) { + //nolint:contextcheck + session, err := s.neo4jClient.Conn() + if err != nil { + return repository.FollowResp{}, err + } + defer session.Close(ctx) + + params := map[string]interface{}{ + "uid": req.UID, + "skip": (req.PageIndex - 1) * req.PageSize, + "limit": req.PageSize, + } + + query := ` + MATCH (follower:User)-[:FRIENDS_WITH]->(user:User {uid: $uid}) + RETURN follower.uid AS uid + SKIP $skip LIMIT $limit + ` + + run, err := session.NewSession(ctx, neo4j.SessionConfig{ + AccessMode: neo4j.AccessModeRead, + }).Run(ctx, query, params) + if err != nil { + return repository.FollowResp{}, err + } + + var uidList []string + for run.Next(ctx) { + record := run.Record() + uid, ok := record.Get("uid") + if ok { + if uidStr, ok := uid.(string); ok { + uidList = append(uidList, uidStr) + } else { + // TODO 可以印 log + continue + } + } + } + + total, err := s.GetFollowerCount(ctx, req.UID) + if err != nil { + return repository.FollowResp{}, err + } + + return repository.FollowResp{ + UIDs: uidList, + Total: total, + }, nil +} + +func (s *SocialNetworkRepository) GetFollowee(ctx context.Context, req repository.FollowReq) (repository.FollowResp, error) { + //nolint:contextcheck + session, err := s.neo4jClient.Conn() + if err != nil { + return repository.FollowResp{}, err + } + defer session.Close(ctx) + + params := map[string]interface{}{ + "uid": req.UID, + "skip": (req.PageIndex - 1) * req.PageSize, + "limit": req.PageSize, + } + + query := ` + MATCH (user:User {uid: $uid})-[:FRIENDS_WITH]->(followee:User) + RETURN followee.uid AS uid + SKIP $skip LIMIT $limit + ` + + run, err := session.NewSession(ctx, neo4j.SessionConfig{ + AccessMode: neo4j.AccessModeRead, + }).Run(ctx, query, params) + if err != nil { + return repository.FollowResp{}, err + } + + var uidList []string + for run.Next(ctx) { + record := run.Record() + uid, ok := record.Get("uid") + if ok { + if uidStr, ok := uid.(string); ok { + uidList = append(uidList, uidStr) + } else { + // 可以印 log + continue + } + } + } + + total, err := s.GetFolloweeCount(ctx, req.UID) + if err != nil { + return repository.FollowResp{}, err + } + + return repository.FollowResp{ + UIDs: uidList, + Total: total, + }, nil +} + +func (s *SocialNetworkRepository) GetFollowerCount(ctx context.Context, uid string) (int64, error) { + //nolint:contextcheck + session, err := s.neo4jClient.Conn() + if err != nil { + return 0, err + } + defer session.Close(ctx) + + params := map[string]interface{}{ + "uid": uid, + } + + query := ` + MATCH (:User)-[:FRIENDS_WITH]->(user:User {uid: $uid}) + RETURN count(*) AS followerCount + ` + + run, err := session.NewSession(ctx, neo4j.SessionConfig{ + AccessMode: neo4j.AccessModeRead, + }).Run(ctx, query, params) + if err != nil { + return 0, err + } + + var count int64 + if run.Next(ctx) { + record := run.Record() + if followerCount, ok := record.Get("followerCount"); ok { + if dc, ok := followerCount.(int64); ok { + count = dc + } else { + logx.Info("followerCount error") + } + } + } + + return count, nil +} + +func (s *SocialNetworkRepository) GetFolloweeCount(ctx context.Context, uid string) (int64, error) { + //nolint:contextcheck + session, err := s.neo4jClient.Conn() + if err != nil { + return 0, err + } + defer session.Close(ctx) + + params := map[string]interface{}{ + "uid": uid, + } + + query := ` + MATCH (user:User {uid: $uid})-[:FRIENDS_WITH]->(:User) + RETURN count(*) AS followeeCount + ` + + run, err := session.NewSession(ctx, neo4j.SessionConfig{ + AccessMode: neo4j.AccessModeRead, + }).Run(ctx, query, params) + if err != nil { + return 0, err + } + + var count int64 + if run.Next(ctx) { + record := run.Record() + if followeeCount, ok := record.Get("followeeCount"); ok { + if dc, ok := followeeCount.(int64); ok { + count = dc + } else { + logx.Info("followeeCount error") + } + } + } + + return count, nil +} + +func (s *SocialNetworkRepository) RemoveFollowerRelation(ctx context.Context, fromUID, toUID string) error { + //nolint:contextcheck + session, err := s.neo4jClient.Conn() + if err != nil { + return err + } + defer session.Close(ctx) + + params := map[string]interface{}{ + "fromUID": fromUID, + "toUID": toUID, + } + + query := ` + MATCH (from:User {uid: $fromUID})-[r:FRIENDS_WITH]->(to:User {uid: $toUID}) + DELETE r + ` + + _, err = session.NewSession(ctx, neo4j.SessionConfig{ + AccessMode: neo4j.AccessModeWrite, + }).Run(ctx, query, params) + if err != nil { + return fmt.Errorf("failed to remove follower relation: %w", err) + } + + return nil +} + +// GetDegreeBetweenUsers 取得這兩個點之間的度數 (最短路徑長度) +func (s *SocialNetworkRepository) GetDegreeBetweenUsers(ctx context.Context, uid1, uid2 string) (int64, error) { + //nolint:contextcheck + session, err := s.neo4jClient.Conn() + if err != nil { + return 0, err + } + defer session.Close(ctx) + + params := map[string]interface{}{ + "uid1": uid1, + "uid2": uid2, + } + + query := ` + MATCH (user1:User {uid: $uid1}), (user2:User {uid: $uid2}) + MATCH p = shortestPath((user1)-[*]-(user2)) + RETURN length(p) AS degree + ` + + run, err := session.NewSession(ctx, neo4j.SessionConfig{ + AccessMode: neo4j.AccessModeRead, + }).Run(ctx, query, params) + if err != nil { + return 0, fmt.Errorf("failed to get degree between users: %w", err) + } + + var degree int64 + if run.Next(ctx) { + record := run.Record() + if deg, ok := record.Get("degree"); ok { + if degreeValue, ok := deg.(int64); ok { + degree = degreeValue + } else { + logx.Info("degree error") + } + } + } + + return degree, nil +} + +// GetUIDsWithinNDegrees 取得某個節點在 n 度內關係所有 UID +func (s *SocialNetworkRepository) GetUIDsWithinNDegrees(ctx context.Context, uid string, degrees, pageSize, pageIndex int64) ([]string, int64, error) { + //nolint:contextcheck + session, err := s.neo4jClient.Conn() + if err != nil { + return nil, 0, err + } + defer session.Close(ctx) + + params := map[string]interface{}{ + "uid": uid, + "degrees": degrees, + "skip": (pageIndex - 1) * pageSize, + "limit": pageSize, + } + + // 查詢結果帶分頁 + query := ` + MATCH (user:User {uid: $uid})-[:FRIENDS_WITH*1..$degrees]-(related:User) + WITH DISTINCT related.uid AS uid + SKIP $skip LIMIT $limit + RETURN uid + ` + + run, err := session.NewSession(ctx, neo4j.SessionConfig{ + AccessMode: neo4j.AccessModeRead, + }).Run(ctx, query, params) + if err != nil { + return nil, 0, fmt.Errorf("failed to get uids within %d degrees of user: %w", degrees, err) + } + + var uidList []string + for run.Next(ctx) { + record := run.Record() + uid, ok := record.Get("uid") + if ok { + if uidStr, ok := uid.(string); ok { + uidList = append(uidList, uidStr) + } else { + // 可以印 log + continue + } + } + } + + // 計算總數 + totalCount, err := s.getTotalUIDsWithinNDegrees(ctx, uid, degrees) + if err != nil { + return nil, 0, err + } + + return uidList, totalCount, nil +} + +func (s *SocialNetworkRepository) getTotalUIDsWithinNDegrees(ctx context.Context, uid string, degrees int64) (int64, error) { + //nolint:contextcheck + session, err := s.neo4jClient.Conn() + if err != nil { + return 0, err + } + defer session.Close(ctx) + + params := map[string]interface{}{ + "uid": uid, + "degrees": degrees, + } + + query := ` + MATCH (user:User {uid: $uid})-[:FRIENDS_WITH*1..$degrees]-(related:User) + RETURN count(DISTINCT related.uid) AS totalCount + ` + + run, err := session.NewSession(ctx, neo4j.SessionConfig{ + AccessMode: neo4j.AccessModeRead, + }).Run(ctx, query, params) + if err != nil { + return 0, fmt.Errorf("failed to get total uids within %d degrees of user: %w", degrees, err) + } + + var totalCount int64 + if run.Next(ctx) { + record := run.Record() + if count, ok := record.Get("totalCount"); ok { + if countV, ok := count.(int64); ok { + totalCount = countV + } else { + logx.Info("totalCount error") + } + } + } + + return totalCount, nil +} diff --git a/internal/repository/social_network_test.go b/internal/repository/social_network_test.go new file mode 100644 index 0000000..50a4378 --- /dev/null +++ b/internal/repository/social_network_test.go @@ -0,0 +1 @@ +package repository diff --git a/internal/repository/timeline_sort_by_timestamp.go b/internal/repository/timeline_sort_by_timestamp.go new file mode 100644 index 0000000..ebf4c1d --- /dev/null +++ b/internal/repository/timeline_sort_by_timestamp.go @@ -0,0 +1,144 @@ +package repository + +import ( + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + "app-cloudep-tweeting-service/internal/config" + "app-cloudep-tweeting-service/internal/domain" + "app-cloudep-tweeting-service/internal/domain/repository" + "context" + "errors" + "time" + + "github.com/zeromicro/go-zero/core/stores/redis" +) + +// TODO 第一版本先使用 Redis 來做,後續如果有效能考量,在考慮使用其他方案 + +type TimelineRepositoryParam struct { + Config config.Config + Redis redis.Redis +} + +type TimelineRepository struct { + cfg config.Config + redis redis.Redis +} + +func MustGenerateRepository(param TimelineRepositoryParam) repository.TimelineRepository { + return &TimelineRepository{ + cfg: param.Config, + redis: param.Redis, + } +} + +// AddPost 將貼文添加到時間線,並根據 Score 排序 +func (t *TimelineRepository) AddPost(ctx context.Context, req repository.AddPostRequest) error { + key := domain.TimelineRedisKey.With(req.UID).ToString() + + // 準備要插入的元素 + zItems := make([]redis.Pair, len(req.PostItems)) + for i, item := range req.PostItems { + zItems[i] = redis.Pair{ + Score: item.Score, + Key: item.PostID, + } + } + + // 將 ZSet 元素添加到 Redis + _, err := t.redis.ZaddsCtx(ctx, key, zItems...) + if err != nil { + return err + } + + // 檢查 ZSet 長度,並在超過 maxLength 時刪除多餘的元素 + if t.cfg.TimelineSetting.MaxLength > 0 { + // 這裡從 0 到 - (maxLength+1) 代表超過限制的元素範圍 + _, err := t.redis.ZremrangebyrankCtx(ctx, key, 0, -(t.cfg.TimelineSetting.MaxLength + 1)) + if err != nil { + return err + } + } + + // 設置過期時間 + return t.redis.ExpireCtx(ctx, key, int(t.cfg.TimelineSetting.Expire)) +} + +// FetchTimeline 獲取指定用戶的動態時報 +func (t *TimelineRepository) FetchTimeline(ctx context.Context, req repository.FetchTimelineRequest) (repository.FetchTimelineResponse, error) { + key := domain.TimelineRedisKey.With(req.UID).ToString() + + start := (req.PageIndex - 1) * req.PageSize + end := start + req.PageSize - 1 + + // 從 Redis 中按分數由高到低獲取時間線元素 + pair, err := t.redis.ZrevrangeWithScoresCtx(ctx, key, start, end) + if err != nil { + return repository.FetchTimelineResponse{}, err + } + + // 構建返回結果 + items := make([]repository.TimelineItem, len(pair)) + for i, z := range pair { + items[i] = repository.TimelineItem{ + PostID: z.Key, + Score: z.Score, + } + } + + // 計算總數量 + total, err := t.redis.ZcardCtx(ctx, key) + if err != nil { + return repository.FetchTimelineResponse{}, err + } + + return repository.FetchTimelineResponse{ + Items: items, + Page: tweeting.Pager{ + Total: int64(total), + Index: req.PageIndex, + Size: req.PageSize, + }, + }, nil +} + +// SetNoMoreDataFlag 標記時間線已完整,避免繼續查詢資料庫 +func (t *TimelineRepository) SetNoMoreDataFlag(ctx context.Context, uid string) error { + key := domain.TimelineRedisKey.With(uid).ToString() + + // 添加一個標誌到時間線的 ZSet + _, err := t.redis.ZaddsCtx(ctx, key, redis.Pair{ + Score: time.Now().UTC().Unix(), + Key: domain.LastOfTimelineFlag, + }) + if err != nil { + return err + } + + // 設置過期時間 + return t.redis.ExpireCtx(ctx, key, int(t.cfg.TimelineSetting.Expire)) +} + +// HasNoMoreData 檢查時間線是否已完整,決定是否需要查詢資料庫 +func (t *TimelineRepository) HasNoMoreData(ctx context.Context, uid string) (bool, error) { + key := domain.TimelineRedisKey.With(uid).ToString() + + // 檢查 "NoMoreData" 標誌是否存在 + score, err := t.redis.ZscoreCtx(ctx, key, domain.LastOfTimelineFlag) + if errors.Is(err, redis.Nil) { + return false, nil // 標誌不存在 + } + if err != nil { + return false, err // 其他錯誤 + } + + return score != 0, nil +} + +// ClearNoMoreDataFlag 清除時間線的 "NoMoreData" 標誌 +func (t *TimelineRepository) ClearNoMoreDataFlag(ctx context.Context, uid string) error { + key := domain.TimelineRedisKey.With(uid).ToString() + // 移除 "NoMoreData" 標誌 + _, err := t.redis.ZremCtx(ctx, key, domain.LastOfTimelineFlag) + + return err +} diff --git a/internal/repository/timeline_sort_by_timestamp_test.go b/internal/repository/timeline_sort_by_timestamp_test.go new file mode 100644 index 0000000..5d81570 --- /dev/null +++ b/internal/repository/timeline_sort_by_timestamp_test.go @@ -0,0 +1,474 @@ +package repository + +import ( + "app-cloudep-tweeting-service/internal/config" + "app-cloudep-tweeting-service/internal/domain" + "app-cloudep-tweeting-service/internal/domain/repository" + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/stretchr/testify/assert" + "github.com/zeromicro/go-zero/core/stores/redis" +) + +func NewRepo() (*miniredis.Miniredis, repository.TimelineRepository, error) { + r1, err := miniredis.Run() + if err != nil { + return nil, nil, err + } + + newRedis, err := redis.NewRedis(redis.RedisConf{ + Host: r1.Addr(), + Type: redis.ClusterType, + Pass: "", + }) + if err != nil { + r1.Close() + return nil, nil, err + } + + c := config.Config{ + TimelineSetting: struct { + Expire int64 + MaxLength int64 + }{Expire: 86400, MaxLength: 1000}, + } + + timelineRepo := MustGenerateRepository(TimelineRepositoryParam{ + Config: c, + Redis: *newRedis, + }) + + return r1, timelineRepo, nil +} + +func TestAddPost(t *testing.T) { + tests := []struct { + name string + action func(t *testing.T, repo repository.TimelineRepository) error + expectErr bool + validate func(t *testing.T, r1 *miniredis.Miniredis) + }{ + { + name: "success", + action: func(t *testing.T, repo repository.TimelineRepository) error { + ctx := context.Background() + uid := "OOOOOOKJ" + + return repo.AddPost(ctx, repository.AddPostRequest{ + UID: uid, + PostItems: []repository.TimelineItem{ + {PostID: "post1", Score: 100}, + }, + }) + }, + expectErr: false, + validate: func(t *testing.T, r1 *miniredis.Miniredis) { + uid := "OOOOOOKJ" + key := domain.TimelineRedisKey.With(uid).ToString() + score, err := r1.ZScore(key, "post1") + assert.NoError(t, err) + assert.Equal(t, float64(100), score) + }, + }, + { + name: "timeout", + action: func(t *testing.T, repo repository.TimelineRepository) error { + ctx := context.Background() + timeoutCtx, cancel := context.WithTimeout(ctx, 1*time.Millisecond) + defer cancel() + time.Sleep(2 * time.Millisecond) + + uid := "OOOOOLK" + return repo.AddPost(timeoutCtx, repository.AddPostRequest{ + UID: uid, + PostItems: []repository.TimelineItem{ + {PostID: "post2", Score: 200}, + }, + }) + }, + expectErr: true, + }, + { + name: "Redis error on Zadd", + action: func(t *testing.T, repo repository.TimelineRepository) error { + r1, repo, err := NewRepo() + assert.NoError(t, err) + r1.Close() // 模拟 Redis 错误 + + ctx := context.Background() + uid := "OOOOOWE" + return repo.AddPost(ctx, repository.AddPostRequest{ + UID: uid, + PostItems: []repository.TimelineItem{ + {PostID: "post3", Score: 300}, + }, + }) + }, + expectErr: true, + }, + { + name: "duplicate Key", + action: func(t *testing.T, repo repository.TimelineRepository) error { + ctx := context.Background() + uid := "OOOOODUP" + + // 第一次插入 + err := repo.AddPost(ctx, repository.AddPostRequest{ + UID: uid, + PostItems: []repository.TimelineItem{ + {PostID: "post1", Score: 100}, + }, + }) + if err != nil { + return err + } + + // 第二次插入,使用相同的 PostID 但不同的 Score + return repo.AddPost(ctx, repository.AddPostRequest{ + UID: uid, + PostItems: []repository.TimelineItem{ + {PostID: "post1", Score: 200}, + }, + }) + }, + expectErr: false, + validate: func(t *testing.T, r1 *miniredis.Miniredis) { + uid := "OOOOODUP" + key := domain.TimelineRedisKey.With(uid).ToString() + score, err := r1.ZScore(key, "post1") + assert.NoError(t, err) + assert.Equal(t, float64(200), score) // 應該是第二次插入的分數 + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r1, repo, err := NewRepo() + assert.NoError(t, err) + defer r1.Close() + + err = tt.action(t, repo) + + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + if tt.validate != nil { + tt.validate(t, r1) + } + } + }) + } +} + +func TestFetchTimeline(t *testing.T) { + tests := []struct { + name string + action func(t *testing.T, repo repository.TimelineRepository) (repository.FetchTimelineResponse, error) + expectErr bool + validate func(t *testing.T, r1 *miniredis.Miniredis, resp *repository.FetchTimelineResponse) + }{ + { + name: "FetchTimeline - success", + action: func(t *testing.T, repo repository.TimelineRepository) (repository.FetchTimelineResponse, error) { + ctx := context.Background() + uid := "user123" + + _ = repo.AddPost(ctx, repository.AddPostRequest{ + UID: uid, + PostItems: []repository.TimelineItem{ + {PostID: "post1", Score: 200}, + {PostID: "post2", Score: 100}, + }, + }) + + return repo.FetchTimeline(ctx, repository.FetchTimelineRequest{ + UID: uid, + PageSize: 10, + PageIndex: 1, + }) + }, + expectErr: false, + validate: func(t *testing.T, r1 *miniredis.Miniredis, resp *repository.FetchTimelineResponse) { + assert.Equal(t, 2, len(resp.Items)) + assert.Equal(t, "post1", resp.Items[0].PostID) + assert.Equal(t, "post2", resp.Items[1].PostID) + }, + }, + { + name: "FetchTimeline - timeout", + action: func(t *testing.T, repo repository.TimelineRepository) (repository.FetchTimelineResponse, error) { + ctx := context.Background() + timeoutCtx, cancel := context.WithTimeout(ctx, 1*time.Millisecond) + defer cancel() + time.Sleep(2 * time.Millisecond) + + uid := "user123" + return repo.FetchTimeline(timeoutCtx, repository.FetchTimelineRequest{ + UID: uid, + PageSize: 10, + PageIndex: 1, + }) + }, + expectErr: true, + }, + { + name: "FetchTimeline - Redis error on ZrangebyscoreWithScoresCtx", + action: func(t *testing.T, repo repository.TimelineRepository) (repository.FetchTimelineResponse, error) { + r1, repo, err := NewRepo() + assert.NoError(t, err) + defer r1.Close() + + uid := "user123" + + _ = repo.AddPost(context.Background(), repository.AddPostRequest{ + UID: uid, + PostItems: []repository.TimelineItem{ + {PostID: "post3", Score: 300}, + }, + }) + + r1.Close() + + return repo.FetchTimeline(context.Background(), repository.FetchTimelineRequest{ + UID: uid, + PageSize: 10, + PageIndex: 1, + }) + }, + expectErr: true, + }, + { + name: "FetchTimeline - Redis error on ZcardCtx", + action: func(t *testing.T, repo repository.TimelineRepository) (repository.FetchTimelineResponse, error) { + r1, repo, err := NewRepo() + assert.NoError(t, err) + defer r1.Close() + + uid := "user123" + + _ = repo.AddPost(context.Background(), repository.AddPostRequest{ + UID: uid, + PostItems: []repository.TimelineItem{ + {PostID: "post4", Score: 400}, + }, + }) + + r1.Close() + + return repo.FetchTimeline(context.Background(), repository.FetchTimelineRequest{ + UID: uid, + PageSize: 10, + PageIndex: 1, + }) + }, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r1, repo, err := NewRepo() + assert.NoError(t, err) + defer r1.Close() + + resp, err := tt.action(t, repo) + + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + if tt.validate != nil { + tt.validate(t, r1, &resp) + } + } + }) + } +} + +func TestSetNoMoreDataFlag(t *testing.T) { + tests := []struct { + name string + action func(t *testing.T, repo repository.TimelineRepository) error + expectErr bool + validate func(t *testing.T, r1 *miniredis.Miniredis) + }{ + { + name: "SetNoMoreDataFlag - success", + action: func(t *testing.T, repo repository.TimelineRepository) error { + ctx := context.Background() + uid := "user123" + return repo.SetNoMoreDataFlag(ctx, uid) + }, + expectErr: false, + validate: func(t *testing.T, r1 *miniredis.Miniredis) { + uid := "user123" + key := domain.TimelineRedisKey.With(uid).ToString() + score, _ := r1.ZScore(key, domain.LastOfTimelineFlag) + assert.NotZero(t, score) + + // 驗證是否設定過期時間 + ttl := r1.TTL(key) + assert.Equal(t, time.Duration(86400)*time.Second, ttl) + }, + }, + { + name: "SetNoMoreDataFlag - Redis error on ZaddsCtx", + action: func(t *testing.T, repo repository.TimelineRepository) error { + r1, repo, err := NewRepo() + assert.NoError(t, err) + r1.Close() // 手動關閉,復現錯誤 + + ctx := context.Background() + uid := "user123" + return repo.SetNoMoreDataFlag(ctx, uid) + }, + expectErr: true, + }, + { + name: "SetNoMoreDataFlag - Redis error on ExpireCtx", + action: func(t *testing.T, repo repository.TimelineRepository) error { + r1, repo, err := NewRepo() + assert.NoError(t, err) + + ctx := context.Background() + uid := "user123" + + r1.Close() + + return repo.SetNoMoreDataFlag(ctx, uid) + }, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r1, repo, err := NewRepo() + assert.NoError(t, err) + defer r1.Close() + + err = tt.action(t, repo) + + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + if tt.validate != nil { + tt.validate(t, r1) + } + } + }) + } +} + +func TestHasNoMoreData(t *testing.T) { + tests := []struct { + name string + action func(t *testing.T, repo repository.TimelineRepository) (bool, error) + expectErr bool + expected bool + setup func(r1 *miniredis.Miniredis) + }{ + { + name: "HasNoMoreData - 標誌存在", + action: func(t *testing.T, repo repository.TimelineRepository) (bool, error) { + ctx := context.Background() + uid := "user123" + return repo.HasNoMoreData(ctx, uid) + }, + expectErr: false, + expected: true, + setup: func(r1 *miniredis.Miniredis) { + uid := "user123" + key := domain.TimelineRedisKey.With(uid).ToString() + _, _ = r1.ZAdd(key, float64(time.Now().UTC().Unix()), domain.LastOfTimelineFlag) + }, + }, + { + name: "HasNoMoreData - 標誌不存在", + action: func(t *testing.T, repo repository.TimelineRepository) (bool, error) { + ctx := context.Background() + uid := "user123" + return repo.HasNoMoreData(ctx, uid) + }, + expectErr: false, + expected: false, + setup: func(r1 *miniredis.Miniredis) {}, // 不設置標誌 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r1, repo, err := NewRepo() + assert.NoError(t, err) + defer r1.Close() + + tt.setup(r1) + + result, err := tt.action(t, repo) + + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +func TestClearNoMoreDataFlag(t *testing.T) { + tests := []struct { + name string + action func(t *testing.T, repo repository.TimelineRepository) error + expectErr bool + setup func(r1 *miniredis.Miniredis) + validate func(t *testing.T, r1 *miniredis.Miniredis) + }{ + { + name: "ClearNoMoreDataFlag - 成功清除標誌", + action: func(t *testing.T, repo repository.TimelineRepository) error { + ctx := context.Background() + uid := "user123" + return repo.ClearNoMoreDataFlag(ctx, uid) + }, + expectErr: false, + setup: func(r1 *miniredis.Miniredis) { + uid := "user123" + key := domain.TimelineRedisKey.With(uid).ToString() + _, err := r1.ZAdd(key, 100, domain.LastOfTimelineFlag) // 設置標誌 + assert.NoError(t, err) + }, + validate: func(t *testing.T, r1 *miniredis.Miniredis) { + uid := "user123" + key := domain.TimelineRedisKey.With(uid).ToString() + _, err := r1.ZScore(key, domain.LastOfTimelineFlag) + assert.Error(t, err) // 標誌應該已被移除 + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r1, repo, err := NewRepo() + assert.NoError(t, err) + defer r1.Close() + + tt.setup(r1) + + err = tt.action(t, repo) + + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + tt.validate(t, r1) + } + }) + } +} diff --git a/internal/server/socialnetworkservice/social_network_service_server.go b/internal/server/socialnetworkservice/social_network_service_server.go new file mode 100644 index 0000000..efb9003 --- /dev/null +++ b/internal/server/socialnetworkservice/social_network_service_server.go @@ -0,0 +1,59 @@ +// Code generated by goctl. DO NOT EDIT. +// Source: tweeting.proto + +package server + +import ( + "context" + + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + socialnetworkservicelogic "app-cloudep-tweeting-service/internal/logic/socialnetworkservice" + "app-cloudep-tweeting-service/internal/svc" +) + +type SocialNetworkServiceServer struct { + svcCtx *svc.ServiceContext + tweeting.UnimplementedSocialNetworkServiceServer +} + +func NewSocialNetworkServiceServer(svcCtx *svc.ServiceContext) *SocialNetworkServiceServer { + return &SocialNetworkServiceServer{ + svcCtx: svcCtx, + } +} + +// MarkFollowRelation 關注 +func (s *SocialNetworkServiceServer) MarkFollowRelation(ctx context.Context, in *tweeting.DoFollowerRelationReq) (*tweeting.OKResp, error) { + l := socialnetworkservicelogic.NewMarkFollowRelationLogic(ctx, s.svcCtx) + return l.MarkFollowRelation(in) +} + +// RemoveFollowRelation 取消關注 +func (s *SocialNetworkServiceServer) RemoveFollowRelation(ctx context.Context, in *tweeting.DoFollowerRelationReq) (*tweeting.OKResp, error) { + l := socialnetworkservicelogic.NewRemoveFollowRelationLogic(ctx, s.svcCtx) + return l.RemoveFollowRelation(in) +} + +// GetFollower 取得跟隨者名單 +func (s *SocialNetworkServiceServer) GetFollower(ctx context.Context, in *tweeting.FollowReq) (*tweeting.FollowResp, error) { + l := socialnetworkservicelogic.NewGetFollowerLogic(ctx, s.svcCtx) + return l.GetFollower(in) +} + +// GetFollowee 取得我跟隨的名單 +func (s *SocialNetworkServiceServer) GetFollowee(ctx context.Context, in *tweeting.FollowReq) (*tweeting.FollowResp, error) { + l := socialnetworkservicelogic.NewGetFolloweeLogic(ctx, s.svcCtx) + return l.GetFollowee(in) +} + +// GetFollowerCount 取得跟隨者數量 +func (s *SocialNetworkServiceServer) GetFollowerCount(ctx context.Context, in *tweeting.FollowCountReq) (*tweeting.FollowCountResp, error) { + l := socialnetworkservicelogic.NewGetFollowerCountLogic(ctx, s.svcCtx) + return l.GetFollowerCount(in) +} + +// GetFolloweeCount 取得我跟隨的數量 +func (s *SocialNetworkServiceServer) GetFolloweeCount(ctx context.Context, in *tweeting.FollowCountReq) (*tweeting.FollowCountResp, error) { + l := socialnetworkservicelogic.NewGetFolloweeCountLogic(ctx, s.svcCtx) + return l.GetFolloweeCount(in) +} diff --git a/internal/server/timelineservice/timeline_service_server.go b/internal/server/timelineservice/timeline_service_server.go new file mode 100644 index 0000000..2d2dc03 --- /dev/null +++ b/internal/server/timelineservice/timeline_service_server.go @@ -0,0 +1,53 @@ +// Code generated by goctl. DO NOT EDIT. +// Source: tweeting.proto + +package server + +import ( + "context" + + "app-cloudep-tweeting-service/gen_result/pb/tweeting" + timelineservicelogic "app-cloudep-tweeting-service/internal/logic/timelineservice" + "app-cloudep-tweeting-service/internal/svc" +) + +type TimelineServiceServer struct { + svcCtx *svc.ServiceContext + tweeting.UnimplementedTimelineServiceServer +} + +func NewTimelineServiceServer(svcCtx *svc.ServiceContext) *TimelineServiceServer { + return &TimelineServiceServer{ + svcCtx: svcCtx, + } +} + +// AddPost 加入貼文,只管一股腦全塞,這裡會自動判斷 +func (s *TimelineServiceServer) AddPost(ctx context.Context, in *tweeting.AddPostToTimelineReq) (*tweeting.OKResp, error) { + l := timelineservicelogic.NewAddPostLogic(ctx, s.svcCtx) + return l.AddPost(in) +} + +// FetchTimeline 取得這個人的動態時報 +func (s *TimelineServiceServer) FetchTimeline(ctx context.Context, in *tweeting.GetTimelineReq) (*tweeting.FetchTimelineResponse, error) { + l := timelineservicelogic.NewFetchTimelineLogic(ctx, s.svcCtx) + return l.FetchTimeline(in) +} + +// SetNoMoreDataFlag 標記時間線已完整,避免繼續查詢資料庫。 +func (s *TimelineServiceServer) SetNoMoreDataFlag(ctx context.Context, in *tweeting.DoNoMoreDataReq) (*tweeting.OKResp, error) { + l := timelineservicelogic.NewSetNoMoreDataFlagLogic(ctx, s.svcCtx) + return l.SetNoMoreDataFlag(in) +} + +// HasNoMoreData 檢查時間線是否已完整,決定是否需要查詢資料庫。 +func (s *TimelineServiceServer) HasNoMoreData(ctx context.Context, in *tweeting.DoNoMoreDataReq) (*tweeting.HasNoMoreDataResp, error) { + l := timelineservicelogic.NewHasNoMoreDataLogic(ctx, s.svcCtx) + return l.HasNoMoreData(in) +} + +// ClearNoMoreDataFlag 清除時間線的 "NoMoreData" 標誌。 +func (s *TimelineServiceServer) ClearNoMoreDataFlag(ctx context.Context, in *tweeting.DoNoMoreDataReq) (*tweeting.OKResp, error) { + l := timelineservicelogic.NewClearNoMoreDataFlagLogic(ctx, s.svcCtx) + return l.ClearNoMoreDataFlag(in) +} diff --git a/internal/svc/init_mongo.go b/internal/svc/init_mongo.go index d815d98..8f9bf1e 100644 --- a/internal/svc/init_mongo.go +++ b/internal/svc/init_mongo.go @@ -6,7 +6,7 @@ import ( "fmt" ) -func mustMongoConnectUrl(c config.Config) string { +func mustMongoConnectURL(c config.Config) string { return fmt.Sprintf("%s://%s:%s", c.Mongo.Schema, c.Mongo.Host, @@ -18,10 +18,12 @@ func mustMongoConnectUrl(c config.Config) string { func MustPostModel(c config.Config) model.PostModel { postCollection := model.Post{} - return model.NewPostModel(mustMongoConnectUrl(c), c.Mongo.Database, postCollection.CollectionName()) + + return model.NewPostModel(mustMongoConnectURL(c), c.Mongo.Database, postCollection.CollectionName()) } func MustCommentModel(c config.Config) model.CommentModel { m := model.Comment{} - return model.NewCommentModel(mustMongoConnectUrl(c), c.Mongo.Database, m.CollectionName()) + + return model.NewCommentModel(mustMongoConnectURL(c), c.Mongo.Database, m.CollectionName()) } diff --git a/internal/svc/service_context.go b/internal/svc/service_context.go index 6dff22c..0c63516 100644 --- a/internal/svc/service_context.go +++ b/internal/svc/service_context.go @@ -2,24 +2,52 @@ package svc import ( "app-cloudep-tweeting-service/internal/config" + domainRepo "app-cloudep-tweeting-service/internal/domain/repository" + "app-cloudep-tweeting-service/internal/lib/neo4j" model "app-cloudep-tweeting-service/internal/model/mongo" + "app-cloudep-tweeting-service/internal/repository" + + "github.com/zeromicro/go-zero/core/stores/redis" vi "code.30cm.net/digimon/library-go/validator" ) type ServiceContext struct { - Config config.Config - Validate vi.Validate - - PostModel model.PostModel - CommentModel model.CommentModel + Config config.Config + Validate vi.Validate + PostModel model.PostModel + CommentModel model.CommentModel + TimelineRepo domainRepo.TimelineRepository + SocialNetworkRepository domainRepo.SocialNetworkRepository } func NewServiceContext(c config.Config) *ServiceContext { + newRedis, err := redis.NewRedis(c.RedisCluster, redis.Cluster()) + if err != nil { + panic(err) + } + + neoClient := neo4j.NewNeo4J(&neo4j.Config{ + URI: c.Neo4J.URI, + Username: c.Neo4J.Username, + Password: c.Neo4J.Password, + MaxConnectionPoolSize: c.Neo4J.MaxConnectionPoolSize, + MaxConnectionLifetime: c.Neo4J.MaxConnectionLifetime, + ConnectionTimeout: c.Neo4J.ConnectionTimeout, + }, neo4j.WithPerformance(), neo4j.WithLogLevel(c.Neo4J.LogLevel)) + return &ServiceContext{ Config: c, Validate: vi.MustValidator(), PostModel: MustPostModel(c), CommentModel: MustCommentModel(c), + TimelineRepo: repository.MustGenerateRepository(repository.TimelineRepositoryParam{ + Config: c, + Redis: *newRedis, + }), + SocialNetworkRepository: repository.MustSocialNetworkRepository(repository.SocialNetworkParam{ + Config: c, + Neo4jClient: neoClient, + }), } } diff --git a/tweeting.go b/tweeting.go index c708b77..45b83b2 100644 --- a/tweeting.go +++ b/tweeting.go @@ -1,13 +1,15 @@ package main import ( - "flag" - "fmt" - "app-cloudep-tweeting-service/gen_result/pb/tweeting" "app-cloudep-tweeting-service/internal/config" + commentserviceServer "app-cloudep-tweeting-service/internal/server/commentservice" postserviceServer "app-cloudep-tweeting-service/internal/server/postservice" + socialnetworkserviceServer "app-cloudep-tweeting-service/internal/server/socialnetworkservice" + timelineserviceServer "app-cloudep-tweeting-service/internal/server/timelineservice" "app-cloudep-tweeting-service/internal/svc" + "flag" + "log" "github.com/zeromicro/go-zero/core/conf" "github.com/zeromicro/go-zero/core/service" @@ -27,6 +29,9 @@ func main() { s := zrpc.MustNewServer(c.RpcServerConf, func(grpcServer *grpc.Server) { tweeting.RegisterPostServiceServer(grpcServer, postserviceServer.NewPostServiceServer(ctx)) + tweeting.RegisterCommentServiceServer(grpcServer, commentserviceServer.NewCommentServiceServer(ctx)) + tweeting.RegisterTimelineServiceServer(grpcServer, timelineserviceServer.NewTimelineServiceServer(ctx)) + tweeting.RegisterSocialNetworkServiceServer(grpcServer, socialnetworkserviceServer.NewSocialNetworkServiceServer(ctx)) if c.Mode == service.DevMode || c.Mode == service.TestMode { reflection.Register(grpcServer) @@ -34,6 +39,6 @@ func main() { }) defer s.Stop() - fmt.Printf("Starting rpc server at %s...\n", c.ListenOn) + log.Printf("Starting rpc server at %s...\n", c.ListenOn) s.Start() }