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:
2025-12-30 09:57:12 +08:00
parent 9ef9642965
commit 5cf2295f91
14 changed files with 741 additions and 52 deletions

View File

@@ -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 {