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.
This commit is contained in:
2025-12-22 21:35:10 +08:00
parent 3cb2a6f586
commit 5dc0f89ac0
17 changed files with 983 additions and 171 deletions

View File

@@ -111,45 +111,137 @@ func (s *ledger) MyLedgerPage(ctx context.Context, tenantID, userID int64, filte
}, nil
}
// AdminLedgerPage 分页查询租户内余额流水(租户后台审计用)。
func (s *ledger) AdminLedgerPage(ctx context.Context, tenantID int64, filter *dto.AdminLedgerListFilter) (*requests.Pager, error) {
if tenantID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0")
}
if filter == nil {
filter = &dto.AdminLedgerListFilter{}
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"operator_user_id": lo.FromPtr(filter.OperatorUserID),
"user_id": lo.FromPtr(filter.UserID),
"type": lo.FromPtr(filter.Type),
"order_id": lo.FromPtr(filter.OrderID),
"biz_ref_type": lo.FromPtr(filter.BizRefType),
"biz_ref_id": lo.FromPtr(filter.BizRefID),
"created_at_from": filter.CreatedAtFrom,
"created_at_to": filter.CreatedAtTo,
"pagination_page": filter.Page,
"pagination_limit": filter.Limit,
"pagination_offset": filter.Offset(),
}).Info("services.ledger.admin.page")
filter.Pagination.Format()
tbl, query := models.TenantLedgerQuery.QueryContext(ctx)
conds := []gen.Condition{tbl.TenantID.Eq(tenantID)}
if filter.OperatorUserID != nil && *filter.OperatorUserID > 0 {
conds = append(conds, tbl.OperatorUserID.Eq(*filter.OperatorUserID))
}
if filter.UserID != nil && *filter.UserID > 0 {
conds = append(conds, tbl.UserID.Eq(*filter.UserID))
}
if filter.Type != nil {
conds = append(conds, tbl.Type.Eq(*filter.Type))
}
if filter.OrderID != nil && *filter.OrderID > 0 {
conds = append(conds, tbl.OrderID.Eq(*filter.OrderID))
}
if filter.BizRefType != nil && *filter.BizRefType != "" {
conds = append(conds, tbl.BizRefType.Eq(*filter.BizRefType))
}
if filter.BizRefID != nil && *filter.BizRefID > 0 {
conds = append(conds, tbl.BizRefID.Eq(*filter.BizRefID))
}
if filter.CreatedAtFrom != nil {
conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom))
}
if filter.CreatedAtTo != nil {
conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo))
}
ledgers, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit))
if err != nil {
return nil, err
}
items := lo.Map(ledgers, func(m *models.TenantLedger, _ int) *dto.AdminLedgerItem {
return &dto.AdminLedgerItem{
Ledger: m,
TypeDescription: m.Type.Description(),
}
})
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
// Freeze 将可用余额转入冻结余额,并写入账本记录。
func (s *ledger) Freeze(ctx context.Context, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
return s.apply(ctx, s.db, tenantID, userID, orderID, consts.TenantLedgerTypeFreeze, amount, -amount, amount, idempotencyKey, remark, now)
// 冻结通常由用户自己发起(下单冻结);操作者默认等于余额账户归属 user_id。
bizRefType := ""
if orderID > 0 {
bizRefType = "order"
}
return s.apply(ctx, s.db, tenantID, userID, userID, orderID, bizRefType, orderID, consts.TenantLedgerTypeFreeze, amount, -amount, amount, idempotencyKey, remark, now)
}
// Unfreeze 将冻结余额转回可用余额,并写入账本记录。
func (s *ledger) Unfreeze(ctx context.Context, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
return s.apply(ctx, s.db, tenantID, userID, orderID, consts.TenantLedgerTypeUnfreeze, amount, amount, -amount, idempotencyKey, remark, now)
// 解冻通常由用户自己发起(失败回滚/退款回滚等可能由系统或管理员触发;此处默认等于 user_id
bizRefType := ""
if orderID > 0 {
bizRefType = "order"
}
return s.apply(ctx, s.db, tenantID, userID, userID, orderID, bizRefType, orderID, consts.TenantLedgerTypeUnfreeze, amount, amount, -amount, idempotencyKey, remark, now)
}
// FreezeTx 为 Freeze 的事务版本(由外层事务控制提交/回滚)。
func (s *ledger) FreezeTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeFreeze, amount, -amount, amount, idempotencyKey, remark, now)
func (s *ledger) FreezeTx(ctx context.Context, tx *gorm.DB, tenantID, operatorUserID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
bizRefType := ""
if orderID > 0 {
bizRefType = "order"
}
return s.apply(ctx, tx, tenantID, operatorUserID, userID, orderID, bizRefType, orderID, consts.TenantLedgerTypeFreeze, amount, -amount, amount, idempotencyKey, remark, now)
}
// UnfreezeTx 为 Unfreeze 的事务版本(由外层事务控制提交/回滚)。
func (s *ledger) UnfreezeTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeUnfreeze, amount, amount, -amount, idempotencyKey, remark, now)
func (s *ledger) UnfreezeTx(ctx context.Context, tx *gorm.DB, tenantID, operatorUserID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
bizRefType := ""
if orderID > 0 {
bizRefType = "order"
}
return s.apply(ctx, tx, tenantID, operatorUserID, userID, orderID, bizRefType, orderID, consts.TenantLedgerTypeUnfreeze, amount, amount, -amount, idempotencyKey, remark, now)
}
// DebitPurchaseTx 将冻结资金转为实际扣款(减少冻结余额),并写入账本记录。
func (s *ledger) DebitPurchaseTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeDebitPurchase, amount, 0, -amount, idempotencyKey, remark, now)
func (s *ledger) DebitPurchaseTx(ctx context.Context, tx *gorm.DB, tenantID, operatorUserID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
return s.apply(ctx, tx, tenantID, operatorUserID, userID, orderID, "order", orderID, consts.TenantLedgerTypeDebitPurchase, amount, 0, -amount, idempotencyKey, remark, now)
}
// CreditRefundTx 将退款金额退回到可用余额,并写入账本记录。
func (s *ledger) CreditRefundTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeCreditRefund, amount, amount, 0, idempotencyKey, remark, now)
func (s *ledger) CreditRefundTx(ctx context.Context, tx *gorm.DB, tenantID, operatorUserID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
return s.apply(ctx, tx, tenantID, operatorUserID, userID, orderID, "order", orderID, consts.TenantLedgerTypeCreditRefund, amount, amount, 0, idempotencyKey, remark, now)
}
// CreditTopupTx 将充值金额记入可用余额,并写入账本记录。
func (s *ledger) CreditTopupTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeCreditTopup, amount, amount, 0, idempotencyKey, remark, now)
func (s *ledger) CreditTopupTx(ctx context.Context, tx *gorm.DB, tenantID, operatorUserID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
return s.apply(ctx, tx, tenantID, operatorUserID, userID, orderID, "order", orderID, consts.TenantLedgerTypeCreditTopup, amount, amount, 0, idempotencyKey, remark, now)
}
func (s *ledger) apply(
ctx context.Context,
tx *gorm.DB,
tenantID, userID, orderID int64,
tenantID, operatorUserID, userID, orderID int64,
bizRefType string, bizRefID int64,
ledgerType consts.TenantLedgerType,
amount, deltaBalance, deltaFrozen int64,
idempotencyKey, remark string,
@@ -165,8 +257,11 @@ func (s *ledger) apply(
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"operator_user_id": operatorUserID,
"user_id": userID,
"order_id": orderID,
"biz_ref_type": bizRefType,
"biz_ref_id": bizRefID,
"type": ledgerType,
"amount": amount,
"idempotency_key": idempotencyKey,
@@ -196,6 +291,24 @@ func (s *ledger) apply(
}
}
// 结构化幂等快速路径:当调用方未传 idempotency_key但提供了 biz_ref 时,按 (tenant,biz_ref,type) 去重。
if idempotencyKey == "" && bizRefType != "" && bizRefID > 0 {
var existing models.TenantLedger
if err := tx.
Where("tenant_id = ? AND biz_ref_type = ? AND biz_ref_id = ? AND type = ?", tenantID, bizRefType, bizRefID, ledgerType).
First(&existing).Error; err == nil {
var current models.TenantUser
if err := tx.Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&current).Error; err != nil {
return err
}
out.Ledger = &existing
out.TenantUser = &current
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
// 使用行锁锁住 tenant_users确保同一租户下同一用户余额更新的串行一致性。
var tu models.TenantUser
if err := tx.
@@ -222,6 +335,20 @@ func (s *ledger) apply(
}
}
// 二次结构化幂等校验:与上面的幂等逻辑一致,避免并发下重复写入。
if idempotencyKey == "" && bizRefType != "" && bizRefID > 0 {
var existing models.TenantLedger
if err := tx.
Where("tenant_id = ? AND biz_ref_type = ? AND biz_ref_id = ? AND type = ?", tenantID, bizRefType, bizRefID, ledgerType).
First(&existing).Error; err == nil {
out.Ledger = &existing
out.TenantUser = &tu
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
balanceBefore := tu.Balance
frozenBefore := tu.BalanceFrozen
balanceAfter := balanceBefore + deltaBalance
@@ -249,8 +376,11 @@ func (s *ledger) apply(
// 写入账本:记录变更前后快照,便于对账与审计;幂等键用于去重。
ledger := &models.TenantLedger{
TenantID: tenantID,
OperatorUserID: operatorUserID,
UserID: userID,
OrderID: orderID,
BizRefType: bizRefType,
BizRefID: bizRefID,
Type: ledgerType,
Amount: amount,
BalanceBefore: balanceBefore,
@@ -274,6 +404,17 @@ func (s *ledger) apply(
return nil
}
}
// 结构化幂等回读:当未传 idempotency_key 时按 biz_ref 回读。
if idempotencyKey == "" && bizRefType != "" && bizRefID > 0 {
var existing models.TenantLedger
if e2 := tx.
Where("tenant_id = ? AND biz_ref_type = ? AND biz_ref_id = ? AND type = ?", tenantID, bizRefType, bizRefID, ledgerType).
First(&existing).Error; e2 == nil {
out.Ledger = &existing
out.TenantUser = &tu
return nil
}
}
return err
}
@@ -287,24 +428,30 @@ func (s *ledger) apply(
})
if err != nil {
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"user_id": userID,
"order_id": orderID,
"type": ledgerType,
"idempotency_key": idempotencyKey,
"tenant_id": tenantID,
"operator_user_id": operatorUserID,
"user_id": userID,
"order_id": orderID,
"biz_ref_type": bizRefType,
"biz_ref_id": bizRefID,
"type": ledgerType,
"idempotency_key": idempotencyKey,
}).WithError(err).Warn("services.ledger.apply.failed")
return nil, err
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"user_id": userID,
"order_id": orderID,
"type": ledgerType,
"ledger_id": out.Ledger.ID,
"idempotency_key": idempotencyKey,
"balance_after": out.TenantUser.Balance,
"frozen_after": out.TenantUser.BalanceFrozen,
"tenant_id": tenantID,
"operator_user_id": operatorUserID,
"user_id": userID,
"order_id": orderID,
"biz_ref_type": bizRefType,
"biz_ref_id": bizRefID,
"type": ledgerType,
"ledger_id": out.Ledger.ID,
"idempotency_key": idempotencyKey,
"balance_after": out.TenantUser.Balance,
"frozen_after": out.TenantUser.BalanceFrozen,
}).Info("services.ledger.apply.ok")
return &out, nil