package payment import ( "context" "encoding/json" "time" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/rentergrowth" "go.uber.org/zap" "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" ) func (r *Repository) StartRefund(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string, remark string) (*RefundDTO, error) { originalPayment, err := r.findOriginalPayment(ctx, orderID) if err != nil { return nil, err } runtimeConfig, err := r.runtimeConfigForPayment(ctx, &originalPayment) if err != nil { return nil, ErrPaymentUnavailable } refundOrder, existing, err := r.prepareRefundOrder(ctx, originalPayment, *runtimeConfig, refundAmountCent, bizType) if err != nil { return nil, err } if existing { latest, syncErr := r.syncRefundPayment(ctx, refundOrder, refundOrder.Status != "refunded") if syncErr != nil { r.log().Warn("payment existing refund sync failed", paymentLogFields(ctx, appendFields( paymentOrderFields(refundOrder), []zap.Field{zap.String("biz_type", bizType), zap.Error(syncErr)}, )..., )...) dto := toRefundDTO(*refundOrder) return &dto, nil } dto := toRefundDTO(*latest) return &dto, nil } if runtimeConfig.isMockMode() { raw := map[string]string{"mock": "true"} if remark != "" { raw["remark"] = remark } if err := r.applyRefundChannelStatus(ctx, refundOrder, refundChannelStatusUpdate{ Status: "refunded", ProviderRefundID: "MOCKREF" + refundOrder.ThirdOrderID, Raw: raw, Source: channelSourceMock, }); err != nil { return nil, err } latest, err := r.findPaymentByID(ctx, refundOrder.ID) if err != nil { return nil, err } r.recordConfigUsage(ctx, runtimeConfig, latest) dto := toRefundDTO(*latest) return &dto, nil } r.log().Info("payment refund start", paymentLogFields(ctx, appendFields( paymentOrderFields(refundOrder), runtimeConfigFields(runtimeConfig), []zap.Field{ zap.String("merchant_refund_id", refundOrder.ThirdOrderID), zap.String("origin_third_order_id", originalPayment.ThirdOrderID), zap.String("origin_provider_order_id", refundOriginProviderOrderID(originalPayment)), }, )..., )...) r.recordConfigUsage(ctx, runtimeConfig, refundOrder) if err := r.markOrderRefunding(ctx, orderID, refundAmountCent); err != nil { r.log().Warn("payment mark order refunding failed", paymentLogFields(ctx, zap.Uint64("order_id", orderID), zap.Int64("refund_amount_cent", refundAmountCent), zap.Error(err), )...) } if runtimeConfig.Channel == nil { if err := r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": "payment channel unavailable"}, nil); err != nil { r.log().Warn("payment mark refund failed status failed", paymentLogFields(ctx, appendFields( paymentOrderFields(refundOrder), []zap.Field{zap.Error(err)}, )..., )...) } return nil, ErrPaymentUnavailable } resp, err := runtimeConfig.Channel.CreateRefund(ctx, channelCreateRefundRequest{ ThirdOrderID: originalPayment.ThirdOrderID, ProviderOrderID: refundOriginProviderOrderID(originalPayment), MerchantRefundID: refundOrder.ThirdOrderID, RefundAmountCent: refundAmountCent, NotifyURL: runtimeConfig.NotifyURL, Attach: originalPayment.OrderNo, Remark: remark, }) if err != nil { var rawRequest map[string]string if resp != nil { rawRequest = resp.RawRequest } if markErr := r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": err.Error()}, rawRequest); markErr != nil { r.log().Warn("payment mark refund failed status failed", paymentLogFields(ctx, appendFields( paymentOrderFields(refundOrder), []zap.Field{zap.Error(markErr)}, )..., )...) } r.log().Warn("payment refund request failed", paymentLogFields(ctx, appendFields( paymentOrderFields(refundOrder), runtimeConfigFields(runtimeConfig), []zap.Field{zap.Error(err)}, )..., )...) return nil, err } if !resp.OK { if markErr := r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, resp.Raw, resp.RawRequest); markErr != nil { r.log().Warn("payment mark refund rejected status failed", paymentLogFields(ctx, appendFields( paymentOrderFields(refundOrder), []zap.Field{zap.Error(markErr)}, )..., )...) } r.log().Warn("payment refund rejected", paymentLogFields(ctx, appendFields( paymentOrderFields(refundOrder), runtimeConfigFields(runtimeConfig), []zap.Field{ zap.String("channel_code", firstNonEmpty(resp.Raw["code"], resp.Raw["resp_code"], resp.Raw["result_code"])), zap.String("channel_message", resp.ErrorMessage), zap.Strings("raw_response_keys", stringMapKeys(resp.Raw)), }, )..., )...) return nil, ErrPaymentUnavailable } if err := r.applyRefundChannelStatus(ctx, refundOrder, refundChannelStatusUpdate{ Status: resp.Status, ProviderRefundID: resp.ProviderRefundID, RawRequest: resp.RawRequest, Raw: resp.Raw, Source: channelSourceCreate, }); err != nil { return nil, err } r.log().Info("payment refund result", paymentLogFields(ctx, appendFields( paymentOrderFields(refundOrder), runtimeConfigFields(runtimeConfig), []zap.Field{ zap.String("refund_status", resp.Status), zap.String("provider_refund_id", resp.ProviderRefundID), }, )..., )...) latest, err := r.findPaymentByID(ctx, refundOrder.ID) if err != nil { return nil, err } dto := toRefundDTO(*latest) return &dto, nil } func (r *Repository) QueryRefundStatus(ctx context.Context, orderID uint64) (*RefundDTO, error) { var payment model.PaymentOrder if err := r.db.WithContext(ctx).Where("order_id = ? AND biz_type IN ?", orderID, refundBizTypes).Order("id DESC").First(&payment).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, ErrPaymentNotFound } return nil, err } latest, err := r.syncRefundPayment(ctx, &payment, payment.Status == "failed") if err != nil { return nil, err } dto := toRefundDTO(*latest) return &dto, nil } func (r *Repository) SyncRefundStatusByPaymentID(ctx context.Context, paymentID uint64) (*RefundDTO, error) { payment, err := r.findPaymentByID(ctx, paymentID) if err != nil { if err == gorm.ErrRecordNotFound { return nil, ErrPaymentNotFound } return nil, err } if !isRefundBizType(payment.BizType) { return nil, ErrPaymentNotFound } latest, err := r.syncRefundPayment(ctx, payment, true) if err != nil { return nil, err } dto := toRefundDTO(*latest) return &dto, nil } func (r *Repository) findOriginalPayment(ctx context.Context, orderID uint64) (model.PaymentOrder, error) { var originalPayment model.PaymentOrder if err := r.db.WithContext(ctx).Where("order_id = ? AND status = 'paid' AND biz_type = 'order_pay'", orderID).Order("id DESC").First(&originalPayment).Error; err != nil { if err == gorm.ErrRecordNotFound { return originalPayment, ErrPaymentNotFound } return originalPayment, err } return originalPayment, nil } // prepareRefundOrder 创建退款支付单,并继承原支付单回溯出的支付配置。 func (r *Repository) prepareRefundOrder(ctx context.Context, originalPayment model.PaymentOrder, runtimeConfig runtimePaymentConfig, refundAmountCent int64, bizType string) (*model.PaymentOrder, bool, error) { var paymentID uint64 existing := false err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var order model.RentalOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, originalPayment.OrderID).Error; err != nil { return err } var existingRefund model.PaymentOrder err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("order_id = ? AND biz_type = ?", originalPayment.OrderID, bizType). Order("id DESC"). First(&existingRefund).Error if err == nil { paymentID = existingRefund.ID existing = true return nil } if err != gorm.ErrRecordNotFound { return err } paymentNo, err := newPaymentNo() if err != nil { return err } merchantRefundID := "REF" + paymentNo[3:] refundOrder := model.PaymentOrder{ PaymentNo: paymentNo, OrderID: originalPayment.OrderID, OrderNo: originalPayment.OrderNo, UserID: originalPayment.UserID, PaymentConfigID: runtimeConfig.ID, Provider: runtimeConfig.Provider, MerchantID: runtimeConfig.MerchantID, ThirdOrderID: merchantRefundID, ProviderOrderID: "", PayWay: originalPayment.PayWay, JSPayFlag: originalPayment.JSPayFlag, AmountCent: refundAmountCent, BizType: bizType, Status: "refunding", } if err := tx.Create(&refundOrder).Error; err != nil { return err } paymentID = refundOrder.ID return nil }) if err != nil { return nil, false, err } payment, err := r.findPaymentByID(ctx, paymentID) if err != nil { return nil, false, err } return payment, existing, nil } func (r *Repository) syncRefundPayment(ctx context.Context, payment *model.PaymentOrder, queryTerminal bool) (*model.PaymentOrder, error) { if payment.Status == "refunded" && !queryTerminal { if err := r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType); err != nil { return nil, err } return payment, nil } if payment.Status == "failed" && !queryTerminal { if err := r.markOrderRefundFailed(ctx, payment.OrderID, payment.AmountCent); err != nil { return nil, err } return payment, nil } runtimeConfig, err := r.runtimeConfigForPayment(ctx, payment) if err != nil { return nil, ErrPaymentUnavailable } if runtimeConfig.isMockMode() { if payment.Status == "refunded" { if err := r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType); err != nil { return nil, err } } return payment, nil } if runtimeConfig.Channel == nil { return nil, ErrPaymentUnavailable } originalPayment, err := r.findOriginalPayment(ctx, payment.OrderID) if err != nil { return nil, err } resp, err := runtimeConfig.Channel.QueryRefund(ctx, refundQueryRequest(*payment, originalPayment)) if err != nil { if resp != nil && resp.RawRequest != nil { if updateErr := r.updateRefundRawRequest(ctx, payment.ID, resp.RawRequest); updateErr != nil { r.log().Warn("payment refund query raw request update failed", paymentLogFields(ctx, appendFields( paymentOrderFields(payment), []zap.Field{zap.Error(updateErr)}, )..., )...) } } return nil, err } if !resp.OK { return nil, ErrPaymentUnavailable } if err := r.applyRefundChannelStatus(ctx, payment, refundChannelStatusUpdate{ Status: resp.Status, ProviderRefundID: resp.ProviderRefundID, RefundTime: resp.RefundTime, Raw: resp.Raw, Source: channelSourceQuery, }); err != nil { return nil, err } return r.findPaymentByID(ctx, payment.ID) } type refundChannelStatusUpdate struct { Status string ProviderRefundID string RefundTime string RawRequest map[string]string Raw map[string]string Source string } func (r *Repository) applyRefundChannelStatus(ctx context.Context, payment *model.PaymentOrder, update refundChannelStatusUpdate) error { status := update.Status if status != "refunded" && status != "failed" { status = "refunding" } updates := map[string]any{ "status": status, "raw_response": jsonMap(withRawSource(update.Raw, update.Source)), } if update.ProviderRefundID != "" { updates["provider_order_id"] = update.ProviderRefundID } if update.RawRequest != nil { updates["raw_request"] = jsonMap(update.RawRequest) } if status == "refunded" { paidAt := parseChannelTime(update.RefundTime) if paidAt == nil { now := time.Now() paidAt = &now } updates["paid_at"] = paidAt } if update.Source == channelSourceNotify { updates["notified_at"] = time.Now() } if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(updates).Error; err != nil { return err } switch status { case "refunded": return r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType) case "failed": return r.markOrderRefundFailed(ctx, payment.OrderID, payment.AmountCent) default: return r.markOrderRefunding(ctx, payment.OrderID, payment.AmountCent) } } func isRefundBizType(bizType string) bool { for _, item := range refundBizTypes { if item == bizType { return true } } return false } func refundQueryRequest(payment model.PaymentOrder, originalPayment model.PaymentOrder) channelQueryRefundRequest { return channelQueryRefundRequest{ ThirdOrderID: originalPayment.ThirdOrderID, ProviderOrderID: refundOriginProviderOrderID(originalPayment), MerchantRefundID: payment.ThirdOrderID, ProviderRefundID: payment.ProviderOrderID, } } func (r *Repository) updateOrderRefundStatus(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var order model.RentalOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil { return err } now := time.Now() order.RefundStatus = "refunded" order.RefundAmountCent = refundAmountCent order.RefundedAt = &now if bizType == "admin_refund" && order.Status == "completed" && refundAmountCent >= order.RentAmountCent+order.DepositAmountCent { if err := rentergrowth.RevokeOrderCompleted(tx, &order); err != nil { return err } } return tx.Save(&order).Error }) } func (r *Repository) markOrderRefunding(ctx context.Context, orderID uint64, refundAmountCent int64) error { return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{ "refund_status": "refunding", "refund_amount_cent": refundAmountCent, "refunded_at": nil, }).Error } func (r *Repository) markOrderRefundFailed(ctx context.Context, orderID uint64, refundAmountCent int64) error { return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{ "refund_status": "failed", "refund_amount_cent": refundAmountCent, "refunded_at": nil, }).Error } func (r *Repository) updateRefundRawRequest(ctx context.Context, paymentID uint64, rawRequest map[string]string) error { if rawRequest == nil { return nil } return r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Update("raw_request", jsonMap(rawRequest)).Error } func (r *Repository) markRefundFailed(ctx context.Context, paymentID uint64, orderID uint64, refundAmountCent int64, raw map[string]string, rawRequest map[string]string) error { if raw == nil { raw = map[string]string{"error": "refund failed"} } updates := map[string]any{ "status": "failed", "raw_response": jsonMap(raw), } if rawRequest != nil { updates["raw_request"] = jsonMap(rawRequest) } if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(updates).Error; err != nil { return err } return r.markOrderRefundFailed(ctx, orderID, refundAmountCent) } func toRefundDTO(payment model.PaymentOrder) RefundDTO { return RefundDTO{ ID: payment.ID, PaymentNo: payment.PaymentNo, OrderID: payment.OrderID, OrderNo: payment.OrderNo, BizType: payment.BizType, AmountCent: payment.AmountCent, Status: payment.Status, ProviderOrderID: payment.ProviderOrderID, PaidAt: payment.PaidAt, CreatedAt: payment.CreatedAt, UpdatedAt: payment.UpdatedAt, } } func refundOriginProviderOrderID(payment model.PaymentOrder) string { if payment.Provider != "lakala" { return payment.ProviderOrderID } if tradeID := lakalaOriginTradeID(payment.RawResponse); tradeID != "" { return tradeID } return payment.ProviderOrderID } func lakalaOriginTradeID(raw datatypes.JSON) string { if len(raw) == 0 { return "" } var payload map[string]any if err := json.Unmarshal(raw, &payload); err != nil { return "" } if tradeID := firstStringValue(payload, "trade_no", "origin_trade_no"); tradeID != "" { return tradeID } value, ok := payload["order_trade_info_list"] if !ok { return "" } switch typed := value.(type) { case string: var items []map[string]any if err := json.Unmarshal([]byte(typed), &items); err != nil { return "" } for _, item := range items { if tradeID := firstStringValue(item, "trade_no", "origin_trade_no"); tradeID != "" { return tradeID } } case []any: for _, item := range typed { itemMap, ok := item.(map[string]any) if !ok { continue } if tradeID := firstStringValue(itemMap, "trade_no", "origin_trade_no"); tradeID != "" { return tradeID } } } return "" } func firstStringValue(values map[string]any, keys ...string) string { for _, key := range keys { value, ok := values[key] if !ok { continue } if text, ok := value.(string); ok && text != "" { return text } } return "" }