diff --git a/backend/app/commands/testx/testing.go b/backend/app/commands/testx/testing.go index bd23232..467004b 100644 --- a/backend/app/commands/testx/testing.go +++ b/backend/app/commands/testx/testing.go @@ -4,13 +4,17 @@ import ( "context" "testing" + jobs_args "quyun/v2/app/jobs/args" "quyun/v2/database" "quyun/v2/providers/job" "quyun/v2/providers/jwt" "quyun/v2/providers/postgres" + "github.com/riverqueue/river" "go.ipao.vip/atom" "go.ipao.vip/atom/container" + "go.ipao.vip/atom/contracts" + "go.ipao.vip/atom/opt" "go.uber.org/dig" "github.com/rogeecn/fabfile" @@ -22,10 +26,33 @@ func Default(providers ...container.ProviderContainer) container.Providers { postgres.DefaultProvider(), jwt.DefaultProvider(), job.DefaultProvider(), + testJobWorkersProvider(), database.DefaultProvider(), }, providers...) } +type orderRefundTestWorker struct { + river.WorkerDefaults[jobs_args.OrderRefundJob] +} + +func (w *orderRefundTestWorker) Work(ctx context.Context, job *river.Job[jobs_args.OrderRefundJob]) error { + return nil +} + +func testJobWorkersProvider() container.ProviderContainer { + return container.ProviderContainer{ + Provider: func(opts ...opt.Option) error { + return container.Container.Provide(func(__job *job.Job) (contracts.Initial, error) { + obj := &orderRefundTestWorker{} + if err := river.AddWorkerSafely(__job.Workers, obj); err != nil { + return nil, err + } + return obj, nil + }, atom.GroupInitial) + }, + } +} + func Serve(providers container.Providers, t *testing.T, invoke any) { Convey("tests boot up", t, func() { // 关键语义:测试用例可能会在同一进程内多次调用 Serve。 diff --git a/backend/app/http/tenant/order_admin.go b/backend/app/http/tenant/order_admin.go index a26648d..0de8782 100644 --- a/backend/app/http/tenant/order_admin.go +++ b/backend/app/http/tenant/order_admin.go @@ -135,23 +135,25 @@ func (*orderAdmin) adminOrderDetail( return &dto.AdminOrderDetail{Order: m}, nil } -// adminRefund -// -// @Summary 订单退款(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param orderID path int64 true "OrderID" -// @Param form body dto.AdminOrderRefundForm true "Form" -// @Success 200 {object} models.Order -// -// @Router /t/:tenantCode/v1/admin/orders/:orderID/refund [post] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind orderID path -// @Bind form body -func (*orderAdmin) adminRefund( + // adminRefund + // + // @Summary 订单退款(租户管理) + // @Description 该接口只负责将订单从 paid 推进到 refunding,并提交异步退款任务;退款入账与权益回收由 worker 异步完成。 + // @Description 重复请求幂等:订单处于 refunding/refunded 时会返回当前订单状态,不会重复入账/重复回收权益。 + // @Tags Tenant + // @Accept json + // @Produce json + // @Param tenantCode path string true "Tenant Code" + // @Param orderID path int64 true "OrderID" + // @Param form body dto.AdminOrderRefundForm true "Form" + // @Success 200 {object} models.Order + // + // @Router /t/:tenantCode/v1/admin/orders/:orderID/refund [post] + // @Bind tenant local key(tenant) + // @Bind tenantUser local key(tenant_user) + // @Bind orderID path + // @Bind form body + func (*orderAdmin) adminRefund( ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, diff --git a/backend/app/services/order_test.go b/backend/app/services/order_test.go index 5ce9278..a4e0e83 100644 --- a/backend/app/services/order_test.go +++ b/backend/app/services/order_test.go @@ -1077,8 +1077,8 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() { So(err, ShouldNotBeNil) }) - Convey("成功退款应回收权益并入账", func() { - s.seedTenantUser(ctx, tenantID, buyerUserID, 0, 0) + Convey("成功退款应回收权益并入账", func() { + s.seedTenantUser(ctx, tenantID, buyerUserID, 0, 0) contentID := int64(123) orderModel := &models.Order{ @@ -1122,26 +1122,45 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() { } So(access.Create(ctx), ShouldBeNil) - refunding, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因", "", now.Add(time.Minute)) - So(err, ShouldBeNil) - So(refunding, ShouldNotBeNil) - So(refunding.Status, ShouldEqual, consts.OrderStatusRefunding) + refunding, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因", "", now.Add(time.Minute)) + So(err, ShouldBeNil) + So(refunding, ShouldNotBeNil) + So(refunding.Status, ShouldEqual, consts.OrderStatusRefunding) - refunded, err := Order.ProcessRefundingOrder(ctx, &ProcessRefundingOrderParams{ - TenantID: tenantID, - OrderID: orderModel.ID, - OperatorUserID: operatorUserID, - Force: false, - Reason: "原因", - Now: now.Add(2 * time.Minute), - }) - So(err, ShouldBeNil) - So(refunded, ShouldNotBeNil) - So(refunded.Status, ShouldEqual, consts.OrderStatusRefunded) + // refunding 期间重复请求应幂等返回 refunding(并允许重复触发入队,不影响最终结果)。 + refunding2, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因2", "", now.Add(90*time.Second)) + So(err, ShouldBeNil) + So(refunding2, ShouldNotBeNil) + So(refunding2.Status, ShouldEqual, consts.OrderStatusRefunding) - var tu models.TenantUser - So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, buyerUserID).First(&tu).Error, ShouldBeNil) - So(tu.Balance, ShouldEqual, 300) + refunded, err := Order.ProcessRefundingOrder(ctx, &ProcessRefundingOrderParams{ + TenantID: tenantID, + OrderID: orderModel.ID, + OperatorUserID: operatorUserID, + Force: false, + Reason: "原因", + Now: now.Add(2 * time.Minute), + }) + So(err, ShouldBeNil) + So(refunded, ShouldNotBeNil) + So(refunded.Status, ShouldEqual, consts.OrderStatusRefunded) + + // worker 重试/重复执行应幂等:不重复入账、不重复回收权益。 + refundedRetry, err := Order.ProcessRefundingOrder(ctx, &ProcessRefundingOrderParams{ + TenantID: tenantID, + OrderID: orderModel.ID, + OperatorUserID: operatorUserID, + Force: false, + Reason: "原因", + Now: now.Add(5 * time.Minute), + }) + So(err, ShouldBeNil) + So(refundedRetry, ShouldNotBeNil) + So(refundedRetry.Status, ShouldEqual, consts.OrderStatusRefunded) + + var tu models.TenantUser + So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, buyerUserID).First(&tu).Error, ShouldBeNil) + So(tu.Balance, ShouldEqual, 300) var access2 models.ContentAccess So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ? AND content_id = ?", tenantID, buyerUserID, contentID).First(&access2).Error, ShouldBeNil) @@ -1157,13 +1176,26 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() { So(tu2.Balance, ShouldEqual, 300) var ledgers []*models.TenantLedger - So(_db.WithContext(ctx). - Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, buyerUserID, fmt.Sprintf("refund:%d", orderModel.ID)). - Find(&ledgers).Error, ShouldBeNil) - So(len(ledgers), ShouldEqual, 1) + So(_db.WithContext(ctx). + Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, buyerUserID, fmt.Sprintf("refund:%d", orderModel.ID)). + Find(&ledgers).Error, ShouldBeNil) + So(len(ledgers), ShouldEqual, 1) + }) + + Convey("不可重试错误分类应稳定", func() { + So(IsRefundJobNonRetryableError(nil), ShouldBeFalse) + So(IsRefundJobNonRetryableError(errors.New("x")), ShouldBeFalse) + + So(IsRefundJobNonRetryableError(errorx.ErrInvalidParameter), ShouldBeTrue) + So(IsRefundJobNonRetryableError(errorx.ErrRecordNotFound), ShouldBeTrue) + So(IsRefundJobNonRetryableError(errorx.ErrStatusConflict), ShouldBeTrue) + So(IsRefundJobNonRetryableError(errorx.ErrPreconditionFailed), ShouldBeTrue) + So(IsRefundJobNonRetryableError(errorx.ErrPermissionDenied), ShouldBeTrue) + + So(IsRefundJobNonRetryableError(errorx.ErrInternalError), ShouldBeFalse) + }) }) - }) -} + } func (s *OrderTestSuite) Test_PurchaseContent() { Convey("Order.PurchaseContent", s.T(), func() {