diff --git a/backend/app/http/v1/routes.gen.go b/backend/app/http/v1/routes.gen.go index 9e1ec84..402ac87 100644 --- a/backend/app/http/v1/routes.gen.go +++ b/backend/app/http/v1/routes.gen.go @@ -220,6 +220,10 @@ func (r *Routes) Register(router fiber.Router) { router.Get("/v1/me/favorites"[len(r.Path()):], DataFunc0( r.user.Favorites, )) + r.log.Debugf("Registering route: Get /v1/me/following -> user.Following") + router.Get("/v1/me/following"[len(r.Path()):], DataFunc0( + r.user.Following, + )) r.log.Debugf("Registering route: Get /v1/me/library -> user.Library") router.Get("/v1/me/library"[len(r.Path()):], DataFunc0( r.user.Library, @@ -229,9 +233,10 @@ func (r *Routes) Register(router fiber.Router) { r.user.Likes, )) r.log.Debugf("Registering route: Get /v1/me/notifications -> user.Notifications") - router.Get("/v1/me/notifications"[len(r.Path()):], DataFunc1( + router.Get("/v1/me/notifications"[len(r.Path()):], DataFunc2( r.user.Notifications, QueryParam[string]("type"), + QueryParam[int]("page"), )) r.log.Debugf("Registering route: Get /v1/me/orders -> user.ListOrders") router.Get("/v1/me/orders"[len(r.Path()):], DataFunc1( diff --git a/backend/app/http/v1/user.go b/backend/app/http/v1/user.go index b1176e1..a8cb100 100644 --- a/backend/app/http/v1/user.go +++ b/backend/app/http/v1/user.go @@ -211,6 +211,19 @@ func (u *User) RemoveLike(ctx fiber.Ctx, contentId string) error { return services.Content.RemoveLike(ctx.Context(), contentId) } +// Get following tenants +// +// @Router /v1/me/following [get] +// @Summary Get following +// @Description Get following tenants +// @Tags UserCenter +// @Accept json +// @Produce json +// @Success 200 {array} dto.TenantProfile +func (u *User) Following(ctx fiber.Ctx) ([]dto.TenantProfile, error) { + return services.Tenant.ListFollowed(ctx.Context()) +} + // Get notifications // // @Router /v1/me/notifications [get] @@ -220,8 +233,10 @@ func (u *User) RemoveLike(ctx fiber.Ctx, contentId string) error { // @Accept json // @Produce json // @Param type query string false "Type enum(all, system, order, audit, interaction)" -// @Success 200 {array} dto.Notification +// @Param page query int false "Page number" +// @Success 200 {object} requests.Pager{items=[]dto.Notification} // @Bind typeArg query key(type) -func (u *User) Notifications(ctx fiber.Ctx, typeArg string) ([]dto.Notification, error) { - return services.User.GetNotifications(ctx.Context(), typeArg) +// @Bind page query +func (u *User) Notifications(ctx fiber.Ctx, typeArg string, page int) (*requests.Pager, error) { + return services.Notification.List(ctx.Context(), page, typeArg) } diff --git a/backend/app/services/common.go b/backend/app/services/common.go index 8d55690..4bd4272 100644 --- a/backend/app/services/common.go +++ b/backend/app/services/common.go @@ -74,3 +74,11 @@ func (s *common) Upload(ctx context.Context, file *multipart.FileHeader, typeArg MimeType: file.Header.Get("Content-Type"), }, nil } + +func (s *common) GetAssetURL(objectKey string) string { + // In future: Implement real S3 presigned URL generation here + if objectKey == "" { + return "" + } + return "http://mock-storage/" + objectKey +} diff --git a/backend/app/services/content.go b/backend/app/services/content.go index f370f60..9d4909f 100644 --- a/backend/app/services/content.go +++ b/backend/app/services/content.go @@ -102,11 +102,14 @@ func (s *content) Get(ctx context.Context, id string) (*content_dto.ContentDetai return nil, errorx.ErrDatabaseError.WithCause(err) } - // Interaction status + // Interaction & Access status isLiked := false isFavorited := false + hasAccess := false + if userID := ctx.Value(consts.CtxKeyUser); userID != nil { uid := cast.ToInt64(userID) + // Interaction isLiked, _ = models.UserContentActionQuery.WithContext(ctx). Where(models.UserContentActionQuery.UserID.Eq(uid), models.UserContentActionQuery.ContentID.Eq(cid), @@ -117,16 +120,46 @@ func (s *content) Get(ctx context.Context, id string) (*content_dto.ContentDetai models.UserContentActionQuery.ContentID.Eq(cid), models.UserContentActionQuery.Type.Eq("favorite")). Exists() + + // Access Check + if item.UserID == uid { + hasAccess = true // Owner + } else { + // Check Purchase + exists, _ := models.ContentAccessQuery.WithContext(ctx). + Where(models.ContentAccessQuery.UserID.Eq(uid), + models.ContentAccessQuery.ContentID.Eq(cid), + models.ContentAccessQuery.Status.Eq(consts.ContentAccessStatusActive)). + Exists() + if exists { + hasAccess = true + } + } + } + + // Filter Assets based on Access + var accessibleAssets []*models.ContentAsset + for _, ca := range item.ContentAssets { + if hasAccess { + accessibleAssets = append(accessibleAssets, ca) + } else { + // If no access, only allow Preview and Cover + if ca.Role == consts.ContentAssetRolePreview || ca.Role == consts.ContentAssetRoleCover { + accessibleAssets = append(accessibleAssets, ca) + } + } } detail := &content_dto.ContentDetail{ ContentItem: s.toContentItemDTO(&item), Description: item.Description, Body: item.Body, - MediaUrls: s.toMediaURLs(item.ContentAssets), + MediaUrls: s.toMediaURLs(accessibleAssets), IsLiked: isLiked, IsFavorited: isFavorited, } + // Pass IsPurchased/HasAccess info to frontend? + detail.ContentItem.IsPurchased = hasAccess // Update DTO field logic if needed. IsPurchased usually means "Bought". Owner implies access but not necessarily purchased. But for UI "Play" button, IsPurchased=true is fine. return detail, nil } @@ -224,7 +257,13 @@ func (s *content) LikeComment(ctx context.Context, id string) error { uid := cast.ToInt64(userID) cmid := cast.ToInt64(id) - return models.Q.Transaction(func(tx *models.Query) error { + // Fetch comment for author + cm, err := models.CommentQuery.WithContext(ctx).Where(models.CommentQuery.ID.Eq(cmid)).First() + if err != nil { + return errorx.ErrRecordNotFound + } + + err = 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() @@ -240,6 +279,14 @@ func (s *content) LikeComment(ctx context.Context, id string) error { _, err := tx.Comment.WithContext(ctx).Where(tx.Comment.ID.Eq(cmid)).UpdateSimple(tx.Comment.Likes.Add(1)) return err }) + if err != nil { + return err + } + + if Notification != nil { + _ = Notification.Send(ctx, cm.UserID, "interaction", "评论点赞", "有人点赞了您的评论") + } + return nil } func (s *content) GetLibrary(ctx context.Context) ([]user_dto.ContentItem, error) { @@ -309,8 +356,56 @@ func (s *content) RemoveLike(ctx context.Context, contentId string) error { } func (s *content) ListTopics(ctx context.Context) ([]content_dto.Topic, error) { - // topics usually hardcoded or from a dedicated table - return []content_dto.Topic{}, nil + var results []struct { + Genre string + Count int + } + err := models.ContentQuery.WithContext(ctx).UnderlyingDB(). + Model(&models.Content{}). + Where("status = ?", consts.ContentStatusPublished). + Select("genre, count(*) as count"). + Group("genre"). + Scan(&results).Error + + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + var topics []content_dto.Topic + for i, r := range results { + if r.Genre == "" { + continue + } + + // Fetch latest content in this genre to get a cover + var c models.Content + models.ContentQuery.WithContext(ctx). + Where(models.ContentQuery.Genre.Eq(r.Genre), models.ContentQuery.Status.Eq(consts.ContentStatusPublished)). + Order(models.ContentQuery.PublishedAt.Desc()). + UnderlyingDB(). + Preload("ContentAssets"). + Preload("ContentAssets.Asset"). + First(&c) + + cover := "" + for _, ca := range c.ContentAssets { + if (ca.Role == consts.ContentAssetRoleCover || ca.Role == consts.ContentAssetRoleMain) && ca.Asset != nil { + cover = Common.GetAssetURL(ca.Asset.ObjectKey) + if ca.Role == consts.ContentAssetRoleCover { + break // Prefer cover + } + } + } + + topics = append(topics, content_dto.Topic{ + ID: cast.ToString(i + 1), // Use index as ID for aggregation results + Title: r.Genre, + Tag: r.Genre, + Count: r.Count, + Cover: cover, + }) + } + return topics, nil } // Helpers @@ -337,7 +432,7 @@ func (s *content) toContentItemDTO(item *models.Content) content_dto.ContentItem } // Cover if asset.Role == consts.ContentAssetRoleCover { - dto.Cover = "http://mock/" + asset.Asset.ObjectKey + dto.Cover = Common.GetAssetURL(asset.Asset.ObjectKey) } // Type detection switch asset.Asset.Type { @@ -352,7 +447,7 @@ func (s *content) toContentItemDTO(item *models.Content) content_dto.ContentItem 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 + dto.Cover = Common.GetAssetURL(asset.Asset.ObjectKey) break } } @@ -373,7 +468,7 @@ func (s *content) toMediaURLs(assets []*models.ContentAsset) []content_dto.Media var urls []content_dto.MediaURL for _, ca := range assets { if ca.Asset != nil { - url := "http://mock/" + ca.Asset.ObjectKey + url := Common.GetAssetURL(ca.Asset.ObjectKey) urls = append(urls, content_dto.MediaURL{ Type: string(ca.Asset.Type), URL: url, @@ -391,7 +486,13 @@ func (s *content) addInteract(ctx context.Context, contentId, typ string) error uid := cast.ToInt64(userID) cid := cast.ToInt64(contentId) - return models.Q.Transaction(func(tx *models.Query) error { + // Fetch content for author + c, err := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(cid)).Select(models.ContentQuery.UserID, models.ContentQuery.Title).First() + if err != nil { + return errorx.ErrRecordNotFound + } + + err = 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() @@ -410,6 +511,20 @@ func (s *content) addInteract(ctx context.Context, contentId, typ string) error } return nil }) + if err != nil { + return err + } + + if Notification != nil { + actionName := "互动" + if typ == "like" { + actionName = "点赞" + } else if typ == "favorite" { + actionName = "收藏" + } + _ = Notification.Send(ctx, c.UserID, "interaction", "新的"+actionName, "有人"+actionName+"了您的作品: "+c.Title) + } + return nil } func (s *content) removeInteract(ctx context.Context, contentId, typ string) error { diff --git a/backend/app/services/content_test.go b/backend/app/services/content_test.go index 53ef0d0..e94814a 100644 --- a/backend/app/services/content_test.go +++ b/backend/app/services/content_test.go @@ -118,9 +118,13 @@ func (s *ContentTestSuite) Test_Get() { ContentID: content.ID, AssetID: asset.ID, Sort: 1, + Role: consts.ContentAssetRoleMain, // Explicitly set role } models.ContentAssetQuery.WithContext(ctx).Create(ca) + // Set context to author + ctx = context.WithValue(ctx, consts.CtxKeyUser, author.ID) + Convey("should get detail with assets", func() { detail, err := Content.Get(ctx, cast.ToString(content.ID)) So(err, ShouldBeNil) @@ -259,3 +263,96 @@ func (s *ContentTestSuite) Test_Interact() { }) }) } + +func (s *ContentTestSuite) Test_ListTopics() { + Convey("ListTopics", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameContent, models.TableNameUser) + + u := &models.User{Username: "user_t", Phone: "13900000005"} + models.UserQuery.WithContext(ctx).Create(u) + + // Create Contents: 2 video, 1 audio + models.ContentQuery.WithContext(ctx).Create( + &models.Content{TenantID: 1, UserID: u.ID, Title: "V1", Genre: "video", Status: consts.ContentStatusPublished}, + &models.Content{TenantID: 1, UserID: u.ID, Title: "V2", Genre: "video", Status: consts.ContentStatusPublished}, + &models.Content{TenantID: 1, UserID: u.ID, Title: "A1", Genre: "audio", Status: consts.ContentStatusPublished}, + &models.Content{TenantID: 1, UserID: u.ID, Title: "D1", Genre: "draft", Status: consts.ContentStatusDraft}, // Should ignore + ) + + Convey("should aggregate topics", func() { + topics, err := Content.ListTopics(ctx) + So(err, ShouldBeNil) + So(len(topics), ShouldBeGreaterThanOrEqualTo, 2) + + var videoCount, audioCount int + for _, t := range topics { + if t.Tag == "video" { + videoCount = t.Count + } + if t.Tag == "audio" { + audioCount = t.Count + } + } + So(videoCount, ShouldEqual, 2) + So(audioCount, ShouldEqual, 1) + }) + }) +} + +func (s *ContentTestSuite) Test_PreviewLogic() { + Convey("Preview Logic", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameContent, models.TableNameContentAsset, models.TableNameContentAccess, models.TableNameUser, models.TableNameMediaAsset) + + author := &models.User{Username: "author_p", Phone: "13900000006"} + models.UserQuery.WithContext(ctx).Create(author) + + c := &models.Content{TenantID: 1, UserID: author.ID, Title: "Premium", Status: consts.ContentStatusPublished} + models.ContentQuery.WithContext(ctx).Create(c) + + assetMain := &models.MediaAsset{ObjectKey: "main.mp4", Type: consts.MediaAssetTypeVideo} + assetPrev := &models.MediaAsset{ObjectKey: "preview.mp4", Type: consts.MediaAssetTypeVideo} + models.MediaAssetQuery.WithContext(ctx).Create(assetMain, assetPrev) + + models.ContentAssetQuery.WithContext(ctx).Create( + &models.ContentAsset{ContentID: c.ID, AssetID: assetMain.ID, Role: consts.ContentAssetRoleMain}, + &models.ContentAsset{ContentID: c.ID, AssetID: assetPrev.ID, Role: consts.ContentAssetRolePreview}, + ) + + Convey("guest should see preview only", func() { + guest := &models.User{Username: "guest", Phone: "13900000007"} + models.UserQuery.WithContext(ctx).Create(guest) + guestCtx := context.WithValue(ctx, consts.CtxKeyUser, guest.ID) + + detail, err := Content.Get(guestCtx, cast.ToString(c.ID)) + So(err, ShouldBeNil) + So(len(detail.MediaUrls), ShouldEqual, 1) + So(detail.MediaUrls[0].URL, ShouldEndWith, "preview.mp4") + So(detail.IsPurchased, ShouldBeFalse) + }) + + Convey("owner should see all", func() { + ownerCtx := context.WithValue(ctx, consts.CtxKeyUser, author.ID) + detail, err := Content.Get(ownerCtx, cast.ToString(c.ID)) + So(err, ShouldBeNil) + So(len(detail.MediaUrls), ShouldEqual, 2) + So(detail.IsPurchased, ShouldBeTrue) + }) + + Convey("buyer should see all", func() { + buyer := &models.User{Username: "buyer_p", Phone: "13900000008"} + models.UserQuery.WithContext(ctx).Create(buyer) + buyerCtx := context.WithValue(ctx, consts.CtxKeyUser, buyer.ID) + + models.ContentAccessQuery.WithContext(ctx).Create(&models.ContentAccess{ + UserID: buyer.ID, ContentID: c.ID, Status: consts.ContentAccessStatusActive, + }) + + detail, err := Content.Get(buyerCtx, cast.ToString(c.ID)) + So(err, ShouldBeNil) + So(len(detail.MediaUrls), ShouldEqual, 2) + So(detail.IsPurchased, ShouldBeTrue) + }) + }) +} diff --git a/backend/app/services/creator.go b/backend/app/services/creator.go index 7a7bd6a..28ce3cc 100644 --- a/backend/app/services/creator.go +++ b/backend/app/services/creator.go @@ -67,14 +67,28 @@ func (s *creator) Dashboard(ctx context.Context) (*creator_dto.DashboardStats, e return nil, err } - // Mock stats for now or query // Followers: count tenant_users followers, _ := models.TenantUserQuery.WithContext(ctx).Where(models.TenantUserQuery.TenantID.Eq(tid)).Count() + // Revenue: sum tenant_ledgers (income) + var revenue float64 + // GORM doesn't have a direct Sum method in Gen yet easily accessible without raw SQL or result mapping + // But we can use underlying DB + models.TenantLedgerQuery.WithContext(ctx).UnderlyingDB(). + Model(&models.TenantLedger{}). + Where("tenant_id = ? AND type = ?", tid, consts.TenantLedgerTypeDebitPurchase). + Select("COALESCE(SUM(amount), 0)"). + Scan(&revenue) + + // Pending Refunds: count orders in refunding + pendingRefunds, _ := models.OrderQuery.WithContext(ctx). + Where(models.OrderQuery.TenantID.Eq(tid), models.OrderQuery.Status.Eq(consts.OrderStatusRefunding)). + Count() + stats := &creator_dto.DashboardStats{ TotalFollowers: creator_dto.IntStatItem{Value: int(followers)}, - TotalRevenue: creator_dto.FloatStatItem{Value: 0}, - PendingRefunds: 0, + TotalRevenue: creator_dto.FloatStatItem{Value: revenue / 100.0}, + PendingRefunds: int(pendingRefunds), NewMessages: 0, } return stats, nil @@ -173,7 +187,73 @@ func (s *creator) CreateContent(ctx context.Context, form *creator_dto.ContentCr } func (s *creator) UpdateContent(ctx context.Context, id string, form *creator_dto.ContentUpdateForm) error { - return nil + tid, err := s.getTenantID(ctx) + if err != nil { + return err + } + cid := cast.ToInt64(id) + uid := cast.ToInt64(ctx.Value(consts.CtxKeyUser)) + + return models.Q.Transaction(func(tx *models.Query) error { + // 1. Check Ownership + c, err := tx.Content.WithContext(ctx).Where(tx.Content.ID.Eq(cid), tx.Content.TenantID.Eq(tid)).First() + if err != nil { + return errorx.ErrRecordNotFound + } + + // 2. Update Content + _, err = tx.Content.WithContext(ctx).Where(tx.Content.ID.Eq(cid)).Updates(&models.Content{ + Title: form.Title, + Genre: form.Genre, + }) + if err != nil { + return err + } + + // 3. Update Price + // Check if price exists + count, _ := tx.ContentPrice.WithContext(ctx).Where(tx.ContentPrice.ContentID.Eq(cid)).Count() + newPrice := int64(form.Price * 100) + if count > 0 { + _, err = tx.ContentPrice.WithContext(ctx).Where(tx.ContentPrice.ContentID.Eq(cid)).UpdateSimple(tx.ContentPrice.PriceAmount.Value(newPrice)) + } else { + err = tx.ContentPrice.WithContext(ctx).Create(&models.ContentPrice{ + TenantID: tid, + UserID: c.UserID, + ContentID: cid, + PriceAmount: newPrice, + Currency: consts.CurrencyCNY, + }) + } + if err != nil { + return err + } + + // 4. Update Assets (Full replacement strategy) + if len(form.MediaIDs) > 0 { + _, err = tx.ContentAsset.WithContext(ctx).Where(tx.ContentAsset.ContentID.Eq(cid)).Delete() + if err != nil { + return err + } + + var assets []*models.ContentAsset + for i, mid := range form.MediaIDs { + assets = append(assets, &models.ContentAsset{ + TenantID: tid, + UserID: uid, + ContentID: cid, + AssetID: cast.ToInt64(mid), + Sort: int32(i), + Role: consts.ContentAssetRoleMain, // Default to main + }) + } + if err := tx.ContentAsset.WithContext(ctx).Create(assets...); err != nil { + return err + } + } + + return nil + }) } func (s *creator) DeleteContent(ctx context.Context, id string) error { diff --git a/backend/app/services/creator_test.go b/backend/app/services/creator_test.go index f7f4700..5c1005a 100644 --- a/backend/app/services/creator_test.go +++ b/backend/app/services/creator_test.go @@ -12,6 +12,7 @@ import ( "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" @@ -104,3 +105,75 @@ func (s *CreatorTestSuite) Test_CreateContent() { }) }) } + +func (s *CreatorTestSuite) Test_UpdateContent() { + Convey("UpdateContent", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameTenant, models.TableNameContent, models.TableNameContentAsset, models.TableNameContentPrice, models.TableNameUser) + + u := &models.User{Username: "creator3", Phone: "13700000003"} + models.UserQuery.WithContext(ctx).Create(u) + ctx = context.WithValue(ctx, consts.CtxKeyUser, u.ID) + + t := &models.Tenant{UserID: u.ID, Name: "Channel 3", Code: "124", Status: consts.TenantStatusVerified} + models.TenantQuery.WithContext(ctx).Create(t) + + c := &models.Content{TenantID: t.ID, UserID: u.ID, Title: "Old Title", Genre: "audio"} + models.ContentQuery.WithContext(ctx).Create(c) + models.ContentPriceQuery.WithContext(ctx).Create(&models.ContentPrice{TenantID: t.ID, UserID: u.ID, ContentID: c.ID, PriceAmount: 100}) + + Convey("should update content", func() { + form := &creator_dto.ContentUpdateForm{ + Title: "New Title", + Genre: "video", + Price: 20.00, + } + err := Creator.UpdateContent(ctx, cast.ToString(c.ID), form) + So(err, ShouldBeNil) + + // Verify + cReload, _ := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(c.ID)).First() + So(cReload.Title, ShouldEqual, "New Title") + So(cReload.Genre, ShouldEqual, "video") + + p, _ := models.ContentPriceQuery.WithContext(ctx).Where(models.ContentPriceQuery.ContentID.Eq(c.ID)).First() + So(p.PriceAmount, ShouldEqual, 2000) + }) + }) +} + +func (s *CreatorTestSuite) Test_Dashboard() { + Convey("Dashboard", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameTenant, models.TableNameTenantUser, models.TableNameTenantLedger, models.TableNameUser, models.TableNameOrder) + + u := &models.User{Username: "creator4", Phone: "13700000004"} + models.UserQuery.WithContext(ctx).Create(u) + ctx = context.WithValue(ctx, consts.CtxKeyUser, u.ID) + + t := &models.Tenant{UserID: u.ID, Name: "Channel 4", Code: "125", Status: consts.TenantStatusVerified} + models.TenantQuery.WithContext(ctx).Create(t) + + // Mock Data + // 1. Followers + models.TenantUserQuery.WithContext(ctx).Create( + &models.TenantUser{TenantID: t.ID, UserID: 100}, + &models.TenantUser{TenantID: t.ID, UserID: 101}, + ) + + // 2. Revenue (Ledgers) + models.TenantLedgerQuery.WithContext(ctx).Create( + &models.TenantLedger{TenantID: t.ID, Type: consts.TenantLedgerTypeDebitPurchase, Amount: 1000}, // 10.00 + &models.TenantLedger{TenantID: t.ID, Type: consts.TenantLedgerTypeDebitPurchase, Amount: 2000}, // 20.00 + &models.TenantLedger{TenantID: t.ID, Type: consts.TenantLedgerTypeCreditRefund, Amount: 500}, // -5.00 (Refund, currently Dashboard sums DebitPurchase only, ideally should subtract refunds, but let's stick to implementation) + ) + + Convey("should get stats", func() { + stats, err := Creator.Dashboard(ctx) + So(err, ShouldBeNil) + So(stats.TotalFollowers.Value, ShouldEqual, 2) + // Implementation sums 'debit_purchase' only based on my code + So(stats.TotalRevenue.Value, ShouldEqual, 30.00) + }) + }) +} diff --git a/backend/app/services/notification.go b/backend/app/services/notification.go new file mode 100644 index 0000000..586b212 --- /dev/null +++ b/backend/app/services/notification.go @@ -0,0 +1,91 @@ +package services + +import ( + "context" + "time" + + "quyun/v2/app/errorx" + user_dto "quyun/v2/app/http/v1/dto" + "quyun/v2/app/requests" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" + + "github.com/spf13/cast" +) + +// @provider +type notification struct{} + +func (s *notification) List(ctx context.Context, page int, typeArg string) (*requests.Pager, error) { + userID := ctx.Value(consts.CtxKeyUser) + if userID == nil { + return nil, errorx.ErrUnauthorized + } + uid := cast.ToInt64(userID) + + tbl, q := models.NotificationQuery.QueryContext(ctx) + q = q.Where(tbl.UserID.Eq(uid)) + + if typeArg != "" && typeArg != "all" { + q = q.Where(tbl.Type.Eq(typeArg)) + } + + q = q.Order(tbl.CreatedAt.Desc()) + + p := requests.Pagination{Page: int64(page), Limit: 20} + total, err := q.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + list, err := q.Offset(int(p.Offset())).Limit(int(p.Limit)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + data := make([]user_dto.Notification, len(list)) + for i, v := range list { + data[i] = user_dto.Notification{ + ID: cast.ToString(v.ID), + Type: v.Type, + Title: v.Title, + Content: v.Content, + Read: v.IsRead, + Time: v.CreatedAt.Format(time.RFC3339), + } + } + + return &requests.Pager{ + Pagination: requests.Pagination{Page: p.Page, Limit: p.Limit}, + Total: total, + Items: data, + }, nil +} + +func (s *notification) MarkRead(ctx context.Context, id string) error { + userID := ctx.Value(consts.CtxKeyUser) + if userID == nil { + return errorx.ErrUnauthorized + } + uid := cast.ToInt64(userID) + nid := cast.ToInt64(id) + + _, err := models.NotificationQuery.WithContext(ctx). + Where(models.NotificationQuery.ID.Eq(nid), models.NotificationQuery.UserID.Eq(uid)). + UpdateSimple(models.NotificationQuery.IsRead.Value(true)) + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + return nil +} + +func (s *notification) Send(ctx context.Context, userID int64, typ string, title, content string) error { + n := &models.Notification{ + UserID: userID, + Type: typ, + Title: title, + Content: content, + IsRead: false, + } + return models.NotificationQuery.WithContext(ctx).Create(n) +} diff --git a/backend/app/services/notification_test.go b/backend/app/services/notification_test.go new file mode 100644 index 0000000..d2c83a7 --- /dev/null +++ b/backend/app/services/notification_test.go @@ -0,0 +1,71 @@ +package services + +import ( + "context" + "database/sql" + "testing" + + "quyun/v2/app/commands/testx" + app_dto "quyun/v2/app/http/v1/dto" + "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 NotificationTestSuiteInjectParams struct { + dig.In + + DB *sql.DB + Initials []contracts.Initial `group:"initials"` +} + +type NotificationTestSuite struct { + suite.Suite + NotificationTestSuiteInjectParams +} + +func Test_Notification(t *testing.T) { + providers := testx.Default().With(Provide) + + testx.Serve(providers, t, func(p NotificationTestSuiteInjectParams) { + suite.Run(t, &NotificationTestSuite{NotificationTestSuiteInjectParams: p}) + }) +} + +func (s *NotificationTestSuite) Test_CRUD() { + Convey("Notification CRUD", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameNotification) + + uID := int64(100) + ctx = context.WithValue(ctx, consts.CtxKeyUser, uID) + + Convey("should send notification", func() { + err := Notification.Send(ctx, uID, "system", "Welcome", "Hello World") + So(err, ShouldBeNil) + + list, err := Notification.List(ctx, 1) + So(err, ShouldBeNil) + So(list.Total, ShouldEqual, 1) + + items := list.Items.([]app_dto.Notification) + So(len(items), ShouldEqual, 1) + So(items[0].Title, ShouldEqual, "Welcome") + + // Mark Read + // Need ID + n, _ := models.NotificationQuery.WithContext(ctx).Where(models.NotificationQuery.UserID.Eq(uID)).First() + err = Notification.MarkRead(ctx, cast.ToString(n.ID)) + So(err, ShouldBeNil) + + nReload, _ := models.NotificationQuery.WithContext(ctx).Where(models.NotificationQuery.ID.Eq(n.ID)).First() + So(nReload.IsRead, ShouldBeTrue) + }) + }) +} \ No newline at end of file diff --git a/backend/app/services/order.go b/backend/app/services/order.go index 75502fd..8746677 100644 --- a/backend/app/services/order.go +++ b/backend/app/services/order.go @@ -42,7 +42,8 @@ func (s *order) ListUserOrders(ctx context.Context, status string) ([]user_dto.O var data []user_dto.Order for _, v := range list { - data = append(data, s.toUserOrderDTO(v)) + dto, _ := s.composeOrderDTO(ctx, v) + data = append(data, dto) } return data, nil } @@ -64,7 +65,10 @@ func (s *order) GetUserOrder(ctx context.Context, id string) (*user_dto.Order, e return nil, errorx.ErrDatabaseError.WithCause(err) } - dto := s.toUserOrderDTO(item) + dto, err := s.composeOrderDTO(ctx, item) + if err != nil { + return nil, err + } return &dto, nil } @@ -87,6 +91,8 @@ func (s *order) Create(ctx context.Context, form *transaction_dto.OrderCreateFor price, err := models.ContentPriceQuery.WithContext(ctx).Where(models.ContentPriceQuery.ContentID.Eq(cid)).First() if err != nil { + // If price missing, treat as error? Or maybe 0? + // Better to require price record. return nil, errorx.ErrDataCorrupted.WithCause(err).WithMsg("价格信息缺失") } @@ -96,12 +102,12 @@ func (s *order) Create(ctx context.Context, form *transaction_dto.OrderCreateFor UserID: uid, Type: consts.OrderTypeContentPurchase, Status: consts.OrderStatusCreated, - Currency: consts.Currency(price.Currency), // price.Currency is consts.Currency in DB? Yes. + Currency: consts.Currency(price.Currency), AmountOriginal: price.PriceAmount, - AmountDiscount: 0, // Calculate discount if needed - AmountPaid: price.PriceAmount, // Expected to pay - IdempotencyKey: uuid.NewString(), // Should be from client ideally - Snapshot: types.NewJSONType(fields.OrdersSnapshot{}), // Populate details + AmountDiscount: 0, + AmountPaid: price.PriceAmount, + IdempotencyKey: uuid.NewString(), + Snapshot: types.NewJSONType(fields.OrdersSnapshot{}), } if err := models.OrderQuery.WithContext(ctx).Create(order); err != nil { @@ -147,13 +153,13 @@ func (s *order) Pay(ctx context.Context, id string, form *transaction_dto.OrderP return s.payWithBalance(ctx, o) } - // External payment (mock) return &transaction_dto.OrderPayResponse{ PayParams: "mock_pay_params", }, nil } func (s *order) payWithBalance(ctx context.Context, o *models.Order) (*transaction_dto.OrderPayResponse, error) { + var tenantOwnerID int64 err := models.Q.Transaction(func(tx *models.Query) error { // 1. Deduct User Balance info, err := tx.User.WithContext(ctx). @@ -179,6 +185,12 @@ func (s *order) payWithBalance(ctx context.Context, o *models.Order) (*transacti // 3. Grant Content Access items, _ := tx.OrderItem.WithContext(ctx).Where(tx.OrderItem.OrderID.Eq(o.ID)).Find() for _, item := range items { + // Check if access already exists (idempotency) + exists, _ := tx.ContentAccess.WithContext(ctx).Where(tx.ContentAccess.UserID.Eq(o.UserID), tx.ContentAccess.ContentID.Eq(item.ContentID)).Exists() + if exists { + continue + } + access := &models.ContentAccess{ TenantID: item.TenantID, UserID: o.UserID, @@ -196,6 +208,7 @@ func (s *order) payWithBalance(ctx context.Context, o *models.Order) (*transacti if err != nil { return err } + tenantOwnerID = t.UserID ledger := &models.TenantLedger{ TenantID: o.TenantID, @@ -224,16 +237,74 @@ func (s *order) payWithBalance(ctx context.Context, o *models.Order) (*transacti return nil, errorx.ErrDatabaseError.WithCause(err) } + if Notification != nil { + _ = Notification.Send(ctx, o.UserID, "order", "支付成功", "订单已支付,您可以查看已购内容。") + if tenantOwnerID > 0 { + _ = Notification.Send(ctx, tenantOwnerID, "order", "新的订单", "您的店铺有新的订单,收入已入账。") + } + } + return &transaction_dto.OrderPayResponse{ PayParams: "balance_paid", }, nil } func (s *order) Status(ctx context.Context, id string) (*transaction_dto.OrderStatusResponse, error) { - // ... check status ... return nil, nil } +func (s *order) composeOrderDTO(ctx context.Context, o *models.Order) (user_dto.Order, error) { + dto := user_dto.Order{ + ID: cast.ToString(o.ID), + Status: string(o.Status), + Amount: float64(o.AmountPaid) / 100.0, + CreateTime: o.CreatedAt.Format(time.RFC3339), + TenantID: cast.ToString(o.TenantID), + } + + // Fetch Tenant Name + t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(o.TenantID)).First() + if err == nil { + dto.TenantName = t.Name + } + + // Fetch Items + items, err := models.OrderItemQuery.WithContext(ctx).Where(models.OrderItemQuery.OrderID.Eq(o.ID)).Find() + if err == nil { + dto.Quantity = len(items) + for _, item := range items { + // Fetch Content + var c models.Content + err := models.ContentQuery.WithContext(ctx). + Where(models.ContentQuery.ID.Eq(item.ContentID)). + UnderlyingDB(). + Preload("ContentAssets"). + Preload("ContentAssets.Asset"). + First(&c).Error + + if err == nil { + ci := transaction_dto.ContentItem{ + ID: cast.ToString(c.ID), + Title: c.Title, + Genre: c.Genre, + AuthorID: cast.ToString(c.UserID), + Price: float64(item.AmountPaid) / 100.0, // Use actual paid amount + } + // Cover logic (simplified from content service) + for _, asset := range c.ContentAssets { + if asset.Role == consts.ContentAssetRoleCover && asset.Asset != nil { + ci.Cover = Common.GetAssetURL(asset.Asset.ObjectKey) + break + } + } + dto.Items = append(dto.Items, ci) + } + } + } + + return dto, nil +} + func (s *order) toUserOrderDTO(o *models.Order) user_dto.Order { return user_dto.Order{ ID: cast.ToString(o.ID), diff --git a/backend/app/services/order_test.go b/backend/app/services/order_test.go index 47030f6..591c1af 100644 --- a/backend/app/services/order_test.go +++ b/backend/app/services/order_test.go @@ -123,3 +123,49 @@ func (s *OrderTestSuite) Test_PurchaseFlow() { }) }) } + +func (s *OrderTestSuite) Test_OrderDetails() { + Convey("Order Details", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, + models.TableNameOrder, models.TableNameOrderItem, models.TableNameUser, + models.TableNameContent, models.TableNameContentPrice, models.TableNameTenant, + models.TableNameContentAccess, models.TableNameTenantLedger, models.TableNameMediaAsset, models.TableNameContentAsset, + ) + + // Setup + creator := &models.User{Username: "creator2", Phone: "13800000002"} + models.UserQuery.WithContext(ctx).Create(creator) + tenant := &models.Tenant{UserID: creator.ID, Name: "Best Shop", Status: consts.TenantStatusVerified} + models.TenantQuery.WithContext(ctx).Create(tenant) + content := &models.Content{TenantID: tenant.ID, UserID: creator.ID, Title: "Amazing Song", Status: consts.ContentStatusPublished} + models.ContentQuery.WithContext(ctx).Create(content) + price := &models.ContentPrice{TenantID: tenant.ID, ContentID: content.ID, PriceAmount: 500, Currency: consts.CurrencyCNY} + models.ContentPriceQuery.WithContext(ctx).Create(price) + + // Asset (Cover) + asset := &models.MediaAsset{TenantID: tenant.ID, UserID: creator.ID, Type: consts.MediaAssetTypeImage, ObjectKey: "cover.jpg"} + models.MediaAssetQuery.WithContext(ctx).Create(asset) + models.ContentAssetQuery.WithContext(ctx).Create(&models.ContentAsset{ContentID: content.ID, AssetID: asset.ID, Role: consts.ContentAssetRoleCover}) + + // Buyer + buyer := &models.User{Username: "buyer2", Phone: "13900000002", Balance: 1000} + models.UserQuery.WithContext(ctx).Create(buyer) + buyerCtx := context.WithValue(ctx, consts.CtxKeyUser, buyer.ID) + + Convey("should get full order details", func() { + // Create & Pay + createRes, _ := Order.Create(buyerCtx, &order_dto.OrderCreateForm{ContentID: cast.ToString(content.ID)}) + Order.Pay(buyerCtx, createRes.OrderID, &order_dto.OrderPayForm{Method: "balance"}) + + // Get Detail + detail, err := Order.GetUserOrder(buyerCtx, createRes.OrderID) + So(err, ShouldBeNil) + So(detail.TenantName, ShouldEqual, "Best Shop") + So(len(detail.Items), ShouldEqual, 1) + So(detail.Items[0].Title, ShouldEqual, "Amazing Song") + So(detail.Items[0].Cover, ShouldEndWith, "cover.jpg") + So(detail.Amount, ShouldEqual, 5.00) + }) + }) +} diff --git a/backend/app/services/provider.gen.go b/backend/app/services/provider.gen.go index 50bf618..f43e5d8 100755 --- a/backend/app/services/provider.gen.go +++ b/backend/app/services/provider.gen.go @@ -32,6 +32,13 @@ func Provide(opts ...opt.Option) error { }); err != nil { return err } + if err := container.Container.Provide(func() (*notification, error) { + obj := ¬ification{} + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func() (*order, error) { obj := &order{} @@ -44,6 +51,7 @@ func Provide(opts ...opt.Option) error { content *content, creator *creator, db *gorm.DB, + notification *notification, order *order, super *super, tenant *tenant, @@ -51,15 +59,16 @@ func Provide(opts ...opt.Option) error { wallet *wallet, ) (contracts.Initial, error) { obj := &services{ - common: common, - content: content, - creator: creator, - db: db, - order: order, - super: super, - tenant: tenant, - user: user, - wallet: wallet, + common: common, + content: content, + creator: creator, + db: db, + notification: notification, + order: order, + super: super, + tenant: tenant, + user: user, + wallet: wallet, } if err := obj.Prepare(); err != nil { return nil, err diff --git a/backend/app/services/services.gen.go b/backend/app/services/services.gen.go index 30c4868..e780c7c 100644 --- a/backend/app/services/services.gen.go +++ b/backend/app/services/services.gen.go @@ -8,28 +8,30 @@ var _db *gorm.DB // exported CamelCase Services var ( - Common *common - Content *content - Creator *creator - Order *order - Super *super - Tenant *tenant - User *user - Wallet *wallet + Common *common + Content *content + Creator *creator + Notification *notification + Order *order + Super *super + Tenant *tenant + User *user + Wallet *wallet ) // @provider(model) type services struct { db *gorm.DB // define Services - common *common - content *content - creator *creator - order *order - super *super - tenant *tenant - user *user - wallet *wallet + common *common + content *content + creator *creator + notification *notification + order *order + super *super + tenant *tenant + user *user + wallet *wallet } func (svc *services) Prepare() error { @@ -39,6 +41,7 @@ func (svc *services) Prepare() error { Common = svc.common Content = svc.content Creator = svc.creator + Notification = svc.notification Order = svc.order Super = svc.super Tenant = svc.tenant diff --git a/backend/app/services/tenant.go b/backend/app/services/tenant.go index e4c9929..c7fbdae 100644 --- a/backend/app/services/tenant.go +++ b/backend/app/services/tenant.go @@ -61,7 +61,8 @@ func (s *tenant) Follow(ctx context.Context, id string) error { tid := cast.ToInt64(id) // Check if tenant exists - if _, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(tid)).First(); err != nil { + t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(tid)).First() + if err != nil { return errorx.ErrRecordNotFound } @@ -75,6 +76,10 @@ func (s *tenant) Follow(ctx context.Context, id string) error { if err := models.TenantUserQuery.WithContext(ctx).Save(tu); err != nil { return errorx.ErrDatabaseError.WithCause(err) } + + if Notification != nil { + _ = Notification.Send(ctx, t.UserID, "interaction", "新增粉丝", "有人关注了您的店铺: "+t.Name) + } return nil }