package refundchannel import ( "context" "time" "go.uber.org/zap" "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // Stats 是一次恢复扫描的可观察结果。 // // Scanned 为本次扫描到的申请数;Confirmed 为回填为渠道明确成功的申请数; // Failed 为回填为渠道失败终态的申请数(含渠道明确失败,以及富友与微信 v2 的本地查询窗口 // 超期后终止本次渠道执行);Pending 为结果仍未知、等待下次扫描的申请数(含查询调用失败); // Skipped 为本地事实不可用或已被并发推进而未由本次扫描改动状态的申请数。 type Stats struct{ Scanned, Confirmed, Failed, Pending, Skipped int } // ProcessBatch 扫描原路处理中的退款并只查询渠道回填结果,绝不重复发起资金动作。 func (s *Service) ProcessBatch(ctx context.Context) (Stats, error) { stats := Stats{} if err := s.requireReady(); err != nil { return stats, err } var refunds []model.RefundRequest if err := s.db.WithContext(ctx). // 已置异常标记的申请转人工处理,必须退出轮询:否则每次扫描都会重复查询同一笔未知结果。 Where("deleted_at IS NULL AND status = ? AND channel_refund_status = ? AND channel_refund_request_no <> ? AND anomaly_flag = ?", model.RefundStatusChannelProcessing, constants.RefundChannelStatusProcessing, "", 0). Order("id ASC").Limit(recoveryBatchSize).Find(&refunds).Error; err != nil { return stats, errors.Wrap(errors.CodeDatabaseError, err, "扫描原路处理中的退款申请失败") } stats.Scanned = len(refunds) if len(refunds) == 0 { return stats, nil } payments, err := s.loadPaidPayments(ctx, refunds) if err != nil { return stats, err } now := s.now().UTC() var firstErr error for index := range refunds { if err := s.recoverOne(ctx, &refunds[index], payments, now, &stats); err != nil { stats.Skipped++ s.logger.Warn("渠道原路退款恢复单条处理失败", zap.Uint("refund_id", refunds[index].ID), zap.Error(err)) if firstErr == nil { firstErr = err } } } return stats, firstErr } // recoverOne 只查询该申请对应的渠道退款状态并按结果回填,不发起任何资金动作。 func (s *Service) recoverOne(ctx context.Context, refund *model.RefundRequest, payments map[uint]*model.Payment, now time.Time, stats *Stats) error { target, failureReason, _, err := s.buildTarget(ctx, refund, nil, payments[refund.OrderID]) if err != nil { return err } if failureReason != "" { // 恢复阶段绝不改写为明确失败:渠道可能已受理资金动作,只能留待人工与环境修复。 stats.Pending++ s.logger.Warn("渠道原路退款恢复缺少本地事实,跳过本次查询", zap.Uint("refund_id", refund.ID), zap.String("failure_reason", failureReason)) return nil } if window, reason := queryWindowPolicy(target.ProviderType); window > 0 && now.Sub(refundWindowStart(refund)) > window { return s.flagQueryWindowExpired(ctx, refund, reason, now, stats) } callCtx, cancel := context.WithTimeout(ctx, channelCallTimeout) defer cancel() result, callErr := s.refunder.Query(callCtx, target) if callErr != nil { // 查询失败不能推断渠道结果,保持原路处理中等待下次扫描。 stats.Pending++ return nil } applied, err := s.writeback(ctx, refund, target, result, constants.AuditActionRefundChannelRecovered, now) if err != nil { return err } if !applied { stats.Skipped++ return nil } switch result.State { case StateSuccess: stats.Confirmed++ case StateFailed: stats.Failed++ default: stats.Pending++ } return nil } // queryWindowPolicy 返回该服务商类型的本地查询窗口与其超期原因。 // 返回 0 表示不设本地窗口,持续查询直到渠道给出终态。 // // - 富友:退款查询接口只支持 3 日内的退款交易,超期后渠道侧已无法查询,属渠道硬约束; // - 微信 v2:受理响应不含退款状态、渠道侧无查询时限,此处按本地阈值放弃轮询并转人工, // 避免一笔未知结果被无限重试。 func queryWindowPolicy(providerType string) (time.Duration, string) { switch providerType { case model.ProviderTypeFuiou: return fuiouQueryWindow, anomalyReasonFuiouQueryWindow case model.ProviderTypeWechatV2: return wechatV2QueryWindow, anomalyReasonWechatV2QueryWindow default: return 0, "" } } // flagQueryWindowExpired 在本地查询窗口超期且结果仍未知时终止本次渠道执行并转人工处理。 // // 生效后果:退款申请转「原路退款失败」、渠道退款状态转「已失败」、写入稳定的 // timeout_unknown 分类与异常标记,并写一次审计;重复扫描不重复写入。 // 「结果未确认」这一性质由 failure_reason 承载(它不是明确失败,因此不进入后续回溯判定), // 而 status 只表达该尝试的渠道路径已终止。 // // 为何不放行自动重提:本次渠道请求可能已被受理但结果未知,放行重提会以新的请求号再次 // 提交资金动作,存在重复退款风险。因此保留异常标记,由人工先向渠道核对再决定处置。 func (s *Service) flagQueryWindowExpired(ctx context.Context, refund *model.RefundRequest, reason string, now time.Time, stats *Stats) error { stats.Failed++ err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { updated := tx.WithContext(ctx).Model(&model.RefundRequest{}). Where("id = ? AND status = ? AND channel_refund_status = ? AND anomaly_flag = 0", refund.ID, model.RefundStatusChannelProcessing, constants.RefundChannelStatusProcessing). // UpdateColumns 不隐式推进 updated_at:窗口起算点必须保留在进入原路处理中的时刻, // 否则置标记会把窗口重置,下一轮扫描将重新查询同一笔未知结果。 UpdateColumns(map[string]any{ "status": model.RefundStatusChannelFailed, "channel_refund_status": constants.RefundChannelStatusFailed, "failure_reason": constants.RefundFailureTimeoutUnknown, "anomaly_flag": 1, "anomaly_reason": reason, }) if updated.Error != nil { return errors.Wrap(errors.CodeDatabaseError, updated.Error, "标记退款查询窗口超期失败") } if updated.RowsAffected != 1 { return nil } refund.Status = model.RefundStatusChannelFailed refund.ChannelRefundStatus = constants.RefundChannelStatusFailed refund.FailureReason = constants.RefundFailureTimeoutUnknown refund.AnomalyFlag = 1 refund.AnomalyReason = reason return s.audit.WriteRefundChannelResult(ctx, tx, refund, constants.AuditActionRefundAnomalyFlagged, reason+",结果未知,已终止渠道执行并转人工核对") }) if err != nil { if appErrorCode(err) != 0 { return err } return errors.Wrap(errors.CodeDatabaseError, err, "标记退款查询窗口超期失败") } return nil } // refundWindowStart 返回查询窗口的起算时点:渠道明确成功时间优先,否则取最后一次实质性状态变更时间。 // 结果未知的回写不会推进 updated_at,因此窗口始终从进入原路处理中的时点起算。 func refundWindowStart(refund *model.RefundRequest) time.Time { if refund.ChannelRefundedAt != nil { return refund.ChannelRefundedAt.UTC() } return refund.UpdatedAt.UTC() } // loadPaidPayments 批量读取该批订单最近一笔已支付的套餐支付单。 func (s *Service) loadPaidPayments(ctx context.Context, refunds []model.RefundRequest) (map[uint]*model.Payment, error) { orderIDs := make([]uint, 0, len(refunds)) for index := range refunds { orderIDs = append(orderIDs, refunds[index].OrderID) } var payments []model.Payment if err := s.db.WithContext(ctx). Where("order_id IN ? AND order_type = ? AND status = ?", orderIDs, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid). Order("id ASC").Find(&payments).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量读取原支付单失败") } latest := make(map[uint]*model.Payment, len(payments)) for index := range payments { latest[payments[index].OrderID] = &payments[index] } return latest, nil }