Files

114 lines
4.2 KiB
Go

package payment
import (
"context"
"errors"
"time"
"hfb_jysh/backend/internal/model"
"hfb_jysh/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
}