feat: add TenantLedger model and query generation

- Introduced TenantLedger model with fields for managing tenant transactions, including ID, TenantID, UserID, OrderID, transaction Type, Amount, and balance details.
- Implemented CRUD operations for TenantLedger with methods for Create, Update, Delete, and Reload.
- Generated query methods for TenantLedger to facilitate database interactions, including filtering, pagination, and aggregation functions.
- Established relationships with Order model for foreign key references.
This commit is contained in:
2025-12-18 13:12:26 +08:00
parent f93caefcb2
commit 1da84f2af3
42 changed files with 6468 additions and 265 deletions

View File

@@ -0,0 +1,217 @@
package services
import (
"context"
"errors"
"time"
"quyun/v2/app/errorx"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// LedgerApplyResult is the result of a single ledger application, including the created ledger record
// and the updated tenant user balance snapshot.
type LedgerApplyResult struct {
// Ledger is the created ledger record (or existing one if idempotent hit).
Ledger *models.TenantLedger
// TenantUser is the updated tenant user record reflecting the post-apply balances.
TenantUser *models.TenantUser
}
// ledger provides tenant balance ledger operations (freeze/unfreeze/etc.) with idempotency and row-locking.
//
// @provider
type ledger struct {
db *gorm.DB
}
// Freeze moves funds from available balance to frozen balance and records a tenant ledger entry.
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)
}
// Unfreeze moves funds from frozen balance back to available balance and records a tenant ledger entry.
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)
}
// FreezeTx is the transaction-scoped variant of 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)
}
// UnfreezeTx is the transaction-scoped variant of 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)
}
// DebitPurchaseTx turns frozen funds into a finalized debit (reduces frozen balance) and records a ledger entry.
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)
}
// CreditRefundTx credits funds back to available balance and records a ledger entry.
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) apply(
ctx context.Context,
tx *gorm.DB,
tenantID, userID, orderID int64,
ledgerType consts.TenantLedgerType,
amount, deltaBalance, deltaFrozen int64,
idempotencyKey, remark string,
now time.Time,
) (*LedgerApplyResult, error) {
if amount <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("amount must be > 0")
}
if now.IsZero() {
now = time.Now()
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"user_id": userID,
"order_id": orderID,
"type": ledgerType,
"amount": amount,
"idempotency_key": idempotencyKey,
"delta_balance": deltaBalance,
"delta_frozen": deltaFrozen,
"remark_non_empty": remark != "",
}).Info("services.ledger.apply")
var out LedgerApplyResult
err := tx.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if idempotencyKey != "" {
var existing models.TenantLedger
if err := tx.
Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey).
First(&existing).Error; err == nil {
var current models.TenantUser
if err := tx.Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&current).Error; err != nil {
return err
}
out.Ledger = &existing
out.TenantUser = &current
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
var tu models.TenantUser
if err := tx.
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("tenant_id = ? AND user_id = ?", tenantID, userID).
First(&tu).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound.WithMsg("tenant user not found")
}
return err
}
if idempotencyKey != "" {
var existing models.TenantLedger
if err := tx.
Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey).
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
frozenAfter := frozenBefore + deltaFrozen
if balanceAfter < 0 {
return errorx.ErrPreconditionFailed.WithMsg("余额不足")
}
if frozenAfter < 0 {
return errorx.ErrPreconditionFailed.WithMsg("冻结余额不足")
}
if err := tx.Model(&models.TenantUser{}).
Where("id = ?", tu.ID).
Updates(map[string]any{
"balance": balanceAfter,
"balance_frozen": frozenAfter,
"updated_at": now,
}).Error; err != nil {
return err
}
ledger := &models.TenantLedger{
TenantID: tenantID,
UserID: userID,
OrderID: orderID,
Type: ledgerType,
Amount: amount,
BalanceBefore: balanceBefore,
BalanceAfter: balanceAfter,
FrozenBefore: frozenBefore,
FrozenAfter: frozenAfter,
IdempotencyKey: idempotencyKey,
Remark: remark,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(ledger).Error; err != nil {
if idempotencyKey != "" {
var existing models.TenantLedger
if e2 := tx.
Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey).
First(&existing).Error; e2 == nil {
out.Ledger = &existing
out.TenantUser = &tu
return nil
}
}
return err
}
tu.Balance = balanceAfter
tu.BalanceFrozen = frozenAfter
tu.UpdatedAt = now
out.Ledger = ledger
out.TenantUser = &tu
return nil
})
if err != nil {
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"user_id": userID,
"order_id": orderID,
"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,
}).Info("services.ledger.apply.ok")
return &out, nil
}

View File

@@ -0,0 +1,612 @@
package services
import (
"context"
"errors"
"fmt"
"time"
"quyun/v2/app/errorx"
"quyun/v2/app/http/tenant/dto"
"quyun/v2/app/requests"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
pkgerrors "github.com/pkg/errors"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
"go.ipao.vip/gen"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"go.ipao.vip/gen/types"
)
// PurchaseContentParams defines parameters for purchasing a content within a tenant using tenant balance.
type PurchaseContentParams struct {
// TenantID is the tenant scope.
TenantID int64
// UserID is the buyer user id.
UserID int64
// ContentID is the target content id.
ContentID int64
// IdempotencyKey is used to ensure a purchase request is processed at most once.
IdempotencyKey string
// Now is the logical time used for created_at/paid_at and ledger snapshots (optional).
Now time.Time
}
// PurchaseContentResult is returned after purchase attempt (idempotent hit returns existing order/access state).
type PurchaseContentResult struct {
// Order is the created or existing order record (may be nil when already purchased without order context).
Order *models.Order
// OrderItem is the related order item record (single-item purchase).
OrderItem *models.OrderItem
// Access is the content access record after purchase grant.
Access *models.ContentAccess
// AmountPaid is the final paid amount in cents (CNY 分).
AmountPaid int64
}
// order provides order domain operations.
//
// @provider
type order struct {
db *gorm.DB
ledger *ledger
}
// MyOrderPage lists orders for current user within a tenant.
func (s *order) MyOrderPage(ctx context.Context, tenantID, userID int64, filter *dto.MyOrderListFilter) (*requests.Pager, error) {
if tenantID <= 0 || userID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id must be > 0")
}
if filter == nil {
filter = &dto.MyOrderListFilter{}
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"user_id": userID,
"status": lo.FromPtr(filter.Status),
}).Info("services.order.me.page")
filter.Pagination.Format()
tbl, query := models.OrderQuery.QueryContext(ctx)
query = query.Preload(tbl.Items)
conds := []gen.Condition{
tbl.TenantID.Eq(tenantID),
tbl.UserID.Eq(userID),
}
if filter.Status != nil {
conds = append(conds, tbl.Status.Eq(*filter.Status))
}
items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit))
if err != nil {
return nil, err
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
// MyOrderDetail returns order detail for current user within a tenant.
func (s *order) MyOrderDetail(ctx context.Context, tenantID, userID, orderID int64) (*models.Order, error) {
if tenantID <= 0 || userID <= 0 || orderID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id/order_id must be > 0")
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"user_id": userID,
"order_id": orderID,
}).Info("services.order.me.detail")
tbl, query := models.OrderQuery.QueryContext(ctx)
m, err := query.Preload(tbl.Items).Where(
tbl.TenantID.Eq(tenantID),
tbl.UserID.Eq(userID),
tbl.ID.Eq(orderID),
).First()
if err != nil {
return nil, err
}
return m, nil
}
// AdminOrderPage lists orders within a tenant for tenant-admin.
func (s *order) AdminOrderPage(ctx context.Context, tenantID int64, filter *dto.AdminOrderListFilter) (*requests.Pager, error) {
if tenantID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0")
}
if filter == nil {
filter = &dto.AdminOrderListFilter{}
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"user_id": lo.FromPtr(filter.UserID),
"status": lo.FromPtr(filter.Status),
}).Info("services.order.admin.page")
filter.Pagination.Format()
tbl, query := models.OrderQuery.QueryContext(ctx)
query = query.Preload(tbl.Items)
conds := []gen.Condition{tbl.TenantID.Eq(tenantID)}
if filter.UserID != nil {
conds = append(conds, tbl.UserID.Eq(*filter.UserID))
}
if filter.Status != nil {
conds = append(conds, tbl.Status.Eq(*filter.Status))
}
items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit))
if err != nil {
return nil, err
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
// AdminOrderDetail returns an order detail within a tenant for tenant-admin.
func (s *order) AdminOrderDetail(ctx context.Context, tenantID, orderID int64) (*models.Order, error) {
if tenantID <= 0 || orderID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/order_id must be > 0")
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"order_id": orderID,
}).Info("services.order.admin.detail")
tbl, query := models.OrderQuery.QueryContext(ctx)
m, err := query.Preload(tbl.Items).Where(tbl.TenantID.Eq(tenantID), tbl.ID.Eq(orderID)).First()
if err != nil {
return nil, err
}
return m, nil
}
// AdminRefundOrder refunds a paid order (supports forced refund) and revokes granted content access.
func (s *order) AdminRefundOrder(ctx context.Context, tenantID, operatorUserID, orderID int64, force bool, reason, idempotencyKey string, now time.Time) (*models.Order, error) {
if tenantID <= 0 || operatorUserID <= 0 || orderID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id/order_id must be > 0")
}
if now.IsZero() {
now = time.Now()
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"operator_user_id": operatorUserID,
"order_id": orderID,
"force": force,
"idempotency_key": idempotencyKey,
}).Info("services.order.admin.refund")
var out *models.Order
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var orderModel models.Order
if err := tx.
Clauses(clause.Locking{Strength: "UPDATE"}).
Preload("Items").
Where("tenant_id = ? AND id = ?", tenantID, orderID).
First(&orderModel).Error; err != nil {
return err
}
if orderModel.Status == consts.OrderStatusRefunded {
out = &orderModel
return nil
}
if orderModel.Status != consts.OrderStatusPaid {
return errorx.ErrStatusConflict.WithMsg("订单非已支付状态,无法退款")
}
if orderModel.PaidAt.IsZero() {
return errorx.ErrPreconditionFailed.WithMsg("订单缺少 paid_at无法退款")
}
if !force {
deadline := orderModel.PaidAt.Add(consts.DefaultOrderRefundWindow)
if now.After(deadline) {
return errorx.ErrPreconditionFailed.WithMsg("已超过默认退款时间窗")
}
}
amount := orderModel.AmountPaid
refundKey := fmt.Sprintf("refund:%d", orderModel.ID)
if amount > 0 {
if _, err := s.ledger.CreditRefundTx(ctx, tx, tenantID, orderModel.UserID, orderModel.ID, amount, refundKey, reason, now); err != nil {
return err
}
}
// revoke content access immediately
for _, item := range orderModel.Items {
if item == nil {
continue
}
if err := tx.Table(models.TableNameContentAccess).
Where("tenant_id = ? AND user_id = ? AND content_id = ?", tenantID, orderModel.UserID, item.ContentID).
Updates(map[string]any{
"status": consts.ContentAccessStatusRevoked,
"revoked_at": now,
"updated_at": now,
}).Error; err != nil {
return err
}
}
if err := tx.Table(models.TableNameOrder).
Where("id = ?", orderModel.ID).
Updates(map[string]any{
"status": consts.OrderStatusRefunded,
"refunded_at": now,
"refund_forced": force,
"refund_operator_user_id": operatorUserID,
"refund_reason": reason,
"updated_at": now,
}).Error; err != nil {
return err
}
orderModel.Status = consts.OrderStatusRefunded
orderModel.RefundedAt = now
orderModel.RefundForced = force
orderModel.RefundOperatorUserID = operatorUserID
orderModel.RefundReason = reason
orderModel.UpdatedAt = now
out = &orderModel
return nil
})
if err != nil {
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"operator_user_id": operatorUserID,
"order_id": orderID,
"force": force,
"idempotency_key": idempotencyKey,
}).WithError(err).Warn("services.order.admin.refund.failed")
return nil, err
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"operator_user_id": operatorUserID,
"order_id": orderID,
"status": out.Status,
"refund_forced": out.RefundForced,
}).Info("services.order.admin.refund.ok")
return out, nil
}
func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentParams) (*PurchaseContentResult, error) {
if params == nil {
return nil, errorx.ErrInvalidParameter.WithMsg("params is required")
}
if params.TenantID <= 0 || params.UserID <= 0 || params.ContentID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id/content_id must be > 0")
}
now := params.Now
if now.IsZero() {
now = time.Now()
}
logrus.WithFields(logrus.Fields{
"tenant_id": params.TenantID,
"user_id": params.UserID,
"content_id": params.ContentID,
"idempotency_key": params.IdempotencyKey,
}).Info("services.order.purchase_content")
var out PurchaseContentResult
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if params.IdempotencyKey != "" {
var existing models.Order
if err := tx.
Preload("Items").
Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", params.TenantID, params.UserID, params.IdempotencyKey).
First(&existing).Error; err == nil {
out.Order = &existing
if len(existing.Items) > 0 {
out.OrderItem = existing.Items[0]
}
if out.OrderItem != nil {
var access models.ContentAccess
if err := tx.
Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, out.OrderItem.ContentID).
First(&access).Error; err == nil {
out.Access = &access
}
}
out.AmountPaid = existing.AmountPaid
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
var content models.Content
if err := tx.
Where("tenant_id = ? AND id = ? AND deleted_at IS NULL", params.TenantID, params.ContentID).
First(&content).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound.WithMsg("content not found")
}
return err
}
if content.Status != consts.ContentStatusPublished {
return errorx.ErrPreconditionFailed.WithMsg("content not published")
}
if content.UserID == params.UserID {
out.AmountPaid = 0
if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, 0, now); err != nil {
return err
}
var access models.ContentAccess
if err := tx.Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).First(&access).Error; err != nil {
return err
}
out.Access = &access
return nil
}
var accessExisting models.ContentAccess
if err := tx.
Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).
First(&accessExisting).Error; err == nil {
if accessExisting.Status == consts.ContentAccessStatusActive {
out.Access = &accessExisting
return nil
}
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
var price models.ContentPrice
priceAmount := int64(0)
if err := tx.Where("tenant_id = ? AND content_id = ?", params.TenantID, params.ContentID).First(&price).Error; err == nil {
priceAmount = price.PriceAmount
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
amountPaid := s.computeFinalPrice(priceAmount, &price, now)
out.AmountPaid = amountPaid
if amountPaid == 0 {
orderModel := &models.Order{
TenantID: params.TenantID,
UserID: params.UserID,
Type: consts.OrderTypeContentPurchase,
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountOriginal: priceAmount,
AmountDiscount: priceAmount - amountPaid,
AmountPaid: amountPaid,
Snapshot: types.JSON([]byte("{}")),
IdempotencyKey: params.IdempotencyKey,
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(orderModel).Error; err != nil {
return err
}
item := &models.OrderItem{
TenantID: params.TenantID,
UserID: params.UserID,
OrderID: orderModel.ID,
ContentID: params.ContentID,
ContentUserID: content.UserID,
AmountPaid: amountPaid,
Snapshot: types.JSON([]byte("{}")),
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(item).Error; err != nil {
return err
}
if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, orderModel.ID, now); err != nil {
return err
}
var access models.ContentAccess
if err := tx.Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).First(&access).Error; err != nil {
return err
}
out.Order = orderModel
out.OrderItem = item
out.Access = &access
return nil
}
orderModel := &models.Order{
TenantID: params.TenantID,
UserID: params.UserID,
Type: consts.OrderTypeContentPurchase,
Status: consts.OrderStatusCreated,
Currency: consts.CurrencyCNY,
AmountOriginal: priceAmount,
AmountDiscount: priceAmount - amountPaid,
AmountPaid: amountPaid,
Snapshot: types.JSON([]byte("{}")),
IdempotencyKey: params.IdempotencyKey,
CreatedAt: now,
UpdatedAt: now,
}
freezeKey := fmt.Sprintf("%s:freeze", params.IdempotencyKey)
if params.IdempotencyKey == "" {
freezeKey = ""
}
if _, err := s.ledger.FreezeTx(ctx, tx, params.TenantID, params.UserID, 0, amountPaid, freezeKey, "purchase freeze", now); err != nil {
return err
}
if err := tx.Create(orderModel).Error; err != nil {
return err
}
item := &models.OrderItem{
TenantID: params.TenantID,
UserID: params.UserID,
OrderID: orderModel.ID,
ContentID: params.ContentID,
ContentUserID: content.UserID,
AmountPaid: amountPaid,
Snapshot: types.JSON([]byte("{}")),
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(item).Error; err != nil {
return err
}
debitKey := fmt.Sprintf("%s:debit", params.IdempotencyKey)
if params.IdempotencyKey == "" {
debitKey = ""
}
if _, err := s.ledger.DebitPurchaseTx(ctx, tx, params.TenantID, params.UserID, orderModel.ID, amountPaid, debitKey, "purchase debit", now); err != nil {
return err
}
if err := tx.Model(&models.Order{}).
Where("id = ?", orderModel.ID).
Updates(map[string]any{
"status": consts.OrderStatusPaid,
"paid_at": now,
"updated_at": now,
}).Error; err != nil {
return err
}
if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, orderModel.ID, now); err != nil {
return err
}
var access models.ContentAccess
if err := tx.Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).First(&access).Error; err != nil {
return err
}
out.Order = orderModel
out.OrderItem = item
out.Access = &access
return nil
})
if err != nil {
logrus.WithFields(logrus.Fields{
"tenant_id": params.TenantID,
"user_id": params.UserID,
"content_id": params.ContentID,
"idempotency_key": params.IdempotencyKey,
}).WithError(err).Warn("services.order.purchase_content.failed")
return nil, pkgerrors.Wrap(err, "purchase content failed")
}
logrus.WithFields(logrus.Fields{
"tenant_id": params.TenantID,
"user_id": params.UserID,
"content_id": params.ContentID,
"order_id": loID(out.Order),
"amount_paid": out.AmountPaid,
"idempotency_key": params.IdempotencyKey,
}).Info("services.order.purchase_content.ok")
return &out, nil
}
func (s *order) computeFinalPrice(priceAmount int64, price *models.ContentPrice, now time.Time) int64 {
if priceAmount <= 0 || price == nil {
return 0
}
discountType := price.DiscountType
if discountType == "" {
discountType = consts.DiscountTypeNone
}
if !price.DiscountStartAt.IsZero() && now.Before(price.DiscountStartAt) {
return priceAmount
}
if !price.DiscountEndAt.IsZero() && now.After(price.DiscountEndAt) {
return priceAmount
}
switch discountType {
case consts.DiscountTypePercent:
percent := price.DiscountValue
if percent <= 0 {
return priceAmount
}
if percent >= 100 {
return 0
}
return priceAmount * (100 - percent) / 100
case consts.DiscountTypeAmount:
amount := price.DiscountValue
if amount <= 0 {
return priceAmount
}
if amount >= priceAmount {
return 0
}
return priceAmount - amount
default:
return priceAmount
}
}
func (s *order) grantAccess(ctx context.Context, tx *gorm.DB, tenantID, userID, contentID, orderID int64, now time.Time) error {
insert := map[string]any{
"tenant_id": tenantID,
"user_id": userID,
"content_id": contentID,
"order_id": orderID,
"status": consts.ContentAccessStatusActive,
"revoked_at": nil,
"created_at": now,
"updated_at": now,
}
if err := tx.Table(models.TableNameContentAccess).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "tenant_id"}, {Name: "user_id"}, {Name: "content_id"}},
DoUpdates: clause.Assignments(map[string]any{
"order_id": orderID,
"status": consts.ContentAccessStatusActive,
"revoked_at": nil,
"updated_at": now,
}),
}).
Create(insert).Error; err != nil {
return err
}
return nil
}
func loID(m *models.Order) int64 {
if m == nil {
return 0
}
return m.ID
}

View File

@@ -16,9 +16,35 @@ func Provide(opts ...opt.Option) error {
}); err != nil {
return err
}
if err := container.Container.Provide(func(
db *gorm.DB,
) (*ledger, error) {
obj := &ledger{
db: db,
}
return obj, nil
}); err != nil {
return err
}
if err := container.Container.Provide(func(
db *gorm.DB,
ledger *ledger,
) (*order, error) {
obj := &order{
db: db,
ledger: ledger,
}
return obj, nil
}); err != nil {
return err
}
if err := container.Container.Provide(func(
content *content,
db *gorm.DB,
ledger *ledger,
order *order,
tenant *tenant,
test *test,
user *user,
@@ -26,6 +52,8 @@ func Provide(opts ...opt.Option) error {
obj := &services{
content: content,
db: db,
ledger: ledger,
order: order,
tenant: tenant,
test: test,
user: user,

View File

@@ -9,6 +9,8 @@ var _db *gorm.DB
// exported CamelCase Services
var (
Content *content
Ledger *ledger
Order *order
Tenant *tenant
Test *test
User *user
@@ -19,6 +21,8 @@ type services struct {
db *gorm.DB
// define Services
content *content
ledger *ledger
order *order
tenant *tenant
test *test
user *user
@@ -29,6 +33,8 @@ func (svc *services) Prepare() error {
// set exported Services here
Content = svc.content
Ledger = svc.ledger
Order = svc.order
Tenant = svc.tenant
Test = svc.test
User = svc.user