feat: 支持从失败状态重新发起退款,添加相关逻辑和测试用例
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user