Files
hfb_sys/backend/internal/modules/payment/late_paid.go
T
yml2213andCursor 590bf1cae3 fix(payment): 渠道已收款时不再把支付单回滚成支付中
租客在待支付超时或取消的最后几十秒完成付款时,订单先被取消,乐刷的支付
结果几秒后才到,入账被判定订单不可支付而拒收。原先订单推进和支付单更新
在同一个事务里,订单一报错整笔回滚,支付单停在支付中且 paid_at 为空:
钱已在渠道,系统却毫不知情。乐刷重试十次后放弃,之后既补不回入账(订单
不再是待支付),后台人工退款也发不出去(退款要求原支付单是已支付)。
日志保留期内 13 天出了 5 笔,合计 4085.60 元。

- 取消前核对渠道:超时取消、租客取消、客服关单都先查一次渠道,已收款并
  已推进订单就放弃取消,让支付正常入账而不是走退款。渠道异常时按未收款
  继续,避免渠道一抖订单全卡住
- 回调兜底记账:订单确实已结束时不再回滚,把支付单如实记成已支付并写入
  paid_at 与渠道单号,后台人工退款从此可用。退款仍由客服人工发起
- 客服关单:待支付订单按实际已收款减已退款的差额退,补上本次事故中关单
  不生成退款的直接原因
- 补齐日志上下文:回调入账失败原先只有 error="order cannot pay",现在带
  支付单号、订单号、订单状态和金额;新增 ERROR「渠道已收款但订单无法入账,
  需人工退款」作为唯一需人工介入的告警

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 09:23:25 +08:00

114 lines
4.2 KiB
Go

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
}