feature/fanout #3
			
				
			
		
		
		
	
							
								
								
									
										6
									
								
								Makefile
								
								
								
								
							
							
						
						
									
										6
									
								
								Makefile
								
								
								
								
							|  | @ -51,9 +51,9 @@ gen-mongo-model: # 建立 rpc 資料庫 | ||||||
| 	# 只產生 Model 剩下的要自己撰寫,連欄位名稱也是 | 	# 只產生 Model 剩下的要自己撰寫,連欄位名稱也是 | ||||||
| 	goctl model mongo -t post --dir ./internal/model/mongo --style $(GO_ZERO_STYLE) | 	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 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 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 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 comment_likes --dir ./internal/model/mongo --style $(GO_ZERO_STYLE) | ||||||
| 	@echo "Generate mongo model files successfully" | 	@echo "Generate mongo model files successfully" | ||||||
| 
 | 
 | ||||||
| .PHONY: mock-gen | .PHONY: mock-gen | ||||||
|  |  | ||||||
|  | @ -11,4 +11,8 @@ Mongo: | ||||||
|   User: "" |   User: "" | ||||||
|   Password: "" |   Password: "" | ||||||
|   Port: "27017" |   Port: "27017" | ||||||
|   Database: digimon_tweeting |   Database: digimon_tweeting | ||||||
|  | 
 | ||||||
|  | TimelineSetting: | ||||||
|  |   Expire: 86400 | ||||||
|  |   MaxLength: 1000 | ||||||
|  | @ -201,8 +201,17 @@ message GetTimelineResp | ||||||
| message AddPostToTimelineReq | message AddPostToTimelineReq | ||||||
| { | { | ||||||
|   string uid = 1; // key |   string uid = 1; // key | ||||||
|   repeated PostDetailItem posts = 2; |   repeated PostTimelineItem posts = 3; | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // 貼文更新 | ||||||
|  | message PostTimelineItem | ||||||
|  | { | ||||||
|  |   string post_id = 1;       // 貼文ID | ||||||
|  |   int64 created_at = 7;     // 發佈時間 -> 排序使用 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| // TimelineService 業務邏輯在外面組合 | // TimelineService 業務邏輯在外面組合 | ||||||
| service TimelineService | service TimelineService | ||||||
| { | { | ||||||
|  |  | ||||||
							
								
								
									
										3
									
								
								go.mod
								
								
								
								
							
							
						
						
									
										3
									
								
								go.mod
								
								
								
								
							|  | @ -5,6 +5,7 @@ go 1.22.3 | ||||||
| require ( | require ( | ||||||
| 	code.30cm.net/digimon/library-go/errs v1.2.4 | 	code.30cm.net/digimon/library-go/errs v1.2.4 | ||||||
| 	code.30cm.net/digimon/library-go/validator v1.0.0 | 	code.30cm.net/digimon/library-go/validator v1.0.0 | ||||||
|  | 	github.com/alicebob/miniredis/v2 v2.33.0 | ||||||
| 	github.com/stretchr/testify v1.9.0 | 	github.com/stretchr/testify v1.9.0 | ||||||
| 	github.com/zeromicro/go-zero v1.7.0 | 	github.com/zeromicro/go-zero v1.7.0 | ||||||
| 	go.mongodb.org/mongo-driver v1.16.0 | 	go.mongodb.org/mongo-driver v1.16.0 | ||||||
|  | @ -14,6 +15,7 @@ require ( | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| require ( | require ( | ||||||
|  | 	github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect | ||||||
| 	github.com/beorn7/perks v1.0.1 // indirect | 	github.com/beorn7/perks v1.0.1 // indirect | ||||||
| 	github.com/cenkalti/backoff/v4 v4.3.0 // indirect | 	github.com/cenkalti/backoff/v4 v4.3.0 // indirect | ||||||
| 	github.com/cespare/xxhash/v2 v2.3.0 // indirect | 	github.com/cespare/xxhash/v2 v2.3.0 // indirect | ||||||
|  | @ -65,6 +67,7 @@ require ( | ||||||
| 	github.com/xdg-go/scram v1.1.2 // indirect | 	github.com/xdg-go/scram v1.1.2 // indirect | ||||||
| 	github.com/xdg-go/stringprep v1.0.4 // indirect | 	github.com/xdg-go/stringprep v1.0.4 // indirect | ||||||
| 	github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a // indirect | 	github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a // indirect | ||||||
|  | 	github.com/yuin/gopher-lua v1.1.1 // indirect | ||||||
| 	go.etcd.io/etcd/api/v3 v3.5.15 // 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/pkg/v3 v3.5.15 // indirect | ||||||
| 	go.etcd.io/etcd/client/v3 v3.5.15 // indirect | 	go.etcd.io/etcd/client/v3 v3.5.15 // indirect | ||||||
|  |  | ||||||
|  | @ -4,6 +4,7 @@ import "github.com/zeromicro/go-zero/zrpc" | ||||||
| 
 | 
 | ||||||
| type Config struct { | type Config struct { | ||||||
| 	zrpc.RpcServerConf | 	zrpc.RpcServerConf | ||||||
|  | 
 | ||||||
| 	Mongo struct { | 	Mongo struct { | ||||||
| 		Schema   string | 		Schema   string | ||||||
| 		User     string | 		User     string | ||||||
|  | @ -12,4 +13,9 @@ type Config struct { | ||||||
| 		Port     string | 		Port     string | ||||||
| 		Database string | 		Database string | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	TimelineSetting struct { | ||||||
|  | 		Expire    int64 // Second
 | ||||||
|  | 		MaxLength int64 // 暫存筆數
 | ||||||
|  | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -11,3 +11,7 @@ const ( | ||||||
| 	AdTypeOnlyAd | 	AdTypeOnlyAd | ||||||
| 	AdTypeOnlyNotAd | 	AdTypeOnlyNotAd | ||||||
| ) | ) | ||||||
|  | 
 | ||||||
|  | const ( | ||||||
|  | 	LastOfTimelineFlag = "NoMoreData" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | @ -33,6 +33,10 @@ const ( | ||||||
| 	CommentListErrorCode | 	CommentListErrorCode | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | const ( | ||||||
|  | 	TimeLineErrorCode ErrorCode = iota + 20 | ||||||
|  | ) | ||||||
|  | 
 | ||||||
| func CommentError(ec ErrorCode, s ...string) *ers.LibError { | func CommentError(ec ErrorCode, s ...string) *ers.LibError { | ||||||
| 	return ers.NewError(code.CloudEPTweeting, code.DBError, | 	return ers.NewError(code.CloudEPTweeting, code.DBError, | ||||||
| 		ec.ToUint32(), | 		ec.ToUint32(), | ||||||
|  |  | ||||||
|  | @ -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" | ||||||
|  | ) | ||||||
|  | @ -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") | ||||||
|  | } | ||||||
|  | @ -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 | ||||||
|  | } | ||||||
|  | @ -0,0 +1,143 @@ | ||||||
|  | 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 MustGenerateUseCase(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 | ||||||
|  | } | ||||||
|  | @ -0,0 +1,439 @@ | ||||||
|  | package repository | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"app-cloudep-tweeting-service/internal/config" | ||||||
|  | 	"app-cloudep-tweeting-service/internal/domain" | ||||||
|  | 	"app-cloudep-tweeting-service/internal/domain/repository" | ||||||
|  | 	"context" | ||||||
|  | 	"github.com/alicebob/miniredis/v2" | ||||||
|  | 	"github.com/stretchr/testify/assert" | ||||||
|  | 	"github.com/zeromicro/go-zero/core/stores/redis" | ||||||
|  | 	"testing" | ||||||
|  | 	"time" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 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 := MustGenerateUseCase(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, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	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) | ||||||
|  | 			} | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | @ -7,7 +7,7 @@ import ( | ||||||
| 	"context" | 	"context" | ||||||
| 
 | 
 | ||||||
| 	"app-cloudep-tweeting-service/gen_result/pb/tweeting" | 	"app-cloudep-tweeting-service/gen_result/pb/tweeting" | ||||||
| 	"app-cloudep-tweeting-service/internal/logic/commentservice" | 	commentservicelogic "app-cloudep-tweeting-service/internal/logic/commentservice" | ||||||
| 	"app-cloudep-tweeting-service/internal/svc" | 	"app-cloudep-tweeting-service/internal/svc" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -7,7 +7,7 @@ import ( | ||||||
| 	"context" | 	"context" | ||||||
| 
 | 
 | ||||||
| 	"app-cloudep-tweeting-service/gen_result/pb/tweeting" | 	"app-cloudep-tweeting-service/gen_result/pb/tweeting" | ||||||
| 	"app-cloudep-tweeting-service/internal/logic/postservice" | 	postservicelogic "app-cloudep-tweeting-service/internal/logic/postservice" | ||||||
| 	"app-cloudep-tweeting-service/internal/svc" | 	"app-cloudep-tweeting-service/internal/svc" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -7,7 +7,7 @@ import ( | ||||||
| 	"context" | 	"context" | ||||||
| 
 | 
 | ||||||
| 	"app-cloudep-tweeting-service/gen_result/pb/tweeting" | 	"app-cloudep-tweeting-service/gen_result/pb/tweeting" | ||||||
| 	"app-cloudep-tweeting-service/internal/logic/timelineservice" | 	timelineservicelogic "app-cloudep-tweeting-service/internal/logic/timelineservice" | ||||||
| 	"app-cloudep-tweeting-service/internal/svc" | 	"app-cloudep-tweeting-service/internal/svc" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue