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

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

View File

@@ -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)
}

View File

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

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 {

View File

@@ -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)
})
})
}

View File

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

View File

@@ -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)
})
})
}

View 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)
}

View 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)
})
})
}

View File

@@ -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),

View File

@@ -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)
})
})
}

View File

@@ -32,6 +32,13 @@ func Provide(opts ...opt.Option) error {
}); err != nil {
return err
}
if err := container.Container.Provide(func() (*notification, error) {
obj := &notification{}
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

View File

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

View File

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