Files
hfb_sys/backend/internal/modules/order/repository.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

125 lines
4.0 KiB
Go

package order
import (
"context"
"hfb_sys/backend/internal/logging"
"hfb_sys/backend/internal/model"
"go.uber.org/zap"
"gorm.io/gorm"
)
// RefundStarter 由支付模块适配实现,避免 order 直接依赖 payment。
type RefundStarter interface {
StartRefund(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string, remark string) (status string, err error)
}
type RefundStarterFunc func(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string, remark string) (status string, err error)
func (fn RefundStarterFunc) StartRefund(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string, remark string) (string, error) {
return fn(ctx, orderID, refundAmountCent, bizType, remark)
}
type OrderChatNotifier interface {
NotifyNewConversation(conversationID uint64)
}
// OrderChatArchiver 由 chat 模块适配实现,在订单封存时于同一事务内解散关联群聊。
// 实际聊天群按 listing 关联(发布群 listing_group),故传 listingID。
type OrderChatArchiver interface {
ArchiveListingConversation(tx *gorm.DB, listingID uint64, reason string) error
}
// ChannelPaymentProbe 由支付模块适配实现,用于在取消或关单前跟渠道核对是否已收款。
type ChannelPaymentProbe interface {
ConfirmChannelPaidBeforeClose(ctx context.Context, orderID uint64) (paid bool, err error)
}
type ChannelPaymentProbeFunc func(ctx context.Context, orderID uint64) (bool, error)
func (fn ChannelPaymentProbeFunc) ConfirmChannelPaidBeforeClose(ctx context.Context, orderID uint64) (bool, error) {
return fn(ctx, orderID)
}
type Dependencies struct {
ChatNotifier OrderChatNotifier
ChatArchiver OrderChatArchiver
RefundStarter RefundStarter
PaymentProbe ChannelPaymentProbe
}
type refundAction struct {
OrderID uint64
RefundAmountCent int64
BizType string
Remark string
}
type Repository struct {
db *gorm.DB
chatNotifier OrderChatNotifier
chatArchiver OrderChatArchiver
refundStarter RefundStarter
paymentProbe ChannelPaymentProbe
}
const defaultPendingPaymentTimeoutMinutes = 15
func NewRepository(db *gorm.DB, deps ...Dependencies) *Repository {
repo := &Repository{db: db}
if len(deps) > 0 {
repo.chatNotifier = deps[0].ChatNotifier
repo.chatArchiver = deps[0].ChatArchiver
repo.refundStarter = deps[0].RefundStarter
repo.paymentProbe = deps[0].PaymentProbe
}
return repo
}
// channelPaidBeforeClose 在关闭待支付订单前跟渠道核对一次,已收款则放弃关闭。
// 核对必须在事务外做(含渠道 HTTP 调用),渠道异常时按「未收款」继续,
// 真漏掉的由支付回调兜底和对账任务收尾。
func (r *Repository) channelPaidBeforeClose(ctx context.Context, orderID uint64) bool {
if r.paymentProbe == nil {
return false
}
paid, err := r.paymentProbe.ConfirmChannelPaidBeforeClose(ctx, orderID)
if err != nil {
logging.FromContext(ctx).Warn("关闭订单前核对渠道支付失败",
zap.String("module", "order"),
zap.Uint64("order_id", orderID),
zap.Error(err),
)
return false
}
if paid {
logging.FromContext(ctx).Warn("关闭订单前核对到渠道已收款,放弃关闭",
zap.String("module", "order"),
zap.Uint64("order_id", orderID),
)
}
return paid
}
// pendingPaymentOrderPaidAtChannel 仅对租客自己的待支付订单做渠道核对,其余情况直接放行。
func (r *Repository) pendingPaymentOrderPaidAtChannel(ctx context.Context, userID uint64, orderID uint64) bool {
if r.paymentProbe == nil {
return false
}
var status string
err := r.db.WithContext(ctx).Model(&model.RentalOrder{}).
Where("id = ? AND renter_id = ?", orderID, userID).
Pluck("status", &status).Error
if err != nil || status != orderStatusPendingPayment {
return false
}
return r.channelPaidBeforeClose(ctx, orderID)
}
func (r *Repository) NotifyNewConversation(conversationID uint64) {
if conversationID > 0 && r.chatNotifier != nil {
r.chatNotifier.NotifyNewConversation(conversationID)
}
}