feat: 添加订单退款处理的异步任务及相关逻辑
This commit is contained in:
@@ -12,11 +12,13 @@ import (
|
||||
|
||||
"quyun/v2/app/errorx"
|
||||
"quyun/v2/app/http/tenant/dto"
|
||||
jobs_args "quyun/v2/app/jobs/args"
|
||||
"quyun/v2/app/requests"
|
||||
"quyun/v2/database"
|
||||
"quyun/v2/database/fields"
|
||||
"quyun/v2/database/models"
|
||||
"quyun/v2/pkg/consts"
|
||||
provider_job "quyun/v2/providers/job"
|
||||
|
||||
pkgerrors "github.com/pkg/errors"
|
||||
"github.com/samber/lo"
|
||||
@@ -41,7 +43,11 @@ func newOrderSnapshot(kind consts.OrderType, payload any) types.JSONType[fields.
|
||||
}
|
||||
|
||||
// AdminOrderExportCSV 租户管理员导出订单列表(CSV 文本)。
|
||||
func (s *order) AdminOrderExportCSV(ctx context.Context, tenantID int64, filter *dto.AdminOrderListFilter) (*dto.AdminOrderExportResponse, error) {
|
||||
func (s *order) AdminOrderExportCSV(
|
||||
ctx context.Context,
|
||||
tenantID int64,
|
||||
filter *dto.AdminOrderListFilter,
|
||||
) (*dto.AdminOrderExportResponse, error) {
|
||||
if tenantID <= 0 {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0")
|
||||
}
|
||||
@@ -349,6 +355,43 @@ type PurchaseContentResult struct {
|
||||
type order struct {
|
||||
db *gorm.DB
|
||||
ledger *ledger
|
||||
job *provider_job.Job
|
||||
}
|
||||
|
||||
type ProcessRefundingOrderParams struct {
|
||||
// TenantID 租户ID。
|
||||
TenantID int64
|
||||
// OrderID 订单ID。
|
||||
OrderID int64
|
||||
// OperatorUserID 退款操作人用户ID(用于补齐订单审计字段)。
|
||||
OperatorUserID int64
|
||||
// Force 是否强制退款(用于补齐订单审计字段)。
|
||||
Force bool
|
||||
// Reason 退款原因(用于补齐订单审计字段)。
|
||||
Reason string
|
||||
// Now 逻辑时间(用于 refunded_at/updated_at)。
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
func IsRefundJobNonRetryableError(err error) bool {
|
||||
var appErr *errorx.AppError
|
||||
if !errors.As(err, &appErr) {
|
||||
return false
|
||||
}
|
||||
switch appErr.Code {
|
||||
case errorx.CodeInvalidParameter,
|
||||
errorx.CodeRecordNotFound,
|
||||
errorx.CodeStatusConflict,
|
||||
errorx.CodePreconditionFailed,
|
||||
errorx.CodePermissionDenied:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *order) enqueueOrderRefundJob(args jobs_args.OrderRefundJob) error {
|
||||
return s.job.Add(args)
|
||||
}
|
||||
|
||||
// AdminTopupUser 租户管理员给租户成员充值(增加该租户下的可用余额)。
|
||||
@@ -747,11 +790,15 @@ func (s *order) AdminRefundOrder(
|
||||
return err
|
||||
}
|
||||
|
||||
// 状态机:已退款直接幂等返回;仅允许已支付订单退款。
|
||||
// 状态机:已退款/退款中直接幂等返回;仅允许已支付订单发起退款请求。
|
||||
if orderModel.Status == consts.OrderStatusRefunded {
|
||||
out = &orderModel
|
||||
return nil
|
||||
}
|
||||
if orderModel.Status == consts.OrderStatusRefunding {
|
||||
out = &orderModel
|
||||
return nil
|
||||
}
|
||||
if orderModel.Status != consts.OrderStatusPaid {
|
||||
return errorx.ErrStatusConflict.WithMsg("订单非已支付状态,无法退款")
|
||||
}
|
||||
@@ -767,38 +814,11 @@ func (s *order) AdminRefundOrder(
|
||||
}
|
||||
}
|
||||
|
||||
amount := orderModel.AmountPaid
|
||||
refundKey := fmt.Sprintf("refund:%d", orderModel.ID)
|
||||
|
||||
// 先退余额(账本入账),后更新订单状态与权益,确保退款可对账且可追溯。
|
||||
if amount > 0 {
|
||||
if _, err := s.ledger.CreditRefundTx(ctx, tx, tenantID, operatorUserID, orderModel.UserID, orderModel.ID, amount, refundKey, reason, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 退款对权益:立即回收 content_access(revoked)。
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 最后更新订单退款字段,保证退款后的最终状态一致。
|
||||
// 将订单推进到 refunding,并记录本次请求的审计字段;实际退款逻辑由异步 job 完成。
|
||||
if err := tx.Table(models.TableNameOrder).
|
||||
Where("id = ?", orderModel.ID).
|
||||
Updates(map[string]any{
|
||||
"status": consts.OrderStatusRefunded,
|
||||
"refunded_at": now,
|
||||
"status": consts.OrderStatusRefunding,
|
||||
"refund_forced": force,
|
||||
"refund_operator_user_id": operatorUserID,
|
||||
"refund_reason": reason,
|
||||
@@ -807,8 +827,7 @@ func (s *order) AdminRefundOrder(
|
||||
return err
|
||||
}
|
||||
|
||||
orderModel.Status = consts.OrderStatusRefunded
|
||||
orderModel.RefundedAt = now
|
||||
orderModel.Status = consts.OrderStatusRefunding
|
||||
orderModel.RefundForced = force
|
||||
orderModel.RefundOperatorUserID = operatorUserID
|
||||
orderModel.RefundReason = reason
|
||||
@@ -828,6 +847,30 @@ func (s *order) AdminRefundOrder(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// refunding 状态需要确保异步任务已入队:入队失败则返回错误,调用方可重试(幂等)。
|
||||
if out != nil && out.Status == consts.OrderStatusRefunding {
|
||||
err := s.enqueueOrderRefundJob(jobs_args.OrderRefundJob{
|
||||
TenantID: tenantID,
|
||||
OrderID: out.ID,
|
||||
OperatorUserID: operatorUserID,
|
||||
Force: force,
|
||||
Reason: reason,
|
||||
})
|
||||
if err != nil {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": tenantID,
|
||||
"operator_user_id": operatorUserID,
|
||||
"order_id": out.ID,
|
||||
}).WithError(err).Warn("services.order.admin.refund.enqueue_failed")
|
||||
return nil, err
|
||||
}
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": tenantID,
|
||||
"operator_user_id": operatorUserID,
|
||||
"order_id": out.ID,
|
||||
}).Info("services.order.admin.refund.enqueued")
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": tenantID,
|
||||
"operator_user_id": operatorUserID,
|
||||
@@ -839,6 +882,131 @@ func (s *order) AdminRefundOrder(
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ProcessRefundingOrder 处理 refunding 订单:退余额、回收权益、推进到 refunded。
|
||||
// 供异步 job/worker 调用;需保持幂等。
|
||||
func (s *order) ProcessRefundingOrder(ctx context.Context, params *ProcessRefundingOrderParams) (*models.Order, error) {
|
||||
if params == nil {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("params is required")
|
||||
}
|
||||
if params.TenantID <= 0 || params.OrderID <= 0 {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/order_id must be > 0")
|
||||
}
|
||||
if params.Now.IsZero() {
|
||||
params.Now = time.Now()
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": params.TenantID,
|
||||
"order_id": params.OrderID,
|
||||
"operator_user_id": params.OperatorUserID,
|
||||
"force": params.Force,
|
||||
}).Info("services.order.refund.process")
|
||||
|
||||
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 = ?", params.TenantID, params.OrderID).
|
||||
First(&orderModel).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errorx.ErrRecordNotFound.WithMsg("order not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 幂等:已退款/已失败直接返回。
|
||||
if orderModel.Status == consts.OrderStatusRefunded || orderModel.Status == consts.OrderStatusFailed {
|
||||
out = &orderModel
|
||||
return nil
|
||||
}
|
||||
|
||||
// 仅允许 refunding 状态进入处理链路:paid->refunding 必须由接口层完成并记录审计字段。
|
||||
if orderModel.Status != consts.OrderStatusRefunding {
|
||||
// 不可重试:状态不符合预期,直接标记 failed,避免 job 无限重试。
|
||||
_ = tx.Table(models.TableNameOrder).
|
||||
Where("id = ?", orderModel.ID).
|
||||
Updates(map[string]any{
|
||||
"status": consts.OrderStatusFailed,
|
||||
"updated_at": params.Now,
|
||||
}).Error
|
||||
return errorx.ErrStatusConflict.WithMsg("order not in refunding status")
|
||||
}
|
||||
|
||||
// 补齐审计字段:以订单字段为准;若为空则用 job 参数兜底(避免历史数据/异常路径导致缺失)。
|
||||
operatorUserID := orderModel.RefundOperatorUserID
|
||||
if operatorUserID == 0 {
|
||||
operatorUserID = params.OperatorUserID
|
||||
}
|
||||
reason := orderModel.RefundReason
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = strings.TrimSpace(params.Reason)
|
||||
}
|
||||
force := orderModel.RefundForced
|
||||
if !force {
|
||||
force = params.Force
|
||||
}
|
||||
|
||||
amount := orderModel.AmountPaid
|
||||
refundKey := fmt.Sprintf("refund:%d", orderModel.ID)
|
||||
|
||||
// 先退余额(账本入账),后回收权益,最后推进订单终态:保证退款可对账且可追溯。
|
||||
if amount > 0 {
|
||||
if _, err := s.ledger.CreditRefundTx(ctx, tx, params.TenantID, operatorUserID, orderModel.UserID, orderModel.ID, amount, refundKey, reason, params.Now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range orderModel.Items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if err := tx.Table(models.TableNameContentAccess).
|
||||
Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, orderModel.UserID, item.ContentID).
|
||||
Updates(map[string]any{
|
||||
"status": consts.ContentAccessStatusRevoked,
|
||||
"revoked_at": params.Now,
|
||||
"updated_at": params.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": params.Now,
|
||||
"refund_forced": force,
|
||||
"refund_operator_user_id": operatorUserID,
|
||||
"refund_reason": reason,
|
||||
"updated_at": params.Now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
orderModel.Status = consts.OrderStatusRefunded
|
||||
orderModel.RefundedAt = params.Now
|
||||
orderModel.RefundForced = force
|
||||
orderModel.RefundOperatorUserID = operatorUserID
|
||||
orderModel.RefundReason = reason
|
||||
orderModel.UpdatedAt = params.Now
|
||||
|
||||
out = &orderModel
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// 不可重试错误由 worker 负责 JobCancel;这里保持原始 error 以便判定。
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": params.TenantID,
|
||||
"order_id": params.OrderID,
|
||||
}).WithError(err).Warn("services.order.refund.process.failed")
|
||||
return nil, err
|
||||
}
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user