Files
quyun-v2/backend/app/services/order.go
Rogee 3249e405ac 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.
2025-12-18 16:24:37 +08:00

1111 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"context"
"encoding/json"
"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"
)
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多租户隔离范围
TenantID int64
// UserID 购买者用户 ID。
UserID int64
// ContentID 内容 ID。
ContentID int64
// IdempotencyKey 幂等键:用于确保同一购买请求“至多处理一次”。
IdempotencyKey string
// Now 逻辑时间:用于 created_at/paid_at 与账本快照(可选,便于测试/一致性)。
Now time.Time
}
// PurchaseContentResult 为购买结果(幂等命中时返回已存在的订单/权益状态)。
type PurchaseContentResult struct {
// Order 订单记录(可能为 nil例如“已购买且无订单上下文”的快捷路径
Order *models.Order
// OrderItem 订单明细(本业务为单内容购买,通常只有 1 条)。
OrderItem *models.OrderItem
// Access 内容权益(购买完成后应为 active
Access *models.ContentAccess
// AmountPaid 实付金额单位CNY
AmountPaid int64
}
// order 提供订单域能力(购买、充值、退款、查询等)。
//
// @provider
type order struct {
db *gorm.DB
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,
tenantID, operatorUserID, targetUserID, amount int64,
idempotencyKey, reason string,
now time.Time,
) (*models.Order, error) {
if tenantID <= 0 || operatorUserID <= 0 || targetUserID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id/target_user_id must be > 0")
}
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,
"operator_user": operatorUserID,
"target_user": targetUserID,
"amount": amount,
"idempotency_key": idempotencyKey,
}).Info("services.order.admin.topup_user")
var out models.Order
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 关键前置条件:目标用户必须属于该租户(同时加行锁,避免并发余额写入冲突)。
var tu models.TenantUser
if err := tx.
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("tenant_id = ? AND user_id = ?", tenantID, targetUserID).
First(&tu).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrPreconditionFailed.WithMsg("目标用户不属于该租户")
}
return err
}
// 充值幂等:按 orders(tenant_id,user_id,idempotency_key) 去重,避免重复入账。
if idempotencyKey != "" {
var existing models.Order
if err := tx.Where(
"tenant_id = ? AND user_id = ? AND idempotency_key = ?",
tenantID, targetUserID, idempotencyKey,
).First(&existing).Error; err == nil {
out = existing
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
// 先落订单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,
Type: consts.OrderTypeTopup,
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountOriginal: amount,
AmountDiscount: 0,
AmountPaid: amount,
Snapshot: snapshot,
IdempotencyKey: idempotencyKey,
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(&orderModel).Error; err != nil {
return err
}
// 账本幂等键固定使用 topup:<orderID>,保证同一订单不会重复入账。
ledgerKey := fmt.Sprintf("topup:%d", orderModel.ID)
remark := reason
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 {
return err
}
out = orderModel
return nil
})
if err != nil {
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"operator_user": operatorUserID,
"target_user": targetUserID,
"amount": amount,
"idempotency_key": idempotencyKey,
}).WithError(err).Warn("services.order.admin.topup_user.failed")
return nil, err
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"target_user": targetUserID,
"order_id": out.ID,
"amount": amount,
}).Info("services.order.admin.topup_user.ok")
return &out, nil
}
// MyOrderPage 分页查询当前用户在租户内的订单。
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 查询当前用户在租户内的订单详情。
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 租户管理员分页查询租户内订单。
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 租户管理员查询租户内订单详情。
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 退款已支付订单(支持强制退款),并立即回收已授予的内容权益。
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无法退款")
}
// 时间窗:默认 paid_at + 24hforce=true 可绕过。
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
}
}
// 退款对权益:立即回收 content_accessrevoked
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
// 幂等购买采用“三段式”流程,保证一致性:
// 1) 先独立事务冻结余额(预留资金);
// 2) 再用单事务写订单+扣款+授予权益;
// 3) 若第 2 步失败,则解冻并写入回滚标记,保证重试稳定返回“失败+已回滚”。
if params.IdempotencyKey != "" {
freezeKey := fmt.Sprintf("%s:freeze", params.IdempotencyKey)
debitKey := fmt.Sprintf("%s:debit", params.IdempotencyKey)
rollbackKey := fmt.Sprintf("%s:rollback", params.IdempotencyKey)
// 1) 若该幂等键已生成订单,则直接返回订单与权益(幂等命中)。
{
tbl, query := models.OrderQuery.QueryContext(ctx)
existing, err := query.Preload(tbl.Items).Where(
tbl.TenantID.Eq(params.TenantID),
tbl.UserID.Eq(params.UserID),
tbl.IdempotencyKey.Eq(params.IdempotencyKey),
).First()
if err == nil {
out.Order = existing
if len(existing.Items) > 0 {
out.OrderItem = existing.Items[0]
}
out.AmountPaid = existing.AmountPaid
if out.OrderItem != nil {
aTbl, aQuery := models.ContentAccessQuery.QueryContext(ctx)
access, err := aQuery.Where(
aTbl.TenantID.Eq(params.TenantID),
aTbl.UserID.Eq(params.UserID),
aTbl.ContentID.Eq(out.OrderItem.ContentID),
).First()
if err == nil {
out.Access = access
}
}
return &out, nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
}
// 2) 若历史已回滚过该幂等请求,则稳定返回“失败+已回滚”(避免重复冻结/重复扣款)。
{
tbl, query := models.TenantLedgerQuery.QueryContext(ctx)
_, err := query.Where(
tbl.TenantID.Eq(params.TenantID),
tbl.UserID.Eq(params.UserID),
tbl.IdempotencyKey.Eq(rollbackKey),
).First()
if err == nil {
return nil, errorx.ErrOperationFailed.WithMsg("失败+已回滚")
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
}
// 查询内容与价格:放在事务外简化逻辑;后续以订单事务为准。
var content models.Content
{
tbl, query := models.ContentQuery.QueryContext(ctx)
m, err := query.Where(
tbl.TenantID.Eq(params.TenantID),
tbl.ID.Eq(params.ContentID),
tbl.DeletedAt.IsNull(),
).First()
if err != nil {
return nil, err
}
content = *m
}
if content.Status != consts.ContentStatusPublished {
return nil, errorx.ErrPreconditionFailed.WithMsg("content not published")
}
// 作者自购:直接授予权益(不走余额冻结/扣款)。
if content.UserID == params.UserID {
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
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.AmountPaid = 0
out.Access = &access
return nil
})
if err != nil {
return nil, err
}
return &out, nil
}
priceAmount := int64(0)
var price models.ContentPrice
{
tbl, query := models.ContentPriceQuery.QueryContext(ctx)
m, err := query.Where(
tbl.TenantID.Eq(params.TenantID),
tbl.ContentID.Eq(params.ContentID),
).First()
if err == nil {
price = *m
priceAmount = m.PriceAmount
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
}
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 {
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: purchaseSnapshot,
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: itemSnapshot,
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
})
if err != nil {
return nil, pkgerrors.Wrap(err, "purchase content failed")
}
return &out, nil
}
// 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)
return err
}); err != nil {
return nil, pkgerrors.Wrap(err, "purchase freeze failed")
}
// 4) 单事务完成:落订单 → 账本扣款(消耗冻结)→ 更新订单 paid → 授予权益。
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
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: purchaseSnapshot,
IdempotencyKey: params.IdempotencyKey,
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: itemSnapshot,
CreatedAt: now,
UpdatedAt: now,
}
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 {
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
}); err != nil {
// 5) 补偿:订单事务失败时,必须解冻,并写入回滚标记,保证后续幂等重试稳定返回失败。
_ = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
_, e1 := s.ledger.UnfreezeTx(
ctx,
tx,
params.TenantID,
params.UserID,
0,
amountPaid,
rollbackKey,
"purchase rollback",
now,
)
return e1
})
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.rollback")
return nil, errorx.ErrOperationFailed.WithMsg("失败+已回滚")
}
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
}
// 非幂等请求走“单事务”旧流程:冻结 + 落单 + 扣款 + 授权全部在一个事务内完成(失败整体回滚)。
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
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")
}
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
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,
UserID: params.UserID,
Type: consts.OrderTypeContentPurchase,
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountOriginal: priceAmount,
AmountDiscount: priceAmount - amountPaid,
AmountPaid: amountPaid,
Snapshot: purchaseSnapshot,
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: itemSnapshot,
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: purchaseSnapshot,
IdempotencyKey: "",
CreatedAt: now,
UpdatedAt: now,
}
if _, err := s.ledger.FreezeTx(ctx, tx, params.TenantID, params.UserID, 0, amountPaid, "", "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: itemSnapshot,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(item).Error; err != nil {
return err
}
if _, err := s.ledger.DebitPurchaseTx(ctx, tx, params.TenantID, params.UserID, orderModel.ID, amountPaid, "", "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 {
// 权益写入策略:按 (tenant_id,user_id,content_id) upsert确保重复购买/重试时权益最终为 active。
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
}