diff --git a/backend/app/http/tenant/dto/ledger_admin.go b/backend/app/http/tenant/dto/ledger_admin.go new file mode 100644 index 0000000..b999671 --- /dev/null +++ b/backend/app/http/tenant/dto/ledger_admin.go @@ -0,0 +1,53 @@ +package dto + +import ( + "time" + + "quyun/v2/app/requests" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" +) + +// AdminLedgerListFilter 定义“租户后台余额流水”查询条件。 +// +// 设计目标: +// - 用于审计/对账:可以按操作者(operator_user_id)检索敏感操作流水; +// - 也可以按用户、订单、类型、业务引用快速定位流水集合。 +type AdminLedgerListFilter struct { + // Pagination 分页参数(page/limit)。 + requests.Pagination `json:",inline" query:",inline"` + + // OperatorUserID 按操作者用户ID过滤(可选)。 + // 典型场景:后台检索“某个管理员发起的充值/退款”等敏感操作流水。 + OperatorUserID *int64 `json:"operator_user_id,omitempty" query:"operator_user_id"` + + // UserID 按余额账户归属用户ID过滤(可选)。 + // 典型场景:查看某个租户成员的资金变化全链路。 + UserID *int64 `json:"user_id,omitempty" query:"user_id"` + + // Type 按流水类型过滤(可选)。 + Type *consts.TenantLedgerType `json:"type,omitempty" query:"type"` + + // OrderID 按关联订单过滤(可选)。 + OrderID *int64 `json:"order_id,omitempty" query:"order_id"` + + // BizRefType 按业务引用类型过滤(可选)。 + // 约定:当前业务写入为 "order";未来可扩展为 refund/topup 等。 + BizRefType *string `json:"biz_ref_type,omitempty" query:"biz_ref_type"` + + // BizRefID 按业务引用ID过滤(可选)。 + BizRefID *int64 `json:"biz_ref_id,omitempty" query:"biz_ref_id"` + + // CreatedAtFrom 创建时间起(可选)。 + CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` + // CreatedAtTo 创建时间止(可选)。 + CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` +} + +// AdminLedgerItem 返回一条余额流水(租户后台视角),并补充展示字段。 +type AdminLedgerItem struct { + // Ledger 流水记录(租户内隔离)。 + Ledger *models.TenantLedger `json:"ledger"` + // TypeDescription 流水类型中文说明(用于前端展示)。 + TypeDescription string `json:"type_description"` +} diff --git a/backend/app/http/tenant/ledger_admin.go b/backend/app/http/tenant/ledger_admin.go new file mode 100644 index 0000000..575f950 --- /dev/null +++ b/backend/app/http/tenant/ledger_admin.go @@ -0,0 +1,57 @@ +package tenant + +import ( + "quyun/v2/app/http/tenant/dto" + "quyun/v2/app/requests" + "quyun/v2/app/services" + "quyun/v2/database/models" + + "github.com/gofiber/fiber/v3" + log "github.com/sirupsen/logrus" +) + +// ledgerAdmin provides tenant-admin ledger audit endpoints. +// +// @provider +type ledgerAdmin struct{} + +// adminLedgers +// +// @Summary 余额流水列表(租户管理/审计) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param filter query dto.AdminLedgerListFilter true "Filter" +// @Success 200 {object} requests.Pager{items=dto.AdminLedgerItem} +// +// @Router /t/:tenantCode/v1/admin/ledgers [get] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind filter query +func (*ledgerAdmin) adminLedgers( + ctx fiber.Ctx, + tenant *models.Tenant, + tenantUser *models.TenantUser, + filter *dto.AdminLedgerListFilter, +) (*requests.Pager, error) { + if err := requireTenantAdmin(tenantUser); err != nil { + return nil, err + } + if filter == nil { + filter = &dto.AdminLedgerListFilter{} + } + + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": tenantUser.UserID, + "operator_user_id": filter.OperatorUserID, + "target_user_id": filter.UserID, + "type": filter.Type, + "order_id": filter.OrderID, + "biz_ref_type": filter.BizRefType, + "biz_ref_id": filter.BizRefID, + }).Info("tenant.admin.ledgers.list") + + return services.Ledger.AdminLedgerPage(ctx.Context(), tenant.ID, filter) +} diff --git a/backend/app/http/tenant/provider.gen.go b/backend/app/http/tenant/provider.gen.go index 83e23ef..fa71259 100755 --- a/backend/app/http/tenant/provider.gen.go +++ b/backend/app/http/tenant/provider.gen.go @@ -24,6 +24,13 @@ func Provide(opts ...opt.Option) error { }); err != nil { return err } + if err := container.Container.Provide(func() (*ledgerAdmin, error) { + obj := &ledgerAdmin{} + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func() (*me, error) { obj := &me{} @@ -62,6 +69,7 @@ func Provide(opts ...opt.Option) error { if err := container.Container.Provide(func( content *content, contentAdmin *contentAdmin, + ledgerAdmin *ledgerAdmin, me *me, mediaAssetAdmin *mediaAssetAdmin, middlewares *middlewares.Middlewares, @@ -75,6 +83,7 @@ func Provide(opts ...opt.Option) error { obj := &Routes{ content: content, contentAdmin: contentAdmin, + ledgerAdmin: ledgerAdmin, me: me, mediaAssetAdmin: mediaAssetAdmin, middlewares: middlewares, diff --git a/backend/app/http/tenant/routes.gen.go b/backend/app/http/tenant/routes.gen.go index c4412a4..cd2e1c8 100644 --- a/backend/app/http/tenant/routes.gen.go +++ b/backend/app/http/tenant/routes.gen.go @@ -26,6 +26,7 @@ type Routes struct { // Controller instances content *content contentAdmin *contentAdmin + ledgerAdmin *ledgerAdmin me *me mediaAssetAdmin *mediaAssetAdmin order *order @@ -112,6 +113,14 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("contentID"), Body[dto.ContentPriceUpsertForm]("form"), )) + // Register routes for controller: ledgerAdmin + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/ledgers -> ledgerAdmin.adminLedgers") + router.Get("/t/:tenantCode/v1/admin/ledgers"[len(r.Path()):], DataFunc3( + r.ledgerAdmin.adminLedgers, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + Query[dto.AdminLedgerListFilter]("filter"), + )) // Register routes for controller: me r.log.Debugf("Registering route: Get /t/:tenantCode/v1/me -> me.get") router.Get("/t/:tenantCode/v1/me"[len(r.Path()):], DataFunc3( diff --git a/backend/app/services/ledger.go b/backend/app/services/ledger.go index 4bd2411..c8f007a 100644 --- a/backend/app/services/ledger.go +++ b/backend/app/services/ledger.go @@ -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(¤t).Error; err != nil { + return err + } + out.Ledger = &existing + out.TenantUser = ¤t + 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 diff --git a/backend/app/services/ledger_test.go b/backend/app/services/ledger_test.go index 07def18..2dd8717 100644 --- a/backend/app/services/ledger_test.go +++ b/backend/app/services/ledger_test.go @@ -14,6 +14,7 @@ import ( "quyun/v2/database/models" "quyun/v2/pkg/consts" + "github.com/samber/lo" . "github.com/smartystreets/goconvey/convey" "github.com/stretchr/testify/suite" @@ -88,6 +89,9 @@ func (s *LedgerTestSuite) Test_Freeze() { 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) }) @@ -150,6 +154,9 @@ func (s *LedgerTestSuite) Test_Unfreeze() { 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) }) @@ -183,12 +190,12 @@ func (s *LedgerTestSuite) Test_DebitPurchaseTx() { s.seedTenantUser(ctx, tenantID, userID, 1000, 0) Convey("金额非法应返回参数错误", func() { - _, err := Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, 123, 0, "k_debit_invalid_amount", "debit", now) + _, 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, 123, 300, "k_debit_no_frozen", "debit", now) + _, err := Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_no_frozen", "debit", now) So(err, ShouldNotBeNil) }) @@ -196,10 +203,13 @@ func (s *LedgerTestSuite) Test_DebitPurchaseTx() { _, 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) + 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) }) @@ -208,10 +218,10 @@ func (s *LedgerTestSuite) Test_DebitPurchaseTx() { _, 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, 123, 300, "k_debit_idem", "debit", now) + _, 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, 123, 300, "k_debit_idem", "debit", now.Add(time.Second)) + _, 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 @@ -232,20 +242,23 @@ func (s *LedgerTestSuite) Test_CreditRefundTx() { s.seedTenantUser(ctx, tenantID, userID, 1000, 0) Convey("金额非法应返回参数错误", func() { - _, err := Ledger.CreditRefundTx(ctx, _db, tenantID, userID, 123, 0, "k_refund_invalid_amount", "refund", now) + _, 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, 123, 300, "k_debit_for_refund", "debit", now) + _, 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, 123, 300, "k_refund_1", "refund", now) + 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) }) @@ -253,12 +266,12 @@ func (s *LedgerTestSuite) Test_CreditRefundTx() { 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, 123, 300, "k_debit_for_refund_idem", "debit", now) + _, 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, 123, 300, "k_refund_idem", "refund", now) + _, 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, 123, 300, "k_refund_idem", "refund", now.Add(time.Second)) + _, 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 @@ -278,23 +291,26 @@ func (s *LedgerTestSuite) Test_CreditTopupTx() { s.seedTenantUser(ctx, tenantID, userID, 1000, 0) Convey("金额非法应返回参数错误", func() { - _, err := Ledger.CreditTopupTx(ctx, _db, tenantID, userID, 456, 0, "k_topup_invalid_amount", "topup", now) + _, 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, userID, 456, 200, "k_topup_1", "topup", now) + 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, userID, 456, 200, "k_topup_idem", "topup", now) + _, err := Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 456, 200, "k_topup_idem", "topup", now) So(err, ShouldBeNil) - _, err = Ledger.CreditTopupTx(ctx, _db, tenantID, userID, 456, 200, "k_topup_idem", "topup", now.Add(time.Second)) + _, 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 @@ -336,7 +352,7 @@ func (s *LedgerTestSuite) Test_MyLedgerPage() { s.seedTenantUser(ctx, tenantID, userID, 1000, 0) - _, err := Ledger.CreditTopupTx(ctx, _db, tenantID, userID, 1, 200, "k_topup_for_page", "topup", now) + _, 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) @@ -356,3 +372,26 @@ func (s *LedgerTestSuite) Test_MyLedgerPage() { }) }) } + +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) + }) + }) +} diff --git a/backend/app/services/order.go b/backend/app/services/order.go index 7f213b5..5ebe0ed 100644 --- a/backend/app/services/order.go +++ b/backend/app/services/order.go @@ -440,7 +440,7 @@ func (s *order) AdminTopupUser( if remark == "" { remark = fmt.Sprintf("topup by tenant_admin:%d", operatorUserID) } - if _, err := s.ledger.CreditTopupTx(ctx, tx, tenantID, targetUserID, orderModel.ID, amount, ledgerKey, remark, now); err != nil { + if _, err := s.ledger.CreditTopupTx(ctx, tx, tenantID, operatorUserID, targetUserID, orderModel.ID, amount, ledgerKey, remark, now); err != nil { return err } @@ -772,7 +772,7 @@ func (s *order) AdminRefundOrder( // 先退余额(账本入账),后更新订单状态与权益,确保退款可对账且可追溯。 if amount > 0 { - if _, err := s.ledger.CreditRefundTx(ctx, tx, tenantID, orderModel.UserID, orderModel.ID, amount, refundKey, reason, now); err != nil { + if _, err := s.ledger.CreditRefundTx(ctx, tx, tenantID, operatorUserID, orderModel.UserID, orderModel.ID, amount, refundKey, reason, now); err != nil { return err } } @@ -1075,7 +1075,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara // 3) 独立事务冻结余额:便于后续在订单事务失败时做补偿解冻。 if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - _, err := s.ledger.FreezeTx(ctx, tx, params.TenantID, params.UserID, 0, amountPaid, freezeKey, "purchase freeze", now) + _, err := s.ledger.FreezeTx(ctx, tx, params.TenantID, params.UserID, params.UserID, 0, amountPaid, freezeKey, "purchase freeze", now) return err }); err != nil { return nil, pkgerrors.Wrap(err, "purchase freeze failed") @@ -1114,7 +1114,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara if err := tx.Create(item).Error; err != nil { return err } - if _, err := s.ledger.DebitPurchaseTx(ctx, tx, params.TenantID, params.UserID, orderModel.ID, amountPaid, debitKey, "purchase debit", now); err != nil { + if _, err := s.ledger.DebitPurchaseTx(ctx, tx, params.TenantID, params.UserID, params.UserID, orderModel.ID, amountPaid, debitKey, "purchase debit", now); err != nil { return err } if err := tx.Model(&models.Order{}). @@ -1152,6 +1152,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara ctx, tx, params.TenantID, + params.UserID, // operator_user_id:购买者本人(下单链路中的补偿动作) params.UserID, 0, amountPaid, @@ -1322,7 +1323,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara UpdatedAt: now, } - if _, err := s.ledger.FreezeTx(ctx, tx, params.TenantID, params.UserID, 0, amountPaid, "", "purchase freeze", now); err != nil { + if _, err := s.ledger.FreezeTx(ctx, tx, params.TenantID, params.UserID, params.UserID, 0, amountPaid, "", "purchase freeze", now); err != nil { return err } @@ -1345,7 +1346,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara return err } - if _, err := s.ledger.DebitPurchaseTx(ctx, tx, params.TenantID, params.UserID, orderModel.ID, amountPaid, "", "purchase debit", now); err != nil { + if _, err := s.ledger.DebitPurchaseTx(ctx, tx, params.TenantID, params.UserID, params.UserID, orderModel.ID, amountPaid, "", "purchase debit", now); err != nil { return err } diff --git a/backend/app/services/order_test.go b/backend/app/services/order_test.go index e8bf0bc..5309cd5 100644 --- a/backend/app/services/order_test.go +++ b/backend/app/services/order_test.go @@ -152,14 +152,14 @@ func (s *OrderTestSuite) Test_AdminTopupUser() { So(orderModel.Status, ShouldEqual, consts.OrderStatusPaid) So(orderModel.AmountPaid, ShouldEqual, 300) - snap := orderModel.Snapshot.Data() - So(snap.Kind, ShouldEqual, string(consts.OrderTypeTopup)) + snap := orderModel.Snapshot.Data() + So(snap.Kind, ShouldEqual, string(consts.OrderTypeTopup)) - var snapData fields.OrdersTopupSnapshot - So(json.Unmarshal(snap.Data, &snapData), ShouldBeNil) - So(snapData.OperatorUserID, ShouldEqual, operatorUserID) - So(snapData.TargetUserID, ShouldEqual, targetUserID) - So(snapData.Amount, ShouldEqual, int64(300)) + var snapData fields.OrdersTopupSnapshot + So(json.Unmarshal(snap.Data, &snapData), ShouldBeNil) + So(snapData.OperatorUserID, ShouldEqual, operatorUserID) + So(snapData.TargetUserID, ShouldEqual, targetUserID) + So(snapData.Amount, ShouldEqual, int64(300)) var tu models.TenantUser So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, targetUserID).First(&tu).Error, ShouldBeNil) diff --git a/backend/database/migrations/20251222211500_tenant_ledgers_audit_fields.sql b/backend/database/migrations/20251222211500_tenant_ledgers_audit_fields.sql new file mode 100644 index 0000000..3309a9f --- /dev/null +++ b/backend/database/migrations/20251222211500_tenant_ledgers_audit_fields.sql @@ -0,0 +1,41 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE tenant_ledgers + ADD COLUMN IF NOT EXISTS operator_user_id bigint, + ADD COLUMN IF NOT EXISTS biz_ref_type varchar(32), + ADD COLUMN IF NOT EXISTS biz_ref_id bigint; + +-- tenant_ledgers.operator_user_id:操作者(谁触发该流水) +-- 用途:用于审计与风控追溯(例如后台代充值/代退款/调账等)。 +COMMENT ON COLUMN tenant_ledgers.operator_user_id IS '操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时)'; + +-- tenant_ledgers.biz_ref_type/biz_ref_id:业务引用(幂等与追溯) +-- 用途:在 idempotency_key 之外提供结构化引用(例如 order/refund/topup 等),便于报表与按业务对象追溯。 +COMMENT ON COLUMN tenant_ledgers.biz_ref_type IS '业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键'; +COMMENT ON COLUMN tenant_ledgers.biz_ref_id IS '业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计'; + +-- 索引:按操作者检索敏感操作流水(后台审计用)。 +CREATE INDEX IF NOT EXISTS ix_tenant_ledgers_tenant_operator ON tenant_ledgers(tenant_id, operator_user_id); + +-- 索引:按业务引用快速定位同一业务对象的流水集合。 +CREATE INDEX IF NOT EXISTS ix_tenant_ledgers_tenant_biz_ref ON tenant_ledgers(tenant_id, biz_ref_type, biz_ref_id); + +-- 结构化幂等(可选):同一业务引用在同一流水类型下只能出现一条。 +-- 说明:biz_ref_* 允许为空;仅当两者都非空时才参与唯一性约束。 +CREATE UNIQUE INDEX IF NOT EXISTS ux_tenant_ledgers_tenant_biz_ref_type_id_type + ON tenant_ledgers(tenant_id, biz_ref_type, biz_ref_id, type) + WHERE biz_ref_type IS NOT NULL AND biz_ref_id IS NOT NULL; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS ux_tenant_ledgers_tenant_biz_ref_type_id_type; +DROP INDEX IF EXISTS ix_tenant_ledgers_tenant_biz_ref; +DROP INDEX IF EXISTS ix_tenant_ledgers_tenant_operator; + +ALTER TABLE tenant_ledgers + DROP COLUMN IF EXISTS biz_ref_id, + DROP COLUMN IF EXISTS biz_ref_type, + DROP COLUMN IF EXISTS operator_user_id; +-- +goose StatementEnd + diff --git a/backend/database/migrations/20251222211800_fix_tenant_ledgers_biz_ref_unique.sql b/backend/database/migrations/20251222211800_fix_tenant_ledgers_biz_ref_unique.sql new file mode 100644 index 0000000..8c55e9a --- /dev/null +++ b/backend/database/migrations/20251222211800_fix_tenant_ledgers_biz_ref_unique.sql @@ -0,0 +1,22 @@ +-- +goose Up +-- +goose StatementBegin +-- 修正:biz_ref_type/biz_ref_id 在 Go 模型侧为 string/int64(非指针),空值会写入 ''/0, +-- 若唯一索引仅判断 NOT NULL,会导致大量流水写入冲突。 +-- 约束策略:仅当 biz_ref_type 非空 且 biz_ref_id > 0 时才参与唯一性约束。 +DROP INDEX IF EXISTS ux_tenant_ledgers_tenant_biz_ref_type_id_type; + +CREATE UNIQUE INDEX IF NOT EXISTS ux_tenant_ledgers_tenant_biz_ref_type_id_type + ON tenant_ledgers(tenant_id, biz_ref_type, biz_ref_id, type) + WHERE biz_ref_type IS NOT NULL AND biz_ref_type <> '' AND biz_ref_id IS NOT NULL AND biz_ref_id <> 0; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS ux_tenant_ledgers_tenant_biz_ref_type_id_type; + +-- Down 回滚为“仅判断 NOT NULL”的版本(不建议在线上使用该版本)。 +CREATE UNIQUE INDEX IF NOT EXISTS ux_tenant_ledgers_tenant_biz_ref_type_id_type + ON tenant_ledgers(tenant_id, biz_ref_type, biz_ref_id, type) + WHERE biz_ref_type IS NOT NULL AND biz_ref_id IS NOT NULL; +-- +goose StatementEnd + diff --git a/backend/database/models/order_items.gen.go b/backend/database/models/order_items.gen.go index 43930e2..3c7507b 100644 --- a/backend/database/models/order_items.gen.go +++ b/backend/database/models/order_items.gen.go @@ -28,8 +28,8 @@ type OrderItem struct { Snapshot types.JSONType[fields.OrderItemsSnapshot] `gorm:"column:snapshot;type:jsonb;not null;default:{};comment:内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计" json:"snapshot"` // 内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计 CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now()" json:"created_at"` // 创建时间:默认 now() UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now()" json:"updated_at"` // 更新时间:默认 now() - Order *Order `gorm:"foreignKey:OrderID;references:ID" json:"order,omitempty"` Content *Content `gorm:"foreignKey:ContentID;references:ID" json:"content,omitempty"` + Order *Order `gorm:"foreignKey:OrderID;references:ID" json:"order,omitempty"` } // Quick operations without importing query package diff --git a/backend/database/models/order_items.query.gen.go b/backend/database/models/order_items.query.gen.go index 91ad511..26afd59 100644 --- a/backend/database/models/order_items.query.gen.go +++ b/backend/database/models/order_items.query.gen.go @@ -35,18 +35,18 @@ func newOrderItem(db *gorm.DB, opts ...gen.DOOption) orderItemQuery { _orderItemQuery.Snapshot = field.NewJSONB(tableName, "snapshot") _orderItemQuery.CreatedAt = field.NewTime(tableName, "created_at") _orderItemQuery.UpdatedAt = field.NewTime(tableName, "updated_at") - _orderItemQuery.Order = orderItemQueryBelongsToOrder{ - db: db.Session(&gorm.Session{}), - - RelationField: field.NewRelation("Order", "Order"), - } - _orderItemQuery.Content = orderItemQueryBelongsToContent{ db: db.Session(&gorm.Session{}), RelationField: field.NewRelation("Content", "Content"), } + _orderItemQuery.Order = orderItemQueryBelongsToOrder{ + db: db.Session(&gorm.Session{}), + + RelationField: field.NewRelation("Order", "Order"), + } + _orderItemQuery.fillFieldMap() return _orderItemQuery @@ -66,9 +66,9 @@ type orderItemQuery struct { Snapshot field.JSONB // 内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计 CreatedAt field.Time // 创建时间:默认 now() UpdatedAt field.Time // 更新时间:默认 now() - Order orderItemQueryBelongsToOrder + Content orderItemQueryBelongsToContent - Content orderItemQueryBelongsToContent + Order orderItemQueryBelongsToOrder fieldMap map[string]field.Expr } @@ -143,101 +143,20 @@ func (o *orderItemQuery) fillFieldMap() { func (o orderItemQuery) clone(db *gorm.DB) orderItemQuery { o.orderItemQueryDo.ReplaceConnPool(db.Statement.ConnPool) - o.Order.db = db.Session(&gorm.Session{Initialized: true}) - o.Order.db.Statement.ConnPool = db.Statement.ConnPool o.Content.db = db.Session(&gorm.Session{Initialized: true}) o.Content.db.Statement.ConnPool = db.Statement.ConnPool + o.Order.db = db.Session(&gorm.Session{Initialized: true}) + o.Order.db.Statement.ConnPool = db.Statement.ConnPool return o } func (o orderItemQuery) replaceDB(db *gorm.DB) orderItemQuery { o.orderItemQueryDo.ReplaceDB(db) - o.Order.db = db.Session(&gorm.Session{}) o.Content.db = db.Session(&gorm.Session{}) + o.Order.db = db.Session(&gorm.Session{}) return o } -type orderItemQueryBelongsToOrder struct { - db *gorm.DB - - field.RelationField -} - -func (a orderItemQueryBelongsToOrder) Where(conds ...field.Expr) *orderItemQueryBelongsToOrder { - if len(conds) == 0 { - return &a - } - - exprs := make([]clause.Expression, 0, len(conds)) - for _, cond := range conds { - exprs = append(exprs, cond.BeCond().(clause.Expression)) - } - a.db = a.db.Clauses(clause.Where{Exprs: exprs}) - return &a -} - -func (a orderItemQueryBelongsToOrder) WithContext(ctx context.Context) *orderItemQueryBelongsToOrder { - a.db = a.db.WithContext(ctx) - return &a -} - -func (a orderItemQueryBelongsToOrder) Session(session *gorm.Session) *orderItemQueryBelongsToOrder { - a.db = a.db.Session(session) - return &a -} - -func (a orderItemQueryBelongsToOrder) Model(m *OrderItem) *orderItemQueryBelongsToOrderTx { - return &orderItemQueryBelongsToOrderTx{a.db.Model(m).Association(a.Name())} -} - -func (a orderItemQueryBelongsToOrder) Unscoped() *orderItemQueryBelongsToOrder { - a.db = a.db.Unscoped() - return &a -} - -type orderItemQueryBelongsToOrderTx struct{ tx *gorm.Association } - -func (a orderItemQueryBelongsToOrderTx) Find() (result *Order, err error) { - return result, a.tx.Find(&result) -} - -func (a orderItemQueryBelongsToOrderTx) Append(values ...*Order) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Append(targetValues...) -} - -func (a orderItemQueryBelongsToOrderTx) Replace(values ...*Order) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Replace(targetValues...) -} - -func (a orderItemQueryBelongsToOrderTx) Delete(values ...*Order) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Delete(targetValues...) -} - -func (a orderItemQueryBelongsToOrderTx) Clear() error { - return a.tx.Clear() -} - -func (a orderItemQueryBelongsToOrderTx) Count() int64 { - return a.tx.Count() -} - -func (a orderItemQueryBelongsToOrderTx) Unscoped() *orderItemQueryBelongsToOrderTx { - a.tx = a.tx.Unscoped() - return &a -} - type orderItemQueryBelongsToContent struct { db *gorm.DB @@ -319,6 +238,87 @@ func (a orderItemQueryBelongsToContentTx) Unscoped() *orderItemQueryBelongsToCon return &a } +type orderItemQueryBelongsToOrder struct { + db *gorm.DB + + field.RelationField +} + +func (a orderItemQueryBelongsToOrder) Where(conds ...field.Expr) *orderItemQueryBelongsToOrder { + if len(conds) == 0 { + return &a + } + + exprs := make([]clause.Expression, 0, len(conds)) + for _, cond := range conds { + exprs = append(exprs, cond.BeCond().(clause.Expression)) + } + a.db = a.db.Clauses(clause.Where{Exprs: exprs}) + return &a +} + +func (a orderItemQueryBelongsToOrder) WithContext(ctx context.Context) *orderItemQueryBelongsToOrder { + a.db = a.db.WithContext(ctx) + return &a +} + +func (a orderItemQueryBelongsToOrder) Session(session *gorm.Session) *orderItemQueryBelongsToOrder { + a.db = a.db.Session(session) + return &a +} + +func (a orderItemQueryBelongsToOrder) Model(m *OrderItem) *orderItemQueryBelongsToOrderTx { + return &orderItemQueryBelongsToOrderTx{a.db.Model(m).Association(a.Name())} +} + +func (a orderItemQueryBelongsToOrder) Unscoped() *orderItemQueryBelongsToOrder { + a.db = a.db.Unscoped() + return &a +} + +type orderItemQueryBelongsToOrderTx struct{ tx *gorm.Association } + +func (a orderItemQueryBelongsToOrderTx) Find() (result *Order, err error) { + return result, a.tx.Find(&result) +} + +func (a orderItemQueryBelongsToOrderTx) Append(values ...*Order) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Append(targetValues...) +} + +func (a orderItemQueryBelongsToOrderTx) Replace(values ...*Order) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Replace(targetValues...) +} + +func (a orderItemQueryBelongsToOrderTx) Delete(values ...*Order) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Delete(targetValues...) +} + +func (a orderItemQueryBelongsToOrderTx) Clear() error { + return a.tx.Clear() +} + +func (a orderItemQueryBelongsToOrderTx) Count() int64 { + return a.tx.Count() +} + +func (a orderItemQueryBelongsToOrderTx) Unscoped() *orderItemQueryBelongsToOrderTx { + a.tx = a.tx.Unscoped() + return &a +} + type orderItemQueryDo struct{ gen.DO } func (o orderItemQueryDo) Debug() *orderItemQueryDo { diff --git a/backend/database/models/tenant_ledgers.gen.go b/backend/database/models/tenant_ledgers.gen.go index 8440c3a..22cc924 100644 --- a/backend/database/models/tenant_ledgers.gen.go +++ b/backend/database/models/tenant_ledgers.gen.go @@ -31,6 +31,9 @@ type TenantLedger struct { Remark string `gorm:"column:remark;type:character varying(255);not null;comment:备注:业务说明/后台操作原因等;用于审计" json:"remark"` // 备注:业务说明/后台操作原因等;用于审计 CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now()" json:"created_at"` // 创建时间:默认 now() UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now()" json:"updated_at"` // 更新时间:默认 now() + OperatorUserID int64 `gorm:"column:operator_user_id;type:bigint;comment:操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时)" json:"operator_user_id"` // 操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时) + BizRefType string `gorm:"column:biz_ref_type;type:character varying(32);comment:业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键" json:"biz_ref_type"` // 业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键 + BizRefID int64 `gorm:"column:biz_ref_id;type:bigint;comment:业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计" json:"biz_ref_id"` // 业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计 Order *Order `gorm:"foreignKey:OrderID;references:ID" json:"order,omitempty"` } diff --git a/backend/database/models/tenant_ledgers.query.gen.go b/backend/database/models/tenant_ledgers.query.gen.go index 490040e..79dabbb 100644 --- a/backend/database/models/tenant_ledgers.query.gen.go +++ b/backend/database/models/tenant_ledgers.query.gen.go @@ -39,6 +39,9 @@ func newTenantLedger(db *gorm.DB, opts ...gen.DOOption) tenantLedgerQuery { _tenantLedgerQuery.Remark = field.NewString(tableName, "remark") _tenantLedgerQuery.CreatedAt = field.NewTime(tableName, "created_at") _tenantLedgerQuery.UpdatedAt = field.NewTime(tableName, "updated_at") + _tenantLedgerQuery.OperatorUserID = field.NewInt64(tableName, "operator_user_id") + _tenantLedgerQuery.BizRefType = field.NewString(tableName, "biz_ref_type") + _tenantLedgerQuery.BizRefID = field.NewInt64(tableName, "biz_ref_id") _tenantLedgerQuery.Order = tenantLedgerQueryBelongsToOrder{ db: db.Session(&gorm.Session{}), @@ -68,6 +71,9 @@ type tenantLedgerQuery struct { Remark field.String // 备注:业务说明/后台操作原因等;用于审计 CreatedAt field.Time // 创建时间:默认 now() UpdatedAt field.Time // 更新时间:默认 now() + OperatorUserID field.Int64 // 操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时) + BizRefType field.String // 业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键 + BizRefID field.Int64 // 业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计 Order tenantLedgerQueryBelongsToOrder fieldMap map[string]field.Expr @@ -99,6 +105,9 @@ func (t *tenantLedgerQuery) updateTableName(table string) *tenantLedgerQuery { t.Remark = field.NewString(table, "remark") t.CreatedAt = field.NewTime(table, "created_at") t.UpdatedAt = field.NewTime(table, "updated_at") + t.OperatorUserID = field.NewInt64(table, "operator_user_id") + t.BizRefType = field.NewString(table, "biz_ref_type") + t.BizRefID = field.NewInt64(table, "biz_ref_id") t.fillFieldMap() @@ -131,7 +140,7 @@ func (t *tenantLedgerQuery) GetFieldByName(fieldName string) (field.OrderExpr, b } func (t *tenantLedgerQuery) fillFieldMap() { - t.fieldMap = make(map[string]field.Expr, 15) + t.fieldMap = make(map[string]field.Expr, 18) t.fieldMap["id"] = t.ID t.fieldMap["tenant_id"] = t.TenantID t.fieldMap["user_id"] = t.UserID @@ -146,6 +155,9 @@ func (t *tenantLedgerQuery) fillFieldMap() { t.fieldMap["remark"] = t.Remark t.fieldMap["created_at"] = t.CreatedAt t.fieldMap["updated_at"] = t.UpdatedAt + t.fieldMap["operator_user_id"] = t.OperatorUserID + t.fieldMap["biz_ref_type"] = t.BizRefType + t.fieldMap["biz_ref_id"] = t.BizRefID } diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 1238b29..add13fa 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -939,6 +939,125 @@ const docTemplate = `{ } } }, + "/t/{tenantCode}/v1/admin/ledgers": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "余额流水列表(租户管理/审计)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "BizRefID 按业务引用ID过滤(可选)。", + "name": "biz_ref_id", + "in": "query" + }, + { + "type": "string", + "description": "BizRefType 按业务引用类型过滤(可选)。\n约定:当前业务写入为 \"order\";未来可扩展为 refund/topup 等。", + "name": "biz_ref_type", + "in": "query" + }, + { + "type": "string", + "description": "CreatedAtFrom 创建时间起(可选)。", + "name": "created_at_from", + "in": "query" + }, + { + "type": "string", + "description": "CreatedAtTo 创建时间止(可选)。", + "name": "created_at_to", + "in": "query" + }, + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "OperatorUserID 按操作者用户ID过滤(可选)。\n典型场景:后台检索“某个管理员发起的充值/退款”等敏感操作流水。", + "name": "operator_user_id", + "in": "query" + }, + { + "type": "integer", + "description": "OrderID 按关联订单过滤(可选)。", + "name": "order_id", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + }, + { + "enum": [ + "credit_topup", + "debit_purchase", + "credit_refund", + "freeze", + "unfreeze", + "adjustment" + ], + "type": "string", + "x-enum-varnames": [ + "TenantLedgerTypeCreditTopup", + "TenantLedgerTypeDebitPurchase", + "TenantLedgerTypeCreditRefund", + "TenantLedgerTypeFreeze", + "TenantLedgerTypeUnfreeze", + "TenantLedgerTypeAdjustment" + ], + "description": "Type 按流水类型过滤(可选)。", + "name": "type", + "in": "query" + }, + { + "type": "integer", + "description": "UserID 按余额账户归属用户ID过滤(可选)。\n典型场景:查看某个租户成员的资金变化全链路。", + "name": "user_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "$ref": "#/definitions/dto.AdminLedgerItem" + } + } + } + ] + } + } + } + } + }, "/t/{tenantCode}/v1/admin/media_assets": { "get": { "consumes": [ @@ -3021,6 +3140,23 @@ const docTemplate = `{ } } }, + "dto.AdminLedgerItem": { + "type": "object", + "properties": { + "ledger": { + "description": "Ledger 流水记录(租户内隔离)。", + "allOf": [ + { + "$ref": "#/definitions/models.TenantLedger" + } + ] + }, + "type_description": { + "description": "TypeDescription 流水类型中文说明(用于前端展示)。", + "type": "string" + } + } + }, "dto.AdminMediaAssetUploadCompleteForm": { "type": "object", "properties": { @@ -4181,10 +4317,11 @@ const docTemplate = `{ }, "snapshot": { "description": "订单快照:JSON;建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示", - "type": "array", - "items": { - "type": "integer" - } + "allOf": [ + { + "$ref": "#/definitions/types.JSONType-fields_OrdersSnapshot" + } + ] }, "status": { "description": "订单状态:created/paid/refunding/refunded/canceled/failed;状态变更需与账本/权益保持一致", @@ -4251,10 +4388,11 @@ const docTemplate = `{ }, "snapshot": { "description": "内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计", - "type": "array", - "items": { - "type": "integer" - } + "allOf": [ + { + "$ref": "#/definitions/types.JSONType-fields_OrderItemsSnapshot" + } + ] }, "tenant_id": { "description": "租户ID:多租户隔离关键字段;必须与 orders.tenant_id 一致", @@ -4439,6 +4577,14 @@ const docTemplate = `{ "description": "变更前可用余额:用于审计与对账回放", "type": "integer" }, + "biz_ref_id": { + "description": "业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计", + "type": "integer" + }, + "biz_ref_type": { + "description": "业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键", + "type": "string" + }, "created_at": { "description": "创建时间:默认 now()", "type": "string" @@ -4459,6 +4605,10 @@ const docTemplate = `{ "description": "幂等键:同一租户同一用户同一业务操作固定;用于防止重复落账(建议由业务层生成)", "type": "string" }, + "operator_user_id": { + "description": "操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时)", + "type": "integer" + }, "order": { "$ref": "#/definitions/models.Order" }, @@ -4609,6 +4759,12 @@ const docTemplate = `{ "type": "integer" } } + }, + "types.JSONType-fields_OrderItemsSnapshot": { + "type": "object" + }, + "types.JSONType-fields_OrdersSnapshot": { + "type": "object" } }, "securityDefinitions": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 9d855e3..9d042d3 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -933,6 +933,125 @@ } } }, + "/t/{tenantCode}/v1/admin/ledgers": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "余额流水列表(租户管理/审计)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "BizRefID 按业务引用ID过滤(可选)。", + "name": "biz_ref_id", + "in": "query" + }, + { + "type": "string", + "description": "BizRefType 按业务引用类型过滤(可选)。\n约定:当前业务写入为 \"order\";未来可扩展为 refund/topup 等。", + "name": "biz_ref_type", + "in": "query" + }, + { + "type": "string", + "description": "CreatedAtFrom 创建时间起(可选)。", + "name": "created_at_from", + "in": "query" + }, + { + "type": "string", + "description": "CreatedAtTo 创建时间止(可选)。", + "name": "created_at_to", + "in": "query" + }, + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "OperatorUserID 按操作者用户ID过滤(可选)。\n典型场景:后台检索“某个管理员发起的充值/退款”等敏感操作流水。", + "name": "operator_user_id", + "in": "query" + }, + { + "type": "integer", + "description": "OrderID 按关联订单过滤(可选)。", + "name": "order_id", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + }, + { + "enum": [ + "credit_topup", + "debit_purchase", + "credit_refund", + "freeze", + "unfreeze", + "adjustment" + ], + "type": "string", + "x-enum-varnames": [ + "TenantLedgerTypeCreditTopup", + "TenantLedgerTypeDebitPurchase", + "TenantLedgerTypeCreditRefund", + "TenantLedgerTypeFreeze", + "TenantLedgerTypeUnfreeze", + "TenantLedgerTypeAdjustment" + ], + "description": "Type 按流水类型过滤(可选)。", + "name": "type", + "in": "query" + }, + { + "type": "integer", + "description": "UserID 按余额账户归属用户ID过滤(可选)。\n典型场景:查看某个租户成员的资金变化全链路。", + "name": "user_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "$ref": "#/definitions/dto.AdminLedgerItem" + } + } + } + ] + } + } + } + } + }, "/t/{tenantCode}/v1/admin/media_assets": { "get": { "consumes": [ @@ -3015,6 +3134,23 @@ } } }, + "dto.AdminLedgerItem": { + "type": "object", + "properties": { + "ledger": { + "description": "Ledger 流水记录(租户内隔离)。", + "allOf": [ + { + "$ref": "#/definitions/models.TenantLedger" + } + ] + }, + "type_description": { + "description": "TypeDescription 流水类型中文说明(用于前端展示)。", + "type": "string" + } + } + }, "dto.AdminMediaAssetUploadCompleteForm": { "type": "object", "properties": { @@ -4175,10 +4311,11 @@ }, "snapshot": { "description": "订单快照:JSON;建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示", - "type": "array", - "items": { - "type": "integer" - } + "allOf": [ + { + "$ref": "#/definitions/types.JSONType-fields_OrdersSnapshot" + } + ] }, "status": { "description": "订单状态:created/paid/refunding/refunded/canceled/failed;状态变更需与账本/权益保持一致", @@ -4245,10 +4382,11 @@ }, "snapshot": { "description": "内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计", - "type": "array", - "items": { - "type": "integer" - } + "allOf": [ + { + "$ref": "#/definitions/types.JSONType-fields_OrderItemsSnapshot" + } + ] }, "tenant_id": { "description": "租户ID:多租户隔离关键字段;必须与 orders.tenant_id 一致", @@ -4433,6 +4571,14 @@ "description": "变更前可用余额:用于审计与对账回放", "type": "integer" }, + "biz_ref_id": { + "description": "业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计", + "type": "integer" + }, + "biz_ref_type": { + "description": "业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键", + "type": "string" + }, "created_at": { "description": "创建时间:默认 now()", "type": "string" @@ -4453,6 +4599,10 @@ "description": "幂等键:同一租户同一用户同一业务操作固定;用于防止重复落账(建议由业务层生成)", "type": "string" }, + "operator_user_id": { + "description": "操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时)", + "type": "integer" + }, "order": { "$ref": "#/definitions/models.Order" }, @@ -4603,6 +4753,12 @@ "type": "integer" } } + }, + "types.JSONType-fields_OrderItemsSnapshot": { + "type": "object" + }, + "types.JSONType-fields_OrdersSnapshot": { + "type": "object" } }, "securityDefinitions": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 2ecc4bb..bd4c723 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -257,6 +257,16 @@ definitions: description: UserID 目标用户ID。 type: integer type: object + dto.AdminLedgerItem: + properties: + ledger: + allOf: + - $ref: '#/definitions/models.TenantLedger' + description: Ledger 流水记录(租户内隔离)。 + type_description: + description: TypeDescription 流水类型中文说明(用于前端展示)。 + type: string + type: object dto.AdminMediaAssetUploadCompleteForm: properties: content_type: @@ -1054,10 +1064,9 @@ definitions: description: 退款完成时间:退款落账成功后写入 type: string snapshot: + allOf: + - $ref: '#/definitions/types.JSONType-fields_OrdersSnapshot' description: 订单快照:JSON;建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示 - items: - type: integer - type: array status: allOf: - $ref: '#/definitions/consts.OrderStatus' @@ -1101,10 +1110,9 @@ definitions: description: 订单ID:关联 orders.id;用于聚合订单明细 type: integer snapshot: + allOf: + - $ref: '#/definitions/types.JSONType-fields_OrderItemsSnapshot' description: 内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计 - items: - type: integer - type: array tenant_id: description: 租户ID:多租户隔离关键字段;必须与 orders.tenant_id 一致 type: integer @@ -1232,6 +1240,12 @@ definitions: balance_before: description: 变更前可用余额:用于审计与对账回放 type: integer + biz_ref_id: + description: 业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计 + type: integer + biz_ref_type: + description: 业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键 + type: string created_at: description: 创建时间:默认 now() type: string @@ -1247,6 +1261,9 @@ definitions: idempotency_key: description: 幂等键:同一租户同一用户同一业务操作固定;用于防止重复落账(建议由业务层生成) type: string + operator_user_id: + description: 操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时) + type: integer order: $ref: '#/definitions/models.Order' order_id: @@ -1350,6 +1367,10 @@ definitions: paging). type: integer type: object + types.JSONType-fields_OrderItemsSnapshot: + type: object + types.JSONType-fields_OrdersSnapshot: + type: object externalDocs: description: OpenAPI url: https://swagger.io/resources/open-api/ @@ -1967,6 +1988,92 @@ paths: summary: 拒绝加入申请(租户管理) tags: - Tenant + /t/{tenantCode}/v1/admin/ledgers: + get: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: BizRefID 按业务引用ID过滤(可选)。 + in: query + name: biz_ref_id + type: integer + - description: |- + BizRefType 按业务引用类型过滤(可选)。 + 约定:当前业务写入为 "order";未来可扩展为 refund/topup 等。 + in: query + name: biz_ref_type + type: string + - description: CreatedAtFrom 创建时间起(可选)。 + in: query + name: created_at_from + type: string + - description: CreatedAtTo 创建时间止(可选)。 + in: query + name: created_at_to + type: string + - description: Limit is page size; only values in {10,20,50,100} are accepted + (otherwise defaults to 10). + in: query + name: limit + type: integer + - description: |- + OperatorUserID 按操作者用户ID过滤(可选)。 + 典型场景:后台检索“某个管理员发起的充值/退款”等敏感操作流水。 + in: query + name: operator_user_id + type: integer + - description: OrderID 按关联订单过滤(可选)。 + in: query + name: order_id + type: integer + - description: Page is 1-based page index; values <= 0 are normalized to 1. + in: query + name: page + type: integer + - description: Type 按流水类型过滤(可选)。 + enum: + - credit_topup + - debit_purchase + - credit_refund + - freeze + - unfreeze + - adjustment + in: query + name: type + type: string + x-enum-varnames: + - TenantLedgerTypeCreditTopup + - TenantLedgerTypeDebitPurchase + - TenantLedgerTypeCreditRefund + - TenantLedgerTypeFreeze + - TenantLedgerTypeUnfreeze + - TenantLedgerTypeAdjustment + - description: |- + UserID 按余额账户归属用户ID过滤(可选)。 + 典型场景:查看某个租户成员的资金变化全链路。 + in: query + name: user_id + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + $ref: '#/definitions/dto.AdminLedgerItem' + type: object + summary: 余额流水列表(租户管理/审计) + tags: + - Tenant /t/{tenantCode}/v1/admin/media_assets: get: consumes: