feat: 支持从失败状态重新发起退款,添加相关逻辑和测试用例

This commit is contained in:
2025-12-23 13:07:12 +08:00
parent d70a33e4f9
commit 39b541accd
4 changed files with 124 additions and 2 deletions

View File

@@ -46,6 +46,10 @@ func (w *OrderRefundJobWorker) Work(ctx context.Context, job *Job[jobs_args.Orde
if err != nil {
// 业务层会返回可识别的“不可重试”错误由它内部完成状态落库failed这里直接 cancel。
if services.IsRefundJobNonRetryableError(err) {
// best-effort将订单标记为 failed便于管理员重新发起退款paid/failed -> refunding
if markErr := services.Order.MarkRefundFailed(ctx, args.TenantID, args.OrderID, time.Now().UTC()); markErr != nil {
logger.WithError(markErr).Warn("jobs.order_refund.mark_failed_failed")
}
logger.WithError(err).Warn("jobs.order_refund.cancel")
return JobCancel(err)
}

View File

@@ -220,6 +220,14 @@ func (s *ContentTestSuite) Test_AttachAsset() {
So(appErr.Code, ShouldEqual, errorx.ErrPreconditionFailed.Code)
})
Convey("preview role 绑定 main variant 应被拒绝", func() {
_, err := Content.AttachAsset(ctx, tenantID, userID, content.ID, asset.ID, consts.ContentAssetRolePreview, 1, now)
So(err, ShouldNotBeNil)
var appErr *errorx.AppError
So(errors.As(err, &appErr), ShouldBeTrue)
So(appErr.Code, ShouldEqual, errorx.ErrPreconditionFailed.Code)
})
Convey("main role 绑定 preview variant 应被拒绝", func() {
previewAsset := &models.MediaAsset{
TenantID: tenantID,

View File

@@ -512,7 +512,11 @@ func (s *order) AdminOrderDetail(ctx context.Context, tenantID, orderID int64) (
return m, nil
}
// AdminRefundOrder 退款已支付订单(支持强制退款),并立即回收已授予的内容权益
// AdminRefundOrder 发起已支付订单退款(支持强制退款)。
//
// 语义:
// - 该方法只负责将订单从 paid 推进到 refunding并入队异步退款任务
// - 退款入账与权益回收由 job/worker 异步完成(见 ProcessRefundingOrder
func (s *order) AdminRefundOrder(
ctx context.Context,
tenantID, operatorUserID, orderID int64,
@@ -557,7 +561,9 @@ func (s *order) AdminRefundOrder(
out = &orderModel
return nil
}
if orderModel.Status != consts.OrderStatusPaid {
// 允许从 failed 重新发起退款:失败状态表示“上一次异步退款未完成/被标记失败”,可由管理员重试推进到 refunding。
if orderModel.Status != consts.OrderStatusPaid && orderModel.Status != consts.OrderStatusFailed {
return errorx.ErrStatusConflict.WithMsg("订单非已支付状态,无法退款")
}
if orderModel.PaidAt.IsZero() {
@@ -765,6 +771,39 @@ func (s *order) ProcessRefundingOrder(ctx context.Context, params *ProcessRefund
return out, nil
}
// MarkRefundFailed marks an order as failed during async refund processing.
// 仅用于 worker 在判定“不可重试”错误时落终态,避免订单长期停留在 refunding。
func (s *order) MarkRefundFailed(ctx context.Context, tenantID, orderID int64, now time.Time) error {
if tenantID <= 0 || orderID <= 0 {
return errorx.ErrInvalidParameter.WithMsg("tenant_id/order_id must be > 0")
}
if now.IsZero() {
now = time.Now()
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var orderModel models.Order
if err := tx.
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("tenant_id = ? AND id = ?", tenantID, orderID).
First(&orderModel).Error; err != nil {
return err
}
// 已退款/已失败都无需变更。
if orderModel.Status == consts.OrderStatusRefunded || orderModel.Status == consts.OrderStatusFailed {
return nil
}
return tx.Table(models.TableNameOrder).
Where("id = ?", orderModel.ID).
Updates(map[string]any{
"status": consts.OrderStatusFailed,
"updated_at": now,
}).Error
})
}
func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentParams) (*PurchaseContentResult, error) {
if params == nil {
return nil, errorx.ErrInvalidParameter.WithMsg("params is required")

View File

@@ -942,6 +942,77 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() {
So(len(ledgers), ShouldEqual, 1)
})
Convey("failed 状态允许重新发起退款paid/failed -> refunding", func() {
s.truncate(
ctx,
models.TableNameTenantLedger,
models.TableNameContentAccess,
models.TableNameOrderItem,
models.TableNameOrder,
models.TableNameTenantUser,
models.TableNameUser,
)
s.seedTenantUser(ctx, tenantID, buyerUserID, 0, 0)
contentID := int64(123)
orderModel := &models.Order{
TenantID: tenantID,
UserID: buyerUserID,
Type: consts.OrderTypeContentPurchase,
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountOriginal: 300,
AmountDiscount: 0,
AmountPaid: 300,
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
}
So(orderModel.Create(ctx), ShouldBeNil)
item := &models.OrderItem{
TenantID: tenantID,
UserID: buyerUserID,
OrderID: orderModel.ID,
ContentID: contentID,
ContentUserID: 999,
AmountPaid: 300,
Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}),
CreatedAt: now,
UpdatedAt: now,
}
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{},
}
So(access.Create(ctx), ShouldBeNil)
// 先发起一次退款进入 refunding再模拟异步失败进入 failed。
refunding, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因", "", now.Add(time.Minute))
So(err, ShouldBeNil)
So(refunding.Status, ShouldEqual, consts.OrderStatusRefunding)
So(Order.MarkRefundFailed(ctx, tenantID, orderModel.ID, now.Add(2*time.Minute)), ShouldBeNil)
var failed models.Order
So(_db.WithContext(ctx).Where("tenant_id = ? AND id = ?", tenantID, orderModel.ID).First(&failed).Error, ShouldBeNil)
So(failed.Status, ShouldEqual, consts.OrderStatusFailed)
// failed -> refunding 允许重新发起,并再次入队(幂等)。
refunding2, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因2", "", now.Add(3*time.Minute))
So(err, ShouldBeNil)
So(refunding2.Status, ShouldEqual, consts.OrderStatusRefunding)
})
Convey("不可重试错误分类应稳定", func() {
So(IsRefundJobNonRetryableError(nil), ShouldBeFalse)
So(IsRefundJobNonRetryableError(errors.New("x")), ShouldBeFalse)