add timeline service

This commit is contained in:
daniel.w 2024-09-01 21:49:28 +08:00
parent c9a0926495
commit d82f5c3337
15 changed files with 741 additions and 8 deletions

View File

@ -51,9 +51,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

View File

@ -12,3 +12,7 @@ Mongo:
Password: ""
Port: "27017"
Database: digimon_tweeting
TimelineSetting:
Expire: 86400
MaxLength: 1000

View File

@ -201,8 +201,17 @@ message GetTimelineResp
message AddPostToTimelineReq
{
string uid = 1; // key
repeated PostDetailItem posts = 2;
repeated PostTimelineItem posts = 3;
}
//
message PostTimelineItem
{
string post_id = 1; // ID
int64 created_at = 7; // -> 使
}
// TimelineService
service TimelineService
{

3
go.mod
View File

@ -5,6 +5,7 @@ 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/stretchr/testify v1.9.0
github.com/zeromicro/go-zero v1.7.0
go.mongodb.org/mongo-driver v1.16.0
@ -14,6 +15,7 @@ require (
)
require (
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
@ -65,6 +67,7 @@ require (
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
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

View File

@ -4,6 +4,7 @@ import "github.com/zeromicro/go-zero/zrpc"
type Config struct {
zrpc.RpcServerConf
Mongo struct {
Schema string
User string
@ -12,4 +13,9 @@ type Config struct {
Port string
Database string
}
TimelineSetting struct {
Expire int64 // Second
MaxLength int64 // 暫存筆數
}
}

View File

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

View File

@ -33,6 +33,10 @@ const (
CommentListErrorCode
)
const (
TimeLineErrorCode ErrorCode = iota + 20
)
func CommentError(ec ErrorCode, s ...string) *ers.LibError {
return ers.NewError(code.CloudEPTweeting, code.DBError,
ec.ToUint32(),

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

View File

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

View File

@ -7,7 +7,7 @@ import (
"context"
"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"
)

View File

@ -7,7 +7,7 @@ import (
"context"
"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"
)

View File

@ -7,7 +7,7 @@ import (
"context"
"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"
)