diff --git a/backend/app/services/ledger_test.go b/backend/app/services/ledger_test.go new file mode 100644 index 0000000..e920b7e --- /dev/null +++ b/backend/app/services/ledger_test.go @@ -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) + }) +} diff --git a/backend/app/services/order.go b/backend/app/services/order.go index c395850..fa68754 100644 --- a/backend/app/services/order.go +++ b/backend/app/services/order.go @@ -57,7 +57,12 @@ type order struct { } // 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 { 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. -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 { 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. -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 { 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. -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 { 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 { // 5) Compensate: unfreeze and persist rollback marker. _ = 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 }) 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{ "tenant_id": tenantID, "user_id": userID,