add ledger test
This commit is contained in:
202
backend/app/services/ledger_test.go
Normal file
202
backend/app/services/ledger_test.go
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"quyun/v2/app/commands/testx"
|
||||||
|
"quyun/v2/app/errorx"
|
||||||
|
"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"
|
||||||
|
"go.ipao.vip/atom/contracts"
|
||||||
|
"go.ipao.vip/gen/types"
|
||||||
|
"go.uber.org/dig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LedgerTestSuiteInjectParams struct {
|
||||||
|
dig.In
|
||||||
|
|
||||||
|
DB *sql.DB
|
||||||
|
Initials []contracts.Initial `group:"initials"` // nolint:structcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
type LedgerTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
|
||||||
|
LedgerTestSuiteInjectParams
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_Ledger(t *testing.T) {
|
||||||
|
providers := testx.Default().With(Provide)
|
||||||
|
|
||||||
|
testx.Serve(providers, t, func(p LedgerTestSuiteInjectParams) {
|
||||||
|
suite.Run(t, &LedgerTestSuite{LedgerTestSuiteInjectParams: p})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LedgerTestSuite) seedTenantUser(ctx context.Context, tenantID, userID, balance, frozen int64) {
|
||||||
|
database.Truncate(ctx, s.DB, models.TableNameTenantLedger, models.TableNameTenantUser)
|
||||||
|
|
||||||
|
tu := &models.TenantUser{
|
||||||
|
TenantID: tenantID,
|
||||||
|
UserID: userID,
|
||||||
|
Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}),
|
||||||
|
Balance: balance,
|
||||||
|
BalanceFrozen: frozen,
|
||||||
|
Status: consts.UserStatusVerified,
|
||||||
|
}
|
||||||
|
So(tu.Create(ctx), ShouldBeNil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LedgerTestSuite) Test_Freeze() {
|
||||||
|
Convey("Ledger.Freeze", s.T(), func() {
|
||||||
|
ctx := s.T().Context()
|
||||||
|
tenantID := int64(1)
|
||||||
|
userID := int64(2)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
s.seedTenantUser(ctx, tenantID, userID, 1000, 0)
|
||||||
|
|
||||||
|
Convey("成功冻结", func() {
|
||||||
|
res, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_1", "freeze", now)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
So(res, ShouldNotBeNil)
|
||||||
|
So(res.Ledger, ShouldNotBeNil)
|
||||||
|
So(res.TenantUser, ShouldNotBeNil)
|
||||||
|
So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeFreeze)
|
||||||
|
So(res.Ledger.Amount, ShouldEqual, 300)
|
||||||
|
So(res.Ledger.BalanceBefore, ShouldEqual, 1000)
|
||||||
|
So(res.Ledger.BalanceAfter, ShouldEqual, 700)
|
||||||
|
So(res.Ledger.FrozenBefore, ShouldEqual, 0)
|
||||||
|
So(res.Ledger.FrozenAfter, ShouldEqual, 300)
|
||||||
|
So(res.TenantUser.Balance, ShouldEqual, 700)
|
||||||
|
So(res.TenantUser.BalanceFrozen, ShouldEqual, 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("幂等键重复调用不应重复扣减", func() {
|
||||||
|
_, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_idem", "freeze", now)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
|
res2, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_idem", "freeze", now.Add(time.Second))
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
So(res2, ShouldNotBeNil)
|
||||||
|
So(res2.Ledger, ShouldNotBeNil)
|
||||||
|
So(res2.Ledger.IdempotencyKey, ShouldEqual, "k_freeze_idem")
|
||||||
|
|
||||||
|
var tu2 models.TenantUser
|
||||||
|
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu2).Error, ShouldBeNil)
|
||||||
|
So(tu2.Balance, ShouldEqual, 700)
|
||||||
|
So(tu2.BalanceFrozen, ShouldEqual, 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("余额不足应返回前置条件失败", func() {
|
||||||
|
_, err := Ledger.Freeze(ctx, tenantID, userID, 0, 999999, "k_over", "freeze", now)
|
||||||
|
So(err, ShouldNotBeNil)
|
||||||
|
|
||||||
|
var appErr *errorx.AppError
|
||||||
|
So(errors.As(err, &appErr), ShouldBeTrue)
|
||||||
|
So(appErr.Code, ShouldEqual, errorx.CodePreconditionFailed)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LedgerTestSuite) Test_Unfreeze_InsufficientFrozen() {
|
||||||
|
Convey("Ledger.Unfreeze", s.T(), func() {
|
||||||
|
ctx := s.T().Context()
|
||||||
|
tenantID := int64(1)
|
||||||
|
userID := int64(2)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
s.seedTenantUser(ctx, tenantID, userID, 1000, 0)
|
||||||
|
|
||||||
|
Convey("冻结余额不足应返回前置条件失败", func() {
|
||||||
|
_, err := Ledger.Unfreeze(ctx, tenantID, userID, 0, 999999, "k_unfreeze_over", "unfreeze", now)
|
||||||
|
So(err, ShouldNotBeNil)
|
||||||
|
|
||||||
|
var appErr *errorx.AppError
|
||||||
|
So(errors.As(err, &appErr), ShouldBeTrue)
|
||||||
|
So(appErr.Code, ShouldEqual, errorx.CodePreconditionFailed)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("成功解冻", func() {
|
||||||
|
_, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_for_unfreeze", "freeze", now)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
|
res, err := Ledger.Unfreeze(ctx, tenantID, userID, 0, 300, "k_unfreeze_ok", "unfreeze", now)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
So(res, ShouldNotBeNil)
|
||||||
|
So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeUnfreeze)
|
||||||
|
So(res.TenantUser.Balance, ShouldEqual, 1000)
|
||||||
|
So(res.TenantUser.BalanceFrozen, ShouldEqual, 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LedgerTestSuite) Test_DebitPurchaseTx() {
|
||||||
|
Convey("Ledger.DebitPurchaseTx 应减少冻结余额并保留可用余额不变", s.T(), func() {
|
||||||
|
ctx := s.T().Context()
|
||||||
|
tenantID := int64(1)
|
||||||
|
userID := int64(2)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
s.seedTenantUser(ctx, tenantID, userID, 1000, 0)
|
||||||
|
_, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_for_debit", "freeze", now)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
|
res, err := Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, 123, 300, "k_debit_1", "debit", now)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
So(res, ShouldNotBeNil)
|
||||||
|
So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeDebitPurchase)
|
||||||
|
So(res.TenantUser.Balance, ShouldEqual, 700)
|
||||||
|
So(res.TenantUser.BalanceFrozen, ShouldEqual, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LedgerTestSuite) Test_CreditRefundTx() {
|
||||||
|
Convey("Ledger.CreditRefundTx 应回滚可用余额并保持冻结余额不变", s.T(), func() {
|
||||||
|
ctx := s.T().Context()
|
||||||
|
tenantID := int64(1)
|
||||||
|
userID := int64(2)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
s.seedTenantUser(ctx, tenantID, userID, 1000, 0)
|
||||||
|
_, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_for_refund", "freeze", now)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
_, err = Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, 123, 300, "k_debit_for_refund", "debit", now)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
|
res, err := Ledger.CreditRefundTx(ctx, _db, tenantID, userID, 123, 300, "k_refund_1", "refund", now)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
So(res, ShouldNotBeNil)
|
||||||
|
So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeCreditRefund)
|
||||||
|
So(res.TenantUser.Balance, ShouldEqual, 1000)
|
||||||
|
So(res.TenantUser.BalanceFrozen, ShouldEqual, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LedgerTestSuite) Test_CreditTopupTx() {
|
||||||
|
Convey("Ledger.CreditTopupTx 应增加可用余额并写入 credit_topup", s.T(), func() {
|
||||||
|
ctx := s.T().Context()
|
||||||
|
tenantID := int64(1)
|
||||||
|
userID := int64(2)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
s.seedTenantUser(ctx, tenantID, userID, 1000, 0)
|
||||||
|
|
||||||
|
res, err := Ledger.CreditTopupTx(ctx, _db, tenantID, userID, 456, 200, "k_topup_1", "topup", now)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
So(res, ShouldNotBeNil)
|
||||||
|
So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeCreditTopup)
|
||||||
|
So(res.TenantUser.Balance, ShouldEqual, 1200)
|
||||||
|
So(res.TenantUser.BalanceFrozen, ShouldEqual, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -57,7 +57,12 @@ type order struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AdminTopupUser credits tenant balance to a tenant member (tenant-admin action).
|
// AdminTopupUser credits tenant balance to a tenant member (tenant-admin action).
|
||||||
func (s *order) AdminTopupUser(ctx context.Context, tenantID, operatorUserID, targetUserID, amount int64, idempotencyKey, reason string, now time.Time) (*models.Order, error) {
|
func (s *order) AdminTopupUser(
|
||||||
|
ctx context.Context,
|
||||||
|
tenantID, operatorUserID, targetUserID, amount int64,
|
||||||
|
idempotencyKey, reason string,
|
||||||
|
now time.Time,
|
||||||
|
) (*models.Order, error) {
|
||||||
if tenantID <= 0 || operatorUserID <= 0 || targetUserID <= 0 {
|
if tenantID <= 0 || operatorUserID <= 0 || targetUserID <= 0 {
|
||||||
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id/target_user_id must be > 0")
|
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id/target_user_id must be > 0")
|
||||||
}
|
}
|
||||||
@@ -158,7 +163,11 @@ func (s *order) AdminTopupUser(ctx context.Context, tenantID, operatorUserID, ta
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MyOrderPage lists orders for current user within a tenant.
|
// MyOrderPage lists orders for current user within a tenant.
|
||||||
func (s *order) MyOrderPage(ctx context.Context, tenantID, userID int64, filter *dto.MyOrderListFilter) (*requests.Pager, error) {
|
func (s *order) MyOrderPage(
|
||||||
|
ctx context.Context,
|
||||||
|
tenantID, userID int64,
|
||||||
|
filter *dto.MyOrderListFilter,
|
||||||
|
) (*requests.Pager, error) {
|
||||||
if tenantID <= 0 || userID <= 0 {
|
if tenantID <= 0 || userID <= 0 {
|
||||||
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id must be > 0")
|
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id must be > 0")
|
||||||
}
|
}
|
||||||
@@ -222,7 +231,11 @@ func (s *order) MyOrderDetail(ctx context.Context, tenantID, userID, orderID int
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AdminOrderPage lists orders within a tenant for tenant-admin.
|
// AdminOrderPage lists orders within a tenant for tenant-admin.
|
||||||
func (s *order) AdminOrderPage(ctx context.Context, tenantID int64, filter *dto.AdminOrderListFilter) (*requests.Pager, error) {
|
func (s *order) AdminOrderPage(
|
||||||
|
ctx context.Context,
|
||||||
|
tenantID int64,
|
||||||
|
filter *dto.AdminOrderListFilter,
|
||||||
|
) (*requests.Pager, error) {
|
||||||
if tenantID <= 0 {
|
if tenantID <= 0 {
|
||||||
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0")
|
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0")
|
||||||
}
|
}
|
||||||
@@ -281,7 +294,13 @@ func (s *order) AdminOrderDetail(ctx context.Context, tenantID, orderID int64) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AdminRefundOrder refunds a paid order (supports forced refund) and revokes granted content access.
|
// AdminRefundOrder refunds a paid order (supports forced refund) and revokes granted content access.
|
||||||
func (s *order) AdminRefundOrder(ctx context.Context, tenantID, operatorUserID, orderID int64, force bool, reason, idempotencyKey string, now time.Time) (*models.Order, error) {
|
func (s *order) AdminRefundOrder(
|
||||||
|
ctx context.Context,
|
||||||
|
tenantID, operatorUserID, orderID int64,
|
||||||
|
force bool,
|
||||||
|
reason, idempotencyKey string,
|
||||||
|
now time.Time,
|
||||||
|
) (*models.Order, error) {
|
||||||
if tenantID <= 0 || operatorUserID <= 0 || orderID <= 0 {
|
if tenantID <= 0 || operatorUserID <= 0 || orderID <= 0 {
|
||||||
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id/order_id must be > 0")
|
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id/order_id must be > 0")
|
||||||
}
|
}
|
||||||
@@ -655,7 +674,17 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
// 5) Compensate: unfreeze and persist rollback marker.
|
// 5) Compensate: unfreeze and persist rollback marker.
|
||||||
_ = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
_ = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
_, e1 := s.ledger.UnfreezeTx(ctx, tx, params.TenantID, params.UserID, 0, amountPaid, rollbackKey, "purchase rollback", now)
|
_, e1 := s.ledger.UnfreezeTx(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
params.TenantID,
|
||||||
|
params.UserID,
|
||||||
|
0,
|
||||||
|
amountPaid,
|
||||||
|
rollbackKey,
|
||||||
|
"purchase rollback",
|
||||||
|
now,
|
||||||
|
)
|
||||||
return e1
|
return e1
|
||||||
})
|
})
|
||||||
logrus.WithFields(logrus.Fields{
|
logrus.WithFields(logrus.Fields{
|
||||||
@@ -892,7 +921,12 @@ func (s *order) computeFinalPrice(priceAmount int64, price *models.ContentPrice,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *order) grantAccess(ctx context.Context, tx *gorm.DB, tenantID, userID, contentID, orderID int64, now time.Time) error {
|
func (s *order) grantAccess(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *gorm.DB,
|
||||||
|
tenantID, userID, contentID, orderID int64,
|
||||||
|
now time.Time,
|
||||||
|
) error {
|
||||||
insert := map[string]any{
|
insert := map[string]any{
|
||||||
"tenant_id": tenantID,
|
"tenant_id": tenantID,
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
|
|||||||
Reference in New Issue
Block a user