package payment import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "log" "math" "time" "hfb_sys/backend/internal/config" "hfb_sys/backend/internal/integrations/payment/leshua" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/order" "hfb_sys/backend/internal/modules/wallet" "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" ) type Repository struct { db *gorm.DB cfg config.PaymentConfig orderRepo *order.Repository walletRepo *wallet.Repository leshua *leshua.Client provider string isMockMode bool } const ( channelSourceCreate = "create" channelSourceQuery = "query" channelSourceNotify = "notify" channelSourceMock = "mock" ) func NewRepository(db *gorm.DB, cfg config.PaymentConfig, orderRepo *order.Repository, walletRepo *wallet.Repository) *Repository { provider := cfg.Provider if provider == "" { provider = "mock" } return &Repository{ db: db, cfg: cfg, orderRepo: orderRepo, walletRepo: walletRepo, leshua: leshua.NewClient(cfg.Leshua), provider: provider, isMockMode: provider != "leshua", } } func (r *Repository) Start(userID uint64, orderID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) { payment, orderRow, err := r.preparePayment(userID, orderID, req) if err != nil { return nil, err } if payment.Status == "paid" { dto := toDTO(*payment) return &dto, nil } if r.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 } dto := toDTO(*latest) return &dto, nil } if payment.Status == "paying" && (payment.TDCode != "" || payment.JSPayURL != "" || payment.JSPayInfo != "") { dto := toDTO(*payment) return &dto, nil } resp, rawReq, err := r.leshua.CreatePayment(context.Background(), leshua.CreatePaymentRequest{ ThirdOrderID: payment.ThirdOrderID, AmountCent: payment.AmountCent, PayWay: payment.PayWay, JSPayFlag: payment.JSPayFlag, NotifyURL: r.cfg.Leshua.NotifyURL, JumpURL: r.cfg.Leshua.JumpURL, ClientIP: clientIP, Body: "租号订单 " + orderRow.OrderNo, Attach: orderRow.OrderNo, }) if err != nil { _ = r.markPaymentFailed(payment.ID, nil, err.Error()) return nil, err } if resp.RespCode != "0" || resp.ResultCode != "0" { _ = 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(rawReq), "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 } 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 } payment, err := r.createWalletRechargePayment(userID, amountCent, req) if err != nil { return nil, err } if r.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 } dto := toDTO(*latest) return &dto, nil } resp, rawReq, err := r.leshua.CreatePayment(context.Background(), leshua.CreatePaymentRequest{ ThirdOrderID: payment.ThirdOrderID, AmountCent: payment.AmountCent, PayWay: payment.PayWay, JSPayFlag: payment.JSPayFlag, NotifyURL: r.cfg.Leshua.NotifyURL, JumpURL: r.cfg.Leshua.JumpURL, ClientIP: clientIP, Body: "钱包充值 " + payment.PaymentNo, Attach: payment.PaymentNo, }) if err != nil { _ = r.markPaymentFailed(payment.ID, nil, err.Error()) return nil, err } if resp.RespCode != "0" || resp.ResultCode != "0" { _ = 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(rawReq), "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 } 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 } if payment.Status == "paid" || r.isMockMode { dto := toDTO(payment) return &dto, nil } resp, err := r.leshua.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 = ?", orderID, userID).Order("id DESC").First(&payment).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, ErrPaymentNotFound } return nil, err } if payment.Status == "paid" || r.isMockMode { dto := toDTO(payment) return &dto, nil } resp, err := r.leshua.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) { var verify leshua.VerifyNotifyResult if !r.isMockMode { verify = r.leshua.VerifyNotifyDetail(params) if !verify.OK { log.Printf( "[payment] leshua notify verify failed third_order_id=%s got=%s expected=%s keys=%v base_string=%s", params["third_order_id"], verify.Got, verify.Expected["notify_key"], verify.ParamKeys, verify.BaseString["notify_key"], ) if err := r.recordNotifyDiagnostic(params, rawPayload, contentType, verify, "verify_failed"); err != nil { log.Printf("[payment] leshua notify diagnostic save failed third_order_id=%s err=%v", params["third_order_id"], err) } return nil, ErrPaymentVerifyFailed } log.Printf("[payment] leshua notify verified third_order_id=%s matched_key=%s", params["third_order_id"], verify.MatchedKey) } 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 } if amount := parseCent(params["amount"]); amount > 0 && amount != payment.AmountCent { if err := r.recordNotifyDiagnostic(params, rawPayload, contentType, verify, "amount_mismatch"); err != nil { log.Printf("[payment] leshua notify diagnostic save failed third_order_id=%s err=%v", params["third_order_id"], err) } return nil, ErrPaymentVerifyFailed } raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, "verified") if err := r.applyChannelStatus(&payment, params["status"], params["pay_time"], raw, channelSourceNotify); err != nil { return nil, err } return &NotifyResult{OK: true, Message: "000000"}, nil } func (r *Repository) preparePayment(userID uint64, orderID uint64, req StartPaymentRequest) (*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 = ?", row.ID). Order("id DESC"). First(&existing).Error if err == nil { existing.PayWay = firstNonEmpty(req.PayWay, existing.PayWay, r.cfg.Leshua.PayWay, "ZFBZF") existing.JSPayFlag = firstNonEmpty(req.JSPayFlag, existing.JSPayFlag, r.cfg.Leshua.JSPayFlag, "2") existing.AmountCent = amountCent existing.Provider = r.provider existing.MerchantID = r.cfg.Leshua.MerchantID if r.isMockMode && existing.ProviderOrderID == "" { existing.ProviderOrderID = "MOCK" + existing.ThirdOrderID } if err := tx.Save(&existing).Error; err != nil { return err } paymentID = existing.ID orderRow = row return nil } if err != gorm.ErrRecordNotFound { return err } paymentNo, err := newPaymentNo() if err != nil { return err } payment := model.PaymentOrder{ PaymentNo: paymentNo, OrderID: row.ID, OrderNo: row.OrderNo, UserID: row.RenterID, Provider: r.provider, MerchantID: r.cfg.Leshua.MerchantID, ThirdOrderID: row.OrderNo, ProviderOrderID: "", PayWay: firstNonEmpty(req.PayWay, r.cfg.Leshua.PayWay, "ZFBZF"), JSPayFlag: firstNonEmpty(req.JSPayFlag, r.cfg.Leshua.JSPayFlag, "2"), AmountCent: amountCent, Status: "created", } if r.isMockMode { payment.ProviderOrderID = "MOCK" + row.OrderNo payment.TDCode = "mock://leshua/pay/" + row.OrderNo } 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 (r *Repository) createWalletRechargePayment(userID uint64, amountCent int64, req WalletRechargePaymentRequest) (*model.PaymentOrder, error) { paymentNo, err := newPaymentNo() if err != nil { return nil, err } payment := model.PaymentOrder{ PaymentNo: paymentNo, OrderID: 0, OrderNo: paymentNo, UserID: userID, Provider: r.provider, MerchantID: r.cfg.Leshua.MerchantID, ThirdOrderID: paymentNo, ProviderOrderID: "", PayWay: firstNonEmpty(req.PayWay, r.cfg.Leshua.PayWay, "ZFBZF"), JSPayFlag: firstNonEmpty(req.JSPayFlag, r.cfg.Leshua.JSPayFlag, "2"), AmountCent: amountCent, Status: "created", } if r.isMockMode { payment.ProviderOrderID = "MOCK" + paymentNo payment.TDCode = "mock://leshua/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 "2", "30": paidAt := parseLeshuaTime(payTime) if paidAt == nil { now := time.Now() paidAt = &now } return r.confirmPaid(payment, status, *paidAt, raw, source) case "6": return r.updateChannelStatus(payment.ID, "closed", raw, source) case "8": 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["leshua_order_id"], 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 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 parseLeshuaTime(value string) *time.Time { if value == "" { return nil } for _, layout := range []string{"2006-01-02 15:04:05", 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(params map[string]string, rawPayload string, contentType string, verify leshua.VerifyNotifyResult, status string) error { thirdOrderID := params["third_order_id"] if thirdOrderID == "" { return nil } raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, status) return r.db.Model(&model.PaymentOrder{}). Where("third_order_id = ?", thirdOrderID). Update("raw_response", jsonMap(raw)).Error } func withNotifyDiagnostic(params map[string]string, rawPayload string, contentType string, verify leshua.VerifyNotifyResult, 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 "" }