package payment import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "log" "math" "time" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/order" "hfb_sys/backend/internal/modules/paymentconfig" "hfb_sys/backend/internal/modules/wallet" "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" ) type Repository struct { db *gorm.DB configRepo *paymentconfig.Repository orderRepo *order.Repository walletRepo *wallet.Repository } type runtimePaymentConfig struct { ID uint64 Provider string MerchantID string PayWay string JSPayFlag string NotifyURL string JumpURL string Channel channelClient } const ( channelSourceCreate = "create" channelSourceQuery = "query" channelSourceNotify = "notify" channelSourceMock = "mock" ) var refundBizTypes = []string{ "cancel_refund", "admin_close_refund", "admin_refund", "checkout_refund", "deposit_refund", "rent_refund", } func NewRepository(db *gorm.DB, configRepo *paymentconfig.Repository, orderRepo *order.Repository, walletRepo *wallet.Repository) *Repository { return &Repository{ db: db, configRepo: configRepo, orderRepo: orderRepo, walletRepo: walletRepo, } } func (c runtimePaymentConfig) isMockMode() bool { return c.Provider == "mock" } func (r *Repository) defaultRuntimeConfig() (*runtimePaymentConfig, error) { if r.configRepo == nil { return nil, ErrPaymentUnavailable } dto, err := r.configRepo.FindDefaultAny(true) if err != nil { return nil, err } return runtimeConfigFromDTO(dto), nil } func (r *Repository) runtimeConfigForPayment(payment *model.PaymentOrder) (*runtimePaymentConfig, error) { provider := firstNonEmpty(payment.Provider, "mock") merchantID := payment.MerchantID if r.configRepo != nil && merchantID != "" { dto, err := r.configRepo.FindByProviderMerchant(provider, merchantID, true) if err == nil { return runtimeConfigFromDTO(dto), nil } if err != paymentconfig.ErrConfigNotFound { return nil, err } } if provider != "leshua" { if provider == "lakala" && r.configRepo != nil { dto, err := r.configRepo.FindDefaultByProvider(provider, true) if err != nil { return nil, err } return runtimeConfigFromDTO(dto), nil } return &runtimePaymentConfig{ Provider: provider, MerchantID: merchantID, }, nil } if r.configRepo == nil { return nil, ErrPaymentUnavailable } dto, err := r.configRepo.FindDefaultByProvider(provider, true) if err != nil { return nil, err } return runtimeConfigFromDTO(dto), nil } func runtimeConfigFromDTO(dto *paymentconfig.ConfigDTO) *runtimePaymentConfig { provider := firstNonEmpty(dto.Provider, "mock") payWay := firstNonEmpty(dto.PayWay, "ZFBZF") jsPayFlag := firstNonEmpty(dto.JSPayFlag, "2") client, err := buildChannelClient(dto) if err != nil { client = nil } return &runtimePaymentConfig{ ID: dto.ID, Provider: provider, MerchantID: dto.MerchantID, PayWay: payWay, JSPayFlag: jsPayFlag, NotifyURL: dto.NotifyURL, JumpURL: dto.JumpURL, Channel: client, } } func (r *Repository) Start(userID uint64, orderID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) { defaultConfig, err := r.defaultRuntimeConfig() if err != nil { return nil, ErrPaymentUnavailable } payment, orderRow, err := r.preparePayment(userID, orderID, req, *defaultConfig) if err != nil { return nil, err } runtimeConfig, err := r.runtimeConfigForPayment(payment) if err != nil { return nil, ErrPaymentUnavailable } if payment.Status == "paid" { r.recordConfigUsage(runtimeConfig, payment) dto := toDTO(*payment) return &dto, nil } if runtimeConfig.isMockMode() { if err := r.confirmPaid(payment, "2", time.Now(), map[string]string{ "mock": "true", "third_order_id": payment.ThirdOrderID, "leshua_order_id": payment.ProviderOrderID, "status": "2", }, channelSourceMock); err != nil { return nil, err } latest, err := r.findPaymentByID(payment.ID) if err != nil { return nil, err } r.recordConfigUsage(runtimeConfig, latest) dto := toDTO(*latest) return &dto, nil } if payment.Status == "paying" && (payment.TDCode != "" || payment.JSPayURL != "" || payment.JSPayInfo != "") { r.recordConfigUsage(runtimeConfig, payment) dto := toDTO(*payment) return &dto, nil } if runtimeConfig.Channel == nil { _ = r.markPaymentFailed(payment.ID, nil, "payment channel unavailable") return nil, ErrPaymentUnavailable } resp, err := runtimeConfig.Channel.CreatePayment(context.Background(), channelCreatePaymentRequest{ ThirdOrderID: payment.ThirdOrderID, AmountCent: payment.AmountCent, PayWay: payment.PayWay, JSPayFlag: payment.JSPayFlag, NotifyURL: runtimeConfig.NotifyURL, JumpURL: runtimeConfig.JumpURL, ClientIP: clientIP, Body: "租号订单 " + orderRow.OrderNo, Attach: orderRow.OrderNo, }) if err != nil { _ = r.markPaymentFailed(payment.ID, nil, err.Error()) return nil, err } if !resp.OK { _ = r.markPaymentFailed(payment.ID, resp.Raw, resp.ErrorMessage) return nil, ErrPaymentUnavailable } if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ "status": "paying", "provider_order_id": resp.ProviderOrderID, "pay_way": firstNonEmpty(resp.PayWay, payment.PayWay), "td_code": resp.TDCode, "jspay_url": resp.JSPayURL, "jspay_info": resp.JSPayInfo, "raw_request": jsonMap(resp.RawRequest), "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)), }).Error; err != nil { return nil, err } latest, err := r.findPaymentByID(payment.ID) if err != nil { return nil, err } r.recordConfigUsage(runtimeConfig, latest) dto := toDTO(*latest) return &dto, nil } func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymentRequest, clientIP string) (*PaymentDTO, error) { amountCent := moneyCent(req.Amount) if userID == 0 || req.Amount < MinWalletRechargeAmount || amountCent <= 0 { return nil, ErrPaymentCannotStart } runtimeConfig, err := r.defaultRuntimeConfig() if err != nil { return nil, ErrPaymentUnavailable } payment, err := r.createWalletRechargePayment(userID, amountCent, req, *runtimeConfig) if err != nil { return nil, err } if runtimeConfig.isMockMode() { if err := r.confirmPaid(payment, "2", time.Now(), map[string]string{ "mock": "true", "third_order_id": payment.ThirdOrderID, "leshua_order_id": payment.ProviderOrderID, "status": "2", }, channelSourceMock); err != nil { return nil, err } latest, err := r.findPaymentByID(payment.ID) if err != nil { return nil, err } r.recordConfigUsage(runtimeConfig, latest) dto := toDTO(*latest) return &dto, nil } if runtimeConfig.Channel == nil { _ = r.markPaymentFailed(payment.ID, nil, "payment channel unavailable") return nil, ErrPaymentUnavailable } resp, err := runtimeConfig.Channel.CreatePayment(context.Background(), channelCreatePaymentRequest{ ThirdOrderID: payment.ThirdOrderID, AmountCent: payment.AmountCent, PayWay: payment.PayWay, JSPayFlag: payment.JSPayFlag, NotifyURL: runtimeConfig.NotifyURL, JumpURL: runtimeConfig.JumpURL, ClientIP: clientIP, Body: "钱包充值 " + payment.PaymentNo, Attach: payment.PaymentNo, }) if err != nil { _ = r.markPaymentFailed(payment.ID, nil, err.Error()) return nil, err } if !resp.OK { _ = r.markPaymentFailed(payment.ID, resp.Raw, resp.ErrorMessage) return nil, ErrPaymentUnavailable } if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ "status": "paying", "provider_order_id": resp.ProviderOrderID, "pay_way": firstNonEmpty(resp.PayWay, payment.PayWay), "td_code": resp.TDCode, "jspay_url": resp.JSPayURL, "jspay_info": resp.JSPayInfo, "raw_request": jsonMap(resp.RawRequest), "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)), }).Error; err != nil { return nil, err } latest, err := r.findPaymentByID(payment.ID) if err != nil { return nil, err } r.recordConfigUsage(runtimeConfig, latest) dto := toDTO(*latest) return &dto, nil } func (r *Repository) QueryWalletRecharge(userID uint64, paymentID uint64) (*PaymentDTO, error) { var payment model.PaymentOrder if err := r.db.Where("id = ? AND user_id = ? AND order_id = 0", paymentID, userID).First(&payment).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, ErrPaymentNotFound } return nil, err } runtimeConfig, err := r.runtimeConfigForPayment(&payment) if err != nil { return nil, ErrPaymentUnavailable } if payment.Status == "paid" || runtimeConfig.isMockMode() { dto := toDTO(payment) return &dto, nil } if runtimeConfig.Channel == nil { return nil, ErrPaymentUnavailable } resp, err := runtimeConfig.Channel.QueryPayment(context.Background(), payment.ThirdOrderID, payment.ProviderOrderID) if err != nil { return nil, err } if err := r.applyChannelStatus(&payment, resp.Status, resp.PayTime, resp.Raw, channelSourceQuery); err != nil { return nil, err } latest, err := r.findPaymentByID(payment.ID) if err != nil { return nil, err } dto := toDTO(*latest) return &dto, nil } func (r *Repository) Query(userID uint64, orderID uint64) (*PaymentDTO, error) { var payment model.PaymentOrder if err := r.db.Where("order_id = ? AND user_id = ? AND biz_type = ?", orderID, userID, "order_pay").Order("id DESC").First(&payment).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, ErrPaymentNotFound } return nil, err } runtimeConfig, err := r.runtimeConfigForPayment(&payment) if err != nil { return nil, ErrPaymentUnavailable } if payment.Status == "paid" || runtimeConfig.isMockMode() { dto := toDTO(payment) return &dto, nil } if runtimeConfig.Channel == nil { return nil, ErrPaymentUnavailable } resp, err := runtimeConfig.Channel.QueryPayment(context.Background(), payment.ThirdOrderID, payment.ProviderOrderID) if err != nil { return nil, err } if err := r.applyChannelStatus(&payment, resp.Status, resp.PayTime, resp.Raw, channelSourceQuery); err != nil { return nil, err } latest, err := r.findPaymentByID(payment.ID) if err != nil { return nil, err } dto := toDTO(*latest) return &dto, nil } func (r *Repository) HandleLeshuaNotify(params map[string]string, rawPayload string, contentType string) (*NotifyResult, error) { return r.HandleNotify("leshua", params, rawPayload, contentType, "") } func (r *Repository) HandleNotify(provider string, params map[string]string, rawPayload string, contentType string, authorization string) (*NotifyResult, error) { // 退款通知会携带 merchant_refund_id 或 leshua_refund_id。 if params["merchant_refund_id"] != "" || params["leshua_refund_id"] != "" || params["provider_refund_id"] != "" { return r.HandleRefundNotify(provider, params, rawPayload, contentType, authorization) } payment, err := r.findPaymentForNotify(params) if err != nil { return nil, err } runtimeConfig, err := r.runtimeConfigForPayment(payment) if err != nil { return nil, ErrPaymentUnavailable } verify, err := r.verifyNotify(payment, runtimeConfig, params, rawPayload, contentType, authorization) if err != nil { return nil, err } if amount := parseCent(params["amount"]); amount > 0 && amount != payment.AmountCent { if err := r.recordNotifyDiagnostic(payment.ID, params, rawPayload, contentType, verify, "amount_mismatch"); err != nil { log.Printf("[payment] %s notify diagnostic save failed third_order_id=%s err=%v", runtimeConfig.Provider, params["third_order_id"], err) } return nil, ErrPaymentVerifyFailed } raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, "verified") if err := r.applyChannelStatus(payment, normalizeNotifyPaymentStatus(runtimeConfig.Provider, params["status"]), params["pay_time"], raw, channelSourceNotify); err != nil { return nil, err } return &NotifyResult{OK: true, Message: "000000"}, nil } // StartRefund 创建退款单,并在本地落库后调用乐刷退款接口。 func (r *Repository) StartRefund(orderID uint64, refundAmountCent int64, bizType string, remark string) (*RefundDTO, error) { var originalPayment model.PaymentOrder if err := r.db.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 nil, ErrPaymentNotFound } return nil, err } runtimeConfig, err := r.runtimeConfigForPayment(&originalPayment) if err != nil { return nil, ErrPaymentUnavailable } var existingRefund model.PaymentOrder err = r.db.Where("order_id = ? AND biz_type = ? AND status NOT IN ('failed')", orderID, bizType).Order("id DESC").First(&existingRefund).Error if err == nil { dto := toRefundDTO(existingRefund) return &dto, nil } if err != gorm.ErrRecordNotFound { return nil, err } paymentNo, err := newPaymentNo() if err != nil { return nil, err } merchantRefundID := "REF" + paymentNo[3:] refundOrder := model.PaymentOrder{ PaymentNo: paymentNo, OrderID: orderID, OrderNo: originalPayment.OrderNo, UserID: originalPayment.UserID, Provider: runtimeConfig.Provider, MerchantID: runtimeConfig.MerchantID, ThirdOrderID: merchantRefundID, ProviderOrderID: "", PayWay: originalPayment.PayWay, JSPayFlag: originalPayment.JSPayFlag, AmountCent: refundAmountCent, BizType: bizType, Status: "refunding", } if runtimeConfig.isMockMode() { refundOrder.ProviderOrderID = "MOCKREF" + merchantRefundID refundOrder.Status = "refunded" now := time.Now() refundOrder.PaidAt = &now if remark != "" { refundOrder.RawResponse = datatypes.JSON([]byte(fmt.Sprintf(`{"mock":"true","remark":"%s"}`, remark))) } if err := r.db.Create(&refundOrder).Error; err != nil { return nil, err } r.recordConfigUsage(runtimeConfig, &refundOrder) if err := r.updateOrderRefundStatus(orderID, refundAmountCent); err != nil { log.Printf("[payment] mock update order refund status failed order_id=%d err=%v", orderID, err) } dto := toRefundDTO(refundOrder) return &dto, nil } if err := r.db.Create(&refundOrder).Error; err != nil { return nil, err } r.recordConfigUsage(runtimeConfig, &refundOrder) if err := r.markOrderRefunding(orderID, refundAmountCent); err != nil { log.Printf("[payment] mark order refunding failed order_id=%d err=%v", orderID, err) } if runtimeConfig.Channel == nil { _ = r.markRefundFailed(refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": "payment channel unavailable"}) return nil, ErrPaymentUnavailable } resp, err := runtimeConfig.Channel.CreateRefund(context.Background(), channelCreateRefundRequest{ ThirdOrderID: originalPayment.ThirdOrderID, ProviderOrderID: originalPayment.ProviderOrderID, MerchantRefundID: merchantRefundID, RefundAmountCent: refundAmountCent, NotifyURL: runtimeConfig.NotifyURL, Attach: originalPayment.OrderNo, Remark: remark, }) if err != nil { _ = r.markRefundFailed(refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": err.Error()}) return nil, err } if !resp.OK { _ = r.markRefundFailed(refundOrder.ID, orderID, refundAmountCent, resp.Raw) return nil, ErrPaymentUnavailable } refundStatus := "refunding" var paidAt *time.Time if resp.Status == "refunded" { refundStatus = "refunded" now := time.Now() paidAt = &now } else if resp.Status == "failed" { refundStatus = "failed" } if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", refundOrder.ID).Updates(map[string]any{ "status": refundStatus, "provider_order_id": resp.ProviderRefundID, "raw_request": jsonMap(resp.RawRequest), "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)), "paid_at": paidAt, }).Error; err != nil { return nil, err } if refundStatus == "refunded" { _ = r.updateOrderRefundStatus(orderID, refundAmountCent) refundOrder.PaidAt = paidAt } else if refundStatus == "failed" { _ = r.markOrderRefundFailed(orderID, refundAmountCent) } else { _ = r.markOrderRefunding(orderID, refundAmountCent) } refundOrder.Status = refundStatus refundOrder.ProviderOrderID = resp.ProviderRefundID dto := toRefundDTO(refundOrder) return &dto, nil } // QueryRefundStatus 查询订单最近一笔退款状态。 func (r *Repository) QueryRefundStatus(orderID uint64) (*RefundDTO, error) { var payment model.PaymentOrder if err := r.db.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 } runtimeConfig, err := r.runtimeConfigForPayment(&payment) if err != nil { return nil, ErrPaymentUnavailable } if payment.Status == "refunded" || payment.Status == "failed" || runtimeConfig.isMockMode() { dto := toRefundDTO(payment) return &dto, nil } if runtimeConfig.Channel == nil { return nil, ErrPaymentUnavailable } resp, err := runtimeConfig.Channel.QueryRefund(context.Background(), channelQueryRefundRequest{ ThirdOrderID: payment.ThirdOrderID, MerchantRefundID: payment.ThirdOrderID, ProviderRefundID: payment.ProviderOrderID, }) if err != nil { return nil, err } if resp.Status == "refunded" { now := time.Now() if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ "status": "refunded", "paid_at": now, "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceQuery)), }).Error; err != nil { return nil, err } payment.Status = "refunded" payment.PaidAt = &now _ = r.updateOrderRefundStatus(orderID, payment.AmountCent) } else if resp.Status == "failed" { if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ "status": "failed", "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceQuery)), }).Error; err != nil { return nil, err } payment.Status = "failed" _ = r.markOrderRefundFailed(orderID, payment.AmountCent) } dto := toRefundDTO(payment) return &dto, nil } // HandleRefundNotify 处理渠道退款通知。 func (r *Repository) HandleRefundNotify(provider string, params map[string]string, rawPayload string, contentType string, authorization string) (*NotifyResult, error) { payment, err := r.findRefundPaymentForNotify(params) if err != nil { return nil, err } runtimeConfig, err := r.runtimeConfigForPayment(payment) if err != nil { return nil, ErrPaymentUnavailable } verify, err := r.verifyNotify(payment, runtimeConfig, params, rawPayload, contentType, authorization) if err != nil { return nil, err } raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, "verified") status := normalizeNotifyRefundStatus(provider, params["status"]) switch status { case "refunded": now := time.Now() if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ "status": "refunded", "paid_at": now, "notified_at": now, "raw_response": jsonMap(raw), }).Error; err != nil { return nil, err } _ = r.updateOrderRefundStatus(payment.OrderID, payment.AmountCent) case "failed": r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ "status": "failed", "notified_at": time.Now(), "raw_response": jsonMap(raw), }) _ = r.markOrderRefundFailed(payment.OrderID, payment.AmountCent) default: r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ "status": "refunding", "raw_response": jsonMap(raw), }) } return &NotifyResult{OK: true, Message: "000000"}, nil } // updateOrderRefundStatus 更新订单退款成功状态。 func (r *Repository) updateOrderRefundStatus(orderID uint64, refundAmountCent int64) error { now := time.Now() return r.db.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(orderID uint64, refundAmountCent int64) error { return r.db.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(orderID uint64, refundAmountCent int64) error { return r.db.Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{ "refund_status": "failed", "refund_amount_cent": refundAmountCent, "refunded_at": nil, }).Error } func (r *Repository) markRefundFailed(paymentID uint64, orderID uint64, refundAmountCent int64, raw map[string]string) error { if raw == nil { raw = map[string]string{"error": "refund failed"} } if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(map[string]any{ "status": "failed", "raw_response": jsonMap(raw), }).Error; err != nil { return err } return r.markOrderRefundFailed(orderID, refundAmountCent) } // toRefundDTO 将支付表里的退款单转换为接口 DTO。 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 (r *Repository) preparePayment(userID uint64, orderID uint64, req StartPaymentRequest, runtimeConfig runtimePaymentConfig) (*model.PaymentOrder, *model.RentalOrder, error) { var paymentID uint64 var orderRow model.RentalOrder err := r.db.Transaction(func(tx *gorm.DB) error { var row model.RentalOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("id = ? AND renter_id = ?", orderID, userID). First(&row).Error; err != nil { return err } if row.Status != "pending_payment" { return ErrPaymentCannotStart } amountCent := moneyCent(row.RentAmount + row.DepositAmount) if amountCent <= 0 { return ErrPaymentCannotStart } var existing model.PaymentOrder err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("order_id = ? AND biz_type = ?", row.ID, "order_pay"). Order("id DESC"). First(&existing).Error if err == nil { if canReuseOrderPayment(existing, runtimeConfig) { existing.PayWay = firstNonEmpty(req.PayWay, existing.PayWay, runtimeConfig.PayWay, "ZFBZF") existing.JSPayFlag = firstNonEmpty(req.JSPayFlag, existing.JSPayFlag, runtimeConfig.JSPayFlag, "2") existing.AmountCent = amountCent existing.Provider = firstNonEmpty(existing.Provider, runtimeConfig.Provider) existing.MerchantID = firstNonEmpty(existing.MerchantID, runtimeConfig.MerchantID) if existing.Provider == "mock" && existing.ProviderOrderID == "" { existing.ProviderOrderID = "MOCK" + existing.ThirdOrderID } if err := tx.Save(&existing).Error; err != nil { return err } paymentID = existing.ID orderRow = row return nil } } else if err != gorm.ErrRecordNotFound { return err } payment, err := newOrderPayment(row, amountCent, req, runtimeConfig) if err != nil { return err } if err := tx.Create(&payment).Error; err != nil { return err } paymentID = payment.ID orderRow = row return nil }) if err != nil { return nil, nil, err } payment, err := r.findPaymentByID(paymentID) if err != nil { return nil, nil, err } return payment, &orderRow, nil } func canReuseOrderPayment(payment model.PaymentOrder, runtimeConfig runtimePaymentConfig) bool { if payment.Status == "paid" { return true } if payment.Status != "created" && payment.Status != "paying" { return false } if payment.Provider != "" && runtimeConfig.Provider != "" && payment.Provider != runtimeConfig.Provider { return false } if payment.MerchantID != "" && runtimeConfig.MerchantID != "" && payment.MerchantID != runtimeConfig.MerchantID { return false } return true } func newOrderPayment(row model.RentalOrder, amountCent int64, req StartPaymentRequest, runtimeConfig runtimePaymentConfig) (model.PaymentOrder, error) { paymentNo, err := newPaymentNo() if err != nil { return model.PaymentOrder{}, err } payment := model.PaymentOrder{ PaymentNo: paymentNo, OrderID: row.ID, OrderNo: row.OrderNo, UserID: row.RenterID, Provider: runtimeConfig.Provider, MerchantID: runtimeConfig.MerchantID, ThirdOrderID: paymentNo, ProviderOrderID: "", PayWay: firstNonEmpty(req.PayWay, runtimeConfig.PayWay, "ZFBZF"), JSPayFlag: firstNonEmpty(req.JSPayFlag, runtimeConfig.JSPayFlag, "2"), AmountCent: amountCent, BizType: "order_pay", Status: "created", } if runtimeConfig.isMockMode() { payment.ProviderOrderID = "MOCK" + paymentNo payment.TDCode = "mock://payment/pay/" + paymentNo } return payment, nil } func (r *Repository) createWalletRechargePayment(userID uint64, amountCent int64, req WalletRechargePaymentRequest, runtimeConfig runtimePaymentConfig) (*model.PaymentOrder, error) { paymentNo, err := newPaymentNo() if err != nil { return nil, err } payment := model.PaymentOrder{ PaymentNo: paymentNo, OrderID: 0, OrderNo: paymentNo, UserID: userID, Provider: runtimeConfig.Provider, MerchantID: runtimeConfig.MerchantID, ThirdOrderID: paymentNo, ProviderOrderID: "", PayWay: firstNonEmpty(req.PayWay, runtimeConfig.PayWay, "ZFBZF"), JSPayFlag: firstNonEmpty(req.JSPayFlag, runtimeConfig.JSPayFlag, "2"), AmountCent: amountCent, BizType: "wallet_recharge", Status: "created", } if runtimeConfig.isMockMode() { payment.ProviderOrderID = "MOCK" + paymentNo payment.TDCode = "mock://payment/recharge/" + paymentNo } if err := r.db.Create(&payment).Error; err != nil { return nil, err } return &payment, nil } func (r *Repository) applyChannelStatus(payment *model.PaymentOrder, status string, payTime string, raw map[string]string, source string) error { switch status { case "paid": paidAt := parseChannelTime(payTime) if paidAt == nil { now := time.Now() paidAt = &now } return r.confirmPaid(payment, status, *paidAt, raw, source) case "closed": return r.updateChannelStatus(payment.ID, "closed", raw, source) case "failed": return r.updateChannelStatus(payment.ID, "failed", raw, source) default: return r.updateChannelStatus(payment.ID, "paying", raw, source) } } func (r *Repository) updateChannelStatus(paymentID uint64, status string, raw map[string]string, source string) error { updates := map[string]any{ "status": status, "raw_response": jsonMap(withRawSource(raw, source)), } if source == channelSourceNotify { updates["notified_at"] = time.Now() } return r.db.Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(updates).Error } func (r *Repository) confirmPaid(payment *model.PaymentOrder, status string, paidAt time.Time, raw map[string]string, source string) error { if payment.Status != "paid" { if payment.OrderID == 0 { if r.walletRepo == nil { return ErrDependencyUnavailable } if err := r.walletRepo.ConfirmRechargeFromChannel(payment.UserID, firstNonEmpty(payment.ProviderOrderID, payment.PaymentNo), payment.AmountCent); err != nil { return err } } else { if r.orderRepo == nil { return ErrDependencyUnavailable } if err := r.orderRepo.ConfirmPaidFromChannel(payment.OrderID, firstNonEmpty(payment.ProviderOrderID, payment.PaymentNo)); err != nil { return err } } } updates := map[string]any{ "status": "paid", "provider_order_id": firstNonEmpty(raw["provider_order_id"], raw["leshua_order_id"], raw["pay_order_no"], raw["trade_no"], payment.ProviderOrderID), "raw_response": jsonMap(withRawSource(raw, source)), "paid_at": paidAt, } if source == channelSourceNotify { updates["notified_at"] = time.Now() } return r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(updates).Error } func (r *Repository) markPaymentFailed(paymentID uint64, raw map[string]string, message string) error { if raw == nil { raw = map[string]string{"error": message} } return r.db.Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(map[string]any{ "status": "failed", "raw_response": jsonMap(raw), }).Error } func (r *Repository) findPaymentByID(paymentID uint64) (*model.PaymentOrder, error) { var payment model.PaymentOrder if err := r.db.First(&payment, paymentID).Error; err != nil { return nil, err } return &payment, nil } func (r *Repository) findPaymentForNotify(params map[string]string) (*model.PaymentOrder, error) { thirdOrderID := params["third_order_id"] if thirdOrderID == "" { return nil, ErrPaymentNotFound } var payment model.PaymentOrder if err := r.db.Where("third_order_id = ?", thirdOrderID).First(&payment).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, ErrPaymentNotFound } return nil, err } return &payment, nil } func (r *Repository) findRefundPaymentForNotify(params map[string]string) (*model.PaymentOrder, error) { merchantRefundID := params["merchant_refund_id"] if merchantRefundID == "" { return nil, ErrPaymentNotFound } var payment model.PaymentOrder if err := r.db.Where("third_order_id = ?", merchantRefundID).First(&payment).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, ErrPaymentNotFound } return nil, err } return &payment, nil } func (r *Repository) verifyNotify(payment *model.PaymentOrder, runtimeConfig *runtimePaymentConfig, params map[string]string, rawPayload string, contentType string, authorization string) (channelVerifyNotifyResult, error) { var verify channelVerifyNotifyResult if runtimeConfig.isMockMode() { return verify, nil } if runtimeConfig.Channel == nil { return verify, ErrPaymentUnavailable } verify, err := runtimeConfig.Channel.VerifyNotify(params, rawPayload, contentType, authorization) if err != nil || !verify.OK { log.Printf( "[payment] %s notify verify failed payment_id=%d third_order_id=%s got=%s expected=%s keys=%v base_string=%s", runtimeConfig.Provider, payment.ID, params["third_order_id"], verify.Got, firstNonEmpty(verify.Expected["notify_key"], verify.Expected["notify_cert"], verify.Expected["error"]), verify.ParamKeys, firstNonEmpty(verify.BaseString["notify_key"], verify.BaseString["notify_cert"]), ) if err := r.recordNotifyDiagnostic(payment.ID, params, rawPayload, contentType, verify, "verify_failed"); err != nil { log.Printf("[payment] %s notify diagnostic save failed payment_id=%d err=%v", runtimeConfig.Provider, payment.ID, err) } return verify, ErrPaymentVerifyFailed } log.Printf("[payment] %s notify verified payment_id=%d third_order_id=%s matched_key=%s", runtimeConfig.Provider, payment.ID, params["third_order_id"], verify.MatchedKey) return verify, nil } func (r *Repository) recordConfigUsage(runtimeConfig *runtimePaymentConfig, payment *model.PaymentOrder) { if r.configRepo == nil || runtimeConfig == nil || payment == nil || runtimeConfig.ID == 0 { return } if err := r.configRepo.RecordUsage(runtimeConfig.ID, payment.ID, runtimeConfig.Provider, runtimeConfig.MerchantID, payment.AmountCent, payment.BizType); err != nil { log.Printf("[payment] record config usage failed config_id=%d payment_id=%d err=%v", runtimeConfig.ID, payment.ID, err) } } func toDTO(payment model.PaymentOrder) PaymentDTO { return PaymentDTO{ ID: payment.ID, PaymentNo: payment.PaymentNo, OrderID: payment.OrderID, OrderNo: payment.OrderNo, Provider: payment.Provider, ThirdOrderID: payment.ThirdOrderID, ProviderOrderID: payment.ProviderOrderID, PayWay: payment.PayWay, JSPayFlag: payment.JSPayFlag, AmountCent: payment.AmountCent, Status: payment.Status, TDCode: payment.TDCode, JSPayURL: payment.JSPayURL, JSPayInfo: payment.JSPayInfo, Paid: payment.Status == "paid", PaidAt: payment.PaidAt, CreatedAt: payment.CreatedAt, UpdatedAt: payment.UpdatedAt, } } func moneyCent(value float64) int64 { return int64(math.Round(value * 100)) } func parseCent(value string) int64 { var amount int64 _, _ = fmt.Sscanf(value, "%d", &amount) return amount } func parseChannelTime(value string) *time.Time { if value == "" { return nil } for _, layout := range []string{"2006-01-02 15:04:05", "20060102150405", time.RFC3339} { parsed, err := time.ParseInLocation(layout, value, time.Local) if err == nil { return &parsed } } return nil } func jsonMap(value map[string]string) datatypes.JSON { if value == nil { return nil } raw, err := json.Marshal(value) if err != nil { return nil } return datatypes.JSON(raw) } func withRawSource(raw map[string]string, source string) map[string]string { out := map[string]string{} for key, value := range raw { out[key] = value } if source != "" { out["_source"] = source } out["_recorded_at"] = time.Now().Format(time.RFC3339) return out } func (r *Repository) recordNotifyDiagnostic(paymentID uint64, params map[string]string, rawPayload string, contentType string, verify channelVerifyNotifyResult, status string) error { if paymentID == 0 { return nil } raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, status) return r.db.Model(&model.PaymentOrder{}). Where("id = ?", paymentID). Update("raw_response", jsonMap(raw)).Error } func withNotifyDiagnostic(params map[string]string, rawPayload string, contentType string, verify channelVerifyNotifyResult, status string) map[string]string { raw := withRawSource(params, channelSourceNotify) raw["_notify_diagnostic_status"] = status raw["_raw_payload"] = rawPayload raw["_raw_content_type"] = contentType raw["_sign_got"] = verify.Got raw["_sign_matched_key"] = verify.MatchedKey raw["_sign_expected"] = jsonString(verify.Expected) raw["_sign_base_strings"] = jsonString(verify.BaseString) return raw } func jsonString(value map[string]string) string { if len(value) == 0 { return "{}" } raw, err := json.Marshal(value) if err != nil { return "{}" } return string(raw) } func newPaymentNo() (string, error) { buf := make([]byte, 4) if _, err := rand.Read(buf); err != nil { return "", err } return fmt.Sprintf("PAY%d%s", time.Now().UnixNano(), hex.EncodeToString(buf)), nil } func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { return value } } return "" }