diff --git a/backend/app/jobs/order_refund.go b/backend/app/jobs/order_refund.go index 72b010c..8b364e5 100644 --- a/backend/app/jobs/order_refund.go +++ b/backend/app/jobs/order_refund.go @@ -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) } diff --git a/backend/app/services/content_test.go b/backend/app/services/content_test.go index 09c6cb0..10ab108 100644 --- a/backend/app/services/content_test.go +++ b/backend/app/services/content_test.go @@ -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, diff --git a/backend/app/services/order.go b/backend/app/services/order.go index a7d0d7b..89bfc02 100644 --- a/backend/app/services/order.go +++ b/backend/app/services/order.go @@ -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") diff --git a/backend/app/services/order_test.go b/backend/app/services/order_test.go index 8c8ec74..01ce261 100644 --- a/backend/app/services/order_test.go +++ b/backend/app/services/order_test.go @@ -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)