From 9ef96429655079d87f91c0e9677285c37d4b4e19 Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 30 Dec 2025 09:17:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=EF=BC=8C=E4=BC=98=E5=8C=96=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E9=A2=84=E5=8A=A0=E8=BD=BD=E5=92=8C=E7=94=A8=E6=88=B7=E4=BA=92?= =?UTF-8?q?=E5=8A=A8=E5=8A=9F=E8=83=BD=EF=BC=9B=E5=A2=9E=E5=8A=A0=E7=A7=9F?= =?UTF-8?q?=E6=88=B7=E5=85=B3=E6=B3=A8=E5=8A=9F=E8=83=BD=E5=8F=8A=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GEMINI.md | 1 + backend/app/services/content.go | 289 +++++++++++++++++++++++---- backend/app/services/content_test.go | 101 ++++++++++ backend/app/services/tenant.go | 115 ++++++----- backend/app/services/tenant_test.go | 81 ++++++++ 5 files changed, 500 insertions(+), 87 deletions(-) create mode 120000 GEMINI.md create mode 100644 backend/app/services/tenant_test.go diff --git a/GEMINI.md b/GEMINI.md new file mode 120000 index 0000000..55bf822 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +./AGENTS.md \ No newline at end of file diff --git a/backend/app/services/content.go b/backend/app/services/content.go index afa51b5..f370f60 100644 --- a/backend/app/services/content.go +++ b/backend/app/services/content.go @@ -34,9 +34,6 @@ func (s *content) List(ctx context.Context, filter *content_dto.ContentListFilte q = q.Where(tbl.TenantID.Eq(tid)) } - // Preload Author - q = q.Preload(tbl.Author) - // Sort sort := "latest" if filter.Sort != nil && *filter.Sort != "" { @@ -59,7 +56,14 @@ func (s *content) List(ctx context.Context, filter *content_dto.ContentListFilte return nil, errorx.ErrDatabaseError.WithCause(err) } - list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find() + // Use UnderlyingDB for complex preloads + var list []*models.Content + err = q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)). + UnderlyingDB(). + Preload("Author"). + Preload("ContentAssets.Asset"). + Find(&list).Error + if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } @@ -82,7 +86,6 @@ func (s *content) Get(ctx context.Context, id string) (*content_dto.ContentDetai _, q := models.ContentQuery.QueryContext(ctx) var item models.Content - // Use UnderlyingDB for complex nested preloading err := q.UnderlyingDB(). Preload("Author"). Preload("ContentAssets", func(db *gorm.DB) *gorm.DB { @@ -91,6 +94,7 @@ func (s *content) Get(ctx context.Context, id string) (*content_dto.ContentDetai Preload("ContentAssets.Asset"). Where("id = ?", cid). First(&item).Error + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errorx.ErrRecordNotFound @@ -98,13 +102,21 @@ func (s *content) Get(ctx context.Context, id string) (*content_dto.ContentDetai return nil, errorx.ErrDatabaseError.WithCause(err) } - // Interaction status (isLiked, isFavorited) - userID := ctx.Value(consts.CtxKeyUser) + // Interaction status isLiked := false isFavorited := false - if userID != nil { - // uid := cast.ToInt64(userID) // Unused for now until interaction query implemented - // ... check likes ... + if userID := ctx.Value(consts.CtxKeyUser); userID != nil { + uid := cast.ToInt64(userID) + isLiked, _ = models.UserContentActionQuery.WithContext(ctx). + Where(models.UserContentActionQuery.UserID.Eq(uid), + models.UserContentActionQuery.ContentID.Eq(cid), + models.UserContentActionQuery.Type.Eq("like")). + Exists() + isFavorited, _ = models.UserContentActionQuery.WithContext(ctx). + Where(models.UserContentActionQuery.UserID.Eq(uid), + models.UserContentActionQuery.ContentID.Eq(cid), + models.UserContentActionQuery.Type.Eq("favorite")). + Exists() } detail := &content_dto.ContentDetail{ @@ -137,27 +149,43 @@ func (s *content) ListComments(ctx context.Context, id string, page int) (*reque return nil, errorx.ErrDatabaseError.WithCause(err) } + // User likes + likedMap := make(map[int64]bool) + if userID := ctx.Value(consts.CtxKeyUser); userID != nil { + uid := cast.ToInt64(userID) + ids := make([]int64, len(list)) + for i, v := range list { + ids[i] = v.ID + } + likes, _ := models.UserCommentActionQuery.WithContext(ctx). + Where(models.UserCommentActionQuery.UserID.Eq(uid), + models.UserCommentActionQuery.CommentID.In(ids...), + models.UserCommentActionQuery.Type.Eq("like")). + Find() + for _, l := range likes { + likedMap[l.CommentID] = true + } + } + data := make([]content_dto.Comment, len(list)) for i, v := range list { data[i] = content_dto.Comment{ ID: cast.ToString(v.ID), Content: v.Content, UserID: cast.ToString(v.UserID), - UserNickname: v.User.Nickname, // Preloaded + UserNickname: v.User.Nickname, UserAvatar: v.User.Avatar, CreateTime: v.CreatedAt.Format("2006-01-02 15:04:05"), Likes: int(v.Likes), ReplyTo: cast.ToString(v.ReplyTo), + IsLiked: likedMap[v.ID], } } return &requests.Pager{ - Pagination: requests.Pagination{ - Page: p.Page, - Limit: p.Limit, - }, - Total: total, - Items: data, + Pagination: requests.Pagination{Page: p.Page, Limit: p.Limit}, + Total: total, + Items: data, }, nil } @@ -189,38 +217,99 @@ func (s *content) CreateComment(ctx context.Context, id string, form *content_dt } func (s *content) LikeComment(ctx context.Context, id string) error { - return nil + userID := ctx.Value(consts.CtxKeyUser) + if userID == nil { + return errorx.ErrUnauthorized + } + uid := cast.ToInt64(userID) + cmid := cast.ToInt64(id) + + return models.Q.Transaction(func(tx *models.Query) error { + exists, _ := tx.UserCommentAction.WithContext(ctx). + Where(tx.UserCommentAction.UserID.Eq(uid), tx.UserCommentAction.CommentID.Eq(cmid), tx.UserCommentAction.Type.Eq("like")). + Exists() + if exists { + return nil + } + + action := &models.UserCommentAction{UserID: uid, CommentID: cmid, Type: "like"} + if err := tx.UserCommentAction.WithContext(ctx).Create(action); err != nil { + return err + } + + _, err := tx.Comment.WithContext(ctx).Where(tx.Comment.ID.Eq(cmid)).UpdateSimple(tx.Comment.Likes.Add(1)) + return err + }) } func (s *content) GetLibrary(ctx context.Context) ([]user_dto.ContentItem, error) { - return []user_dto.ContentItem{}, nil + userID := ctx.Value(consts.CtxKeyUser) + if userID == nil { + return nil, errorx.ErrUnauthorized + } + uid := cast.ToInt64(userID) + + tbl, q := models.ContentAccessQuery.QueryContext(ctx) + accessList, err := q.Where(tbl.UserID.Eq(uid), tbl.Status.Eq(consts.ContentAccessStatusActive)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + if len(accessList) == 0 { + return []user_dto.ContentItem{}, nil + } + + var contentIDs []int64 + for _, a := range accessList { + contentIDs = append(contentIDs, a.ContentID) + } + + ctbl, cq := models.ContentQuery.QueryContext(ctx) + var list []*models.Content + err = cq.Where(ctbl.ID.In(contentIDs...)). + UnderlyingDB(). + Preload("Author"). + Preload("ContentAssets.Asset"). + Find(&list).Error + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + var data []user_dto.ContentItem + for _, item := range list { + dto := s.toContentItemDTO(item) + dto.IsPurchased = true + data = append(data, dto) + } + return data, nil } func (s *content) GetFavorites(ctx context.Context) ([]user_dto.ContentItem, error) { - return []user_dto.ContentItem{}, nil + return s.getInteractList(ctx, "favorite") } func (s *content) AddFavorite(ctx context.Context, contentId string) error { - return nil + return s.addInteract(ctx, contentId, "favorite") } func (s *content) RemoveFavorite(ctx context.Context, contentId string) error { - return nil + return s.removeInteract(ctx, contentId, "favorite") } func (s *content) GetLikes(ctx context.Context) ([]user_dto.ContentItem, error) { - return []user_dto.ContentItem{}, nil + return s.getInteractList(ctx, "like") } func (s *content) AddLike(ctx context.Context, contentId string) error { - return nil + return s.addInteract(ctx, contentId, "like") } func (s *content) RemoveLike(ctx context.Context, contentId string) error { - return nil + return s.removeInteract(ctx, contentId, "like") } func (s *content) ListTopics(ctx context.Context) ([]content_dto.Topic, error) { + // topics usually hardcoded or from a dedicated table return []content_dto.Topic{}, nil } @@ -228,17 +317,55 @@ func (s *content) ListTopics(ctx context.Context) ([]content_dto.Topic, error) { func (s *content) toContentItemDTO(item *models.Content) content_dto.ContentItem { dto := content_dto.ContentItem{ - ID: cast.ToString(item.ID), - Title: item.Title, - Genre: item.Genre, - AuthorID: cast.ToString(item.UserID), - Views: int(item.Views), - Likes: int(item.Likes), + ID: cast.ToString(item.ID), + Title: item.Title, + Genre: item.Genre, + AuthorID: cast.ToString(item.UserID), + Views: int(item.Views), + Likes: int(item.Likes), } if item.Author != nil { dto.AuthorName = item.Author.Nickname dto.AuthorAvatar = item.Author.Avatar } + + // Determine Type and Cover from assets + var hasVideo, hasAudio bool + for _, asset := range item.ContentAssets { + if asset.Asset == nil { + continue + } + // Cover + if asset.Role == consts.ContentAssetRoleCover { + dto.Cover = "http://mock/" + asset.Asset.ObjectKey + } + // Type detection + switch asset.Asset.Type { + case consts.MediaAssetTypeVideo: + hasVideo = true + case consts.MediaAssetTypeAudio: + hasAudio = true + } + } + + // Fallback for cover if not explicitly set as cover role (take first image or whatever) + if dto.Cover == "" && len(item.ContentAssets) > 0 { + for _, asset := range item.ContentAssets { + if asset.Asset != nil && asset.Asset.Type == consts.MediaAssetTypeImage { + dto.Cover = "http://mock/" + asset.Asset.ObjectKey + break + } + } + } + + if hasVideo { + dto.Type = "video" + } else if hasAudio { + dto.Type = "audio" + } else { + dto.Type = "article" + } + return dto } @@ -246,14 +373,108 @@ func (s *content) toMediaURLs(assets []*models.ContentAsset) []content_dto.Media var urls []content_dto.MediaURL for _, ca := range assets { if ca.Asset != nil { - // Construct URL based on Asset info (Bucket/Key/Provider) - // For prototype: mock url url := "http://mock/" + ca.Asset.ObjectKey urls = append(urls, content_dto.MediaURL{ - Type: string(ca.Asset.Type), // Assuming type is enum or string + Type: string(ca.Asset.Type), URL: url, }) } } return urls } + +func (s *content) addInteract(ctx context.Context, contentId, typ string) error { + userID := ctx.Value(consts.CtxKeyUser) + if userID == nil { + return errorx.ErrUnauthorized + } + uid := cast.ToInt64(userID) + cid := cast.ToInt64(contentId) + + return models.Q.Transaction(func(tx *models.Query) error { + exists, _ := tx.UserContentAction.WithContext(ctx). + Where(tx.UserContentAction.UserID.Eq(uid), tx.UserContentAction.ContentID.Eq(cid), tx.UserContentAction.Type.Eq(typ)). + Exists() + if exists { + return nil + } + + action := &models.UserContentAction{UserID: uid, ContentID: cid, Type: typ} + if err := tx.UserContentAction.WithContext(ctx).Create(action); err != nil { + return err + } + + if typ == "like" { + _, err := tx.Content.WithContext(ctx).Where(tx.Content.ID.Eq(cid)).UpdateSimple(tx.Content.Likes.Add(1)) + return err + } + return nil + }) +} + +func (s *content) removeInteract(ctx context.Context, contentId, typ string) error { + userID := ctx.Value(consts.CtxKeyUser) + if userID == nil { + return errorx.ErrUnauthorized + } + uid := cast.ToInt64(userID) + cid := cast.ToInt64(contentId) + + return models.Q.Transaction(func(tx *models.Query) error { + res, err := tx.UserContentAction.WithContext(ctx). + Where(tx.UserContentAction.UserID.Eq(uid), tx.UserContentAction.ContentID.Eq(cid), tx.UserContentAction.Type.Eq(typ)). + Delete() + if err != nil { + return err + } + if res.RowsAffected == 0 { + return nil + } + + if typ == "like" { + _, err := tx.Content.WithContext(ctx).Where(tx.Content.ID.Eq(cid)).UpdateSimple(tx.Content.Likes.Sub(1)) + return err + } + return nil + }) +} + +func (s *content) getInteractList(ctx context.Context, typ string) ([]user_dto.ContentItem, error) { + userID := ctx.Value(consts.CtxKeyUser) + if userID == nil { + return nil, errorx.ErrUnauthorized + } + uid := cast.ToInt64(userID) + + tbl, q := models.UserContentActionQuery.QueryContext(ctx) + actions, err := q.Where(tbl.UserID.Eq(uid), tbl.Type.Eq(typ)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + if len(actions) == 0 { + return []user_dto.ContentItem{}, nil + } + + var contentIDs []int64 + for _, a := range actions { + contentIDs = append(contentIDs, a.ContentID) + } + + ctbl, cq := models.ContentQuery.QueryContext(ctx) + var list []*models.Content + err = cq.Where(ctbl.ID.In(contentIDs...)). + UnderlyingDB(). + Preload("Author"). + Preload("ContentAssets.Asset"). + Find(&list).Error + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + var data []user_dto.ContentItem + for _, item := range list { + data = append(data, s.toContentItemDTO(item)) + } + return data, nil +} \ No newline at end of file diff --git a/backend/app/services/content_test.go b/backend/app/services/content_test.go index 7d3c05f..53ef0d0 100644 --- a/backend/app/services/content_test.go +++ b/backend/app/services/content_test.go @@ -158,3 +158,104 @@ func (s *ContentTestSuite) Test_CreateComment() { }) }) } + +func (s *ContentTestSuite) Test_Library() { + Convey("Library", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameContent, models.TableNameContentAccess, models.TableNameUser, models.TableNameContentAsset, models.TableNameMediaAsset) + + // User + u := &models.User{Username: "user_lib", Phone: "13900000002"} + models.UserQuery.WithContext(ctx).Create(u) + ctx = context.WithValue(ctx, consts.CtxKeyUser, u.ID) + + // Content + c := &models.Content{TenantID: 1, UserID: u.ID, Title: "Paid Content", Genre: "video"} + models.ContentQuery.WithContext(ctx).Create(c) + + // Asset (Video & Cover) + assetVid := &models.MediaAsset{TenantID: 1, UserID: u.ID, Type: consts.MediaAssetTypeVideo, ObjectKey: "video.mp4"} + assetImg := &models.MediaAsset{TenantID: 1, UserID: u.ID, Type: consts.MediaAssetTypeImage, ObjectKey: "cover.jpg"} + models.MediaAssetQuery.WithContext(ctx).Create(assetVid, assetImg) + + models.ContentAssetQuery.WithContext(ctx).Create( + &models.ContentAsset{ContentID: c.ID, AssetID: assetVid.ID, Role: consts.ContentAssetRoleMain}, + &models.ContentAsset{ContentID: c.ID, AssetID: assetImg.ID, Role: consts.ContentAssetRoleCover}, + ) + + // Access + models.ContentAccessQuery.WithContext(ctx).Create(&models.ContentAccess{ + TenantID: 1, UserID: u.ID, ContentID: c.ID, Status: "active", + }) + + Convey("should get library content with details", func() { + list, err := Content.GetLibrary(ctx) + So(err, ShouldBeNil) + So(len(list), ShouldEqual, 1) + So(list[0].Title, ShouldEqual, "Paid Content") + So(list[0].Type, ShouldEqual, "video") + So(list[0].Cover, ShouldEndWith, "cover.jpg") + So(list[0].IsPurchased, ShouldBeTrue) + }) + }) +} + +func (s *ContentTestSuite) Test_Interact() { + Convey("Interact", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameContent, models.TableNameUserContentAction, models.TableNameUser) + + // User & Content + u := &models.User{Username: "user_act", Phone: "13900000003"} + models.UserQuery.WithContext(ctx).Create(u) + c := &models.Content{TenantID: 1, UserID: u.ID, Title: "Liked Content", Likes: 0} + models.ContentQuery.WithContext(ctx).Create(c) + + ctx = context.WithValue(ctx, consts.CtxKeyUser, u.ID) + + Convey("Like flow", func() { + // Add Like + err := Content.AddLike(ctx, cast.ToString(c.ID)) + So(err, ShouldBeNil) + + // Verify count + cReload, _ := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(c.ID)).First() + So(cReload.Likes, ShouldEqual, 1) + + // Get Likes + likes, err := Content.GetLikes(ctx) + So(err, ShouldBeNil) + So(len(likes), ShouldEqual, 1) + So(likes[0].ID, ShouldEqual, cast.ToString(c.ID)) + + // Remove Like + err = Content.RemoveLike(ctx, cast.ToString(c.ID)) + So(err, ShouldBeNil) + + // Verify count + cReload, _ = models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(c.ID)).First() + So(cReload.Likes, ShouldEqual, 0) + }) + + Convey("Favorite flow", func() { + // Add Favorite + err := Content.AddFavorite(ctx, cast.ToString(c.ID)) + So(err, ShouldBeNil) + + // Get Favorites + favs, err := Content.GetFavorites(ctx) + So(err, ShouldBeNil) + So(len(favs), ShouldEqual, 1) + So(favs[0].ID, ShouldEqual, cast.ToString(c.ID)) + + // Remove Favorite + err = Content.RemoveFavorite(ctx, cast.ToString(c.ID)) + So(err, ShouldBeNil) + + // Get Favorites + favs, err = Content.GetFavorites(ctx) + So(err, ShouldBeNil) + So(len(favs), ShouldEqual, 0) + }) + }) +} diff --git a/backend/app/services/tenant.go b/backend/app/services/tenant.go index 602734d..e4c9929 100644 --- a/backend/app/services/tenant.go +++ b/backend/app/services/tenant.go @@ -5,7 +5,7 @@ import ( "errors" "quyun/v2/app/errorx" - tenant_dto "quyun/v2/app/http/v1/dto" + "quyun/v2/app/http/v1/dto" "quyun/v2/database/models" "quyun/v2/pkg/consts" @@ -17,21 +17,9 @@ import ( // @provider type tenant struct{} -func (s *tenant) GetPublicProfile(ctx context.Context, id string) (*tenant_dto.TenantProfile, error) { - // id could be Code or ID. Try Code first, then ID. - tbl, q := models.TenantQuery.QueryContext(ctx) - - // Try to find by code or ID - var t *models.Tenant - var err error - - // Assume id is ID for simplicity if numeric, or try both. - if cast.ToInt64(id) > 0 { - t, err = q.Where(tbl.ID.Eq(cast.ToInt64(id))).First() - } else { - t, err = q.Where(tbl.Code.Eq(id)).First() - } - +func (s *tenant) GetPublicProfile(ctx context.Context, id string) (*dto.TenantProfile, error) { + tid := cast.ToInt64(id) + t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(tid)).First() if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errorx.ErrRecordNotFound @@ -40,39 +28,25 @@ func (s *tenant) GetPublicProfile(ctx context.Context, id string) (*tenant_dto.T } // Stats - // Followers - followers, _ := models.TenantUserQuery.WithContext(ctx).Where(models.TenantUserQuery.TenantID.Eq(t.ID)).Count() - // Contents - contentsCount, _ := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.TenantID.Eq(t.ID), models.ContentQuery.Status.Eq(consts.ContentStatusPublished)).Count() - // Likes - var likes int64 - // Sum content likes - // Mock likes for now or fetch + followers, _ := models.TenantUserQuery.WithContext(ctx).Where(models.TenantUserQuery.TenantID.Eq(tid)).Count() + contents, _ := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.TenantID.Eq(tid), models.ContentQuery.Status.Eq(consts.ContentStatusPublished)).Count() - // IsFollowing + // Following status isFollowing := false - userID := ctx.Value(consts.CtxKeyUser) - if userID != nil { + if userID := ctx.Value(consts.CtxKeyUser); userID != nil { uid := cast.ToInt64(userID) - count, _ := models.TenantUserQuery.WithContext(ctx).Where(models.TenantUserQuery.TenantID.Eq(t.ID), models.TenantUserQuery.UserID.Eq(uid)).Count() - isFollowing = count > 0 + isFollowing, _ = models.TenantUserQuery.WithContext(ctx). + Where(models.TenantUserQuery.TenantID.Eq(tid), models.TenantUserQuery.UserID.Eq(uid)). + Exists() } - // Config parsing (Unused for now as we don't map to bio yet) - // config := t.Config.Data() - - return &tenant_dto.TenantProfile{ - ID: cast.ToString(t.ID), - Name: t.Name, - Avatar: "", // From config - Cover: "", // From config - Bio: "", // From config - Description: "", // From config - CertType: "personal", // Mock - Stats: tenant_dto.Stats{ + return &dto.TenantProfile{ + ID: cast.ToString(t.ID), + Name: t.Name, + Avatar: "", // Extract from config if available + Stats: dto.Stats{ Followers: int(followers), - Contents: int(contentsCount), - Likes: int(likes), + Contents: int(contents), }, IsFollowing: isFollowing, }, nil @@ -87,12 +61,10 @@ func (s *tenant) Follow(ctx context.Context, id string) error { tid := cast.ToInt64(id) // Check if tenant exists - _, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(tid)).First() - if err != nil { + if _, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(tid)).First(); err != nil { return errorx.ErrRecordNotFound } - // Add to tenant_users tu := &models.TenantUser{ TenantID: tid, UserID: uid, @@ -100,12 +72,7 @@ func (s *tenant) Follow(ctx context.Context, id string) error { Status: consts.UserStatusVerified, } - count, _ := models.TenantUserQuery.WithContext(ctx).Where(models.TenantUserQuery.TenantID.Eq(tid), models.TenantUserQuery.UserID.Eq(uid)).Count() - if count > 0 { - return nil // Already following - } - - if err := models.TenantUserQuery.WithContext(ctx).Create(tu); err != nil { + if err := models.TenantUserQuery.WithContext(ctx).Save(tu); err != nil { return errorx.ErrDatabaseError.WithCause(err) } return nil @@ -119,9 +86,51 @@ func (s *tenant) Unfollow(ctx context.Context, id string) error { uid := cast.ToInt64(userID) tid := cast.ToInt64(id) - _, err := models.TenantUserQuery.WithContext(ctx).Where(models.TenantUserQuery.TenantID.Eq(tid), models.TenantUserQuery.UserID.Eq(uid)).Delete() + _, err := models.TenantUserQuery.WithContext(ctx). + Where(models.TenantUserQuery.TenantID.Eq(tid), models.TenantUserQuery.UserID.Eq(uid)). + Delete() if err != nil { return errorx.ErrDatabaseError.WithCause(err) } return nil } + +func (s *tenant) ListFollowed(ctx context.Context) ([]dto.TenantProfile, error) { + userID := ctx.Value(consts.CtxKeyUser) + if userID == nil { + return nil, errorx.ErrUnauthorized + } + uid := cast.ToInt64(userID) + + tbl, q := models.TenantUserQuery.QueryContext(ctx) + list, err := q.Where(tbl.UserID.Eq(uid)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + var data []dto.TenantProfile + for _, tu := range list { + // Fetch Tenant + t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(tu.TenantID)).First() + if err != nil { + continue + } + + // Stats + followers, _ := models.TenantUserQuery.WithContext(ctx).Where(models.TenantUserQuery.TenantID.Eq(tu.TenantID)).Count() + contents, _ := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.TenantID.Eq(tu.TenantID), models.ContentQuery.Status.Eq(consts.ContentStatusPublished)).Count() + + data = append(data, dto.TenantProfile{ + ID: cast.ToString(t.ID), + Name: t.Name, + Avatar: "", + Stats: dto.Stats{ + Followers: int(followers), + Contents: int(contents), + }, + IsFollowing: true, + }) + } + + return data, nil +} diff --git a/backend/app/services/tenant_test.go b/backend/app/services/tenant_test.go new file mode 100644 index 0000000..48bb764 --- /dev/null +++ b/backend/app/services/tenant_test.go @@ -0,0 +1,81 @@ +package services + +import ( + "context" + "database/sql" + "testing" + + "quyun/v2/app/commands/testx" + "quyun/v2/database" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" + + . "github.com/smartystreets/goconvey/convey" + "github.com/spf13/cast" + "github.com/stretchr/testify/suite" + "go.ipao.vip/atom/contracts" + "go.uber.org/dig" +) + +type TenantTestSuiteInjectParams struct { + dig.In + + DB *sql.DB + Initials []contracts.Initial `group:"initials"` +} + +type TenantTestSuite struct { + suite.Suite + TenantTestSuiteInjectParams +} + +func Test_Tenant(t *testing.T) { + providers := testx.Default().With(Provide) + + testx.Serve(providers, t, func(p TenantTestSuiteInjectParams) { + suite.Run(t, &TenantTestSuite{TenantTestSuiteInjectParams: p}) + }) +} + +func (s *TenantTestSuite) Test_Follow() { + Convey("Follow Flow", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameTenant, models.TableNameTenantUser, models.TableNameUser) + + // User + u := &models.User{Username: "user_f", Phone: "13900000004"} + models.UserQuery.WithContext(ctx).Create(u) + ctx = context.WithValue(ctx, consts.CtxKeyUser, u.ID) + + // Tenant + t := &models.Tenant{Name: "Tenant A", Status: consts.TenantStatusVerified} + models.TenantQuery.WithContext(ctx).Create(t) + + Convey("should follow tenant", func() { + err := Tenant.Follow(ctx, cast.ToString(t.ID)) + So(err, ShouldBeNil) + + // Verify stats + profile, err := Tenant.GetPublicProfile(ctx, cast.ToString(t.ID)) + So(err, ShouldBeNil) + So(profile.IsFollowing, ShouldBeTrue) + So(profile.Stats.Followers, ShouldEqual, 1) + + // List Followed + list, err := Tenant.ListFollowed(ctx) + So(err, ShouldBeNil) + So(len(list), ShouldEqual, 1) + So(list[0].Name, ShouldEqual, "Tenant A") + + // Unfollow + err = Tenant.Unfollow(ctx, cast.ToString(t.ID)) + So(err, ShouldBeNil) + + // Verify + profile, err = Tenant.GetPublicProfile(ctx, cast.ToString(t.ID)) + So(err, ShouldBeNil) + So(profile.IsFollowing, ShouldBeFalse) + So(profile.Stats.Followers, ShouldEqual, 0) + }) + }) +}