租客在待支付超时或取消的最后几十秒完成付款时,订单先被取消,乐刷的支付 结果几秒后才到,入账被判定订单不可支付而拒收。原先订单推进和支付单更新 在同一个事务里,订单一报错整笔回滚,支付单停在支付中且 paid_at 为空: 钱已在渠道,系统却毫不知情。乐刷重试十次后放弃,之后既补不回入账(订单 不再是待支付),后台人工退款也发不出去(退款要求原支付单是已支付)。 日志保留期内 13 天出了 5 笔,合计 4085.60 元。 - 取消前核对渠道:超时取消、租客取消、客服关单都先查一次渠道,已收款并 已推进订单就放弃取消,让支付正常入账而不是走退款。渠道异常时按未收款 继续,避免渠道一抖订单全卡住 - 回调兜底记账:订单确实已结束时不再回滚,把支付单如实记成已支付并写入 paid_at 与渠道单号,后台人工退款从此可用。退款仍由客服人工发起 - 客服关单:待支付订单按实际已收款减已退款的差额退,补上本次事故中关单 不生成退款的直接原因 - 补齐日志上下文:回调入账失败原先只有 error="order cannot pay",现在带 支付单号、订单号、订单状态和金额;新增 ERROR「渠道已收款但订单无法入账, 需人工退款」作为唯一需人工介入的告警 Co-authored-by: Cursor <cursoragent@cursor.com>
114 lines
4.2 KiB
Go
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
|
|
}
|