package payment import ( "context" "errors" "time" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/order" "go.uber.org/zap" "gorm.io/gorm" "gorm.io/gorm/clause" ) // isOrderUnpayable 判断订单侧的报错是否属于「钱已到渠道,但订单再也无法入账」。 // 订单被取消或关单后回调才到属于前者,商品被他人抢租属于后者。 func isOrderUnpayable(err error) bool { return errors.Is(err, order.ErrOrderCannotPay) || errors.Is(err, order.ErrListingUnavailable) } func unpayableReason(err error) string { switch { case errors.Is(err, order.ErrOrderCannotPay): return "order_not_payable" case errors.Is(err, order.ErrListingUnavailable): return "listing_unavailable" default: return "unknown" } } // settleUnpayableOrderPayment 处理「渠道已收款、订单却无法入账」。 // // 钱已经进了商户账户,这个事实必须先落库:把支付单如实记成已支付。 // 如果让订单侧的报错把整笔事务回滚,支付单会停在「支付中」且 paid_at 为空, // 这时候既补不回入账(订单已不是待支付),也退不出去——后台人工退款要求 // 原支付单是已支付,所以连客服都无能为力,钱就彻底对不上了。 // // 这里只记账并告警,不自动退款:退款由客服在后台确认后人工发起, // 落账之后后台的人工退款就能正常走通。 func (r *Repository) settleUnpayableOrderPayment(ctx context.Context, payment *model.PaymentOrder, paidAt time.Time, raw map[string]string, source string, cause error) (error, bool) { if payment == nil || payment.BizType != "order_pay" || !isOrderUnpayable(cause) { return nil, false } reason := unpayableReason(cause) logFields := func(extra ...zap.Field) []zap.Field { return paymentLogFields(ctx, appendFields( paymentOrderFields(payment), []zap.Field{ zap.String("channel_source", source), zap.String("unpayable_reason", reason), zap.String("order_status", r.orderStatus(ctx, payment.OrderID)), }, extra, )...) } if err := r.markOrderPaymentPaid(ctx, payment, paidAt, withUnpayableDiagnostic(raw, reason), source); err != nil { r.log().Error("渠道已收款但支付单落账失败", logFields(zap.Error(err))...) return err, true } // 返回 nil 让渠道停止重试:重试解决不了问题,只会把同一条告警刷十遍。 r.log().Error("渠道已收款但订单无法入账,需人工退款", logFields()...) return nil, true } // markOrderPaymentPaid 只更新支付单自身,不碰订单状态,用于订单已无法推进时如实记录收款。 func (r *Repository) markOrderPaymentPaid(ctx context.Context, payment *model.PaymentOrder, paidAt time.Time, raw map[string]string, source string) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var latest model.PaymentOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&latest, payment.ID).Error; err != nil { return err } if latest.Status == "paid" && latest.PaidAt != nil { return nil } updates := map[string]any{ "status": "paid", "provider_order_id": firstNonEmpty(raw["provider_order_id"], raw["schc_order_id"], raw["leshua_order_id"], raw["pay_order_no"], raw["trade_no"], latest.ProviderOrderID), "raw_response": jsonMap(withRawSource(raw, source)), "paid_at": paidAt, } if source == channelSourceNotify { updates["notified_at"] = time.Now() } return tx.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(updates).Error }) } // withUnpayableDiagnostic 在 raw_response 里留下排查标记,后台支付列表可以直接看到原因。 func withUnpayableDiagnostic(raw map[string]string, reason string) map[string]string { out := make(map[string]string, len(raw)+2) for key, value := range raw { out[key] = value } out["_order_unpayable_reason"] = reason out["_manual_refund_required"] = "true" return out } func (r *Repository) orderStatus(ctx context.Context, orderID uint64) string { if orderID == 0 { return "" } var status string if err := r.db.WithContext(ctx).Model(&model.RentalOrder{}). Where("id = ?", orderID). Pluck("status", &status).Error; err != nil { return "" } return status }