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" "hfb_sys/backend/internal/timeutil" "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", "arbitration_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 } log.Printf("[payment] payment start order_id=%d order_no=%s payment_id=%d provider=%s amount_cent=%d third_order_id=%s", orderID, orderRow.OrderNo, payment.ID, runtimeConfig.Provider, payment.AmountCent, payment.ThirdOrderID) 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()) log.Printf("[payment] payment request failed order_id=%d payment_id=%d provider=%s amount_cent=%d err=%v", orderID, payment.ID, runtimeConfig.Provider, payment.AmountCent, err) return nil, err } if !resp.OK { _ = r.markPaymentFailed(payment.ID, resp.Raw, resp.ErrorMessage) log.Printf("[payment] payment rejected order_id=%d payment_id=%d provider=%s amount_cent=%d code=%s message=%s", orderID, payment.ID, runtimeConfig.Provider, payment.AmountCent, firstNonEmpty(resp.Raw["code"], resp.Raw["resp_code"], resp.Raw["result_code"]), 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) log.Printf("[payment] payment result order_id=%d order_no=%s payment_id=%d provider=%s amount_cent=%d status=%s provider_order_id=%s", orderID, orderRow.OrderNo, latest.ID, runtimeConfig.Provider, latest.AmountCent, latest.Status, latest.ProviderOrderID) dto := toDTO(*latest) return &dto, nil } func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymentRequest, clientIP string) (*PaymentDTO, error) { amountCent := req.AmountCent if userID == 0 || amountCent < moneyCent(MinWalletRechargeAmount) { 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 } log.Printf("[payment] wallet recharge start user_id=%d payment_id=%d provider=%s amount_cent=%d third_order_id=%s", userID, payment.ID, runtimeConfig.Provider, payment.AmountCent, payment.ThirdOrderID) 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()) log.Printf("[payment] wallet recharge request failed user_id=%d payment_id=%d provider=%s amount_cent=%d err=%v", userID, payment.ID, runtimeConfig.Provider, payment.AmountCent, err) return nil, err } if !resp.OK { _ = r.markPaymentFailed(payment.ID, resp.Raw, resp.ErrorMessage) log.Printf("[payment] wallet recharge rejected user_id=%d payment_id=%d provider=%s amount_cent=%d code=%s message=%s", userID, payment.ID, runtimeConfig.Provider, payment.AmountCent, firstNonEmpty(resp.Raw["code"], resp.Raw["resp_code"], resp.Raw["result_code"]), 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) log.Printf("[payment] wallet recharge result user_id=%d payment_id=%d provider=%s amount_cent=%d status=%s provider_order_id=%s", userID, latest.ID, runtimeConfig.Provider, latest.AmountCent, latest.Status, latest.ProviderOrderID) 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 } 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, merchantRefundID, originalPayment.ThirdOrderID, refundOriginProviderOrderID(originalPayment)) 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: refundOriginProviderOrderID(originalPayment), 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()}) 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 { _ = r.markRefundFailed(refundOrder.ID, orderID, refundAmountCent, resp.Raw) 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 } 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 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, refundStatus, 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 } func (r *Repository) AdminList(query AdminPaymentQuery) (*PaginatedResult, error) { db := r.db.Table("payment_orders AS p"). Select("p.*, COALESCE(u.phone, '') AS user_phone"). Joins("LEFT JOIN users AS u ON u.id = p.user_id") countDB := r.db.Model(&model.PaymentOrder{}) if query.UserID > 0 { db = db.Where("p.user_id = ?", query.UserID) countDB = countDB.Where("user_id = ?", query.UserID) } if query.OrderID > 0 { db = db.Where("p.order_id = ?", query.OrderID) countDB = countDB.Where("order_id = ?", query.OrderID) } if query.OrderNo != "" { db = db.Where("p.order_no = ?", query.OrderNo) countDB = countDB.Where("order_no = ?", query.OrderNo) } if query.BizType != "" { db = db.Where("p.biz_type = ?", query.BizType) countDB = countDB.Where("biz_type = ?", query.BizType) } if query.Status != "" { db = db.Where("p.status = ?", query.Status) countDB = countDB.Where("status = ?", query.Status) } if query.Provider != "" { db = db.Where("p.provider = ?", query.Provider) countDB = countDB.Where("provider = ?", query.Provider) } var total int64 if err := countDB.Count(&total).Error; err != nil { return nil, err } offset := (query.Page - 1) * query.PageSize var rows []adminPaymentRow if err := db.Order("p.id DESC").Offset(offset).Limit(query.PageSize).Scan(&rows).Error; err != nil { return nil, err } items := make([]AdminPaymentDTO, 0, len(rows)) for _, row := range rows { items = append(items, row.toDTO()) } return &PaginatedResult{Items: items, Total: total, Page: query.Page, PageSize: query.PageSize}, 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 := row.RentAmountCent + row.DepositAmountCent 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 } if payment.Status == "paying" && paymentCashierExpired(payment) { return false } return true } func paymentCashierExpired(payment model.PaymentOrder) bool { if len(payment.RawRequest) == 0 { return false } var raw map[string]string if err := json.Unmarshal(payment.RawRequest, &raw); err != nil { return false } deadline := parseChannelTime(raw["order_efficient_time"]) if deadline == nil { return false } return !timeutil.ShanghaiNow().Before(*deadline) } 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, } } type adminPaymentRow struct { model.PaymentOrder UserPhone string } func (row adminPaymentRow) toDTO() AdminPaymentDTO { errorCode, errorMessage := paymentErrorSummary(row.Status, row.RawResponse) return AdminPaymentDTO{ ID: row.ID, PaymentNo: row.PaymentNo, OrderID: row.OrderID, OrderNo: row.OrderNo, UserID: row.UserID, UserPhone: row.UserPhone, Provider: row.Provider, MerchantID: row.MerchantID, ThirdOrderID: row.ThirdOrderID, ProviderOrderID: row.ProviderOrderID, PayWay: row.PayWay, AmountCent: row.AmountCent, BizType: row.BizType, Status: row.Status, ErrorCode: errorCode, ErrorMessage: errorMessage, RawRequest: row.RawRequest, RawResponse: row.RawResponse, PaidAt: row.PaidAt, NotifiedAt: row.NotifiedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } } func paymentErrorSummary(status string, raw datatypes.JSON) (string, string) { if status != "failed" { return "", "" } if len(raw) == 0 { return "", "" } var payload map[string]any if err := json.Unmarshal(raw, &payload); err != nil { return "", "" } code := firstStringValue(payload, "code", "resp_code", "result_code", "error_code", "status") message := firstStringValue(payload, "msg", "message", "error", "error_message", "result_msg", "result_desc") return code, message } 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 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 "" } 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, timeutil.ShanghaiLocation()) 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 "" }