Files
quyun-v2/backend/app/services/ledger_test.go
Rogee 5dc0f89ac0 feat: add operator and business reference fields to tenant ledgers
- Added `operator_user_id`, `biz_ref_type`, and `biz_ref_id` fields to the TenantLedger model for enhanced auditing and traceability.
- Updated the tenant ledgers query generation to include new fields.
- Introduced new API endpoint for retrieving tenant ledger records with filtering options based on the new fields.
- Enhanced Swagger documentation to reflect the new endpoint and its parameters.
- Created DTOs for admin ledger filtering and item representation.
- Implemented the admin ledger retrieval logic in the tenant service.
- Added database migration scripts to introduce new fields and indexes for efficient querying.
2025-12-22 21:35:10 +08:00

398 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"context"
"database/sql"
"errors"
"testing"
"time"
"quyun/v2/app/commands/testx"
"quyun/v2/app/errorx"
"quyun/v2/app/http/tenant/dto"
"quyun/v2/database"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"github.com/samber/lo"
. "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() {
_, err := Ledger.Freeze(ctx, tenantID, userID, 0, 0, "k_freeze_invalid_amount", "freeze", now)
So(err, ShouldNotBeNil)
var appErr *errorx.AppError
So(errors.As(err, &appErr), ShouldBeTrue)
So(appErr.Code, ShouldEqual, errorx.CodeInvalidParameter)
})
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.Ledger.OperatorUserID, ShouldEqual, userID)
So(res.Ledger.BizRefType, ShouldEqual, "")
So(res.Ledger.BizRefID, ShouldEqual, int64(0))
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() {
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, 0, "k_unfreeze_invalid_amount", "unfreeze", now)
So(err, ShouldNotBeNil)
})
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.Ledger.OperatorUserID, ShouldEqual, userID)
So(res.Ledger.BizRefType, ShouldEqual, "")
So(res.Ledger.BizRefID, ShouldEqual, int64(0))
So(res.TenantUser.Balance, ShouldEqual, 1000)
So(res.TenantUser.BalanceFrozen, ShouldEqual, 0)
})
Convey("幂等键重复调用不应重复入账", func() {
_, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_for_unfreeze_idem", "freeze", now)
So(err, ShouldBeNil)
_, err = Ledger.Unfreeze(ctx, tenantID, userID, 0, 300, "k_unfreeze_idem", "unfreeze", now)
So(err, ShouldBeNil)
res2, err := Ledger.Unfreeze(ctx, tenantID, userID, 0, 300, "k_unfreeze_idem", "unfreeze", now.Add(time.Second))
So(err, ShouldBeNil)
So(res2, ShouldNotBeNil)
var tu2 models.TenantUser
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu2).Error, ShouldBeNil)
So(tu2.Balance, ShouldEqual, 1000)
So(tu2.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)
Convey("金额非法应返回参数错误", func() {
_, err := Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 0, "k_debit_invalid_amount", "debit", now)
So(err, ShouldNotBeNil)
})
Convey("冻结余额不足应返回前置条件失败", func() {
_, err := Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_no_frozen", "debit", now)
So(err, ShouldNotBeNil)
})
Convey("成功扣款应减少冻结余额并保持可用余额不变", func() {
_, 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, userID, 123, 300, "k_debit_1", "debit", now)
So(err, ShouldBeNil)
So(res, ShouldNotBeNil)
So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeDebitPurchase)
So(res.Ledger.OperatorUserID, ShouldEqual, userID)
So(res.Ledger.BizRefType, ShouldEqual, "order")
So(res.Ledger.BizRefID, ShouldEqual, int64(123))
So(res.TenantUser.Balance, ShouldEqual, 700)
So(res.TenantUser.BalanceFrozen, ShouldEqual, 0)
})
Convey("幂等键重复调用不应重复扣减冻结余额", func() {
_, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_for_debit_idem", "freeze", now)
So(err, ShouldBeNil)
_, err = Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_idem", "debit", now)
So(err, ShouldBeNil)
_, err = Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_idem", "debit", now.Add(time.Second))
So(err, ShouldBeNil)
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, 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)
Convey("金额非法应返回参数错误", func() {
_, err := Ledger.CreditRefundTx(ctx, _db, tenantID, userID, userID, 123, 0, "k_refund_invalid_amount", "refund", now)
So(err, ShouldNotBeNil)
})
Convey("成功退款应增加可用余额", func() {
_, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_for_refund", "freeze", now)
So(err, ShouldBeNil)
_, err = Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_for_refund", "debit", now)
So(err, ShouldBeNil)
res, err := Ledger.CreditRefundTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_refund_1", "refund", now)
So(err, ShouldBeNil)
So(res, ShouldNotBeNil)
So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeCreditRefund)
So(res.Ledger.OperatorUserID, ShouldEqual, userID)
So(res.Ledger.BizRefType, ShouldEqual, "order")
So(res.Ledger.BizRefID, ShouldEqual, int64(123))
So(res.TenantUser.Balance, ShouldEqual, 1000)
So(res.TenantUser.BalanceFrozen, ShouldEqual, 0)
})
Convey("幂等键重复调用不应重复退款入账", func() {
_, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_for_refund_idem", "freeze", now)
So(err, ShouldBeNil)
_, err = Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_for_refund_idem", "debit", now)
So(err, ShouldBeNil)
_, err = Ledger.CreditRefundTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_refund_idem", "refund", now)
So(err, ShouldBeNil)
_, err = Ledger.CreditRefundTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_refund_idem", "refund", now.Add(time.Second))
So(err, ShouldBeNil)
var tu2 models.TenantUser
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu2).Error, ShouldBeNil)
So(tu2.Balance, ShouldEqual, 1000)
})
})
}
func (s *LedgerTestSuite) Test_CreditTopupTx() {
Convey("Ledger.CreditTopupTx", 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.CreditTopupTx(ctx, _db, tenantID, 999, userID, 456, 0, "k_topup_invalid_amount", "topup", now)
So(err, ShouldNotBeNil)
})
Convey("成功充值应增加可用余额并写入账本", func() {
res, err := Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 456, 200, "k_topup_1", "topup", now)
So(err, ShouldBeNil)
So(res, ShouldNotBeNil)
So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeCreditTopup)
So(res.Ledger.OperatorUserID, ShouldEqual, int64(999))
So(res.Ledger.BizRefType, ShouldEqual, "order")
So(res.Ledger.BizRefID, ShouldEqual, int64(456))
So(res.TenantUser.Balance, ShouldEqual, 1200)
So(res.TenantUser.BalanceFrozen, ShouldEqual, 0)
})
Convey("幂等键重复调用不应重复充值入账", func() {
_, err := Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 456, 200, "k_topup_idem", "topup", now)
So(err, ShouldBeNil)
_, err = Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 456, 200, "k_topup_idem", "topup", now.Add(time.Second))
So(err, ShouldBeNil)
var tu2 models.TenantUser
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu2).Error, ShouldBeNil)
So(tu2.Balance, ShouldEqual, 1200)
})
})
}
func (s *LedgerTestSuite) Test_MyBalance() {
Convey("Ledger.MyBalance", s.T(), func() {
ctx := s.T().Context()
tenantID := int64(1)
userID := int64(2)
s.seedTenantUser(ctx, tenantID, userID, 1000, 200)
Convey("成功返回租户内余额", func() {
m, err := Ledger.MyBalance(ctx, tenantID, userID)
So(err, ShouldBeNil)
So(m, ShouldNotBeNil)
So(m.Balance, ShouldEqual, 1000)
So(m.BalanceFrozen, ShouldEqual, 200)
})
Convey("参数非法应返回错误", func() {
_, err := Ledger.MyBalance(ctx, 0, userID)
So(err, ShouldNotBeNil)
})
})
}
func (s *LedgerTestSuite) Test_MyLedgerPage() {
Convey("Ledger.MyLedgerPage", 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.CreditTopupTx(ctx, _db, tenantID, userID, userID, 1, 200, "k_topup_for_page", "topup", now)
So(err, ShouldBeNil)
_, err = Ledger.Freeze(ctx, tenantID, userID, 2, 100, "k_freeze_for_page", "freeze", now.Add(time.Second))
So(err, ShouldBeNil)
Convey("分页返回流水列表", func() {
pager, err := Ledger.MyLedgerPage(ctx, tenantID, userID, &dto.MyLedgerListFilter{})
So(err, ShouldBeNil)
So(pager, ShouldNotBeNil)
So(pager.Total, ShouldBeGreaterThanOrEqualTo, 2)
})
Convey("按 type 过滤", func() {
typ := consts.TenantLedgerTypeCreditTopup
pager, err := Ledger.MyLedgerPage(ctx, tenantID, userID, &dto.MyLedgerListFilter{Type: &typ})
So(err, ShouldBeNil)
So(pager.Total, ShouldEqual, 1)
})
})
}
func (s *LedgerTestSuite) Test_AdminLedgerPage() {
Convey("Ledger.AdminLedgerPage", s.T(), func() {
ctx := s.T().Context()
tenantID := int64(1)
userID := int64(2)
now := time.Now().UTC()
s.seedTenantUser(ctx, tenantID, userID, 1000, 0)
// 模拟后台管理员为用户充值operator_user_id 与 user_id 不同。
_, err := Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 777, 200, "k_admin_topup_for_page", "topup", now)
So(err, ShouldBeNil)
Convey("按 operator_user_id 过滤", func() {
pager, err := Ledger.AdminLedgerPage(ctx, tenantID, &dto.AdminLedgerListFilter{
OperatorUserID: lo.ToPtr(int64(999)),
})
So(err, ShouldBeNil)
So(pager.Total, ShouldEqual, 1)
})
})
}