Files
quyun-v2/backend/app/services/creator.go

780 lines
21 KiB
Go

package services
import (
"context"
"errors"
"time"
"quyun/v2/app/errorx"
creator_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 creator struct{}
func (s *creator) Apply(ctx context.Context, 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, userID int64) (*creator_dto.DashboardStats, error) {
tid, err := s.getTenantID(ctx, 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,
userID int64,
filter *creator_dto.CreatorContentListFilter,
) ([]creator_dto.CreatorContentItem, error) {
tid, err := s.getTenantID(ctx, 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.Genre != nil && *filter.Genre != "" {
q = q.Where(tbl.Genre.Eq(*filter.Genre))
}
if filter.Keyword != nil && *filter.Keyword != "" {
q = q.Where(tbl.Title.Like("%" + *filter.Keyword + "%"))
}
var list []*models.Content
err = q.Order(tbl.CreatedAt.Desc()).
UnderlyingDB().
Preload("ContentAssets").
Preload("ContentAssets.Asset").
Find(&list).Error
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
// Fetch Prices
ids := make([]int64, len(list))
for i, item := range list {
ids[i] = item.ID
}
priceMap := make(map[int64]float64)
if len(ids) > 0 {
prices, _ := models.ContentPriceQuery.WithContext(ctx).Where(models.ContentPriceQuery.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: cast.ToString(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),
IsPurchased: false,
})
}
return data, nil
}
func (s *creator) CreateContent(ctx context.Context, userID int64, form *creator_dto.ContentCreateForm) error {
tid, err := s.getTenantID(ctx, 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)
}
// 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: cast.ToInt64(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: cast.ToInt64(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,
userID int64,
id string,
form *creator_dto.ContentUpdateForm,
) error {
tid, err := s.getTenantID(ctx, userID)
if err != nil {
return err
}
cid := cast.ToInt64(id)
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(cid), 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)
}
_, err = tx.Content.WithContext(ctx).Where(tx.Content.ID.Eq(cid)).Updates(contentUpdates)
if 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(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)
_, err = tx.ContentAsset.WithContext(ctx).Where(tx.ContentAsset.ContentID.Eq(cid)).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: cid,
AssetID: cast.ToInt64(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: cid,
AssetID: cast.ToInt64(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, userID int64, id string) error {
cid := cast.ToInt64(id)
tid, err := s.getTenantID(ctx, userID)
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.WithCause(err)
}
return nil
}
func (s *creator) GetContent(ctx context.Context, userID int64, id string) (*creator_dto.ContentEditDTO, error) {
tid, err := s.getTenantID(ctx, userID)
if err != nil {
return nil, err
}
cid := cast.ToInt64(id)
// Fetch Content with preloads
var c models.Content
err = models.ContentQuery.WithContext(ctx).
Where(models.ContentQuery.ID.Eq(cid), 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(cid)).First()
if err == nil {
price = float64(cp.PriceAmount) / 100.0
}
dto := &creator_dto.ContentEditDTO{
ID: cast.ToString(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 := cast.ToString(float64(int(sizeMB*100))/100.0) + " MB"
dto.Assets = append(dto.Assets, creator_dto.AssetDTO{
ID: cast.ToString(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,
userID int64,
filter *creator_dto.CreatorOrderListFilter,
) ([]creator_dto.Order, error) {
tid, err := s.getTenantID(ctx, 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)))
}
// Keyword could match ID or other fields if needed
list, err := q.Order(tbl.CreatedAt.Desc()).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
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, userID int64, id string, form *creator_dto.RefundForm) error {
tid, err := s.getTenantID(ctx, userID)
if err != nil {
return err
}
oid := cast.ToInt64(id)
uid := userID // Creator ID
// Fetch Order
o, err := models.OrderQuery.WithContext(ctx).
Where(models.OrderQuery.ID.Eq(oid), 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(oid)).Updates(&models.Order{
Status: consts.OrderStatusPaid,
RefundReason: form.Reason, // Store reject reason? Or clear it?
})
return err
}
if form.Action == "accept" {
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(oid)).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(oid)).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: oid,
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, userID int64) (*creator_dto.Settings, error) {
tid, err := s.getTenantID(ctx, 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{
Name: t.Name,
Bio: cfg.Bio,
Avatar: cfg.Avatar,
Cover: cfg.Cover,
Description: cfg.Description,
}, nil
}
func (s *creator) UpdateSettings(ctx context.Context, userID int64, form *creator_dto.Settings) error {
tid, err := s.getTenantID(ctx, 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, userID int64) ([]creator_dto.PayoutAccount, error) {
tid, err := s.getTenantID(ctx, userID)
if err != nil {
return nil, err
}
list, err := models.PayoutAccountQuery.WithContext(ctx).Where(models.PayoutAccountQuery.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: cast.ToString(v.ID),
Type: v.Type,
Name: v.Name,
Account: v.Account,
Realname: v.Realname,
})
}
return data, nil
}
func (s *creator) AddPayoutAccount(ctx context.Context, userID int64, form *creator_dto.PayoutAccount) error {
tid, err := s.getTenantID(ctx, userID)
if err != nil {
return err
}
uid := userID
pa := &models.PayoutAccount{
TenantID: tid,
UserID: uid,
Type: form.Type,
Name: form.Name,
Account: form.Account,
Realname: form.Realname,
}
if err := models.PayoutAccountQuery.WithContext(ctx).Create(pa); err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
return nil
}
func (s *creator) RemovePayoutAccount(ctx context.Context, userID int64, id string) error {
tid, err := s.getTenantID(ctx, userID)
if err != nil {
return err
}
pid := cast.ToInt64(id)
_, err = models.PayoutAccountQuery.WithContext(ctx).
Where(models.PayoutAccountQuery.ID.Eq(pid), models.PayoutAccountQuery.TenantID.Eq(tid)).
Delete()
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
return nil
}
func (s *creator) Withdraw(ctx context.Context, userID int64, form *creator_dto.WithdrawForm) error {
tid, err := s.getTenantID(ctx, userID)
if err != nil {
return err
}
uid := userID
amount := int64(form.Amount * 100)
if amount <= 0 {
return errorx.ErrBadRequest.WithMsg("金额无效")
}
// Validate Payout Account
_, err = models.PayoutAccountQuery.WithContext(ctx).
Where(models.PayoutAccountQuery.ID.Eq(cast.ToInt64(form.AccountID)), models.PayoutAccountQuery.TenantID.Eq(tid)).
First()
if err != nil {
return errorx.ErrRecordNotFound.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{}), // Can store account details here
}
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, 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)
}
return t.ID, nil
}