feat: Implement notification service and integrate with user interactions
- Added notification service to handle sending and listing notifications. - Integrated notification sending on user follow and order payment events. - Updated user service to include fetching followed tenants. - Enhanced content service to manage access control for content assets. - Implemented logic for listing content topics based on genre. - Updated creator service to manage content updates and pricing. - Improved order service to include detailed order information and notifications. - Added tests for notification CRUD operations and order details.
This commit is contained in:
@@ -220,6 +220,10 @@ func (r *Routes) Register(router fiber.Router) {
|
|||||||
router.Get("/v1/me/favorites"[len(r.Path()):], DataFunc0(
|
router.Get("/v1/me/favorites"[len(r.Path()):], DataFunc0(
|
||||||
r.user.Favorites,
|
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")
|
r.log.Debugf("Registering route: Get /v1/me/library -> user.Library")
|
||||||
router.Get("/v1/me/library"[len(r.Path()):], DataFunc0(
|
router.Get("/v1/me/library"[len(r.Path()):], DataFunc0(
|
||||||
r.user.Library,
|
r.user.Library,
|
||||||
@@ -229,9 +233,10 @@ func (r *Routes) Register(router fiber.Router) {
|
|||||||
r.user.Likes,
|
r.user.Likes,
|
||||||
))
|
))
|
||||||
r.log.Debugf("Registering route: Get /v1/me/notifications -> user.Notifications")
|
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,
|
r.user.Notifications,
|
||||||
QueryParam[string]("type"),
|
QueryParam[string]("type"),
|
||||||
|
QueryParam[int]("page"),
|
||||||
))
|
))
|
||||||
r.log.Debugf("Registering route: Get /v1/me/orders -> user.ListOrders")
|
r.log.Debugf("Registering route: Get /v1/me/orders -> user.ListOrders")
|
||||||
router.Get("/v1/me/orders"[len(r.Path()):], DataFunc1(
|
router.Get("/v1/me/orders"[len(r.Path()):], DataFunc1(
|
||||||
|
|||||||
@@ -211,6 +211,19 @@ func (u *User) RemoveLike(ctx fiber.Ctx, contentId string) error {
|
|||||||
return services.Content.RemoveLike(ctx.Context(), contentId)
|
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
|
// Get notifications
|
||||||
//
|
//
|
||||||
// @Router /v1/me/notifications [get]
|
// @Router /v1/me/notifications [get]
|
||||||
@@ -220,8 +233,10 @@ func (u *User) RemoveLike(ctx fiber.Ctx, contentId string) error {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param type query string false "Type enum(all, system, order, audit, interaction)"
|
// @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)
|
// @Bind typeArg query key(type)
|
||||||
func (u *User) Notifications(ctx fiber.Ctx, typeArg string) ([]dto.Notification, error) {
|
// @Bind page query
|
||||||
return services.User.GetNotifications(ctx.Context(), typeArg)
|
func (u *User) Notifications(ctx fiber.Ctx, typeArg string, page int) (*requests.Pager, error) {
|
||||||
|
return services.Notification.List(ctx.Context(), page, typeArg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,3 +74,11 @@ func (s *common) Upload(ctx context.Context, file *multipart.FileHeader, typeArg
|
|||||||
MimeType: file.Header.Get("Content-Type"),
|
MimeType: file.Header.Get("Content-Type"),
|
||||||
}, nil
|
}, 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -102,11 +102,14 @@ func (s *content) Get(ctx context.Context, id string) (*content_dto.ContentDetai
|
|||||||
return nil, errorx.ErrDatabaseError.WithCause(err)
|
return nil, errorx.ErrDatabaseError.WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interaction status
|
// Interaction & Access status
|
||||||
isLiked := false
|
isLiked := false
|
||||||
isFavorited := false
|
isFavorited := false
|
||||||
|
hasAccess := false
|
||||||
|
|
||||||
if userID := ctx.Value(consts.CtxKeyUser); userID != nil {
|
if userID := ctx.Value(consts.CtxKeyUser); userID != nil {
|
||||||
uid := cast.ToInt64(userID)
|
uid := cast.ToInt64(userID)
|
||||||
|
// Interaction
|
||||||
isLiked, _ = models.UserContentActionQuery.WithContext(ctx).
|
isLiked, _ = models.UserContentActionQuery.WithContext(ctx).
|
||||||
Where(models.UserContentActionQuery.UserID.Eq(uid),
|
Where(models.UserContentActionQuery.UserID.Eq(uid),
|
||||||
models.UserContentActionQuery.ContentID.Eq(cid),
|
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.ContentID.Eq(cid),
|
||||||
models.UserContentActionQuery.Type.Eq("favorite")).
|
models.UserContentActionQuery.Type.Eq("favorite")).
|
||||||
Exists()
|
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{
|
detail := &content_dto.ContentDetail{
|
||||||
ContentItem: s.toContentItemDTO(&item),
|
ContentItem: s.toContentItemDTO(&item),
|
||||||
Description: item.Description,
|
Description: item.Description,
|
||||||
Body: item.Body,
|
Body: item.Body,
|
||||||
MediaUrls: s.toMediaURLs(item.ContentAssets),
|
MediaUrls: s.toMediaURLs(accessibleAssets),
|
||||||
IsLiked: isLiked,
|
IsLiked: isLiked,
|
||||||
IsFavorited: isFavorited,
|
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
|
return detail, nil
|
||||||
}
|
}
|
||||||
@@ -224,7 +257,13 @@ func (s *content) LikeComment(ctx context.Context, id string) error {
|
|||||||
uid := cast.ToInt64(userID)
|
uid := cast.ToInt64(userID)
|
||||||
cmid := cast.ToInt64(id)
|
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).
|
exists, _ := tx.UserCommentAction.WithContext(ctx).
|
||||||
Where(tx.UserCommentAction.UserID.Eq(uid), tx.UserCommentAction.CommentID.Eq(cmid), tx.UserCommentAction.Type.Eq("like")).
|
Where(tx.UserCommentAction.UserID.Eq(uid), tx.UserCommentAction.CommentID.Eq(cmid), tx.UserCommentAction.Type.Eq("like")).
|
||||||
Exists()
|
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))
|
_, err := tx.Comment.WithContext(ctx).Where(tx.Comment.ID.Eq(cmid)).UpdateSimple(tx.Comment.Likes.Add(1))
|
||||||
return err
|
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) {
|
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) {
|
func (s *content) ListTopics(ctx context.Context) ([]content_dto.Topic, error) {
|
||||||
// topics usually hardcoded or from a dedicated table
|
var results []struct {
|
||||||
return []content_dto.Topic{}, nil
|
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
|
// Helpers
|
||||||
@@ -337,7 +432,7 @@ func (s *content) toContentItemDTO(item *models.Content) content_dto.ContentItem
|
|||||||
}
|
}
|
||||||
// Cover
|
// Cover
|
||||||
if asset.Role == consts.ContentAssetRoleCover {
|
if asset.Role == consts.ContentAssetRoleCover {
|
||||||
dto.Cover = "http://mock/" + asset.Asset.ObjectKey
|
dto.Cover = Common.GetAssetURL(asset.Asset.ObjectKey)
|
||||||
}
|
}
|
||||||
// Type detection
|
// Type detection
|
||||||
switch asset.Asset.Type {
|
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 {
|
if dto.Cover == "" && len(item.ContentAssets) > 0 {
|
||||||
for _, asset := range item.ContentAssets {
|
for _, asset := range item.ContentAssets {
|
||||||
if asset.Asset != nil && asset.Asset.Type == consts.MediaAssetTypeImage {
|
if asset.Asset != nil && asset.Asset.Type == consts.MediaAssetTypeImage {
|
||||||
dto.Cover = "http://mock/" + asset.Asset.ObjectKey
|
dto.Cover = Common.GetAssetURL(asset.Asset.ObjectKey)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,7 +468,7 @@ func (s *content) toMediaURLs(assets []*models.ContentAsset) []content_dto.Media
|
|||||||
var urls []content_dto.MediaURL
|
var urls []content_dto.MediaURL
|
||||||
for _, ca := range assets {
|
for _, ca := range assets {
|
||||||
if ca.Asset != nil {
|
if ca.Asset != nil {
|
||||||
url := "http://mock/" + ca.Asset.ObjectKey
|
url := Common.GetAssetURL(ca.Asset.ObjectKey)
|
||||||
urls = append(urls, content_dto.MediaURL{
|
urls = append(urls, content_dto.MediaURL{
|
||||||
Type: string(ca.Asset.Type),
|
Type: string(ca.Asset.Type),
|
||||||
URL: url,
|
URL: url,
|
||||||
@@ -391,7 +486,13 @@ func (s *content) addInteract(ctx context.Context, contentId, typ string) error
|
|||||||
uid := cast.ToInt64(userID)
|
uid := cast.ToInt64(userID)
|
||||||
cid := cast.ToInt64(contentId)
|
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).
|
exists, _ := tx.UserContentAction.WithContext(ctx).
|
||||||
Where(tx.UserContentAction.UserID.Eq(uid), tx.UserContentAction.ContentID.Eq(cid), tx.UserContentAction.Type.Eq(typ)).
|
Where(tx.UserContentAction.UserID.Eq(uid), tx.UserContentAction.ContentID.Eq(cid), tx.UserContentAction.Type.Eq(typ)).
|
||||||
Exists()
|
Exists()
|
||||||
@@ -410,6 +511,20 @@ func (s *content) addInteract(ctx context.Context, contentId, typ string) error
|
|||||||
}
|
}
|
||||||
return nil
|
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 {
|
func (s *content) removeInteract(ctx context.Context, contentId, typ string) error {
|
||||||
|
|||||||
@@ -118,9 +118,13 @@ func (s *ContentTestSuite) Test_Get() {
|
|||||||
ContentID: content.ID,
|
ContentID: content.ID,
|
||||||
AssetID: asset.ID,
|
AssetID: asset.ID,
|
||||||
Sort: 1,
|
Sort: 1,
|
||||||
|
Role: consts.ContentAssetRoleMain, // Explicitly set role
|
||||||
}
|
}
|
||||||
models.ContentAssetQuery.WithContext(ctx).Create(ca)
|
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() {
|
Convey("should get detail with assets", func() {
|
||||||
detail, err := Content.Get(ctx, cast.ToString(content.ID))
|
detail, err := Content.Get(ctx, cast.ToString(content.ID))
|
||||||
So(err, ShouldBeNil)
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,14 +67,28 @@ func (s *creator) Dashboard(ctx context.Context) (*creator_dto.DashboardStats, e
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock stats for now or query
|
|
||||||
// Followers: count tenant_users
|
// Followers: count tenant_users
|
||||||
followers, _ := models.TenantUserQuery.WithContext(ctx).Where(models.TenantUserQuery.TenantID.Eq(tid)).Count()
|
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{
|
stats := &creator_dto.DashboardStats{
|
||||||
TotalFollowers: creator_dto.IntStatItem{Value: int(followers)},
|
TotalFollowers: creator_dto.IntStatItem{Value: int(followers)},
|
||||||
TotalRevenue: creator_dto.FloatStatItem{Value: 0},
|
TotalRevenue: creator_dto.FloatStatItem{Value: revenue / 100.0},
|
||||||
PendingRefunds: 0,
|
PendingRefunds: int(pendingRefunds),
|
||||||
NewMessages: 0,
|
NewMessages: 0,
|
||||||
}
|
}
|
||||||
return stats, nil
|
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 {
|
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 {
|
func (s *creator) DeleteContent(ctx context.Context, id string) error {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"quyun/v2/pkg/consts"
|
"quyun/v2/pkg/consts"
|
||||||
|
|
||||||
. "github.com/smartystreets/goconvey/convey"
|
. "github.com/smartystreets/goconvey/convey"
|
||||||
|
"github.com/spf13/cast"
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
"go.ipao.vip/atom/contracts"
|
"go.ipao.vip/atom/contracts"
|
||||||
"go.uber.org/dig"
|
"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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
91
backend/app/services/notification.go
Normal file
91
backend/app/services/notification.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
71
backend/app/services/notification_test.go
Normal file
71
backend/app/services/notification_test.go
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -42,7 +42,8 @@ func (s *order) ListUserOrders(ctx context.Context, status string) ([]user_dto.O
|
|||||||
|
|
||||||
var data []user_dto.Order
|
var data []user_dto.Order
|
||||||
for _, v := range list {
|
for _, v := range list {
|
||||||
data = append(data, s.toUserOrderDTO(v))
|
dto, _ := s.composeOrderDTO(ctx, v)
|
||||||
|
data = append(data, dto)
|
||||||
}
|
}
|
||||||
return data, nil
|
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)
|
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
|
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()
|
price, err := models.ContentPriceQuery.WithContext(ctx).Where(models.ContentPriceQuery.ContentID.Eq(cid)).First()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// If price missing, treat as error? Or maybe 0?
|
||||||
|
// Better to require price record.
|
||||||
return nil, errorx.ErrDataCorrupted.WithCause(err).WithMsg("价格信息缺失")
|
return nil, errorx.ErrDataCorrupted.WithCause(err).WithMsg("价格信息缺失")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,12 +102,12 @@ func (s *order) Create(ctx context.Context, form *transaction_dto.OrderCreateFor
|
|||||||
UserID: uid,
|
UserID: uid,
|
||||||
Type: consts.OrderTypeContentPurchase,
|
Type: consts.OrderTypeContentPurchase,
|
||||||
Status: consts.OrderStatusCreated,
|
Status: consts.OrderStatusCreated,
|
||||||
Currency: consts.Currency(price.Currency), // price.Currency is consts.Currency in DB? Yes.
|
Currency: consts.Currency(price.Currency),
|
||||||
AmountOriginal: price.PriceAmount,
|
AmountOriginal: price.PriceAmount,
|
||||||
AmountDiscount: 0, // Calculate discount if needed
|
AmountDiscount: 0,
|
||||||
AmountPaid: price.PriceAmount, // Expected to pay
|
AmountPaid: price.PriceAmount,
|
||||||
IdempotencyKey: uuid.NewString(), // Should be from client ideally
|
IdempotencyKey: uuid.NewString(),
|
||||||
Snapshot: types.NewJSONType(fields.OrdersSnapshot{}), // Populate details
|
Snapshot: types.NewJSONType(fields.OrdersSnapshot{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := models.OrderQuery.WithContext(ctx).Create(order); err != nil {
|
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)
|
return s.payWithBalance(ctx, o)
|
||||||
}
|
}
|
||||||
|
|
||||||
// External payment (mock)
|
|
||||||
return &transaction_dto.OrderPayResponse{
|
return &transaction_dto.OrderPayResponse{
|
||||||
PayParams: "mock_pay_params",
|
PayParams: "mock_pay_params",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *order) payWithBalance(ctx context.Context, o *models.Order) (*transaction_dto.OrderPayResponse, error) {
|
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 {
|
err := models.Q.Transaction(func(tx *models.Query) error {
|
||||||
// 1. Deduct User Balance
|
// 1. Deduct User Balance
|
||||||
info, err := tx.User.WithContext(ctx).
|
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
|
// 3. Grant Content Access
|
||||||
items, _ := tx.OrderItem.WithContext(ctx).Where(tx.OrderItem.OrderID.Eq(o.ID)).Find()
|
items, _ := tx.OrderItem.WithContext(ctx).Where(tx.OrderItem.OrderID.Eq(o.ID)).Find()
|
||||||
for _, item := range items {
|
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{
|
access := &models.ContentAccess{
|
||||||
TenantID: item.TenantID,
|
TenantID: item.TenantID,
|
||||||
UserID: o.UserID,
|
UserID: o.UserID,
|
||||||
@@ -196,6 +208,7 @@ func (s *order) payWithBalance(ctx context.Context, o *models.Order) (*transacti
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
tenantOwnerID = t.UserID
|
||||||
|
|
||||||
ledger := &models.TenantLedger{
|
ledger := &models.TenantLedger{
|
||||||
TenantID: o.TenantID,
|
TenantID: o.TenantID,
|
||||||
@@ -224,16 +237,74 @@ func (s *order) payWithBalance(ctx context.Context, o *models.Order) (*transacti
|
|||||||
return nil, errorx.ErrDatabaseError.WithCause(err)
|
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{
|
return &transaction_dto.OrderPayResponse{
|
||||||
PayParams: "balance_paid",
|
PayParams: "balance_paid",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *order) Status(ctx context.Context, id string) (*transaction_dto.OrderStatusResponse, error) {
|
func (s *order) Status(ctx context.Context, id string) (*transaction_dto.OrderStatusResponse, error) {
|
||||||
// ... check status ...
|
|
||||||
return nil, nil
|
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 {
|
func (s *order) toUserOrderDTO(o *models.Order) user_dto.Order {
|
||||||
return user_dto.Order{
|
return user_dto.Order{
|
||||||
ID: cast.ToString(o.ID),
|
ID: cast.ToString(o.ID),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ func Provide(opts ...opt.Option) error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
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) {
|
if err := container.Container.Provide(func() (*order, error) {
|
||||||
obj := &order{}
|
obj := &order{}
|
||||||
|
|
||||||
@@ -44,6 +51,7 @@ func Provide(opts ...opt.Option) error {
|
|||||||
content *content,
|
content *content,
|
||||||
creator *creator,
|
creator *creator,
|
||||||
db *gorm.DB,
|
db *gorm.DB,
|
||||||
|
notification *notification,
|
||||||
order *order,
|
order *order,
|
||||||
super *super,
|
super *super,
|
||||||
tenant *tenant,
|
tenant *tenant,
|
||||||
@@ -51,15 +59,16 @@ func Provide(opts ...opt.Option) error {
|
|||||||
wallet *wallet,
|
wallet *wallet,
|
||||||
) (contracts.Initial, error) {
|
) (contracts.Initial, error) {
|
||||||
obj := &services{
|
obj := &services{
|
||||||
common: common,
|
common: common,
|
||||||
content: content,
|
content: content,
|
||||||
creator: creator,
|
creator: creator,
|
||||||
db: db,
|
db: db,
|
||||||
order: order,
|
notification: notification,
|
||||||
super: super,
|
order: order,
|
||||||
tenant: tenant,
|
super: super,
|
||||||
user: user,
|
tenant: tenant,
|
||||||
wallet: wallet,
|
user: user,
|
||||||
|
wallet: wallet,
|
||||||
}
|
}
|
||||||
if err := obj.Prepare(); err != nil {
|
if err := obj.Prepare(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -8,28 +8,30 @@ var _db *gorm.DB
|
|||||||
|
|
||||||
// exported CamelCase Services
|
// exported CamelCase Services
|
||||||
var (
|
var (
|
||||||
Common *common
|
Common *common
|
||||||
Content *content
|
Content *content
|
||||||
Creator *creator
|
Creator *creator
|
||||||
Order *order
|
Notification *notification
|
||||||
Super *super
|
Order *order
|
||||||
Tenant *tenant
|
Super *super
|
||||||
User *user
|
Tenant *tenant
|
||||||
Wallet *wallet
|
User *user
|
||||||
|
Wallet *wallet
|
||||||
)
|
)
|
||||||
|
|
||||||
// @provider(model)
|
// @provider(model)
|
||||||
type services struct {
|
type services struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
// define Services
|
// define Services
|
||||||
common *common
|
common *common
|
||||||
content *content
|
content *content
|
||||||
creator *creator
|
creator *creator
|
||||||
order *order
|
notification *notification
|
||||||
super *super
|
order *order
|
||||||
tenant *tenant
|
super *super
|
||||||
user *user
|
tenant *tenant
|
||||||
wallet *wallet
|
user *user
|
||||||
|
wallet *wallet
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *services) Prepare() error {
|
func (svc *services) Prepare() error {
|
||||||
@@ -39,6 +41,7 @@ func (svc *services) Prepare() error {
|
|||||||
Common = svc.common
|
Common = svc.common
|
||||||
Content = svc.content
|
Content = svc.content
|
||||||
Creator = svc.creator
|
Creator = svc.creator
|
||||||
|
Notification = svc.notification
|
||||||
Order = svc.order
|
Order = svc.order
|
||||||
Super = svc.super
|
Super = svc.super
|
||||||
Tenant = svc.tenant
|
Tenant = svc.tenant
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ func (s *tenant) Follow(ctx context.Context, id string) error {
|
|||||||
tid := cast.ToInt64(id)
|
tid := cast.ToInt64(id)
|
||||||
|
|
||||||
// Check if tenant exists
|
// 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
|
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 {
|
if err := models.TenantUserQuery.WithContext(ctx).Save(tu); err != nil {
|
||||||
return errorx.ErrDatabaseError.WithCause(err)
|
return errorx.ErrDatabaseError.WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if Notification != nil {
|
||||||
|
_ = Notification.Send(ctx, t.UserID, "interaction", "新增粉丝", "有人关注了您的店铺: "+t.Name)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user