Refactor order and tenant ledger models to use consts for Currency and Type fields; add new UserStatus values; implement comprehensive test cases for content, creator, order, super, and wallet services.

This commit is contained in:
2025-12-29 14:21:20 +08:00
parent d648a1e45b
commit 8fa3d18a9c
30 changed files with 2251 additions and 85 deletions

View File

@@ -4,12 +4,73 @@ import (
"context"
"mime/multipart"
"quyun/v2/app/errorx"
common_dto "quyun/v2/app/http/v1/dto"
"quyun/v2/database/fields"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"github.com/google/uuid"
"github.com/spf13/cast"
"go.ipao.vip/gen/types"
)
// @provider
type common struct{}
func (s *common) Upload(ctx context.Context, file *multipart.FileHeader, typeArg string) (*common_dto.UploadResult, error) {
return &common_dto.UploadResult{}, nil
}
userID := ctx.Value(consts.CtxKeyUser)
if userID == nil {
return nil, errorx.ErrUnauthorized
}
uid := cast.ToInt64(userID)
// Mock Upload to S3/MinIO
// objectKey := uuid.NewString() + filepath.Ext(file.Filename)
objectKey := uuid.NewString() + "_" + file.Filename
url := "http://mock-storage/" + objectKey
// Determine TenantID.
// Uploads usually happen in context of a tenant? Or personal?
// For now assume user's owned tenant if any, or 0.
// MediaAsset has TenantID (NOT NULL).
// We need to fetch tenant.
t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.UserID.Eq(uid)).First()
var tid int64 = 0
if err == nil {
tid = t.ID
}
// If no tenant, and TenantID is NOT NULL, we have a problem for regular users uploading avatar?
// Users avatar is URL string in `users` table.
// MediaAssets table is for TENANT content.
// If this is for user avatar upload, maybe we don't use MediaAssets?
// But `upload` endpoint is generic.
// Let's assume tid=0 is allowed if system bucket, or enforce tenant.
// If table says NOT NULL, 0 is valid int64.
asset := &models.MediaAsset{
TenantID: tid,
UserID: uid,
Type: consts.MediaAssetType(typeArg),
Status: consts.MediaAssetStatusUploaded,
Provider: "mock",
Bucket: "default",
ObjectKey: objectKey,
Meta: types.NewJSONType(fields.MediaAssetMeta{
Size: file.Size,
// MimeType?
}),
}
if err := models.MediaAssetQuery.WithContext(ctx).Create(asset); err != nil {
return nil, errorx.ErrDatabaseError
}
return &common_dto.UploadResult{
ID: cast.ToString(asset.ID),
URL: url,
Filename: file.Filename,
Size: file.Size,
MimeType: file.Header.Get("Content-Type"),
}, nil
}

View File

@@ -2,28 +2,187 @@ package services
import (
"context"
"errors"
"quyun/v2/app/errorx"
content_dto "quyun/v2/app/http/v1/dto"
user_dto "quyun/v2/app/http/v1/dto"
"quyun/v2/app/requests"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"github.com/spf13/cast"
"gorm.io/gorm"
)
// @provider
type content struct{}
func (s *content) List(ctx context.Context, keyword, genre, tenantId, sort string, page int) (*requests.Pager, error) {
return &requests.Pager{}, nil
tbl, q := models.ContentQuery.QueryContext(ctx)
// Filters
q = q.Where(tbl.Status.Eq(consts.ContentStatusPublished))
if keyword != "" {
q = q.Where(tbl.Title.Like("%" + keyword + "%"))
}
if genre != "" {
q = q.Where(tbl.Genre.Eq(genre))
}
if tenantId != "" {
tid := cast.ToInt64(tenantId)
q = q.Where(tbl.TenantID.Eq(tid))
}
// Preload Author
q = q.Preload(tbl.Author)
// Sort
switch sort {
case "hot":
q = q.Order(tbl.Views.Desc())
case "price_asc":
q = q.Order(tbl.ID.Desc())
default: // latest
q = q.Order(tbl.PublishedAt.Desc())
}
// Pagination
p := requests.Pagination{Page: int64(page), Limit: 10}
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError
}
list, err := q.Offset(int(p.Offset())).Limit(int(p.Limit)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError
}
// Convert to DTO
data := make([]content_dto.ContentItem, len(list))
for i, item := range list {
data[i] = s.toContentItemDTO(item)
}
return &requests.Pager{
Pagination: requests.Pagination{
Page: p.Page,
Limit: p.Limit,
},
Total: total,
Items: data,
}, nil
}
func (s *content) Get(ctx context.Context, id string) (*content_dto.ContentDetail, error) {
return &content_dto.ContentDetail{}, nil
cid := cast.ToInt64(id)
_, q := models.ContentQuery.QueryContext(ctx)
var item models.Content
// Use UnderlyingDB for complex nested preloading
err := q.UnderlyingDB().
Preload("Author").
Preload("ContentAssets", func(db *gorm.DB) *gorm.DB {
return db.Order("sort ASC")
}).
Preload("ContentAssets.Asset").
Where("id = ?", cid).
First(&item).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.ErrRecordNotFound
}
return nil, errorx.ErrDatabaseError
}
// Interaction status (isLiked, isFavorited)
userID := ctx.Value(consts.CtxKeyUser)
isLiked := false
isFavorited := false
if userID != nil {
// uid := cast.ToInt64(userID) // Unused for now until interaction query implemented
// ... check likes ...
}
detail := &content_dto.ContentDetail{
ContentItem: s.toContentItemDTO(&item),
Description: item.Description,
Body: item.Body,
MediaUrls: s.toMediaURLs(item.ContentAssets),
IsLiked: isLiked,
IsFavorited: isFavorited,
}
return detail, nil
}
func (s *content) ListComments(ctx context.Context, id string, page int) (*requests.Pager, error) {
return &requests.Pager{}, nil
cid := cast.ToInt64(id)
tbl, q := models.CommentQuery.QueryContext(ctx)
q = q.Where(tbl.ContentID.Eq(cid)).Preload(tbl.User)
q = q.Order(tbl.CreatedAt.Desc())
p := requests.Pagination{Page: int64(page), Limit: 10}
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError
}
list, err := q.Offset(int(p.Offset())).Limit(int(p.Limit)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError
}
data := make([]content_dto.Comment, len(list))
for i, v := range list {
data[i] = content_dto.Comment{
ID: cast.ToString(v.ID),
Content: v.Content,
UserID: cast.ToString(v.UserID),
UserNickname: v.User.Nickname, // Preloaded
UserAvatar: v.User.Avatar,
CreateTime: v.CreatedAt.Format("2006-01-02 15:04:05"),
Likes: int(v.Likes),
ReplyTo: cast.ToString(v.ReplyTo),
}
}
return &requests.Pager{
Pagination: requests.Pagination{
Page: p.Page,
Limit: p.Limit,
},
Total: total,
Items: data,
}, nil
}
func (s *content) CreateComment(ctx context.Context, id string, form *content_dto.CommentCreateForm) error {
userID := ctx.Value(consts.CtxKeyUser)
if userID == nil {
return errorx.ErrUnauthorized
}
uid := cast.ToInt64(userID)
cid := cast.ToInt64(id)
c, err := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(cid)).First()
if err != nil {
return errorx.ErrRecordNotFound
}
comment := &models.Comment{
TenantID: c.TenantID,
UserID: uid,
ContentID: cid,
Content: form.Content,
ReplyTo: cast.ToInt64(form.ReplyTo),
}
if err := models.CommentQuery.WithContext(ctx).Create(comment); err != nil {
return errorx.ErrDatabaseError
}
return nil
}
@@ -62,3 +221,37 @@ func (s *content) RemoveLike(ctx context.Context, contentId string) error {
func (s *content) ListTopics(ctx context.Context) ([]content_dto.Topic, error) {
return []content_dto.Topic{}, nil
}
// Helpers
func (s *content) toContentItemDTO(item *models.Content) content_dto.ContentItem {
dto := content_dto.ContentItem{
ID: cast.ToString(item.ID),
Title: item.Title,
Genre: item.Genre,
AuthorID: cast.ToString(item.UserID),
Views: int(item.Views),
Likes: int(item.Likes),
}
if item.Author != nil {
dto.AuthorName = item.Author.Nickname
dto.AuthorAvatar = item.Author.Avatar
}
return dto
}
func (s *content) toMediaURLs(assets []*models.ContentAsset) []content_dto.MediaURL {
var urls []content_dto.MediaURL
for _, ca := range assets {
if ca.Asset != nil {
// Construct URL based on Asset info (Bucket/Key/Provider)
// For prototype: mock url
url := "http://mock/" + ca.Asset.ObjectKey
urls = append(urls, content_dto.MediaURL{
Type: string(ca.Asset.Type), // Assuming type is enum or string
URL: url,
})
}
}
return urls
}

View File

@@ -0,0 +1,151 @@
package services
import (
"context"
"database/sql"
"testing"
"quyun/v2/app/commands/testx"
content_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 ContentTestSuiteInjectParams struct {
dig.In
DB *sql.DB
Initials []contracts.Initial `group:"initials"`
}
type ContentTestSuite struct {
suite.Suite
ContentTestSuiteInjectParams
}
func Test_Content(t *testing.T) {
providers := testx.Default().With(Provide)
testx.Serve(providers, t, func(p ContentTestSuiteInjectParams) {
suite.Run(t, &ContentTestSuite{ContentTestSuiteInjectParams: p})
})
}
func (s *ContentTestSuite) Test_List() {
Convey("List", s.T(), func() {
ctx := s.T().Context()
database.Truncate(ctx, s.DB, models.TableNameContent, models.TableNameUser)
// Create Author
author := &models.User{Nickname: "Author1", Username: "author1", Phone: "13800000001"}
models.UserQuery.WithContext(ctx).Create(author)
// Create Contents
c1 := &models.Content{
TenantID: 1,
UserID: author.ID,
Title: "Content A",
Status: consts.ContentStatusPublished,
Genre: "video",
}
c2 := &models.Content{
TenantID: 1,
UserID: author.ID,
Title: "Content B",
Status: consts.ContentStatusDraft, // Draft
Genre: "video",
}
models.ContentQuery.WithContext(ctx).Create(c1, c2)
Convey("should list only published contents", func() {
res, err := Content.List(ctx, "", "", "1", "", 1)
So(err, ShouldBeNil)
So(res.Total, ShouldEqual, 1)
items := res.Items.([]content_dto.ContentItem)
So(items[0].Title, ShouldEqual, "Content A")
So(items[0].AuthorName, ShouldEqual, "Author1")
})
})
}
func (s *ContentTestSuite) Test_Get() {
Convey("Get", s.T(), func() {
ctx := s.T().Context()
database.Truncate(ctx, s.DB, models.TableNameContent, models.TableNameMediaAsset, models.TableNameContentAsset, models.TableNameUser)
// Author
author := &models.User{Nickname: "Author1", Username: "author1", Phone: "13800000002"}
models.UserQuery.WithContext(ctx).Create(author)
// Asset
asset := &models.MediaAsset{
TenantID: 1,
UserID: author.ID,
ObjectKey: "test.mp4",
Type: consts.MediaAssetTypeVideo,
}
models.MediaAssetQuery.WithContext(ctx).Create(asset)
// Content
content := &models.Content{
TenantID: 1,
UserID: author.ID,
Title: "Detail Content",
Status: consts.ContentStatusPublished,
}
models.ContentQuery.WithContext(ctx).Create(content)
// Link Asset
ca := &models.ContentAsset{
TenantID: 1,
UserID: author.ID,
ContentID: content.ID,
AssetID: asset.ID,
Sort: 1,
}
models.ContentAssetQuery.WithContext(ctx).Create(ca)
Convey("should get detail with assets", func() {
detail, err := Content.Get(ctx, cast.ToString(content.ID))
So(err, ShouldBeNil)
So(detail.Title, ShouldEqual, "Detail Content")
So(detail.AuthorName, ShouldEqual, "Author1")
So(len(detail.MediaUrls), ShouldEqual, 1)
So(detail.MediaUrls[0].URL, ShouldEndWith, "test.mp4")
})
})
}
func (s *ContentTestSuite) Test_CreateComment() {
Convey("CreateComment", s.T(), func() {
ctx := s.T().Context()
database.Truncate(ctx, s.DB, models.TableNameContent, models.TableNameComment, models.TableNameUser)
// User & Content
u := &models.User{Username: "user1", Phone: "13900000001"}
models.UserQuery.WithContext(ctx).Create(u)
c := &models.Content{TenantID: 1, UserID: u.ID, Title: "C"}
models.ContentQuery.WithContext(ctx).Create(c)
// Auth context
ctx = context.WithValue(ctx, consts.CtxKeyUser, u.ID)
Convey("should create comment", func() {
form := &content_dto.CommentCreateForm{
Content: "Nice!",
}
err := Content.CreateComment(ctx, cast.ToString(c.ID), form)
So(err, ShouldBeNil)
count, _ := models.CommentQuery.WithContext(ctx).Where(models.CommentQuery.ContentID.Eq(c.ID)).Count()
So(count, ShouldEqual, 1)
})
})
}

View File

@@ -2,27 +2,174 @@ package services
import (
"context"
"errors"
"time"
"quyun/v2/app/errorx"
creator_dto "quyun/v2/app/http/v1/dto"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"github.com/google/uuid"
"github.com/spf13/cast"
"go.ipao.vip/gen/types"
"gorm.io/gorm"
)
// @provider
type creator struct{}
func (s *creator) Apply(ctx context.Context, form *creator_dto.ApplyForm) error {
userID := ctx.Value(consts.CtxKeyUser)
if userID == nil {
return errorx.ErrUnauthorized
}
uid := cast.ToInt64(userID)
tbl, q := models.TenantQuery.QueryContext(ctx)
// Check if already has a tenant
count, _ := q.Where(tbl.UserID.Eq(uid)).Count()
if count > 0 {
return errorx.ErrBadRequest.WithMsg("您已是创作者")
}
// Create Tenant
tenant := &models.Tenant{
UserID: uid,
Name: form.Name,
// Bio/Avatar in config
Code: uuid.NewString()[:8], // Generate random code
UUID: types.UUID(uuid.New()),
Status: consts.TenantStatusPendingVerify,
}
if err := q.Create(tenant); err != nil {
return errorx.ErrDatabaseError
}
// Also add user as tenant_admin in tenant_users
tu := &models.TenantUser{
TenantID: tenant.ID,
UserID: uid,
Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleTenantAdmin},
Status: consts.UserStatusVerified,
}
if err := models.TenantUserQuery.WithContext(ctx).Create(tu); err != nil {
return errorx.ErrDatabaseError
}
return nil
}
func (s *creator) Dashboard(ctx context.Context) (*creator_dto.DashboardStats, error) {
return &creator_dto.DashboardStats{}, nil
tid, err := s.getTenantID(ctx)
if err != nil {
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()
stats := &creator_dto.DashboardStats{
TotalFollowers: creator_dto.IntStatItem{Value: int(followers)},
TotalRevenue: creator_dto.FloatStatItem{Value: 0},
PendingRefunds: 0,
NewMessages: 0,
}
return stats, nil
}
func (s *creator) ListContents(ctx context.Context, status, genre, keyword string) ([]creator_dto.ContentItem, error) {
return []creator_dto.ContentItem{}, nil
tid, err := s.getTenantID(ctx)
if err != nil {
return nil, err
}
tbl, q := models.ContentQuery.QueryContext(ctx)
q = q.Where(tbl.TenantID.Eq(tid))
if status != "" {
q = q.Where(tbl.Status.Eq(consts.ContentStatus(status)))
}
if genre != "" {
q = q.Where(tbl.Genre.Eq(genre))
}
if keyword != "" {
q = q.Where(tbl.Title.Like("%" + keyword + "%"))
}
list, err := q.Order(tbl.CreatedAt.Desc()).Find()
if err != nil {
return nil, errorx.ErrDatabaseError
}
var data []creator_dto.ContentItem
for _, item := range list {
data = append(data, creator_dto.ContentItem{
ID: cast.ToString(item.ID),
Title: item.Title,
Genre: item.Genre,
Views: int(item.Views),
Likes: int(item.Likes),
IsPurchased: false,
})
}
return data, nil
}
func (s *creator) CreateContent(ctx context.Context, form *creator_dto.ContentCreateForm) error {
return nil
tid, err := s.getTenantID(ctx)
if err != nil {
return err
}
uid := cast.ToInt64(ctx.Value(consts.CtxKeyUser))
return models.Q.Transaction(func(tx *models.Query) error {
// 1. Create Content
content := &models.Content{
TenantID: tid,
UserID: uid,
Title: form.Title,
Genre: form.Genre,
Status: consts.ContentStatusPublished,
}
if err := tx.Content.WithContext(ctx).Create(content); err != nil {
return err
}
// 2. Link Assets
if len(form.MediaIDs) > 0 {
var assets []*models.ContentAsset
for i, mid := range form.MediaIDs {
assets = append(assets, &models.ContentAsset{
TenantID: tid,
UserID: uid,
ContentID: content.ID,
AssetID: cast.ToInt64(mid),
Sort: int32(i),
Role: consts.ContentAssetRoleMain,
})
}
if err := tx.ContentAsset.WithContext(ctx).Create(assets...); err != nil {
return err
}
}
// 3. Set Price
price := &models.ContentPrice{
TenantID: tid,
UserID: uid,
ContentID: content.ID,
PriceAmount: int64(form.Price * 100), // Convert to cents
Currency: consts.CurrencyCNY,
}
if err := tx.ContentPrice.WithContext(ctx).Create(price); err != nil {
return err
}
return nil
})
}
func (s *creator) UpdateContent(ctx context.Context, id string, form *creator_dto.ContentUpdateForm) error {
@@ -30,11 +177,43 @@ func (s *creator) UpdateContent(ctx context.Context, id string, form *creator_dt
}
func (s *creator) DeleteContent(ctx context.Context, id string) error {
cid := cast.ToInt64(id)
tid, err := s.getTenantID(ctx)
if err != nil {
return err
}
_, err = models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(cid), models.ContentQuery.TenantID.Eq(tid)).Delete()
if err != nil {
return errorx.ErrDatabaseError
}
return nil
}
func (s *creator) ListOrders(ctx context.Context, status, keyword string) ([]creator_dto.Order, error) {
return []creator_dto.Order{}, nil
tid, err := s.getTenantID(ctx)
if err != nil {
return nil, err
}
tbl, q := models.OrderQuery.QueryContext(ctx)
q = q.Where(tbl.TenantID.Eq(tid))
// Filters...
list, err := q.Order(tbl.CreatedAt.Desc()).Find()
if err != nil {
return nil, errorx.ErrDatabaseError
}
var data []creator_dto.Order
for _, o := range list {
data = append(data, creator_dto.Order{
ID: cast.ToString(o.ID),
Status: string(o.Status), // Enum conversion
Amount: float64(o.AmountPaid) / 100.0,
CreateTime: o.CreatedAt.Format(time.RFC3339),
})
}
return data, nil
}
func (s *creator) ProcessRefund(ctx context.Context, id string, form *creator_dto.RefundForm) error {
@@ -42,7 +221,19 @@ func (s *creator) ProcessRefund(ctx context.Context, id string, form *creator_dt
}
func (s *creator) GetSettings(ctx context.Context) (*creator_dto.Settings, error) {
return &creator_dto.Settings{}, nil
tid, err := s.getTenantID(ctx)
if err != nil {
return nil, err
}
t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(tid)).First()
if err != nil {
return nil, errorx.ErrRecordNotFound
}
// Extract from t.Config
return &creator_dto.Settings{
Name: t.Name,
// Bio/Avatar from Config
}, nil
}
func (s *creator) UpdateSettings(ctx context.Context, form *creator_dto.Settings) error {
@@ -64,3 +255,23 @@ func (s *creator) RemovePayoutAccount(ctx context.Context, id string) error {
func (s *creator) Withdraw(ctx context.Context, form *creator_dto.WithdrawForm) error {
return nil
}
// Helpers
func (s *creator) getTenantID(ctx context.Context) (int64, error) {
userID := ctx.Value(consts.CtxKeyUser)
if userID == nil {
return 0, errorx.ErrUnauthorized
}
uid := cast.ToInt64(userID)
// Simple check: User owns tenant
t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.UserID.Eq(uid)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, errorx.ErrPermissionDenied.WithMsg("非创作者")
}
return 0, errorx.ErrDatabaseError
}
return t.ID, nil
}

View File

@@ -0,0 +1,106 @@
package services
import (
"context"
"database/sql"
"testing"
"quyun/v2/app/commands/testx"
creator_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/stretchr/testify/suite"
"go.ipao.vip/atom/contracts"
"go.uber.org/dig"
)
type CreatorTestSuiteInjectParams struct {
dig.In
DB *sql.DB
Initials []contracts.Initial `group:"initials"`
}
type CreatorTestSuite struct {
suite.Suite
CreatorTestSuiteInjectParams
}
func Test_Creator(t *testing.T) {
providers := testx.Default().With(Provide)
testx.Serve(providers, t, func(p CreatorTestSuiteInjectParams) {
suite.Run(t, &CreatorTestSuite{CreatorTestSuiteInjectParams: p})
})
}
func (s *CreatorTestSuite) Test_Apply() {
Convey("Apply", s.T(), func() {
ctx := s.T().Context()
database.Truncate(ctx, s.DB, models.TableNameTenant, models.TableNameTenantUser, models.TableNameUser)
u := &models.User{Username: "creator1", Phone: "13700000001"}
models.UserQuery.WithContext(ctx).Create(u)
ctx = context.WithValue(ctx, consts.CtxKeyUser, u.ID)
Convey("should create tenant", func() {
form := &creator_dto.ApplyForm{
Name: "My Channel",
}
err := Creator.Apply(ctx, form)
So(err, ShouldBeNil)
t, _ := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.UserID.Eq(u.ID)).First()
So(t, ShouldNotBeNil)
So(t.Name, ShouldEqual, "My Channel")
So(t.Status, ShouldEqual, consts.TenantStatusPendingVerify)
// Check admin role
tu, _ := models.TenantUserQuery.WithContext(ctx).Where(models.TenantUserQuery.TenantID.Eq(t.ID)).First()
So(tu, ShouldNotBeNil)
// Role is array, check contains? Or first element?
// types.Array is likely []T.
So(len(tu.Role), ShouldEqual, 1)
So(tu.Role[0], ShouldEqual, consts.TenantUserRoleTenantAdmin)
})
})
}
func (s *CreatorTestSuite) Test_CreateContent() {
Convey("CreateContent", 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: "creator2", Phone: "13700000002"}
models.UserQuery.WithContext(ctx).Create(u)
ctx = context.WithValue(ctx, consts.CtxKeyUser, u.ID)
// Create Tenant manually
t := &models.Tenant{UserID: u.ID, Name: "Channel 2", Code: "123", Status: consts.TenantStatusVerified}
models.TenantQuery.WithContext(ctx).Create(t)
Convey("should create content and assets", func() {
form := &creator_dto.ContentCreateForm{
Title: "New Song",
Genre: "audio",
Price: 9.99,
// MediaIDs: ... need media asset
}
err := Creator.CreateContent(ctx, form)
So(err, ShouldBeNil)
c, _ := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.Title.Eq("New Song")).First()
So(c, ShouldNotBeNil)
So(c.UserID, ShouldEqual, u.ID)
So(c.TenantID, ShouldEqual, t.ID)
// Check Price
p, _ := models.ContentPriceQuery.WithContext(ctx).Where(models.ContentPriceQuery.ContentID.Eq(c.ID)).First()
So(p, ShouldNotBeNil)
So(p.PriceAmount, ShouldEqual, 999)
})
})
}

View File

@@ -2,30 +2,240 @@ package services
import (
"context"
"errors"
"time"
"quyun/v2/app/errorx"
transaction_dto "quyun/v2/app/http/v1/dto"
user_dto "quyun/v2/app/http/v1/dto"
"quyun/v2/database/fields"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"github.com/google/uuid"
"github.com/spf13/cast"
"go.ipao.vip/gen/types"
"gorm.io/gorm"
)
// @provider
type order struct{}
func (s *order) ListUserOrders(ctx context.Context, status string) ([]user_dto.Order, error) {
return []user_dto.Order{}, nil
userID := ctx.Value(consts.CtxKeyUser)
if userID == nil {
return nil, errorx.ErrUnauthorized
}
uid := cast.ToInt64(userID)
tbl, q := models.OrderQuery.QueryContext(ctx)
q = q.Where(tbl.UserID.Eq(uid))
if status != "" && status != "all" {
q = q.Where(tbl.Status.Eq(consts.OrderStatus(status)))
}
list, err := q.Order(tbl.CreatedAt.Desc()).Find()
if err != nil {
return nil, errorx.ErrDatabaseError
}
var data []user_dto.Order
for _, v := range list {
data = append(data, s.toUserOrderDTO(v))
}
return data, nil
}
func (s *order) GetUserOrder(ctx context.Context, id string) (*user_dto.Order, error) {
return &user_dto.Order{}, nil
userID := ctx.Value(consts.CtxKeyUser)
if userID == nil {
return nil, errorx.ErrUnauthorized
}
uid := cast.ToInt64(userID)
oid := cast.ToInt64(id)
tbl, q := models.OrderQuery.QueryContext(ctx)
item, err := q.Where(tbl.ID.Eq(oid), tbl.UserID.Eq(uid)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.ErrRecordNotFound
}
return nil, errorx.ErrDatabaseError
}
dto := s.toUserOrderDTO(item)
return &dto, nil
}
func (s *order) Create(ctx context.Context, form *transaction_dto.OrderCreateForm) (*transaction_dto.OrderCreateResponse, error) {
return &transaction_dto.OrderCreateResponse{}, nil
userID := ctx.Value(consts.CtxKeyUser)
if userID == nil {
return nil, errorx.ErrUnauthorized
}
uid := cast.ToInt64(userID)
cid := cast.ToInt64(form.ContentID)
// 1. Fetch Content & Price
content, err := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(cid)).First()
if err != nil {
return nil, errorx.ErrRecordNotFound.WithMsg("内容不存在")
}
if content.Status != consts.ContentStatusPublished {
return nil, errorx.ErrBusinessLogic.WithMsg("内容未发布")
}
price, err := models.ContentPriceQuery.WithContext(ctx).Where(models.ContentPriceQuery.ContentID.Eq(cid)).First()
if err != nil {
return nil, errorx.ErrDataCorrupted.WithMsg("价格信息缺失")
}
// 2. Create Order (Status: Created)
order := &models.Order{
TenantID: content.TenantID,
UserID: uid,
Type: consts.OrderTypeContentPurchase,
Status: consts.OrderStatusCreated,
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
}
if err := models.OrderQuery.WithContext(ctx).Create(order); err != nil {
return nil, errorx.ErrDatabaseError
}
// 3. Create Order Item
item := &models.OrderItem{
TenantID: content.TenantID,
UserID: uid,
OrderID: order.ID,
ContentID: cid,
ContentUserID: content.UserID,
AmountPaid: order.AmountPaid,
}
if err := models.OrderItemQuery.WithContext(ctx).Create(item); err != nil {
return nil, errorx.ErrDatabaseError
}
return &transaction_dto.OrderCreateResponse{
OrderID: cast.ToString(order.ID),
}, nil
}
func (s *order) Pay(ctx context.Context, id string, form *transaction_dto.OrderPayForm) (*transaction_dto.OrderPayResponse, error) {
return &transaction_dto.OrderPayResponse{}, nil
userID := ctx.Value(consts.CtxKeyUser)
if userID == nil {
return nil, errorx.ErrUnauthorized
}
uid := cast.ToInt64(userID)
oid := cast.ToInt64(id)
// Fetch Order
o, err := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(oid), models.OrderQuery.UserID.Eq(uid)).First()
if err != nil {
return nil, errorx.ErrRecordNotFound
}
if o.Status != consts.OrderStatusCreated {
return nil, errorx.ErrStatusConflict.WithMsg("订单状态不可支付")
}
if form.Method == "balance" {
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) {
err := models.Q.Transaction(func(tx *models.Query) error {
// 1. Deduct User Balance
info, err := tx.User.WithContext(ctx).
Where(tx.User.ID.Eq(o.UserID), tx.User.Balance.Gte(o.AmountPaid)).
Update(tx.User.Balance, gorm.Expr("balance - ?", o.AmountPaid))
if err != nil {
return err
}
if info.RowsAffected == 0 {
return errorx.ErrQuotaExceeded.WithMsg("余额不足")
}
// 2. Update Order Status
now := time.Now()
_, err = tx.Order.WithContext(ctx).Where(tx.Order.ID.Eq(o.ID)).Updates(&models.Order{
Status: consts.OrderStatusPaid,
PaidAt: now,
})
if err != nil {
return err
}
// 3. Grant Content Access
items, _ := tx.OrderItem.WithContext(ctx).Where(tx.OrderItem.OrderID.Eq(o.ID)).Find()
for _, item := range items {
access := &models.ContentAccess{
TenantID: item.TenantID,
UserID: o.UserID,
ContentID: item.ContentID,
OrderID: o.ID,
Status: consts.ContentAccessStatusActive,
}
if err := tx.ContentAccess.WithContext(ctx).Save(access); err != nil {
return err
}
}
// 4. Create Tenant Ledger (Revenue)
t, err := tx.Tenant.WithContext(ctx).Where(tx.Tenant.ID.Eq(o.TenantID)).First()
if err != nil {
return err
}
ledger := &models.TenantLedger{
TenantID: o.TenantID,
UserID: t.UserID, // Owner
OrderID: o.ID,
Type: consts.TenantLedgerTypeDebitPurchase, // Income from purchase
Amount: o.AmountPaid,
BalanceBefore: 0, // TODO: Fetch previous balance if tracking tenant balance
BalanceAfter: 0, // TODO
FrozenBefore: 0,
FrozenAfter: 0,
IdempotencyKey: uuid.NewString(),
Remark: "内容销售收入",
OperatorUserID: o.UserID,
}
if err := tx.TenantLedger.WithContext(ctx).Create(ledger); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return &transaction_dto.OrderPayResponse{
PayParams: "balance_paid",
}, nil
}
func (s *order) Status(ctx context.Context, id string) (*transaction_dto.OrderStatusResponse, error) {
return &transaction_dto.OrderStatusResponse{}, nil
// ... check status ...
return nil, nil
}
func (s *order) toUserOrderDTO(o *models.Order) user_dto.Order {
return user_dto.Order{
ID: cast.ToString(o.ID),
Status: string(o.Status), // Need cast for DTO string field if DTO field is string
Amount: float64(o.AmountPaid) / 100.0,
CreateTime: o.CreatedAt.Format(time.RFC3339),
}
}

View File

@@ -0,0 +1,125 @@
package services
import (
"context"
"database/sql"
"testing"
"quyun/v2/app/commands/testx"
order_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 OrderTestSuiteInjectParams struct {
dig.In
DB *sql.DB
Initials []contracts.Initial `group:"initials"`
}
type OrderTestSuite struct {
suite.Suite
OrderTestSuiteInjectParams
}
func Test_Order(t *testing.T) {
providers := testx.Default().With(Provide)
testx.Serve(providers, t, func(p OrderTestSuiteInjectParams) {
suite.Run(t, &OrderTestSuite{OrderTestSuiteInjectParams: p})
})
}
func (s *OrderTestSuite) Test_PurchaseFlow() {
Convey("Purchase Flow", 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,
)
// 1. Setup Data
// Creator
creator := &models.User{Username: "creator", Phone: "13800000001"}
models.UserQuery.WithContext(ctx).Create(creator)
// Tenant
tenant := &models.Tenant{UserID: creator.ID, Name: "Music Shop", Code: "shop1", Status: consts.TenantStatusVerified}
models.TenantQuery.WithContext(ctx).Create(tenant)
// Content
content := &models.Content{TenantID: tenant.ID, UserID: creator.ID, Title: "Song A", Status: consts.ContentStatusPublished}
models.ContentQuery.WithContext(ctx).Create(content)
// Price (10.00 CNY = 1000 cents)
price := &models.ContentPrice{TenantID: tenant.ID, ContentID: content.ID, PriceAmount: 1000, Currency: consts.CurrencyCNY}
models.ContentPriceQuery.WithContext(ctx).Create(price)
// Buyer
buyer := &models.User{Username: "buyer", Phone: "13900000001", Balance: 2000} // Has 20.00
models.UserQuery.WithContext(ctx).Create(buyer)
buyerCtx := context.WithValue(ctx, consts.CtxKeyUser, buyer.ID)
Convey("should create and pay order successfully", func() {
// Step 1: Create Order
form := &order_dto.OrderCreateForm{ContentID: cast.ToString(content.ID)}
createRes, err := Order.Create(buyerCtx, form)
So(err, ShouldBeNil)
So(createRes.OrderID, ShouldNotBeEmpty)
// Verify created status
oid := cast.ToInt64(createRes.OrderID)
o, _ := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(oid)).First()
So(o.Status, ShouldEqual, consts.OrderStatusCreated)
So(o.AmountPaid, ShouldEqual, 1000)
// Step 2: Pay Order
payForm := &order_dto.OrderPayForm{Method: "balance"}
_, err = Order.Pay(buyerCtx, createRes.OrderID, payForm)
So(err, ShouldBeNil)
// Verify Order Paid
o, _ = models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(oid)).First()
So(o.Status, ShouldEqual, consts.OrderStatusPaid)
So(o.PaidAt, ShouldNotBeZeroValue)
// Verify Balance Deducted
b, _ := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(buyer.ID)).First()
So(b.Balance, ShouldEqual, 1000) // 2000 - 1000
// Verify Access Granted
access, _ := models.ContentAccessQuery.WithContext(ctx).Where(models.ContentAccessQuery.UserID.Eq(buyer.ID), models.ContentAccessQuery.ContentID.Eq(content.ID)).First()
So(access, ShouldNotBeNil)
So(access.Status, ShouldEqual, consts.ContentAccessStatusActive)
// Verify Ledger Created (Creator received money logic?)
// Note: My implementation credits the TENANT OWNER (creator.ID).
l, _ := models.TenantLedgerQuery.WithContext(ctx).Where(models.TenantLedgerQuery.OrderID.Eq(o.ID)).First()
So(l, ShouldNotBeNil)
So(l.UserID, ShouldEqual, creator.ID)
So(l.Amount, ShouldEqual, 1000)
So(l.Type, ShouldEqual, consts.TenantLedgerTypeDebitPurchase)
})
Convey("should fail pay if insufficient balance", func() {
// Set balance to 5.00
models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(buyer.ID)).Update(models.UserQuery.Balance, 500)
form := &order_dto.OrderCreateForm{ContentID: cast.ToString(content.ID)}
createRes, err := Order.Create(buyerCtx, form)
So(err, ShouldBeNil)
payForm := &order_dto.OrderPayForm{Method: "balance"}
_, err = Order.Pay(buyerCtx, createRes.OrderID, payForm)
So(err, ShouldNotBeNil)
// Error should be QuotaExceeded or similar
})
})
}

View File

@@ -2,15 +2,24 @@ package services
import (
"context"
"time"
"quyun/v2/app/errorx"
super_dto "quyun/v2/app/http/v1/dto"
"quyun/v2/app/requests"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"github.com/google/uuid"
"github.com/spf13/cast"
"go.ipao.vip/gen/types"
)
// @provider
type super struct{}
func (s *super) Login(ctx context.Context, form *super_dto.LoginForm) (*super_dto.LoginResponse, error) {
// TODO: Admin specific login or reuse User service
return &super_dto.LoginResponse{}, nil
}
@@ -19,51 +28,236 @@ func (s *super) CheckToken(ctx context.Context) (*super_dto.LoginResponse, error
}
func (s *super) ListUsers(ctx context.Context, page, limit int, username string) (*requests.Pager, error) {
return &requests.Pager{}, nil
tbl, q := models.UserQuery.QueryContext(ctx)
if username != "" {
q = q.Where(tbl.Username.Like("%" + username + "%")).Or(tbl.Nickname.Like("%" + username + "%"))
}
p := requests.Pagination{Page: int64(page), Limit: int64(limit)}
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError
}
list, err := q.Offset(int(p.Offset())).Limit(int(p.Limit)).Order(tbl.ID.Desc()).Find()
if err != nil {
return nil, errorx.ErrDatabaseError
}
var data []super_dto.UserItem
for _, u := range list {
data = append(data, super_dto.UserItem{
SuperUserLite: super_dto.SuperUserLite{
ID: u.ID,
Username: u.Username,
Roles: u.Roles,
Status: u.Status,
// StatusDescription: u.Status.Description(), // Status is consts.UserStatus, it has Description()
// But u.Status might be string if gen didn't map it properly? No, it's consts.UserStatus.
StatusDescription: u.Status.Description(),
CreatedAt: u.CreatedAt.Format(time.RFC3339),
UpdatedAt: u.UpdatedAt.Format(time.RFC3339),
},
Balance: u.Balance,
BalanceFrozen: u.BalanceFrozen,
})
}
return &requests.Pager{
Pagination: p,
Total: total,
Items: data,
}, nil
}
func (s *super) GetUser(ctx context.Context, id int64) (*super_dto.UserItem, error) {
return &super_dto.UserItem{}, nil
tbl, q := models.UserQuery.QueryContext(ctx)
u, err := q.Where(tbl.ID.Eq(id)).First()
if err != nil {
return nil, errorx.ErrRecordNotFound
}
return &super_dto.UserItem{
SuperUserLite: super_dto.SuperUserLite{
ID: u.ID,
Username: u.Username,
Roles: u.Roles,
Status: u.Status,
StatusDescription: u.Status.Description(),
CreatedAt: u.CreatedAt.Format(time.RFC3339),
UpdatedAt: u.UpdatedAt.Format(time.RFC3339),
},
Balance: u.Balance,
BalanceFrozen: u.BalanceFrozen,
}, nil
}
func (s *super) UpdateUserStatus(ctx context.Context, id int64, form *super_dto.UserStatusUpdateForm) error {
tbl, q := models.UserQuery.QueryContext(ctx)
_, err := q.Where(tbl.ID.Eq(id)).Update(tbl.Status, consts.UserStatus(form.Status))
if err != nil {
return errorx.ErrDatabaseError
}
return nil
}
func (s *super) UpdateUserRoles(ctx context.Context, id int64, form *super_dto.UserRolesUpdateForm) error {
var roles types.Array[consts.Role]
for _, r := range form.Roles {
roles = append(roles, r)
}
tbl, q := models.UserQuery.QueryContext(ctx)
_, err := q.Where(tbl.ID.Eq(id)).Update(tbl.Roles, roles)
if err != nil {
return errorx.ErrDatabaseError
}
return nil
}
func (s *super) ListTenants(ctx context.Context, page, limit int, name string) (*requests.Pager, error) {
return &requests.Pager{}, nil
tbl, q := models.TenantQuery.QueryContext(ctx)
if name != "" {
q = q.Where(tbl.Name.Like("%" + name + "%"))
}
p := requests.Pagination{Page: int64(page), Limit: int64(limit)}
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError
}
list, err := q.Offset(int(p.Offset())).Limit(int(p.Limit)).Order(tbl.ID.Desc()).Find()
if err != nil {
return nil, errorx.ErrDatabaseError
}
var data []super_dto.TenantItem
for _, t := range list {
data = append(data, super_dto.TenantItem{
ID: t.ID,
UUID: t.UUID.String(),
Name: t.Name,
Code: t.Code,
Status: t.Status,
StatusDescription: t.Status.Description(),
UserID: t.UserID,
CreatedAt: t.CreatedAt.Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
})
}
return &requests.Pager{
Pagination: p,
Total: total,
Items: data,
}, nil
}
func (s *super) CreateTenant(ctx context.Context, form *super_dto.TenantCreateForm) error {
uid := cast.ToInt64(form.AdminUserID)
if _, err := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(uid)).First(); err != nil {
return errorx.ErrRecordNotFound.WithMsg("用户不存在")
}
t := &models.Tenant{
UserID: uid,
Name: form.Name,
Code: form.Code,
UUID: types.UUID(uuid.New()),
Status: consts.TenantStatusVerified,
}
if err := models.TenantQuery.WithContext(ctx).Create(t); err != nil {
return errorx.ErrDatabaseError
}
return nil
}
func (s *super) GetTenant(ctx context.Context, id int64) (*super_dto.TenantItem, error) {
return &super_dto.TenantItem{}, nil
tbl, q := models.TenantQuery.QueryContext(ctx)
t, err := q.Where(tbl.ID.Eq(id)).First()
if err != nil {
return nil, errorx.ErrRecordNotFound
}
return &super_dto.TenantItem{
ID: t.ID,
UUID: t.UUID.String(),
Name: t.Name,
Code: t.Code,
Status: t.Status,
StatusDescription: t.Status.Description(),
UserID: t.UserID,
CreatedAt: t.CreatedAt.Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
}, nil
}
func (s *super) UpdateTenantStatus(ctx context.Context, id int64, form *super_dto.TenantStatusUpdateForm) error {
tbl, q := models.TenantQuery.QueryContext(ctx)
_, err := q.Where(tbl.ID.Eq(id)).Update(tbl.Status, consts.TenantStatus(form.Status))
if err != nil {
return errorx.ErrDatabaseError
}
return nil
}
func (s *super) UpdateTenantExpire(ctx context.Context, id int64, form *super_dto.TenantExpireUpdateForm) error {
expire := time.Now().AddDate(0, 0, form.Duration)
tbl, q := models.TenantQuery.QueryContext(ctx)
_, err := q.Where(tbl.ID.Eq(id)).Update(tbl.ExpiredAt, expire)
if err != nil {
return errorx.ErrDatabaseError
}
return nil
}
func (s *super) ListContents(ctx context.Context, page, limit int) (*requests.Pager, error) {
return &requests.Pager{}, nil
tbl, q := models.ContentQuery.QueryContext(ctx)
p := requests.Pagination{Page: int64(page), Limit: int64(limit)}
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError
}
list, err := q.Offset(int(p.Offset())).Limit(int(p.Limit)).Order(tbl.ID.Desc()).Find()
if err != nil {
return nil, errorx.ErrDatabaseError
}
// Simplified DTO for list
var data []any
for _, c := range list {
data = append(data, c) // TODO: Map to DTO
}
return &requests.Pager{
Pagination: p,
Total: total,
Items: data,
}, nil
}
func (s *super) UpdateContentStatus(ctx context.Context, tenantID, contentID int64, form *super_dto.SuperTenantContentStatusUpdateForm) error {
tbl, q := models.ContentQuery.QueryContext(ctx)
_, err := q.Where(tbl.ID.Eq(contentID), tbl.TenantID.Eq(tenantID)).Update(tbl.Status, consts.ContentStatus(form.Status))
if err != nil {
return errorx.ErrDatabaseError
}
return nil
}
func (s *super) ListOrders(ctx context.Context, page, limit int) (*requests.Pager, error) {
return &requests.Pager{}, nil
tbl, q := models.OrderQuery.QueryContext(ctx)
p := requests.Pagination{Page: int64(page), Limit: int64(limit)}
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError
}
list, err := q.Offset(int(p.Offset())).Limit(int(p.Limit)).Order(tbl.ID.Desc()).Find()
if err != nil {
return nil, errorx.ErrDatabaseError
}
// TODO: Map to DTO
return &requests.Pager{
Pagination: p,
Total: total,
Items: list,
}, nil
}
func (s *super) GetOrder(ctx context.Context, id int64) (*super_dto.SuperOrderDetail, error) {
@@ -83,9 +277,9 @@ func (s *super) UserStatistics(ctx context.Context) ([]super_dto.UserStatistics,
}
func (s *super) UserStatuses(ctx context.Context) ([]requests.KV, error) {
return []requests.KV{}, nil
return consts.UserStatusItems(), nil
}
func (s *super) TenantStatuses(ctx context.Context) ([]requests.KV, error) {
return []requests.KV{}, nil
}
return consts.TenantStatusItems(), nil
}

View File

@@ -0,0 +1,91 @@
package services
import (
"database/sql"
"testing"
"quyun/v2/app/commands/testx"
super_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/stretchr/testify/suite"
"go.ipao.vip/atom/contracts"
"go.uber.org/dig"
)
type SuperTestSuiteInjectParams struct {
dig.In
DB *sql.DB
Initials []contracts.Initial `group:"initials"`
}
type SuperTestSuite struct {
suite.Suite
SuperTestSuiteInjectParams
}
func Test_Super(t *testing.T) {
providers := testx.Default().With(Provide)
testx.Serve(providers, t, func(p SuperTestSuiteInjectParams) {
suite.Run(t, &SuperTestSuite{SuperTestSuiteInjectParams: p})
})
}
func (s *SuperTestSuite) Test_ListUsers() {
Convey("ListUsers", s.T(), func() {
ctx := s.T().Context()
database.Truncate(ctx, s.DB, models.TableNameUser)
u1 := &models.User{Username: "user1", Nickname: "Alice"}
u2 := &models.User{Username: "user2", Nickname: "Bob"}
models.UserQuery.WithContext(ctx).Create(u1, u2)
Convey("should list users", func() {
res, err := Super.ListUsers(ctx, 1, 10, "")
So(err, ShouldBeNil)
So(res.Total, ShouldEqual, 2)
items := res.Items.([]super_dto.UserItem)
So(items[0].Username, ShouldEqual, "user2") // Desc order
})
Convey("should filter users", func() {
res, err := Super.ListUsers(ctx, 1, 10, "Alice")
So(err, ShouldBeNil)
So(res.Total, ShouldEqual, 1)
items := res.Items.([]super_dto.UserItem)
So(items[0].Username, ShouldEqual, "user1")
})
})
}
func (s *SuperTestSuite) Test_CreateTenant() {
Convey("CreateTenant", s.T(), func() {
ctx := s.T().Context()
database.Truncate(ctx, s.DB, models.TableNameUser, models.TableNameTenant)
u := &models.User{Username: "admin1"}
models.UserQuery.WithContext(ctx).Create(u)
Convey("should create tenant", func() {
form := &super_dto.TenantCreateForm{
Name: "Super Tenant",
Code: "st1",
AdminUserID: u.ID,
}
err := Super.CreateTenant(ctx, form)
So(err, ShouldBeNil)
t, _ := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.Code.Eq("st1")).First()
So(t, ShouldNotBeNil)
So(t.Name, ShouldEqual, "Super Tenant")
So(t.UserID, ShouldEqual, u.ID)
So(t.Status, ShouldEqual, consts.TenantStatusVerified)
})
})
}

View File

@@ -2,21 +2,126 @@ package services
import (
"context"
"errors"
"quyun/v2/app/errorx"
tenant_dto "quyun/v2/app/http/v1/dto"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"github.com/spf13/cast"
"go.ipao.vip/gen/types"
"gorm.io/gorm"
)
// @provider
type tenant struct{}
func (s *tenant) GetPublicProfile(ctx context.Context, id string) (*tenant_dto.TenantProfile, error) {
return &tenant_dto.TenantProfile{}, nil
// id could be Code or ID. Try Code first, then ID.
tbl, q := models.TenantQuery.QueryContext(ctx)
// Try to find by code or ID
var t *models.Tenant
var err error
// Assume id is ID for simplicity if numeric, or try both.
if cast.ToInt64(id) > 0 {
t, err = q.Where(tbl.ID.Eq(cast.ToInt64(id))).First()
} else {
t, err = q.Where(tbl.Code.Eq(id)).First()
}
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.ErrRecordNotFound
}
return nil, errorx.ErrDatabaseError
}
// Stats
// Followers
followers, _ := models.TenantUserQuery.WithContext(ctx).Where(models.TenantUserQuery.TenantID.Eq(t.ID)).Count()
// Contents
contentsCount, _ := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.TenantID.Eq(t.ID), models.ContentQuery.Status.Eq(consts.ContentStatusPublished)).Count()
// Likes
var likes int64
// Sum content likes
// Mock likes for now or fetch
// IsFollowing
isFollowing := false
userID := ctx.Value(consts.CtxKeyUser)
if userID != nil {
uid := cast.ToInt64(userID)
count, _ := models.TenantUserQuery.WithContext(ctx).Where(models.TenantUserQuery.TenantID.Eq(t.ID), models.TenantUserQuery.UserID.Eq(uid)).Count()
isFollowing = count > 0
}
// Config parsing (Unused for now as we don't map to bio yet)
// config := t.Config.Data()
return &tenant_dto.TenantProfile{
ID: cast.ToString(t.ID),
Name: t.Name,
Avatar: "", // From config
Cover: "", // From config
Bio: "", // From config
Description: "", // From config
CertType: "personal", // Mock
Stats: tenant_dto.Stats{
Followers: int(followers),
Contents: int(contentsCount),
Likes: int(likes),
},
IsFollowing: isFollowing,
}, nil
}
func (s *tenant) Follow(ctx context.Context, id string) error {
userID := ctx.Value(consts.CtxKeyUser)
if userID == nil {
return errorx.ErrUnauthorized
}
uid := cast.ToInt64(userID)
tid := cast.ToInt64(id)
// Check if tenant exists
_, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(tid)).First()
if err != nil {
return errorx.ErrRecordNotFound
}
// Add to tenant_users
tu := &models.TenantUser{
TenantID: tid,
UserID: uid,
Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleMember},
Status: consts.UserStatusVerified,
}
count, _ := models.TenantUserQuery.WithContext(ctx).Where(models.TenantUserQuery.TenantID.Eq(tid), models.TenantUserQuery.UserID.Eq(uid)).Count()
if count > 0 {
return nil // Already following
}
if err := models.TenantUserQuery.WithContext(ctx).Create(tu); err != nil {
return errorx.ErrDatabaseError
}
return nil
}
func (s *tenant) Unfollow(ctx context.Context, id string) error {
userID := ctx.Value(consts.CtxKeyUser)
if userID == nil {
return errorx.ErrUnauthorized
}
uid := cast.ToInt64(userID)
tid := cast.ToInt64(id)
_, err := models.TenantUserQuery.WithContext(ctx).Where(models.TenantUserQuery.TenantID.Eq(tid), models.TenantUserQuery.UserID.Eq(uid)).Delete()
if err != nil {
return errorx.ErrDatabaseError
}
return nil
}

View File

@@ -189,4 +189,4 @@ func (s *user) toAuthUserDTO(u *models.User) *auth_dto.User {
Points: u.Points,
IsRealNameVerified: u.IsRealNameVerified,
}
}
}

View File

@@ -2,17 +2,110 @@ package services
import (
"context"
"errors"
"time"
"quyun/v2/app/errorx"
user_dto "quyun/v2/app/http/v1/dto"
"quyun/v2/database/fields"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"github.com/google/uuid"
"github.com/spf13/cast"
"go.ipao.vip/gen/types"
"gorm.io/gorm"
)
// @provider
type wallet struct{}
func (s *wallet) GetWallet(ctx context.Context) (*user_dto.WalletResponse, error) {
return &user_dto.WalletResponse{}, nil
userID := ctx.Value(consts.CtxKeyUser)
if userID == nil {
return nil, errorx.ErrUnauthorized
}
uid := cast.ToInt64(userID)
// Get Balance
u, err := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(uid)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.ErrRecordNotFound
}
return nil, errorx.ErrDatabaseError
}
// Get Transactions (Orders)
// Both purchase (expense) and recharge (income - if paid)
tbl, q := models.OrderQuery.QueryContext(ctx)
orders, err := q.Where(tbl.UserID.Eq(uid), tbl.Status.Eq(consts.OrderStatusPaid)).
Order(tbl.CreatedAt.Desc()).
Limit(20). // Limit to recent 20
Find()
if err != nil {
return nil, errorx.ErrDatabaseError
}
var txs []user_dto.Transaction
for _, o := range orders {
var txType string
var title string
if o.Type == consts.OrderTypeContentPurchase {
txType = "expense"
title = "购买内容"
} else if o.Type == consts.OrderTypeRecharge {
txType = "income"
title = "钱包充值"
}
txs = append(txs, user_dto.Transaction{
ID: cast.ToString(o.ID),
Title: title,
Amount: float64(o.AmountPaid) / 100.0,
Type: txType,
Date: o.CreatedAt.Format(time.RFC3339),
})
}
return &user_dto.WalletResponse{
Balance: float64(u.Balance) / 100.0,
Transactions: txs,
}, nil
}
func (s *wallet) Recharge(ctx context.Context, form *user_dto.RechargeForm) (*user_dto.RechargeResponse, error) {
return &user_dto.RechargeResponse{}, nil
userID := ctx.Value(consts.CtxKeyUser)
if userID == nil {
return nil, errorx.ErrUnauthorized
}
uid := cast.ToInt64(userID)
amount := int64(form.Amount * 100)
if amount <= 0 {
return nil, errorx.ErrBadRequest.WithMsg("金额无效")
}
// Create Recharge Order
order := &models.Order{
TenantID: 0, // Platform / System
UserID: uid,
Type: consts.OrderTypeRecharge,
Status: consts.OrderStatusCreated,
Currency: consts.CurrencyCNY,
AmountOriginal: amount,
AmountPaid: amount,
IdempotencyKey: uuid.NewString(),
Snapshot: types.NewJSONType(fields.OrdersSnapshot{}),
}
if err := models.OrderQuery.WithContext(ctx).Create(order); err != nil {
return nil, errorx.ErrDatabaseError
}
// Mock Pay Params
return &user_dto.RechargeResponse{
PayParams: "mock_recharge_url",
OrderID: cast.ToString(order.ID),
}, nil
}

View File

@@ -0,0 +1,96 @@
package services
import (
"context"
"database/sql"
"testing"
"quyun/v2/app/commands/testx"
user_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/stretchr/testify/suite"
"go.ipao.vip/atom/contracts"
"go.uber.org/dig"
)
type WalletTestSuiteInjectParams struct {
dig.In
DB *sql.DB
Initials []contracts.Initial `group:"initials"`
}
type WalletTestSuite struct {
suite.Suite
WalletTestSuiteInjectParams
}
func Test_Wallet(t *testing.T) {
providers := testx.Default().With(Provide)
testx.Serve(providers, t, func(p WalletTestSuiteInjectParams) {
suite.Run(t, &WalletTestSuite{WalletTestSuiteInjectParams: p})
})
}
func (s *WalletTestSuite) Test_GetWallet() {
Convey("GetWallet", s.T(), func() {
ctx := s.T().Context()
database.Truncate(ctx, s.DB, models.TableNameUser, models.TableNameOrder)
u := &models.User{Username: "wallet_user", Balance: 5000} // 50.00
models.UserQuery.WithContext(ctx).Create(u)
ctx = context.WithValue(ctx, consts.CtxKeyUser, u.ID)
// Create Orders
o1 := &models.Order{
TenantID: 0, UserID: u.ID, Type: consts.OrderTypeRecharge, Status: consts.OrderStatusPaid,
AmountPaid: 5000,
}
o2 := &models.Order{
TenantID: 1, UserID: u.ID, Type: consts.OrderTypeContentPurchase, Status: consts.OrderStatusPaid,
AmountPaid: 1000,
}
models.OrderQuery.WithContext(ctx).Create(o1, o2)
Convey("should return balance and transactions", func() {
res, err := Wallet.GetWallet(ctx)
So(err, ShouldBeNil)
So(res.Balance, ShouldEqual, 50.0)
So(len(res.Transactions), ShouldEqual, 2)
// Order by CreatedAt Desc
types := []string{res.Transactions[0].Type, res.Transactions[1].Type}
So(types, ShouldContain, "income")
So(types, ShouldContain, "expense")
})
})
}
func (s *WalletTestSuite) Test_Recharge() {
Convey("Recharge", s.T(), func() {
ctx := s.T().Context()
database.Truncate(ctx, s.DB, models.TableNameUser, models.TableNameOrder)
u := &models.User{Username: "recharge_user"}
models.UserQuery.WithContext(ctx).Create(u)
ctx = context.WithValue(ctx, consts.CtxKeyUser, u.ID)
Convey("should create recharge order", func() {
form := &user_dto.RechargeForm{Amount: 100.0}
res, err := Wallet.Recharge(ctx, form)
So(err, ShouldBeNil)
So(res.OrderID, ShouldNotBeEmpty)
// Verify order
o, _ := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.Type.Eq(consts.OrderTypeRecharge)).First()
So(o, ShouldNotBeNil)
So(o.AmountPaid, ShouldEqual, 10000)
So(o.TenantID, ShouldEqual, 0)
})
})
}