1102 lines
29 KiB
Go
1102 lines
29 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"quyun/v2/app/errorx"
|
|
creator_dto "quyun/v2/app/http/v1/dto"
|
|
"quyun/v2/app/requests"
|
|
"quyun/v2/database/fields"
|
|
"quyun/v2/database/models"
|
|
"quyun/v2/pkg/consts"
|
|
|
|
"github.com/google/uuid"
|
|
"go.ipao.vip/gen/types"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// @provider
|
|
type creator struct{}
|
|
|
|
var genreMap = map[string]string{
|
|
"Jingju": "京剧",
|
|
"Kunqu": "昆曲",
|
|
"Yueju": "越剧",
|
|
"Yuju": "豫剧",
|
|
"Huangmeixi": "黄梅戏",
|
|
"Pingju": "评剧",
|
|
"Qinqiang": "秦腔",
|
|
}
|
|
|
|
func (s *creator) Apply(ctx context.Context, tenantID, userID int64, form *creator_dto.ApplyForm) error {
|
|
if userID == 0 {
|
|
return errorx.ErrUnauthorized
|
|
}
|
|
uid := 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.WithCause(err)
|
|
}
|
|
|
|
// 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.WithCause(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *creator) Dashboard(ctx context.Context, tenantID, userID int64) (*creator_dto.DashboardStats, error) {
|
|
tid, err := s.getTenantID(ctx, tenantID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 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: revenue / 100.0},
|
|
PendingRefunds: int(pendingRefunds),
|
|
NewMessages: 0,
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
func (s *creator) ListContents(
|
|
ctx context.Context,
|
|
tenantID int64,
|
|
userID int64,
|
|
filter *creator_dto.CreatorContentListFilter,
|
|
) (*requests.Pager, error) {
|
|
tid, err := s.getTenantID(ctx, tenantID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tbl, q := models.ContentQuery.QueryContext(ctx)
|
|
q = q.Where(tbl.TenantID.Eq(tid))
|
|
|
|
if filter.Status != nil && *filter.Status != "" {
|
|
q = q.Where(tbl.Status.Eq(consts.ContentStatus(*filter.Status)))
|
|
}
|
|
if filter.Visibility != nil && *filter.Visibility != "" {
|
|
q = q.Where(tbl.Visibility.Eq(consts.ContentVisibility(*filter.Visibility)))
|
|
}
|
|
if filter.Genre != nil && *filter.Genre != "" {
|
|
val := *filter.Genre
|
|
if cn, ok := genreMap[val]; ok {
|
|
q = q.Where(tbl.Genre.In(val, cn))
|
|
} else {
|
|
q = q.Where(tbl.Genre.Eq(val))
|
|
}
|
|
}
|
|
if filter.Key != nil && *filter.Key != "" {
|
|
q = q.Where(tbl.Key.Eq(*filter.Key))
|
|
}
|
|
if filter.Keyword != nil && *filter.Keyword != "" {
|
|
q = q.Where(tbl.Title.Like("%" + *filter.Keyword + "%"))
|
|
}
|
|
|
|
// Pagination
|
|
filter.Pagination.Format()
|
|
total, err := q.Count()
|
|
if err != nil {
|
|
return nil, errorx.ErrDatabaseError.WithCause(err)
|
|
}
|
|
|
|
var list []*models.Content
|
|
|
|
// Sorting
|
|
sort := "latest"
|
|
if filter.Sort != nil && *filter.Sort != "" {
|
|
sort = *filter.Sort
|
|
}
|
|
switch sort {
|
|
case "oldest":
|
|
q = q.Order(tbl.ID.Asc())
|
|
case "views":
|
|
q = q.Order(tbl.Views.Desc())
|
|
case "likes":
|
|
q = q.Order(tbl.Likes.Desc())
|
|
default:
|
|
q = q.Order(tbl.ID.Desc())
|
|
}
|
|
|
|
err = q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).
|
|
UnderlyingDB().
|
|
Preload("ContentAssets").
|
|
Preload("ContentAssets.Asset").
|
|
Find(&list).Error
|
|
if err != nil {
|
|
return nil, errorx.ErrDatabaseError.WithCause(err)
|
|
}
|
|
|
|
// Fetch Prices
|
|
priceMap := make(map[int64]float64)
|
|
if len(list) > 0 {
|
|
ids := make([]int64, len(list))
|
|
for i, item := range list {
|
|
ids[i] = item.ID
|
|
}
|
|
pTbl, pQ := models.ContentPriceQuery.QueryContext(ctx)
|
|
prices, _ := pQ.Where(pTbl.ContentID.In(ids...)).Find()
|
|
for _, p := range prices {
|
|
priceMap[p.ContentID] = float64(p.PriceAmount) / 100.0
|
|
}
|
|
}
|
|
|
|
var data []creator_dto.CreatorContentItem
|
|
for _, item := range list {
|
|
var imageCount, videoCount, audioCount int
|
|
var cover string
|
|
var firstImage string
|
|
|
|
for _, ca := range item.ContentAssets {
|
|
if ca.Asset == nil {
|
|
continue
|
|
}
|
|
|
|
// Count logic
|
|
switch ca.Asset.Type {
|
|
case consts.MediaAssetTypeImage:
|
|
imageCount++
|
|
if firstImage == "" {
|
|
firstImage = Common.GetAssetURL(ca.Asset.ObjectKey)
|
|
}
|
|
case consts.MediaAssetTypeVideo:
|
|
videoCount++
|
|
case consts.MediaAssetTypeAudio:
|
|
audioCount++
|
|
}
|
|
|
|
// Cover logic
|
|
if ca.Role == consts.ContentAssetRoleCover && cover == "" {
|
|
cover = Common.GetAssetURL(ca.Asset.ObjectKey)
|
|
}
|
|
}
|
|
|
|
if cover == "" {
|
|
cover = firstImage
|
|
}
|
|
|
|
data = append(data, creator_dto.CreatorContentItem{
|
|
ID: item.ID,
|
|
Title: item.Title,
|
|
Genre: item.Genre,
|
|
Key: item.Key,
|
|
Price: priceMap[item.ID],
|
|
Views: int(item.Views),
|
|
Likes: int(item.Likes),
|
|
Cover: cover,
|
|
ImageCount: imageCount,
|
|
VideoCount: videoCount,
|
|
AudioCount: audioCount,
|
|
Status: string(item.Status),
|
|
Visibility: string(item.Visibility),
|
|
CreatedAt: item.CreatedAt.Format("2006-01-02 15:04"),
|
|
IsPinned: item.IsPinned,
|
|
IsPurchased: false,
|
|
})
|
|
}
|
|
|
|
return &requests.Pager{
|
|
Pagination: filter.Pagination,
|
|
Total: total,
|
|
Items: data,
|
|
}, nil
|
|
}
|
|
|
|
func (s *creator) CreateContent(ctx context.Context, tenantID, userID int64, form *creator_dto.ContentCreateForm) error {
|
|
tid, err := s.getTenantID(ctx, tenantID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
uid := userID
|
|
|
|
return models.Q.Transaction(func(tx *models.Query) error {
|
|
status := consts.ContentStatusPublished
|
|
if form.Status != "" {
|
|
status = consts.ContentStatus(form.Status)
|
|
}
|
|
// 校验素材归属,避免跨租户引用。
|
|
if err := s.validateContentAssets(ctx, tx, tid, uid, form.CoverIDs, form.MediaIDs); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 1. Create Content
|
|
content := &models.Content{
|
|
TenantID: tid,
|
|
UserID: uid,
|
|
Title: form.Title,
|
|
Genre: form.Genre,
|
|
Key: form.Key,
|
|
Status: status,
|
|
}
|
|
if err := tx.Content.WithContext(ctx).Create(content); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 2. Link Assets
|
|
var assets []*models.ContentAsset
|
|
// Covers
|
|
for i, mid := range form.CoverIDs {
|
|
assets = append(assets, &models.ContentAsset{
|
|
TenantID: tid,
|
|
UserID: uid,
|
|
ContentID: content.ID,
|
|
AssetID: mid,
|
|
Sort: int32(i),
|
|
Role: consts.ContentAssetRoleCover,
|
|
})
|
|
}
|
|
// Main Media
|
|
for i, mid := range form.MediaIDs {
|
|
assets = append(assets, &models.ContentAsset{
|
|
TenantID: tid,
|
|
UserID: uid,
|
|
ContentID: content.ID,
|
|
AssetID: mid,
|
|
Sort: int32(i),
|
|
Role: consts.ContentAssetRoleMain,
|
|
})
|
|
}
|
|
if len(assets) > 0 {
|
|
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,
|
|
tenantID int64,
|
|
userID int64,
|
|
id int64,
|
|
form *creator_dto.ContentUpdateForm,
|
|
) error {
|
|
tid, err := s.getTenantID(ctx, tenantID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
uid := userID
|
|
|
|
return models.Q.Transaction(func(tx *models.Query) error {
|
|
// 1. Check Ownership
|
|
c, err := tx.Content.WithContext(ctx).Where(tx.Content.ID.Eq(id), tx.Content.TenantID.Eq(tid)).First()
|
|
if err != nil {
|
|
return errorx.ErrRecordNotFound
|
|
}
|
|
|
|
// 2. Update Content
|
|
contentUpdates := &models.Content{
|
|
Title: form.Title,
|
|
Genre: form.Genre,
|
|
Key: form.Key,
|
|
}
|
|
if form.Status != "" {
|
|
contentUpdates.Status = consts.ContentStatus(form.Status)
|
|
}
|
|
|
|
// Determine final status
|
|
finalStatus := c.Status
|
|
if form.Status != "" {
|
|
finalStatus = consts.ContentStatus(form.Status)
|
|
}
|
|
|
|
// Validation: Only published content can be pinned
|
|
if form.IsPinned != nil && *form.IsPinned && finalStatus != consts.ContentStatusPublished {
|
|
return errorx.ErrBadRequest.WithMsg("只有已发布的内容支持置顶")
|
|
}
|
|
|
|
// Perform standard updates
|
|
_, err = tx.Content.WithContext(ctx).Where(tx.Content.ID.Eq(id)).Updates(contentUpdates)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Handle IsPinned Logic
|
|
if finalStatus != consts.ContentStatusPublished {
|
|
// Force Unpin if not published
|
|
_, err = tx.Content.WithContext(ctx).Where(tx.Content.ID.Eq(id)).UpdateSimple(tx.Content.IsPinned.Value(false))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else if form.IsPinned != nil {
|
|
// Explicit Pin Update requested
|
|
_, err = tx.Content.WithContext(ctx).Where(tx.Content.ID.Eq(id)).UpdateSimple(tx.Content.IsPinned.Value(*form.IsPinned))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// If setting to true, unpin others
|
|
if *form.IsPinned {
|
|
if _, err := tx.Content.WithContext(ctx).
|
|
Where(tx.Content.TenantID.Eq(tid), tx.Content.ID.Neq(id)).
|
|
UpdateSimple(tx.Content.IsPinned.Value(false)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Update Price
|
|
// Check if price exists
|
|
if form.Price != nil {
|
|
count, _ := tx.ContentPrice.WithContext(ctx).Where(tx.ContentPrice.ContentID.Eq(id)).Count()
|
|
newPrice := int64(*form.Price * 100)
|
|
if count > 0 {
|
|
_, err = tx.ContentPrice.WithContext(ctx).
|
|
Where(tx.ContentPrice.ContentID.Eq(id)).
|
|
UpdateSimple(tx.ContentPrice.PriceAmount.Value(newPrice))
|
|
} else {
|
|
err = tx.ContentPrice.WithContext(ctx).Create(&models.ContentPrice{
|
|
TenantID: tid,
|
|
UserID: c.UserID,
|
|
ContentID: id,
|
|
PriceAmount: newPrice,
|
|
Currency: consts.CurrencyCNY,
|
|
})
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// 4. Update Assets (Full replacement strategy)
|
|
if err := s.validateContentAssets(ctx, tx, tid, uid, form.CoverIDs, form.MediaIDs); err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = tx.ContentAsset.WithContext(ctx).Where(tx.ContentAsset.ContentID.Eq(id)).Delete()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var assets []*models.ContentAsset
|
|
// Covers
|
|
for i, mid := range form.CoverIDs {
|
|
assets = append(assets, &models.ContentAsset{
|
|
TenantID: tid,
|
|
UserID: uid,
|
|
ContentID: id,
|
|
AssetID: mid,
|
|
Sort: int32(i),
|
|
Role: consts.ContentAssetRoleCover,
|
|
})
|
|
}
|
|
// Main Media
|
|
for i, mid := range form.MediaIDs {
|
|
assets = append(assets, &models.ContentAsset{
|
|
TenantID: tid,
|
|
UserID: uid,
|
|
ContentID: id,
|
|
AssetID: mid,
|
|
Sort: int32(i),
|
|
Role: consts.ContentAssetRoleMain,
|
|
})
|
|
}
|
|
if len(assets) > 0 {
|
|
if err := tx.ContentAsset.WithContext(ctx).Create(assets...); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *creator) DeleteContent(ctx context.Context, tenantID, userID, id int64) error {
|
|
tid, err := s.getTenantID(ctx, tenantID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check if purchased (ContentAccess exists)
|
|
count, _ := models.ContentAccessQuery.WithContext(ctx).Where(models.ContentAccessQuery.ContentID.Eq(id)).Count()
|
|
if count > 0 {
|
|
return errorx.ErrPreconditionFailed.WithMsg("该内容已被购买,无法删除")
|
|
}
|
|
|
|
_, err = models.ContentQuery.WithContext(ctx).
|
|
Where(models.ContentQuery.ID.Eq(id), models.ContentQuery.TenantID.Eq(tid)).
|
|
Delete()
|
|
if err != nil {
|
|
return errorx.ErrDatabaseError.WithCause(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *creator) GetContent(ctx context.Context, tenantID, userID, id int64) (*creator_dto.ContentEditDTO, error) {
|
|
tid, err := s.getTenantID(ctx, tenantID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Fetch Content with preloads
|
|
var c models.Content
|
|
err = models.ContentQuery.WithContext(ctx).
|
|
Where(models.ContentQuery.ID.Eq(id), models.ContentQuery.TenantID.Eq(tid)).
|
|
UnderlyingDB().
|
|
Preload("ContentAssets", func(db *gorm.DB) *gorm.DB {
|
|
return db.Order("sort ASC")
|
|
}).
|
|
Preload("ContentAssets.Asset").
|
|
First(&c).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errorx.ErrRecordNotFound
|
|
}
|
|
return nil, errorx.ErrDatabaseError.WithCause(err)
|
|
}
|
|
|
|
// Fetch Price
|
|
var price float64
|
|
cp, err := models.ContentPriceQuery.WithContext(ctx).Where(models.ContentPriceQuery.ContentID.Eq(id)).First()
|
|
if err == nil {
|
|
price = float64(cp.PriceAmount) / 100.0
|
|
}
|
|
|
|
dto := &creator_dto.ContentEditDTO{
|
|
ID: c.ID,
|
|
Title: c.Title,
|
|
Genre: c.Genre,
|
|
Key: c.Key,
|
|
Description: c.Description,
|
|
Status: string(c.Status),
|
|
Price: price,
|
|
EnableTrial: c.PreviewSeconds > 0,
|
|
PreviewSeconds: int(c.PreviewSeconds),
|
|
Assets: make([]creator_dto.AssetDTO, 0),
|
|
}
|
|
|
|
for _, ca := range c.ContentAssets {
|
|
if ca.Asset != nil {
|
|
meta := ca.Asset.Meta.Data()
|
|
name := meta.Filename
|
|
if name == "" {
|
|
// Fallback: strip UUID prefix (36 chars + 1 underscore = 37)
|
|
if len(ca.Asset.ObjectKey) > 37 && ca.Asset.ObjectKey[36] == '_' {
|
|
name = ca.Asset.ObjectKey[37:]
|
|
} else {
|
|
name = ca.Asset.ObjectKey
|
|
}
|
|
}
|
|
|
|
sizeBytes := meta.Size
|
|
sizeMB := float64(sizeBytes) / 1024.0 / 1024.0
|
|
sizeStr := strconv.FormatFloat(float64(int(sizeMB*100))/100.0, 'f', -1, 64) + " MB"
|
|
|
|
dto.Assets = append(dto.Assets, creator_dto.AssetDTO{
|
|
ID: ca.AssetID,
|
|
Role: string(ca.Role),
|
|
Type: string(ca.Asset.Type),
|
|
URL: Common.GetAssetURL(ca.Asset.ObjectKey),
|
|
Name: name,
|
|
Size: sizeStr,
|
|
Sort: int(ca.Sort),
|
|
})
|
|
}
|
|
}
|
|
return dto, nil
|
|
}
|
|
|
|
func (s *creator) ListOrders(
|
|
ctx context.Context,
|
|
tenantID int64,
|
|
userID int64,
|
|
filter *creator_dto.CreatorOrderListFilter,
|
|
) ([]creator_dto.Order, error) {
|
|
tid, err := s.getTenantID(ctx, tenantID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tbl, q := models.OrderQuery.QueryContext(ctx)
|
|
q = q.Where(tbl.TenantID.Eq(tid))
|
|
|
|
if filter.Status != nil && *filter.Status != "" {
|
|
q = q.Where(tbl.Status.Eq(consts.OrderStatus(*filter.Status)))
|
|
}
|
|
|
|
if filter.Keyword != nil && *filter.Keyword != "" {
|
|
k := *filter.Keyword
|
|
if id, err := strconv.ParseInt(k, 10, 64); err == nil {
|
|
q = q.Where(tbl.ID.Eq(id))
|
|
} else {
|
|
uTbl, uQ := models.UserQuery.QueryContext(ctx)
|
|
users, _ := uQ.Where(uTbl.Nickname.Like("%" + k + "%")).Find()
|
|
uids := make([]int64, len(users))
|
|
for i, u := range users {
|
|
uids[i] = u.ID
|
|
}
|
|
if len(uids) > 0 {
|
|
q = q.Where(tbl.UserID.In(uids...))
|
|
} else {
|
|
q = q.Where(tbl.ID.Eq(-1)) // Match nothing
|
|
}
|
|
}
|
|
}
|
|
|
|
list, err := q.Order(tbl.CreatedAt.Desc()).Find()
|
|
if err != nil {
|
|
return nil, errorx.ErrDatabaseError.WithCause(err)
|
|
}
|
|
|
|
if len(list) == 0 {
|
|
return []creator_dto.Order{}, nil
|
|
}
|
|
|
|
// 批量加载买家、订单明细、内容,避免列表场景 N+1 查询。
|
|
orderIDs := make([]int64, 0, len(list))
|
|
userSet := make(map[int64]struct{}, len(list))
|
|
for _, o := range list {
|
|
orderIDs = append(orderIDs, o.ID)
|
|
if o.UserID > 0 {
|
|
userSet[o.UserID] = struct{}{}
|
|
}
|
|
}
|
|
|
|
userIDs := make([]int64, 0, len(userSet))
|
|
for id := range userSet {
|
|
userIDs = append(userIDs, id)
|
|
}
|
|
|
|
userMap := make(map[int64]*models.User, len(userIDs))
|
|
if len(userIDs) > 0 {
|
|
uTbl, uQ := models.UserQuery.QueryContext(ctx)
|
|
users, err := uQ.Where(uTbl.ID.In(userIDs...)).Find()
|
|
if err != nil {
|
|
return nil, errorx.ErrDatabaseError.WithCause(err)
|
|
}
|
|
for _, u := range users {
|
|
userMap[u.ID] = u
|
|
}
|
|
}
|
|
|
|
itemMap := make(map[int64]*models.OrderItem, len(orderIDs))
|
|
contentSet := make(map[int64]struct{}, len(orderIDs))
|
|
if len(orderIDs) > 0 {
|
|
itemTbl, itemQ := models.OrderItemQuery.QueryContext(ctx)
|
|
items, err := itemQ.Where(itemTbl.OrderID.In(orderIDs...)).
|
|
Order(itemTbl.OrderID.Asc(), itemTbl.ID.Asc()).
|
|
Find()
|
|
if err != nil {
|
|
return nil, errorx.ErrDatabaseError.WithCause(err)
|
|
}
|
|
for _, item := range items {
|
|
if _, exists := itemMap[item.OrderID]; exists {
|
|
continue
|
|
}
|
|
itemMap[item.OrderID] = item
|
|
if item.ContentID > 0 {
|
|
contentSet[item.ContentID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
contentIDs := make([]int64, 0, len(contentSet))
|
|
for id := range contentSet {
|
|
contentIDs = append(contentIDs, id)
|
|
}
|
|
|
|
contentMap := make(map[int64]*models.Content, len(contentIDs))
|
|
if len(contentIDs) > 0 {
|
|
var contents []*models.Content
|
|
cTbl, cQ := models.ContentQuery.QueryContext(ctx)
|
|
err := cQ.Where(cTbl.ID.In(contentIDs...)).
|
|
UnderlyingDB().
|
|
Preload("ContentAssets").
|
|
Preload("ContentAssets.Asset").
|
|
Find(&contents).Error
|
|
if err != nil {
|
|
return nil, errorx.ErrDatabaseError.WithCause(err)
|
|
}
|
|
for _, c := range contents {
|
|
contentMap[c.ID] = c
|
|
}
|
|
}
|
|
|
|
data := make([]creator_dto.Order, 0, len(list))
|
|
for _, o := range list {
|
|
buyerName := "未知用户"
|
|
buyerAvatar := ""
|
|
if u := userMap[o.UserID]; u != nil {
|
|
buyerName = u.Nickname
|
|
buyerAvatar = u.Avatar
|
|
}
|
|
|
|
var title, cover string
|
|
if item := itemMap[o.ID]; item != nil {
|
|
if c := contentMap[item.ContentID]; c != nil {
|
|
title = c.Title
|
|
for _, ca := range c.ContentAssets {
|
|
if ca.Role == consts.ContentAssetRoleCover && ca.Asset != nil {
|
|
cover = Common.GetAssetURL(ca.Asset.ObjectKey)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
data = append(data, creator_dto.Order{
|
|
ID: o.ID,
|
|
Status: string(o.Status),
|
|
Amount: float64(o.AmountPaid) / 100.0,
|
|
CreateTime: o.CreatedAt.Format(time.RFC3339),
|
|
BuyerName: buyerName,
|
|
BuyerAvatar: buyerAvatar,
|
|
Title: title,
|
|
Cover: cover,
|
|
})
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func (s *creator) ProcessRefund(ctx context.Context, tenantID, userID, id int64, form *creator_dto.RefundForm) error {
|
|
tid, err := s.getTenantID(ctx, tenantID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
uid := userID // Creator ID
|
|
|
|
// Fetch Order
|
|
o, err := models.OrderQuery.WithContext(ctx).
|
|
Where(models.OrderQuery.ID.Eq(id), models.OrderQuery.TenantID.Eq(tid)).
|
|
First()
|
|
if err != nil {
|
|
return errorx.ErrRecordNotFound
|
|
}
|
|
|
|
// Validate Status
|
|
// Allow refunding 'refunding' orders. Or 'paid' if we treat this as "Initiate Refund".
|
|
// Given "Action" (accept/reject), assume 'refunding'.
|
|
if o.Status != consts.OrderStatusRefunding {
|
|
return errorx.ErrStatusConflict.WithMsg("订单状态不是退款中")
|
|
}
|
|
|
|
if form.Action == "reject" {
|
|
_, err := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(id)).Updates(&models.Order{
|
|
Status: consts.OrderStatusPaid,
|
|
RefundReason: form.Reason, // Store reject reason? Or clear it?
|
|
})
|
|
return err
|
|
}
|
|
|
|
if form.Action == "accept" {
|
|
// 关键退款事务:遇到数据库冲突/死锁时短暂退避重试,避免退款卡住。
|
|
return retryCriticalWrite(ctx, func() error {
|
|
return models.Q.Transaction(func(tx *models.Query) error {
|
|
// 1. Deduct Creator Balance
|
|
// We credited Creator User Balance in Order.Pay. Now deduct it.
|
|
info, err := tx.User.WithContext(ctx).
|
|
Where(tx.User.ID.Eq(uid), 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. Credit Buyer Balance
|
|
_, err = tx.User.WithContext(ctx).
|
|
Where(tx.User.ID.Eq(o.UserID)).
|
|
Update(tx.User.Balance, gorm.Expr("balance + ?", o.AmountPaid))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 3. Update Order Status
|
|
_, err = tx.Order.WithContext(ctx).Where(tx.Order.ID.Eq(id)).Updates(&models.Order{
|
|
Status: consts.OrderStatusRefunded,
|
|
RefundedAt: time.Now(),
|
|
RefundOperatorUserID: uid,
|
|
RefundReason: form.Reason,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 4. Revoke Content Access
|
|
// Fetch order items to get content IDs
|
|
items, _ := tx.OrderItem.WithContext(ctx).Where(tx.OrderItem.OrderID.Eq(o.ID)).Find()
|
|
contentIDs := make([]int64, len(items))
|
|
for i, item := range items {
|
|
contentIDs[i] = item.ContentID
|
|
}
|
|
if len(contentIDs) > 0 {
|
|
_, err = tx.ContentAccess.WithContext(ctx).
|
|
Where(tx.ContentAccess.UserID.Eq(o.UserID), tx.ContentAccess.ContentID.In(contentIDs...)).
|
|
UpdateSimple(tx.ContentAccess.Status.Value(consts.ContentAccessStatusRevoked))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// 5. Create Tenant Ledger
|
|
ledger := &models.TenantLedger{
|
|
TenantID: tid,
|
|
UserID: uid,
|
|
OrderID: o.ID,
|
|
Type: consts.TenantLedgerTypeCreditRefund,
|
|
Amount: o.AmountPaid,
|
|
Remark: "退款: " + form.Reason,
|
|
OperatorUserID: uid,
|
|
IdempotencyKey: uuid.NewString(),
|
|
}
|
|
if err := tx.TenantLedger.WithContext(ctx).Create(ledger); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
})
|
|
}
|
|
|
|
return errorx.ErrBadRequest.WithMsg("无效的操作")
|
|
}
|
|
|
|
func (s *creator) GetSettings(ctx context.Context, tenantID, userID int64) (*creator_dto.Settings, error) {
|
|
tid, err := s.getTenantID(ctx, tenantID, userID)
|
|
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
|
|
}
|
|
cfg := t.Config.Data()
|
|
return &creator_dto.Settings{
|
|
ID: t.ID,
|
|
Name: t.Name,
|
|
Bio: cfg.Bio,
|
|
Avatar: cfg.Avatar,
|
|
Cover: cfg.Cover,
|
|
Description: cfg.Description,
|
|
}, nil
|
|
}
|
|
|
|
func (s *creator) UpdateSettings(ctx context.Context, tenantID, userID int64, form *creator_dto.Settings) error {
|
|
tid, err := s.getTenantID(ctx, tenantID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(tid)).First()
|
|
if err != nil {
|
|
return errorx.ErrRecordNotFound
|
|
}
|
|
|
|
cfg := t.Config.Data()
|
|
cfg.Bio = form.Bio
|
|
cfg.Avatar = form.Avatar
|
|
cfg.Cover = form.Cover
|
|
cfg.Description = form.Description
|
|
|
|
_, err = models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(tid)).Updates(&models.Tenant{
|
|
Name: form.Name,
|
|
Config: types.NewJSONType(cfg),
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (s *creator) ListPayoutAccounts(ctx context.Context, tenantID, userID int64) ([]creator_dto.PayoutAccount, error) {
|
|
tid, err := s.getTenantID(ctx, tenantID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tbl, q := models.PayoutAccountQuery.QueryContext(ctx)
|
|
list, err := q.Where(tbl.TenantID.Eq(tid)).Find()
|
|
if err != nil {
|
|
return nil, errorx.ErrDatabaseError.WithCause(err)
|
|
}
|
|
|
|
var data []creator_dto.PayoutAccount
|
|
for _, v := range list {
|
|
data = append(data, creator_dto.PayoutAccount{
|
|
ID: v.ID,
|
|
Type: string(v.Type),
|
|
Name: v.Name,
|
|
Account: v.Account,
|
|
Realname: v.Realname,
|
|
Status: v.Status,
|
|
StatusDescription: v.Status.Description(),
|
|
ReviewedAt: s.formatTime(v.ReviewedAt),
|
|
ReviewReason: v.ReviewReason,
|
|
})
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func (s *creator) AddPayoutAccount(ctx context.Context, tenantID, userID int64, form *creator_dto.PayoutAccount) error {
|
|
tid, err := s.getTenantID(ctx, tenantID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
uid := userID
|
|
|
|
pa := &models.PayoutAccount{
|
|
TenantID: tid,
|
|
UserID: uid,
|
|
Type: consts.PayoutAccountType(form.Type),
|
|
Name: form.Name,
|
|
Account: form.Account,
|
|
Realname: form.Realname,
|
|
Status: consts.PayoutAccountStatusPending,
|
|
}
|
|
if err := models.PayoutAccountQuery.WithContext(ctx).Create(pa); err != nil {
|
|
return errorx.ErrDatabaseError.WithCause(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *creator) RemovePayoutAccount(ctx context.Context, tenantID, userID, id int64) error {
|
|
tid, err := s.getTenantID(ctx, tenantID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tbl, q := models.PayoutAccountQuery.QueryContext(ctx)
|
|
_, err = q.Where(tbl.ID.Eq(id), tbl.TenantID.Eq(tid)).Delete()
|
|
if err != nil {
|
|
return errorx.ErrDatabaseError.WithCause(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *creator) Withdraw(ctx context.Context, tenantID, userID int64, form *creator_dto.WithdrawForm) error {
|
|
tid, err := s.getTenantID(ctx, tenantID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
uid := userID
|
|
|
|
// 校验用户实名认证状态,未通过不允许提现。
|
|
user, err := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(uid)).First()
|
|
if err != nil {
|
|
return errorx.ErrDatabaseError.WithCause(err)
|
|
}
|
|
if !user.IsRealNameVerified {
|
|
return errorx.ErrPreconditionFailed.WithMsg("请先完成实名认证后再申请提现")
|
|
}
|
|
|
|
amount := int64(form.Amount * 100)
|
|
if amount <= 0 {
|
|
return errorx.ErrBadRequest.WithMsg("金额无效")
|
|
}
|
|
|
|
// 校验收款账户可用性与审核状态。
|
|
tbl, q := models.PayoutAccountQuery.QueryContext(ctx)
|
|
account, err := q.Where(tbl.ID.Eq(form.AccountID), tbl.TenantID.Eq(tid)).First()
|
|
if err != nil {
|
|
return errorx.ErrRecordNotFound.WithMsg("收款账户不存在")
|
|
}
|
|
if account.Status != consts.PayoutAccountStatusApproved {
|
|
reason := strings.TrimSpace(account.ReviewReason)
|
|
if account.Status == consts.PayoutAccountStatusRejected && reason != "" {
|
|
return errorx.ErrPreconditionFailed.WithMsg("收款账户审核未通过:" + reason)
|
|
}
|
|
return errorx.ErrPreconditionFailed.WithMsg("收款账户未审核通过")
|
|
}
|
|
|
|
// 将收款账户快照写入订单,便于超管审核与打款核对。
|
|
snapshotPayload, err := json.Marshal(fields.OrdersWithdrawalSnapshot{
|
|
Method: form.Method,
|
|
AccountID: account.ID,
|
|
AccountType: string(account.Type),
|
|
AccountName: account.Name,
|
|
Account: account.Account,
|
|
AccountRealname: account.Realname,
|
|
})
|
|
if err != nil {
|
|
return errorx.ErrInternalError.WithCause(err).WithMsg("构建提现快照失败")
|
|
}
|
|
|
|
return models.Q.Transaction(func(tx *models.Query) error {
|
|
// 1. Deduct Balance
|
|
info, err := tx.User.WithContext(ctx).
|
|
Where(tx.User.ID.Eq(uid), tx.User.Balance.Gte(amount)).
|
|
Update(tx.User.Balance, gorm.Expr("balance - ?", amount))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.RowsAffected == 0 {
|
|
return errorx.ErrQuotaExceeded.WithMsg("余额不足")
|
|
}
|
|
|
|
// 2. Create Order (Withdrawal)
|
|
order := &models.Order{
|
|
TenantID: tid,
|
|
UserID: uid,
|
|
Type: consts.OrderTypeWithdrawal,
|
|
Status: consts.OrderStatusCreated, // Created = Pending Processing
|
|
Currency: consts.CurrencyCNY,
|
|
AmountOriginal: amount,
|
|
AmountPaid: amount, // Actually Amount Withdrawn
|
|
IdempotencyKey: uuid.NewString(),
|
|
Snapshot: types.NewJSONType(fields.OrdersSnapshot{
|
|
Kind: "withdrawal",
|
|
Data: snapshotPayload,
|
|
}),
|
|
}
|
|
if err := tx.Order.WithContext(ctx).Create(order); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 3. Create Tenant Ledger
|
|
ledger := &models.TenantLedger{
|
|
TenantID: tid,
|
|
UserID: uid,
|
|
OrderID: order.ID,
|
|
Type: consts.TenantLedgerTypeCreditWithdrawal,
|
|
Amount: amount,
|
|
Remark: "提现申请",
|
|
OperatorUserID: uid,
|
|
IdempotencyKey: uuid.NewString(),
|
|
}
|
|
if err := tx.TenantLedger.WithContext(ctx).Create(ledger); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// Helpers
|
|
|
|
func (s *creator) getTenantID(ctx context.Context, tenantID, userID int64) (int64, error) {
|
|
if userID == 0 {
|
|
return 0, errorx.ErrUnauthorized
|
|
}
|
|
uid := 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.WithCause(err)
|
|
}
|
|
if tenantID > 0 && t.ID != tenantID {
|
|
return 0, errorx.ErrPermissionDenied.WithMsg("无权限访问该租户")
|
|
}
|
|
return t.ID, nil
|
|
}
|
|
|
|
func (s *creator) formatTime(t time.Time) string {
|
|
if t.IsZero() {
|
|
return ""
|
|
}
|
|
return t.Format(time.RFC3339)
|
|
}
|
|
|
|
func (s *creator) validateContentAssets(
|
|
ctx context.Context,
|
|
tx *models.Query,
|
|
tenantID int64,
|
|
userID int64,
|
|
coverIDs []int64,
|
|
mediaIDs []int64,
|
|
) error {
|
|
if len(coverIDs) == 0 && len(mediaIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
ids := make(map[int64]struct{}, len(coverIDs)+len(mediaIDs))
|
|
for _, id := range coverIDs {
|
|
ids[id] = struct{}{}
|
|
}
|
|
for _, id := range mediaIDs {
|
|
ids[id] = struct{}{}
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
|
|
assetIDs := make([]int64, 0, len(ids))
|
|
for id := range ids {
|
|
assetIDs = append(assetIDs, id)
|
|
}
|
|
|
|
list, err := tx.MediaAsset.WithContext(ctx).Where(tx.MediaAsset.ID.In(assetIDs...)).Find()
|
|
if err != nil {
|
|
return errorx.ErrDatabaseError.WithCause(err)
|
|
}
|
|
if len(list) != len(assetIDs) {
|
|
return errorx.ErrRecordNotFound.WithMsg("素材不存在")
|
|
}
|
|
|
|
for _, asset := range list {
|
|
if asset.TenantID == tenantID {
|
|
continue
|
|
}
|
|
if asset.TenantID == 0 && asset.UserID == userID {
|
|
continue
|
|
}
|
|
return errorx.ErrForbidden.WithMsg("素材不属于当前租户")
|
|
}
|
|
|
|
return nil
|
|
}
|