package payment import ( "context" "encoding/json" "log" "time" "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" "hfb_sys/backend/internal/model" ) 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 { log.Printf("[payment] sync existing refund failed order_id=%d payment_id=%d biz_type=%s err=%v", orderID, refundOrder.ID, bizType, 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 } log.Printf("[payment] refund start order_id=%d order_no=%s payment_id=%d biz_type=%s provider=%s amount_cent=%d merchant_refund_id=%s origin_third_order_id=%s origin_provider_order_id=%s", orderID, originalPayment.OrderNo, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, refundOrder.ThirdOrderID, originalPayment.ThirdOrderID, refundOriginProviderOrderID(originalPayment)) r.recordConfigUsage(ctx, runtimeConfig, refundOrder) if err := r.markOrderRefunding(ctx, orderID, refundAmountCent); err != nil { log.Printf("[payment] mark order refunding failed order_id=%d err=%v", orderID, err) } if runtimeConfig.Channel == nil { if err := r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": "payment channel unavailable"}, nil); err != nil { log.Printf("[payment] mark refund failed status failed order_id=%d payment_id=%d err=%v", orderID, refundOrder.ID, 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 { log.Printf("[payment] mark refund failed status failed order_id=%d payment_id=%d err=%v", orderID, refundOrder.ID, markErr) } log.Printf("[payment] refund request failed order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d err=%v", orderID, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, err) return nil, err } if !resp.OK { if markErr := r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, resp.Raw, resp.RawRequest); markErr != nil { log.Printf("[payment] mark refund rejected status failed order_id=%d payment_id=%d err=%v", orderID, refundOrder.ID, markErr) } log.Printf("[payment] refund rejected order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d code=%s message=%s", orderID, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, firstNonEmpty(resp.Raw["code"], resp.Raw["resp_code"], resp.Raw["result_code"]), resp.ErrorMessage) 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 } log.Printf("[payment] refund result order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d status=%s provider_refund_id=%s", orderID, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, resp.Status, 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); 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); 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 { log.Printf("[payment] update refund query raw request failed payment_id=%d err=%v", payment.ID, 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) 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) error { now := time.Now() return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{ "refund_status": "refunded", "refund_amount_cent": refundAmountCent, "refunded_at": now, }).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 "" }