From a5e55bc045c46ae71fd04d8a6779eb28f396dc85 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 7 Jun 2026 20:05:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E4=BF=AE=E5=A4=8D15=E5=88=86?= =?UTF-8?q?=E9=92=9Fbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/modules/order/dto.go | 13 +-- backend/internal/modules/order/repository.go | 17 +++- .../internal/modules/payment/repository.go | 95 ++++++++++++------- .../modules/payment/repository_test.go | 87 +++++++++++++++++ frontend/src/features/orders/api/orders.ts | 1 + .../orders/composables/usePaymentPolling.ts | 2 +- .../src/features/orders/views/OrdersView.vue | 8 +- 7 files changed, 178 insertions(+), 45 deletions(-) create mode 100644 backend/internal/modules/payment/repository_test.go diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index e00cb02..b6ed123 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -33,6 +33,7 @@ type OrderDTO struct { HandoffStatus string `json:"handoff_status"` SettlementStatus string `json:"settlement_status"` Checkout *CheckoutDTO `json:"checkout,omitempty"` + PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -80,12 +81,12 @@ type PaginatedResult struct { } type RefundStatusDTO struct { - OrderID uint64 `json:"order_id"` - OrderNo string `json:"order_no"` - RefundStatus string `json:"refund_status"` - RefundAmountCent int64 `json:"refund_amount_cent"` - RefundedAt *time.Time `json:"refunded_at,omitempty"` - TotalAmount float64 `json:"total_amount"` + OrderID uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + RefundStatus string `json:"refund_status"` + RefundAmountCent int64 `json:"refund_amount_cent"` + RefundedAt *time.Time `json:"refunded_at,omitempty"` + TotalAmount float64 `json:"total_amount"` } type HandoffRecordDTO struct { diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go index 59d13ae..000c2b6 100644 --- a/backend/internal/modules/order/repository.go +++ b/backend/internal/modules/order/repository.go @@ -697,8 +697,10 @@ func (r *Repository) ListForUser(userID uint64) ([]OrderDTO, error) { return nil, err } items := make([]OrderDTO, 0, len(rows)) + paymentTimeoutMinutes := pendingPaymentTimeoutMinutes(r.db) for _, row := range rows { dto := row.toDTOForUser(userID) + applyPaymentDeadline(&dto, row.RentalOrder, paymentTimeoutMinutes) if shouldAttachCheckout(row.Status) { dto.Checkout = r.latestCheckoutDTOForUser(row.ID, userID, row.RentalOrder) } @@ -725,8 +727,11 @@ func (r *Repository) ListAdmin(page, pageSize int) (*PaginatedResult, error) { } items := make([]OrderDTO, 0, len(rows)) + paymentTimeoutMinutes := pendingPaymentTimeoutMinutes(r.db) for _, row := range rows { - items = append(items, row.toAdminDTO()) + dto := row.toAdminDTO() + applyPaymentDeadline(&dto, row.RentalOrder, paymentTimeoutMinutes) + items = append(items, dto) } return &PaginatedResult{ @@ -743,6 +748,7 @@ func (r *Repository) FindAdmin(orderID uint64) (*OrderDTO, error) { return nil, err } dto := row.toAdminDTO() + applyPaymentDeadline(&dto, row.RentalOrder, pendingPaymentTimeoutMinutes(r.db)) dto.Checkout = r.latestCheckoutAdminDTO(orderID) return &dto, nil } @@ -972,6 +978,7 @@ func (r *Repository) FindForUser(userID uint64, orderID uint64) (*OrderDTO, erro return nil, err } dto := row.toDTOForUser(userID) + applyPaymentDeadline(&dto, row.RentalOrder, pendingPaymentTimeoutMinutes(r.db)) dto.Checkout = r.latestCheckoutDTOForUser(orderID, userID, row.RentalOrder) return &dto, nil } @@ -1164,6 +1171,14 @@ func pendingPaymentTimeoutMinutes(tx *gorm.DB) int { return value } +func applyPaymentDeadline(dto *OrderDTO, order model.RentalOrder, timeoutMinutes int) { + if dto == nil || order.Status != "pending_payment" || timeoutMinutes <= 0 { + return + } + deadline := order.CreatedAt.Add(time.Duration(timeoutMinutes) * time.Minute) + dto.PaymentDeadlineAt = &deadline +} + func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, content string, evidenceURLS []string, consumableAmount float64, coinConsumedM float64, otherAmount float64, explicitDeduct float64, useExplicitDeduct bool) (model.OrderCheckout, error) { if consumableAmount < 0 || coinConsumedM < 0 || otherAmount < 0 || explicitDeduct < 0 { return model.OrderCheckout{}, ErrInvalidCheckoutAmount diff --git a/backend/internal/modules/payment/repository.go b/backend/internal/modules/payment/repository.go index 649c62c..e1e4606 100644 --- a/backend/internal/modules/payment/repository.go +++ b/backend/internal/modules/payment/repository.go @@ -703,47 +703,29 @@ func (r *Repository) preparePayment(userID uint64, orderID uint64, req StartPaym Order("id DESC"). First(&existing).Error if err == nil { - 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 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 } - if err := tx.Save(&existing).Error; err != nil { - return err - } - paymentID = existing.ID - orderRow = row - return nil - } - if err != gorm.ErrRecordNotFound { + } else if err != gorm.ErrRecordNotFound { return err } - paymentNo, err := newPaymentNo() + payment, err := newOrderPayment(row, amountCent, req, runtimeConfig) if err != nil { return err } - payment := model.PaymentOrder{ - PaymentNo: paymentNo, - OrderID: row.ID, - OrderNo: row.OrderNo, - UserID: row.RenterID, - Provider: runtimeConfig.Provider, - MerchantID: runtimeConfig.MerchantID, - ThirdOrderID: row.OrderNo, - 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" + row.OrderNo - payment.TDCode = "mock://payment/pay/" + row.OrderNo - } if err := tx.Create(&payment).Error; err != nil { return err } @@ -761,6 +743,49 @@ func (r *Repository) preparePayment(userID uint64, orderID uint64, req StartPaym 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 { diff --git a/backend/internal/modules/payment/repository_test.go b/backend/internal/modules/payment/repository_test.go new file mode 100644 index 0000000..cafab48 --- /dev/null +++ b/backend/internal/modules/payment/repository_test.go @@ -0,0 +1,87 @@ +package payment + +import ( + "testing" + + "hfb_sys/backend/internal/model" +) + +func TestCanReuseOrderPaymentRejectsTerminalAndOldChannel(t *testing.T) { + runtimeConfig := runtimePaymentConfig{ + Provider: "lakala", + MerchantID: "M2", + } + + cases := []struct { + name string + payment model.PaymentOrder + want bool + }{ + { + name: "同商户支付中可复用", + payment: model.PaymentOrder{Status: "paying", Provider: "lakala", MerchantID: "M2"}, + want: true, + }, + { + name: "已支付保持幂等", + payment: model.PaymentOrder{Status: "paid", Provider: "leshua", MerchantID: "M1"}, + want: true, + }, + { + name: "失败支付单不复用", + payment: model.PaymentOrder{Status: "failed", Provider: "lakala", MerchantID: "M2"}, + want: false, + }, + { + name: "关闭支付单不复用", + payment: model.PaymentOrder{Status: "closed", Provider: "lakala", MerchantID: "M2"}, + want: false, + }, + { + name: "旧渠道支付单不复用", + payment: model.PaymentOrder{Status: "paying", Provider: "leshua", MerchantID: "M1"}, + want: false, + }, + { + name: "旧商户支付单不复用", + payment: model.PaymentOrder{Status: "paying", Provider: "lakala", MerchantID: "M1"}, + want: false, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if got := canReuseOrderPayment(tt.payment, runtimeConfig); got != tt.want { + t.Fatalf("canReuseOrderPayment() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNewOrderPaymentUsesPaymentNoAsThirdOrderID(t *testing.T) { + order := model.RentalOrder{ + ID: 11, + OrderNo: "ORD123", + RenterID: 7, + } + runtimeConfig := runtimePaymentConfig{ + Provider: "lakala", + MerchantID: "M2", + PayWay: "ZFBZF", + JSPayFlag: "2", + } + + payment, err := newOrderPayment(order, 100, StartPaymentRequest{}, runtimeConfig) + if err != nil { + t.Fatalf("newOrderPayment() error = %v", err) + } + if payment.ThirdOrderID != payment.PaymentNo { + t.Fatalf("ThirdOrderID = %q, want PaymentNo %q", payment.ThirdOrderID, payment.PaymentNo) + } + if payment.ThirdOrderID == order.OrderNo { + t.Fatalf("ThirdOrderID should not reuse order no %q", order.OrderNo) + } + if payment.OrderNo != order.OrderNo || payment.OrderID != order.ID || payment.UserID != order.RenterID { + t.Fatalf("payment order fields mismatch: %+v", payment) + } +} diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index 1b69e98..3a3a792 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -30,6 +30,7 @@ export interface Order { handoff_status: HandoffStatus settlement_status: SettlementStatus checkout?: Checkout + payment_deadline_at?: string created_at: string updated_at: string } diff --git a/frontend/src/features/orders/composables/usePaymentPolling.ts b/frontend/src/features/orders/composables/usePaymentPolling.ts index b2dfdcc..dbf6123 100644 --- a/frontend/src/features/orders/composables/usePaymentPolling.ts +++ b/frontend/src/features/orders/composables/usePaymentPolling.ts @@ -38,7 +38,7 @@ export function usePaymentPolling() { checkingPayment.value = true try { - const updated = await queryOrderPayment(activePayment.value.id) + const updated = await queryOrderPayment(activePayment.value.order_id) if (updated.paid) { stopPaymentPolling() onSuccess?.() diff --git a/frontend/src/features/orders/views/OrdersView.vue b/frontend/src/features/orders/views/OrdersView.vue index 4a53052..3ebcce8 100644 --- a/frontend/src/features/orders/views/OrdersView.vue +++ b/frontend/src/features/orders/views/OrdersView.vue @@ -26,6 +26,7 @@ const statusTabs = [ ] const tabKeys = new Set(statusTabs.map((item) => item.key)) const activeTab = ref(readTab(route.query.tab)) +const fallbackPendingPaymentMinutes = 15 const displayOrders = computed(() => { let filtered = orders.value @@ -152,9 +153,12 @@ async function copyOrderNo(orderNo: string) { function getPaymentDeadline(order: Order) { if (order.status !== 'pending_payment' || !order.created_at) return null + if (order.payment_deadline_at) { + const serverDeadline = new Date(order.payment_deadline_at) + if (!Number.isNaN(serverDeadline.getTime())) return serverDeadline + } const created = new Date(order.created_at) - const deadline = new Date(created.getTime() + 30 * 60 * 1000) - return deadline + return new Date(created.getTime() + fallbackPendingPaymentMinutes * 60 * 1000) } function getCountdownMinutes(order: Order) {