feature/fanout #3

Merged
daniel.w merged 11 commits from feature/fanout into main 2024-09-03 11:45:06 +00:00
59 changed files with 4240 additions and 36 deletions

View File

@ -117,6 +117,14 @@ issues:
- gocognit
- contextcheck
exclude-dirs:
- internal/model
exclude-files:
- .*_test.go
linters-settings:
gci:
sections:

View File

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

View File

@ -12,3 +12,20 @@ Mongo:
Password: ""
Port: "27017"
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

View File

@ -1,5 +1,2 @@
use digimon_tweeting;
db.comment.createIndex({ "post_id": 1,"createAt":1});
// TODO 看是否有要刪除過多的索引,要在測試一下

View File

@ -0,0 +1,5 @@
// 企業版才能用,社群版只能用預設的
CREATE DATABASE relation;
// 創建 User 節點 UID 是唯一鍵
CREATE CONSTRAINT FOR (u:User) REQUIRE u.uid IS UNIQUE

View File

@ -182,3 +182,117 @@ service CommentService
// 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);
}

37
go.mod
View File

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

View File

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

View File

@ -11,3 +11,7 @@ const (
AdTypeOnlyAd
AdTypeOnlyNotAd
)
const (
LastOfTimelineFlag = "NoMoreData"
)

View File

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

19
internal/domain/redis.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
package repository

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,12 @@ 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"
)
@ -10,16 +15,39 @@ import (
type ServiceContext struct {
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,
}),
}
}

View File

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