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(
|
||||
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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
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),
|
||||
|
||||
@@ -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 {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user