From 3249e405ac468dd743a326f3a2a2b77191440d89 Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 18 Dec 2025 16:24:37 +0800 Subject: [PATCH] feat: add balance and ledger endpoints for tenant - Implemented MyBalance and MyLedgerPage methods in the ledger service to retrieve current user balance and transaction history for a specified tenant. - Added corresponding test cases for MyBalance and MyLedgerPage methods in the ledger test suite. - Created DTOs for balance response and ledger items to structure the response data. - Updated Swagger documentation to include new endpoints for retrieving tenant balance and ledgers. - Added HTTP tests for the new endpoints to ensure proper functionality. --- backend/app/http/tenant/dto/ledger_me.go | 31 ++++ backend/app/http/tenant/dto/me_balance.go | 19 ++ backend/app/http/tenant/me.go | 45 +++++ backend/app/http/tenant/routes.gen.go | 13 ++ backend/app/services/content_test.go | 17 +- backend/app/services/ledger.go | 82 +++++++++ backend/app/services/ledger_test.go | 54 ++++++ backend/app/services/order.go | 157 ++++++++++++++++- backend/app/services/order_test.go | 54 ++++-- backend/docs/docs.go | 203 ++++++++++++++++++++++ backend/docs/swagger.json | 203 ++++++++++++++++++++++ backend/docs/swagger.yaml | 135 ++++++++++++++ backend/tests/tenant.http | 10 ++ 13 files changed, 990 insertions(+), 33 deletions(-) create mode 100644 backend/app/http/tenant/dto/ledger_me.go create mode 100644 backend/app/http/tenant/dto/me_balance.go diff --git a/backend/app/http/tenant/dto/ledger_me.go b/backend/app/http/tenant/dto/ledger_me.go new file mode 100644 index 0000000..777de17 --- /dev/null +++ b/backend/app/http/tenant/dto/ledger_me.go @@ -0,0 +1,31 @@ +package dto + +import ( + "time" + + "quyun/v2/app/requests" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" +) + +// MyLedgerListFilter 定义“我的余额流水”查询条件。 +type MyLedgerListFilter struct { + // Pagination 分页参数(page/limit)。 + requests.Pagination `json:",inline" query:",inline"` + // Type 按流水类型过滤(可选)。 + Type *consts.TenantLedgerType `json:"type,omitempty" query:"type"` + // OrderID 按关联订单过滤(可选)。 + OrderID *int64 `json:"order_id,omitempty" query:"order_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"` +} + +// MyLedgerItem 返回一条余额流水,并补充展示字段。 +type MyLedgerItem struct { + // Ledger 流水记录(租户内隔离)。 + Ledger *models.TenantLedger `json:"ledger"` + // TypeDescription 流水类型中文说明(用于前端展示)。 + TypeDescription string `json:"type_description"` +} diff --git a/backend/app/http/tenant/dto/me_balance.go b/backend/app/http/tenant/dto/me_balance.go new file mode 100644 index 0000000..f1012fd --- /dev/null +++ b/backend/app/http/tenant/dto/me_balance.go @@ -0,0 +1,19 @@ +package dto + +import ( + "time" + + "quyun/v2/pkg/consts" +) + +// MeBalanceResponse 返回当前用户在当前租户下的余额信息(租户内隔离)。 +type MeBalanceResponse struct { + // Currency 币种:当前固定 CNY(金额单位为分)。 + Currency consts.Currency `json:"currency"` + // Balance 可用余额:可用于购买/消费。 + Balance int64 `json:"balance"` + // BalanceFrozen 冻结余额:用于下单冻结/争议期等。 + BalanceFrozen int64 `json:"balance_frozen"` + // UpdatedAt 更新时间:余额变更时更新。 + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/backend/app/http/tenant/me.go b/backend/app/http/tenant/me.go index c5132ff..0cf8afb 100644 --- a/backend/app/http/tenant/me.go +++ b/backend/app/http/tenant/me.go @@ -2,7 +2,10 @@ package tenant import ( "quyun/v2/app/http/tenant/dto" + "quyun/v2/app/requests" + "quyun/v2/app/services" "quyun/v2/database/models" + "quyun/v2/pkg/consts" "github.com/gofiber/fiber/v3" ) @@ -32,3 +35,45 @@ func (*me) get(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, tenantUs TenantUser: tenantUser, }, nil } + +// balance +// +// @Summary 当前租户余额信息 +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Success 200 {object} dto.MeBalanceResponse +// +// @Router /t/:tenantCode/v1/me/balance [get] +// @Bind tenant local key(tenant) +// @Bind user local key(user) +func (*me) balance(ctx fiber.Ctx, tenant *models.Tenant, user *models.User) (*dto.MeBalanceResponse, error) { + m, err := services.Ledger.MyBalance(ctx.Context(), tenant.ID, user.ID) + if err != nil { + return nil, err + } + return &dto.MeBalanceResponse{ + Currency: consts.CurrencyCNY, + Balance: m.Balance, + BalanceFrozen: m.BalanceFrozen, + UpdatedAt: m.UpdatedAt, + }, nil +} + +// ledgers +// +// @Summary 当前租户余额流水(分页) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Success 200 {object} requests.Pager{items=dto.MyLedgerItem} +// +// @Router /t/:tenantCode/v1/me/ledgers [get] +// @Bind tenant local key(tenant) +// @Bind user local key(user) +// @Bind filter query +func (*me) ledgers(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, filter *dto.MyLedgerListFilter) (*requests.Pager, error) { + return services.Ledger.MyLedgerPage(ctx.Context(), tenant.ID, user.ID, filter) +} diff --git a/backend/app/http/tenant/routes.gen.go b/backend/app/http/tenant/routes.gen.go index c0dbe9d..a3d6df7 100644 --- a/backend/app/http/tenant/routes.gen.go +++ b/backend/app/http/tenant/routes.gen.go @@ -116,6 +116,19 @@ func (r *Routes) Register(router fiber.Router) { Local[*models.User]("user"), Local[*models.TenantUser]("tenant_user"), )) + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/me/balance -> me.balance") + router.Get("/t/:tenantCode/v1/me/balance"[len(r.Path()):], DataFunc2( + r.me.balance, + Local[*models.Tenant]("tenant"), + Local[*models.User]("user"), + )) + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/me/ledgers -> me.ledgers") + router.Get("/t/:tenantCode/v1/me/ledgers"[len(r.Path()):], DataFunc3( + r.me.ledgers, + Local[*models.Tenant]("tenant"), + Local[*models.User]("user"), + Query[dto.MyLedgerListFilter]("filter"), + )) // Register routes for controller: order r.log.Debugf("Registering route: Post /t/:tenantCode/v1/contents/:contentID/purchase -> order.purchaseContent") router.Post("/t/:tenantCode/v1/contents/:contentID/purchase"[len(r.Path()):], DataFunc4( diff --git a/backend/app/services/content_test.go b/backend/app/services/content_test.go index d132eaa..427ed0e 100644 --- a/backend/app/services/content_test.go +++ b/backend/app/services/content_test.go @@ -224,14 +224,14 @@ func (s *ContentTestSuite) Test_HasAccess() { Convey("权益 active 应返回 true", func() { access := &models.ContentAccess{ - TenantID: tenantID, - UserID: userID, - ContentID: content.ID, - OrderID: 0, - Status: consts.ContentAccessStatusActive, - RevokedAt: time.Time{}, - CreatedAt: now, - UpdatedAt: now, + TenantID: tenantID, + UserID: userID, + ContentID: content.ID, + OrderID: 0, + Status: consts.ContentAccessStatusActive, + RevokedAt: time.Time{}, + CreatedAt: now, + UpdatedAt: now, } So(access.Create(ctx), ShouldBeNil) @@ -241,4 +241,3 @@ func (s *ContentTestSuite) Test_HasAccess() { }) }) } - diff --git a/backend/app/services/ledger.go b/backend/app/services/ledger.go index 94d07cd..4bd2411 100644 --- a/backend/app/services/ledger.go +++ b/backend/app/services/ledger.go @@ -6,10 +6,14 @@ import ( "time" "quyun/v2/app/errorx" + "quyun/v2/app/http/tenant/dto" + "quyun/v2/app/requests" "quyun/v2/database/models" "quyun/v2/pkg/consts" + "github.com/samber/lo" "github.com/sirupsen/logrus" + "go.ipao.vip/gen" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -29,6 +33,84 @@ type ledger struct { db *gorm.DB } +// MyBalance 查询当前用户在指定租户下的余额信息(可用/冻结)。 +func (s *ledger) MyBalance(ctx context.Context, tenantID, userID int64) (*models.TenantUser, error) { + if tenantID <= 0 || userID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id must be > 0") + } + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "user_id": userID, + }).Info("services.ledger.me.balance") + + tbl, query := models.TenantUserQuery.QueryContext(ctx) + m, err := query.Where(tbl.TenantID.Eq(tenantID), tbl.UserID.Eq(userID)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errorx.ErrRecordNotFound.WithMsg("tenant user not found") + } + return nil, err + } + return m, nil +} + +// MyLedgerPage 分页查询当前用户在指定租户下的余额流水(用于“我的流水”)。 +func (s *ledger) MyLedgerPage(ctx context.Context, tenantID, userID int64, filter *dto.MyLedgerListFilter) (*requests.Pager, error) { + if tenantID <= 0 || userID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id must be > 0") + } + if filter == nil { + filter = &dto.MyLedgerListFilter{} + } + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "user_id": userID, + "type": lo.FromPtr(filter.Type), + "order_id": lo.FromPtr(filter.OrderID), + }).Info("services.ledger.me.ledgers.page") + + filter.Pagination.Format() + + tbl, query := models.TenantLedgerQuery.QueryContext(ctx) + + conds := []gen.Condition{ + tbl.TenantID.Eq(tenantID), + tbl.UserID.Eq(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.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.MyLedgerItem { + return &dto.MyLedgerItem{ + 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) diff --git a/backend/app/services/ledger_test.go b/backend/app/services/ledger_test.go index 36e6c58..07def18 100644 --- a/backend/app/services/ledger_test.go +++ b/backend/app/services/ledger_test.go @@ -9,6 +9,7 @@ import ( "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" @@ -302,3 +303,56 @@ func (s *LedgerTestSuite) Test_CreditTopupTx() { }) }) } + +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, 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) + }) + }) +} diff --git a/backend/app/services/order.go b/backend/app/services/order.go index 22f6a44..e11cdf3 100644 --- a/backend/app/services/order.go +++ b/backend/app/services/order.go @@ -2,6 +2,7 @@ package services import ( "context" + "encoding/json" "errors" "fmt" "time" @@ -22,6 +23,44 @@ import ( "go.ipao.vip/gen/types" ) +type PurchaseOrderSnapshot struct { + ContentID int64 `json:"content_id"` + ContentTitle string `json:"content_title"` + ContentUserID int64 `json:"content_user_id"` + ContentVisibility consts.ContentVisibility `json:"content_visibility"` + PreviewSeconds int32 `json:"preview_seconds"` + PreviewDownloadable bool `json:"preview_downloadable"` + Currency consts.Currency `json:"currency"` + PriceAmount int64 `json:"price_amount"` + DiscountType consts.DiscountType `json:"discount_type"` + DiscountValue int64 `json:"discount_value"` + DiscountStartAt *time.Time `json:"discount_start_at,omitempty"` + DiscountEndAt *time.Time `json:"discount_end_at,omitempty"` + AmountOriginal int64 `json:"amount_original"` + AmountDiscount int64 `json:"amount_discount"` + AmountPaid int64 `json:"amount_paid"` + PurchaseAt time.Time `json:"purchase_at"` + PurchaseIdempotency string `json:"purchase_idempotency_key,omitempty"` + PurchasePricingNotes string `json:"purchase_pricing_notes,omitempty"` +} + +type OrderItemSnapshot struct { + ContentID int64 `json:"content_id"` + ContentTitle string `json:"content_title"` + ContentUserID int64 `json:"content_user_id"` + AmountPaid int64 `json:"amount_paid"` +} + +type TopupOrderSnapshot struct { + OperatorUserID int64 `json:"operator_user_id"` + TargetUserID int64 `json:"target_user_id"` + Amount int64 `json:"amount"` + Currency consts.Currency `json:"currency"` + Reason string `json:"reason,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + TopupAt time.Time `json:"topup_at"` +} + // PurchaseContentParams 定义“租户内使用余额购买内容”的入参。 type PurchaseContentParams struct { // TenantID 租户 ID(多租户隔离范围)。 @@ -56,6 +95,17 @@ type order struct { ledger *ledger } +func marshalSnapshot(v any) types.JSON { + b, err := json.Marshal(v) + if err != nil { + return types.JSON([]byte("{}")) + } + if len(b) == 0 { + return types.JSON([]byte("{}")) + } + return types.JSON(b) +} + // AdminTopupUser 租户管理员给租户成员充值(增加该租户下的可用余额)。 func (s *order) AdminTopupUser( ctx context.Context, @@ -111,6 +161,15 @@ func (s *order) AdminTopupUser( } // 先落订单(paid),再写入账本(credit_topup),确保“订单可追溯 + 账本可对账”。 + snapshot := marshalSnapshot(&TopupOrderSnapshot{ + OperatorUserID: operatorUserID, + TargetUserID: targetUserID, + Amount: amount, + Currency: consts.CurrencyCNY, + Reason: reason, + IdempotencyKey: idempotencyKey, + TopupAt: now, + }) orderModel := models.Order{ TenantID: tenantID, UserID: targetUserID, @@ -120,7 +179,7 @@ func (s *order) AdminTopupUser( AmountOriginal: amount, AmountDiscount: 0, AmountPaid: amount, - Snapshot: types.JSON([]byte("{}")), + Snapshot: snapshot, IdempotencyKey: idempotencyKey, PaidAt: now, CreatedAt: now, @@ -559,6 +618,47 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara amountPaid := s.computeFinalPrice(priceAmount, &price, now) out.AmountPaid = amountPaid + discountType := price.DiscountType + if discountType == "" { + discountType = consts.DiscountTypeNone + } + var discountStartAt *time.Time + if !price.DiscountStartAt.IsZero() { + t := price.DiscountStartAt + discountStartAt = &t + } + var discountEndAt *time.Time + if !price.DiscountEndAt.IsZero() { + t := price.DiscountEndAt + discountEndAt = &t + } + + purchaseSnapshot := marshalSnapshot(&PurchaseOrderSnapshot{ + ContentID: content.ID, + ContentTitle: content.Title, + ContentUserID: content.UserID, + ContentVisibility: content.Visibility, + PreviewSeconds: content.PreviewSeconds, + PreviewDownloadable: content.PreviewDownloadable, + Currency: consts.CurrencyCNY, + PriceAmount: priceAmount, + DiscountType: discountType, + DiscountValue: price.DiscountValue, + DiscountStartAt: discountStartAt, + DiscountEndAt: discountEndAt, + AmountOriginal: priceAmount, + AmountDiscount: priceAmount - amountPaid, + AmountPaid: amountPaid, + PurchaseAt: now, + PurchaseIdempotency: params.IdempotencyKey, + }) + itemSnapshot := marshalSnapshot(&OrderItemSnapshot{ + ContentID: content.ID, + ContentTitle: content.Title, + ContentUserID: content.UserID, + AmountPaid: amountPaid, + }) + // 免费内容:无需冻结,保持单事务写订单+权益。 if amountPaid == 0 { err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -571,7 +671,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara AmountOriginal: priceAmount, AmountDiscount: priceAmount - amountPaid, AmountPaid: amountPaid, - Snapshot: types.JSON([]byte("{}")), + Snapshot: purchaseSnapshot, IdempotencyKey: params.IdempotencyKey, PaidAt: now, CreatedAt: now, @@ -587,7 +687,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara ContentID: params.ContentID, ContentUserID: content.UserID, AmountPaid: amountPaid, - Snapshot: types.JSON([]byte("{}")), + Snapshot: itemSnapshot, CreatedAt: now, UpdatedAt: now, } @@ -631,7 +731,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara AmountOriginal: priceAmount, AmountDiscount: priceAmount - amountPaid, AmountPaid: amountPaid, - Snapshot: types.JSON([]byte("{}")), + Snapshot: purchaseSnapshot, IdempotencyKey: params.IdempotencyKey, CreatedAt: now, UpdatedAt: now, @@ -646,7 +746,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara ContentID: params.ContentID, ContentUserID: content.UserID, AmountPaid: amountPaid, - Snapshot: types.JSON([]byte("{}")), + Snapshot: itemSnapshot, CreatedAt: now, UpdatedAt: now, } @@ -753,6 +853,45 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara amountPaid := s.computeFinalPrice(priceAmount, &price, now) out.AmountPaid = amountPaid + discountType := price.DiscountType + if discountType == "" { + discountType = consts.DiscountTypeNone + } + var discountStartAt *time.Time + if !price.DiscountStartAt.IsZero() { + t := price.DiscountStartAt + discountStartAt = &t + } + var discountEndAt *time.Time + if !price.DiscountEndAt.IsZero() { + t := price.DiscountEndAt + discountEndAt = &t + } + purchaseSnapshot := marshalSnapshot(&PurchaseOrderSnapshot{ + ContentID: content.ID, + ContentTitle: content.Title, + ContentUserID: content.UserID, + ContentVisibility: content.Visibility, + PreviewSeconds: content.PreviewSeconds, + PreviewDownloadable: content.PreviewDownloadable, + Currency: consts.CurrencyCNY, + PriceAmount: priceAmount, + DiscountType: discountType, + DiscountValue: price.DiscountValue, + DiscountStartAt: discountStartAt, + DiscountEndAt: discountEndAt, + AmountOriginal: priceAmount, + AmountDiscount: priceAmount - amountPaid, + AmountPaid: amountPaid, + PurchaseAt: now, + }) + itemSnapshot := marshalSnapshot(&OrderItemSnapshot{ + ContentID: content.ID, + ContentTitle: content.Title, + ContentUserID: content.UserID, + AmountPaid: amountPaid, + }) + if amountPaid == 0 { orderModel := &models.Order{ TenantID: params.TenantID, @@ -763,7 +902,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara AmountOriginal: priceAmount, AmountDiscount: priceAmount - amountPaid, AmountPaid: amountPaid, - Snapshot: types.JSON([]byte("{}")), + Snapshot: purchaseSnapshot, IdempotencyKey: "", PaidAt: now, CreatedAt: now, @@ -779,7 +918,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara ContentID: params.ContentID, ContentUserID: content.UserID, AmountPaid: amountPaid, - Snapshot: types.JSON([]byte("{}")), + Snapshot: itemSnapshot, CreatedAt: now, UpdatedAt: now, } @@ -808,7 +947,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara AmountOriginal: priceAmount, AmountDiscount: priceAmount - amountPaid, AmountPaid: amountPaid, - Snapshot: types.JSON([]byte("{}")), + Snapshot: purchaseSnapshot, IdempotencyKey: "", CreatedAt: now, UpdatedAt: now, @@ -829,7 +968,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara ContentID: params.ContentID, ContentUserID: content.UserID, AmountPaid: amountPaid, - Snapshot: types.JSON([]byte("{}")), + Snapshot: itemSnapshot, CreatedAt: now, UpdatedAt: now, } diff --git a/backend/app/services/order_test.go b/backend/app/services/order_test.go index 99029b0..983735f 100644 --- a/backend/app/services/order_test.go +++ b/backend/app/services/order_test.go @@ -3,6 +3,7 @@ package services import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "testing" @@ -78,13 +79,13 @@ func (s *OrderTestSuite) seedPublishedContent(ctx context.Context, tenantID, own func (s *OrderTestSuite) seedContentPrice(ctx context.Context, tenantID, contentID, priceAmount int64) { p := &models.ContentPrice{ - TenantID: tenantID, - UserID: 1, - ContentID: contentID, - Currency: consts.CurrencyCNY, - PriceAmount: priceAmount, - DiscountType: consts.DiscountTypeNone, - DiscountValue: 0, + TenantID: tenantID, + UserID: 1, + ContentID: contentID, + Currency: consts.CurrencyCNY, + PriceAmount: priceAmount, + DiscountType: consts.DiscountTypeNone, + DiscountValue: 0, DiscountStartAt: time.Time{}, DiscountEndAt: time.Time{}, } @@ -141,6 +142,12 @@ func (s *OrderTestSuite) Test_AdminTopupUser() { So(orderModel.Status, ShouldEqual, consts.OrderStatusPaid) So(orderModel.AmountPaid, ShouldEqual, 300) + var snap map[string]any + So(json.Unmarshal([]byte(orderModel.Snapshot), &snap), ShouldBeNil) + So(snap["operator_user_id"], ShouldEqual, float64(operatorUserID)) + So(snap["target_user_id"], ShouldEqual, float64(targetUserID)) + So(snap["amount"], ShouldEqual, float64(300)) + var tu models.TenantUser So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, targetUserID).First(&tu).Error, ShouldBeNil) So(tu.Balance, ShouldEqual, 300) @@ -360,14 +367,14 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() { So(item.Create(ctx), ShouldBeNil) access := &models.ContentAccess{ - TenantID: tenantID, - UserID: buyerUserID, - ContentID: contentID, - OrderID: orderModel.ID, - Status: consts.ContentAccessStatusActive, - CreatedAt: now, - UpdatedAt: now, - RevokedAt: time.Time{}, + TenantID: tenantID, + UserID: buyerUserID, + ContentID: contentID, + OrderID: orderModel.ID, + Status: consts.ContentAccessStatusActive, + CreatedAt: now, + UpdatedAt: now, + RevokedAt: time.Time{}, } So(access.Create(ctx), ShouldBeNil) @@ -477,6 +484,12 @@ func (s *OrderTestSuite) Test_PurchaseContent() { So(res1.Access, ShouldNotBeNil) So(res1.Access.Status, ShouldEqual, consts.ContentAccessStatusActive) + var snap map[string]any + So(json.Unmarshal([]byte(res1.Order.Snapshot), &snap), ShouldBeNil) + So(snap["content_id"], ShouldEqual, float64(content.ID)) + So(snap["content_title"], ShouldEqual, content.Title) + So(snap["amount_paid"], ShouldEqual, float64(0)) + res2, err := Order.PurchaseContent(ctx, &PurchaseContentParams{ TenantID: tenantID, UserID: buyerUserID, @@ -518,6 +531,17 @@ func (s *OrderTestSuite) Test_PurchaseContent() { So(res1.Access, ShouldNotBeNil) So(res1.Access.Status, ShouldEqual, consts.ContentAccessStatusActive) + var snap map[string]any + So(json.Unmarshal([]byte(res1.Order.Snapshot), &snap), ShouldBeNil) + So(snap["content_id"], ShouldEqual, float64(content.ID)) + So(snap["amount_paid"], ShouldEqual, float64(300)) + So(snap["amount_original"], ShouldEqual, float64(300)) + + var itemSnap map[string]any + So(json.Unmarshal([]byte(res1.OrderItem.Snapshot), &itemSnap), ShouldBeNil) + So(itemSnap["content_id"], ShouldEqual, float64(content.ID)) + So(itemSnap["amount_paid"], ShouldEqual, float64(300)) + var tu models.TenantUser So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, buyerUserID).First(&tu).Error, ShouldBeNil) So(tu.Balance, ShouldEqual, 700) diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 23c4903..9a04643 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -1078,6 +1078,80 @@ const docTemplate = `{ } } }, + "/t/{tenantCode}/v1/me/balance": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "当前租户余额信息", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.MeBalanceResponse" + } + } + } + } + }, + "/t/{tenantCode}/v1/me/ledgers": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "当前租户余额流水(分页)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "$ref": "#/definitions/dto.MyLedgerItem" + } + } + } + ] + } + } + } + } + }, "/t/{tenantCode}/v1/orders": { "get": { "consumes": [ @@ -1345,6 +1419,25 @@ const docTemplate = `{ "RoleSuperAdmin" ] }, + "consts.TenantLedgerType": { + "type": "string", + "enum": [ + "credit_topup", + "debit_purchase", + "credit_refund", + "freeze", + "unfreeze", + "adjustment" + ], + "x-enum-varnames": [ + "TenantLedgerTypeCreditTopup", + "TenantLedgerTypeDebitPurchase", + "TenantLedgerTypeCreditRefund", + "TenantLedgerTypeFreeze", + "TenantLedgerTypeUnfreeze", + "TenantLedgerTypeAdjustment" + ] + }, "consts.TenantStatus": { "type": "string", "enum": [ @@ -1638,6 +1731,31 @@ const docTemplate = `{ } } }, + "dto.MeBalanceResponse": { + "type": "object", + "properties": { + "balance": { + "description": "Balance 可用余额:可用于购买/消费。", + "type": "integer" + }, + "balance_frozen": { + "description": "BalanceFrozen 冻结余额:用于下单冻结/争议期等。", + "type": "integer" + }, + "currency": { + "description": "Currency 币种:当前固定 CNY(金额单位为分)。", + "allOf": [ + { + "$ref": "#/definitions/consts.Currency" + } + ] + }, + "updated_at": { + "description": "UpdatedAt 更新时间:余额变更时更新。", + "type": "string" + } + } + }, "dto.MeResponse": { "type": "object", "properties": { @@ -1667,6 +1785,23 @@ const docTemplate = `{ } } }, + "dto.MyLedgerItem": { + "type": "object", + "properties": { + "ledger": { + "description": "Ledger 流水记录(租户内隔离)。", + "allOf": [ + { + "$ref": "#/definitions/models.TenantLedger" + } + ] + }, + "type_description": { + "description": "TypeDescription 流水类型中文说明(用于前端展示)。", + "type": "string" + } + } + }, "dto.PurchaseContentForm": { "type": "object", "properties": { @@ -2381,6 +2516,74 @@ const docTemplate = `{ } } }, + "models.TenantLedger": { + "type": "object", + "properties": { + "amount": { + "description": "流水金额:分/最小货币单位;通常为正数,方向由 type 决定(由业务层约束)", + "type": "integer" + }, + "balance_after": { + "description": "变更后可用余额:用于审计与对账回放", + "type": "integer" + }, + "balance_before": { + "description": "变更前可用余额:用于审计与对账回放", + "type": "integer" + }, + "created_at": { + "description": "创建时间:默认 now()", + "type": "string" + }, + "frozen_after": { + "description": "变更后冻结余额:用于审计与对账回放", + "type": "integer" + }, + "frozen_before": { + "description": "变更前冻结余额:用于审计与对账回放", + "type": "integer" + }, + "id": { + "description": "主键ID:自增", + "type": "integer" + }, + "idempotency_key": { + "description": "幂等键:同一租户同一用户同一业务操作固定;用于防止重复落账(建议由业务层生成)", + "type": "string" + }, + "order": { + "$ref": "#/definitions/models.Order" + }, + "order_id": { + "description": "关联订单ID:购买/退款类流水应关联 orders.id;非订单类可为空", + "type": "integer" + }, + "remark": { + "description": "备注:业务说明/后台操作原因等;用于审计", + "type": "string" + }, + "tenant_id": { + "description": "租户ID:多租户隔离关键字段;必须与 tenant_users.tenant_id 一致", + "type": "integer" + }, + "type": { + "description": "流水类型:credit_topup/debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向", + "allOf": [ + { + "$ref": "#/definitions/consts.TenantLedgerType" + } + ] + }, + "updated_at": { + "description": "更新时间:默认 now()", + "type": "string" + }, + "user_id": { + "description": "用户ID:余额账户归属用户;对应 tenant_users.user_id", + "type": "integer" + } + } + }, "models.TenantUser": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 1e3f732..c7983bb 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -1072,6 +1072,80 @@ } } }, + "/t/{tenantCode}/v1/me/balance": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "当前租户余额信息", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.MeBalanceResponse" + } + } + } + } + }, + "/t/{tenantCode}/v1/me/ledgers": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "当前租户余额流水(分页)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "$ref": "#/definitions/dto.MyLedgerItem" + } + } + } + ] + } + } + } + } + }, "/t/{tenantCode}/v1/orders": { "get": { "consumes": [ @@ -1339,6 +1413,25 @@ "RoleSuperAdmin" ] }, + "consts.TenantLedgerType": { + "type": "string", + "enum": [ + "credit_topup", + "debit_purchase", + "credit_refund", + "freeze", + "unfreeze", + "adjustment" + ], + "x-enum-varnames": [ + "TenantLedgerTypeCreditTopup", + "TenantLedgerTypeDebitPurchase", + "TenantLedgerTypeCreditRefund", + "TenantLedgerTypeFreeze", + "TenantLedgerTypeUnfreeze", + "TenantLedgerTypeAdjustment" + ] + }, "consts.TenantStatus": { "type": "string", "enum": [ @@ -1632,6 +1725,31 @@ } } }, + "dto.MeBalanceResponse": { + "type": "object", + "properties": { + "balance": { + "description": "Balance 可用余额:可用于购买/消费。", + "type": "integer" + }, + "balance_frozen": { + "description": "BalanceFrozen 冻结余额:用于下单冻结/争议期等。", + "type": "integer" + }, + "currency": { + "description": "Currency 币种:当前固定 CNY(金额单位为分)。", + "allOf": [ + { + "$ref": "#/definitions/consts.Currency" + } + ] + }, + "updated_at": { + "description": "UpdatedAt 更新时间:余额变更时更新。", + "type": "string" + } + } + }, "dto.MeResponse": { "type": "object", "properties": { @@ -1661,6 +1779,23 @@ } } }, + "dto.MyLedgerItem": { + "type": "object", + "properties": { + "ledger": { + "description": "Ledger 流水记录(租户内隔离)。", + "allOf": [ + { + "$ref": "#/definitions/models.TenantLedger" + } + ] + }, + "type_description": { + "description": "TypeDescription 流水类型中文说明(用于前端展示)。", + "type": "string" + } + } + }, "dto.PurchaseContentForm": { "type": "object", "properties": { @@ -2375,6 +2510,74 @@ } } }, + "models.TenantLedger": { + "type": "object", + "properties": { + "amount": { + "description": "流水金额:分/最小货币单位;通常为正数,方向由 type 决定(由业务层约束)", + "type": "integer" + }, + "balance_after": { + "description": "变更后可用余额:用于审计与对账回放", + "type": "integer" + }, + "balance_before": { + "description": "变更前可用余额:用于审计与对账回放", + "type": "integer" + }, + "created_at": { + "description": "创建时间:默认 now()", + "type": "string" + }, + "frozen_after": { + "description": "变更后冻结余额:用于审计与对账回放", + "type": "integer" + }, + "frozen_before": { + "description": "变更前冻结余额:用于审计与对账回放", + "type": "integer" + }, + "id": { + "description": "主键ID:自增", + "type": "integer" + }, + "idempotency_key": { + "description": "幂等键:同一租户同一用户同一业务操作固定;用于防止重复落账(建议由业务层生成)", + "type": "string" + }, + "order": { + "$ref": "#/definitions/models.Order" + }, + "order_id": { + "description": "关联订单ID:购买/退款类流水应关联 orders.id;非订单类可为空", + "type": "integer" + }, + "remark": { + "description": "备注:业务说明/后台操作原因等;用于审计", + "type": "string" + }, + "tenant_id": { + "description": "租户ID:多租户隔离关键字段;必须与 tenant_users.tenant_id 一致", + "type": "integer" + }, + "type": { + "description": "流水类型:credit_topup/debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向", + "allOf": [ + { + "$ref": "#/definitions/consts.TenantLedgerType" + } + ] + }, + "updated_at": { + "description": "更新时间:默认 now()", + "type": "string" + }, + "user_id": { + "description": "用户ID:余额账户归属用户;对应 tenant_users.user_id", + "type": "integer" + } + } + }, "models.TenantUser": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 0073916..9fc4e2f 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -116,6 +116,22 @@ definitions: x-enum-varnames: - RoleUser - RoleSuperAdmin + consts.TenantLedgerType: + enum: + - credit_topup + - debit_purchase + - credit_refund + - freeze + - unfreeze + - adjustment + type: string + x-enum-varnames: + - TenantLedgerTypeCreditTopup + - TenantLedgerTypeDebitPurchase + - TenantLedgerTypeCreditRefund + - TenantLedgerTypeFreeze + - TenantLedgerTypeUnfreeze + - TenantLedgerTypeAdjustment consts.TenantStatus: enum: - pending_verify @@ -317,6 +333,22 @@ definitions: token: type: string type: object + dto.MeBalanceResponse: + properties: + balance: + description: Balance 可用余额:可用于购买/消费。 + type: integer + balance_frozen: + description: BalanceFrozen 冻结余额:用于下单冻结/争议期等。 + type: integer + currency: + allOf: + - $ref: '#/definitions/consts.Currency' + description: Currency 币种:当前固定 CNY(金额单位为分)。 + updated_at: + description: UpdatedAt 更新时间:余额变更时更新。 + type: string + type: object dto.MeResponse: properties: tenant: @@ -333,6 +365,16 @@ definitions: - $ref: '#/definitions/models.User' description: User is the authenticated user derived from JWT `user_id`. type: object + dto.MyLedgerItem: + properties: + ledger: + allOf: + - $ref: '#/definitions/models.TenantLedger' + description: Ledger 流水记录(租户内隔离)。 + type_description: + description: TypeDescription 流水类型中文说明(用于前端展示)。 + type: string + type: object dto.PurchaseContentForm: properties: idempotency_key: @@ -809,6 +851,54 @@ definitions: uuid: type: string type: object + models.TenantLedger: + properties: + amount: + description: 流水金额:分/最小货币单位;通常为正数,方向由 type 决定(由业务层约束) + type: integer + balance_after: + description: 变更后可用余额:用于审计与对账回放 + type: integer + balance_before: + description: 变更前可用余额:用于审计与对账回放 + type: integer + created_at: + description: 创建时间:默认 now() + type: string + frozen_after: + description: 变更后冻结余额:用于审计与对账回放 + type: integer + frozen_before: + description: 变更前冻结余额:用于审计与对账回放 + type: integer + id: + description: 主键ID:自增 + type: integer + idempotency_key: + description: 幂等键:同一租户同一用户同一业务操作固定;用于防止重复落账(建议由业务层生成) + type: string + order: + $ref: '#/definitions/models.Order' + order_id: + description: 关联订单ID:购买/退款类流水应关联 orders.id;非订单类可为空 + type: integer + remark: + description: 备注:业务说明/后台操作原因等;用于审计 + type: string + tenant_id: + description: 租户ID:多租户隔离关键字段;必须与 tenant_users.tenant_id 一致 + type: integer + type: + allOf: + - $ref: '#/definitions/consts.TenantLedgerType' + description: 流水类型:credit_topup/debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向 + updated_at: + description: 更新时间:默认 now() + type: string + user_id: + description: 用户ID:余额账户归属用户;对应 tenant_users.user_id + type: integer + type: object models.TenantUser: properties: balance: @@ -1600,6 +1690,51 @@ paths: summary: 当前租户上下文信息 tags: - Tenant + /t/{tenantCode}/v1/me/balance: + get: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.MeBalanceResponse' + summary: 当前租户余额信息 + tags: + - Tenant + /t/{tenantCode}/v1/me/ledgers: + get: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + $ref: '#/definitions/dto.MyLedgerItem' + type: object + summary: 当前租户余额流水(分页) + tags: + - Tenant /t/{tenantCode}/v1/orders: get: consumes: diff --git a/backend/tests/tenant.http b/backend/tests/tenant.http index 5a54b58..d75e3ff 100644 --- a/backend/tests/tenant.http +++ b/backend/tests/tenant.http @@ -12,6 +12,16 @@ GET {{ host }}/t/{{ tenantCode }}/v1/me Content-Type: application/json Authorization: Bearer {{ token }} +### Tenant - My balance +GET {{ host }}/t/{{ tenantCode }}/v1/me/balance +Content-Type: application/json +Authorization: Bearer {{ token }} + +### Tenant - My ledgers (paged) +GET {{ host }}/t/{{ tenantCode }}/v1/me/ledgers?page=1&limit=20 +Content-Type: application/json +Authorization: Bearer {{ token }} + ### Tenant - Contents list (published) GET {{ host }}/t/{{ tenantCode }}/v1/contents?page=1&limit=10 Content-Type: application/json