diff --git a/backend/internal/jobs/ordertimeout/job.go b/backend/internal/jobs/ordertimeout/job.go index 8c0f7a9..1204bea 100644 --- a/backend/internal/jobs/ordertimeout/job.go +++ b/backend/internal/jobs/ordertimeout/job.go @@ -219,6 +219,9 @@ func (j *Job) handlePendingPaymentTimeout(ctx context.Context, now time.Time, cf order.HandoffStatus = "cancelled" listing.InTransaction = false orderID := order.ID + if err := closePendingOrderPayments(tx, order.ID, "order_timeout"); err != nil { + return err + } if err := notification.Append(tx, notification.Entry{ UserID: order.RenterID, Type: "timeout", @@ -249,6 +252,23 @@ func (j *Job) handlePendingPaymentTimeout(ctx context.Context, now time.Time, cf return count, nil } +// closePendingOrderPayments 在系统自动取消订单时同步关闭未完成支付单,保持订单和支付流水状态一致。 +func closePendingOrderPayments(tx *gorm.DB, orderID uint64, source string) error { + raw, err := json.Marshal(map[string]string{ + "source": source, + "reason": "订单已取消,关闭未完成支付单", + }) + if err != nil { + return err + } + return tx.Model(&model.PaymentOrder{}). + Where("order_id = ? AND biz_type = ? AND status IN ?", orderID, "order_pay", []string{"created", "paying"}). + Updates(map[string]any{ + "status": "closed", + "raw_response": datatypes.JSON(raw), + }).Error +} + func (j *Job) handleOwnerSubmitTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) { if cfg.OwnerSubmitTimeoutMinutes <= 0 { return 0, nil diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index 245d9c1..70263e8 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -2,12 +2,14 @@ package order import ( "context" + "encoding/json" "time" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/chat" "hfb_sys/backend/internal/modules/notification" + "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -264,6 +266,9 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64) return err } releaseAssetsForRental(listing, account) + if err := closePendingOrderPayments(tx, order.ID, "order_cancel"); err != nil { + return err + } if err := tx.Save(&order).Error; err != nil { return err } @@ -279,6 +284,23 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64) return nil } +// closePendingOrderPayments 在订单取消时关闭仍未完成的下单支付流水,避免后台一直显示“支付中”。 +func closePendingOrderPayments(tx *gorm.DB, orderID uint64, source string) error { + raw, err := json.Marshal(map[string]string{ + "source": source, + "reason": "订单已取消,关闭未完成支付单", + }) + if err != nil { + return err + } + return tx.Model(&model.PaymentOrder{}). + Where("order_id = ? AND biz_type = ? AND status IN ?", orderID, "order_pay", []string{"created", "paying"}). + Updates(map[string]any{ + "status": "closed", + "raw_response": datatypes.JSON(raw), + }).Error +} + func (r *Repository) ConfirmReceive(ctx context.Context, userID uint64, orderID uint64) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var order model.RentalOrder diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index 040b6c5..11305cd 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -24,6 +24,8 @@ func setupOrderTestDB(t *testing.T) *gorm.DB { &model.GameAccount{}, &model.RentalListing{}, &model.RentalOrder{}, + &model.PaymentOrder{}, + &model.Notification{}, &model.HandoffRecord{}, &model.OrderCheckout{}, ); err != nil { @@ -375,3 +377,78 @@ func TestOrderDurationHoursUsesDefault(t *testing.T) { t.Fatalf("hours = %d, want %d", hours, internalOrderHours) } } + +// TestCancelClosesPendingPaymentOrder 验证租客取消待支付订单时,未完成支付流水会同步关闭。 +func TestCancelClosesPendingPaymentOrder(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db) + owner := model.User{Phone: "13800001001"} + renter := model.User{Phone: "13900001001"} + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("create owner failed: %v", err) + } + if err := db.Create(&renter).Error; err != nil { + t.Fatalf("create renter failed: %v", err) + } + account := model.GameAccount{ + OwnerID: owner.ID, + Status: "rented", + ServerRegion: "国服", + LoginPlatform: "steam", + Title: "测试账号", + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("create account failed: %v", err) + } + listing := model.RentalListing{ + ListingNo: "LST-CANCEL-001", + OwnerID: owner.ID, + AccountID: account.ID, + Status: "published", + ReviewStatus: "approved", + InTransaction: true, + PriceCent: 1000, + } + if err := db.Create(&listing).Error; err != nil { + t.Fatalf("create listing failed: %v", err) + } + order := model.RentalOrder{ + OrderNo: "ORD-CANCEL-001", + ListingID: listing.ID, + AccountID: account.ID, + OwnerID: owner.ID, + RenterID: renter.ID, + RentAmountCent: 1000, + Status: "pending_payment", + HandoffStatus: "none", + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("create order failed: %v", err) + } + payment := model.PaymentOrder{ + PaymentNo: "PAY-CANCEL-001", + OrderID: order.ID, + OrderNo: order.OrderNo, + UserID: renter.ID, + Provider: "lakala", + ThirdOrderID: "PAY-CANCEL-001", + AmountCent: 1000, + BizType: "order_pay", + Status: "paying", + } + if err := db.Create(&payment).Error; err != nil { + t.Fatalf("create payment failed: %v", err) + } + + if err := repo.Cancel(t.Context(), renter.ID, order.ID); err != nil { + t.Fatalf("Cancel() error = %v", err) + } + + var latestPayment model.PaymentOrder + if err := db.First(&latestPayment, payment.ID).Error; err != nil { + t.Fatalf("find payment failed: %v", err) + } + if latestPayment.Status != "closed" { + t.Fatalf("payment status = %q, want closed", latestPayment.Status) + } +} diff --git a/backend/internal/modules/payment/channel_status.go b/backend/internal/modules/payment/channel_status.go index 7e054fd..7f18cec 100644 --- a/backend/internal/modules/payment/channel_status.go +++ b/backend/internal/modules/payment/channel_status.go @@ -34,7 +34,14 @@ func (r *Repository) updateChannelStatus(ctx context.Context, paymentID uint64, if source == channelSourceNotify { updates["notified_at"] = time.Now() } - return r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(updates).Error + return r.db.WithContext(ctx).Model(&model.PaymentOrder{}). + Where("id = ? AND status NOT IN ?", paymentID, []string{"paid", "closed", "failed"}). + Updates(updates).Error +} + +// isOrderPaymentTerminalStatus 判断订单支付流水是否已进入终态,终态不再被查询或普通回调刷回支付中。 +func isOrderPaymentTerminalStatus(status string) bool { + return status == "paid" || status == "closed" || status == "failed" } func (r *Repository) confirmPaid(ctx context.Context, payment *model.PaymentOrder, status string, paidAt time.Time, raw map[string]string, source string) error { var newConvID uint64 @@ -71,6 +78,11 @@ func (r *Repository) confirmPaid(ctx context.Context, payment *model.PaymentOrde if newConvID > 0 && r.orderRepo != nil { r.orderRepo.NotifyNewConversation(newConvID) } + if latest, err := r.findPaymentByID(ctx, payment.ID); err == nil { + if runtimeConfig, err := r.runtimeConfigForPayment(ctx, latest); err == nil { + r.recordConfigUsage(ctx, runtimeConfig, latest) + } + } return nil } func (r *Repository) markPaymentFailed(ctx context.Context, paymentID uint64, raw map[string]string, message string) error { diff --git a/backend/internal/modules/payment/query.go b/backend/internal/modules/payment/query.go index 9c8434a..c377405 100644 --- a/backend/internal/modules/payment/query.go +++ b/backend/internal/modules/payment/query.go @@ -14,11 +14,15 @@ func (r *Repository) Query(ctx context.Context, userID uint64, orderID uint64) ( } return nil, err } + if isOrderPaymentTerminalStatus(payment.Status) { + dto := toDTO(payment) + return &dto, nil + } runtimeConfig, err := r.runtimeConfigForPayment(ctx, &payment) if err != nil { return nil, ErrPaymentUnavailable } - if payment.Status == "paid" || runtimeConfig.isMockMode() { + if runtimeConfig.isMockMode() { dto := toDTO(payment) return &dto, nil } diff --git a/backend/internal/modules/payment/repository.go b/backend/internal/modules/payment/repository.go index b8c5a9b..7f4c76f 100644 --- a/backend/internal/modules/payment/repository.go +++ b/backend/internal/modules/payment/repository.go @@ -150,11 +150,14 @@ func runtimeConfigFromDTO(dto *paymentconfig.ConfigDTO) *runtimePaymentConfig { } } -// recordConfigUsage 记录支付配置命中情况,失败只写日志不影响主流程。 +// recordConfigUsage 记录成功支付使用的配置,失败只写日志不影响主流程。 func (r *Repository) recordConfigUsage(ctx context.Context, runtimeConfig *runtimePaymentConfig, payment *model.PaymentOrder) { if r.configRepo == nil || runtimeConfig == nil || payment == nil || runtimeConfig.ID == 0 { return } + if payment.BizType != "order_pay" || payment.Status != "paid" { + return + } if err := r.configRepo.RecordUsage(ctx, 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) } diff --git a/backend/internal/modules/payment/repository_integration_test.go b/backend/internal/modules/payment/repository_integration_test.go index ca6d9db..3514e52 100644 --- a/backend/internal/modules/payment/repository_integration_test.go +++ b/backend/internal/modules/payment/repository_integration_test.go @@ -512,3 +512,63 @@ func TestConfirmPaidRollsBackOrderWhenPaymentUpdateFails(t *testing.T) { t.Fatalf("asset status = %q/%q, want rollback to published/published", latestListing.Status, latestAccount.Status) } } + +// TestQueryClosedPaymentReturnsLocalStatus 验证关闭状态不再依赖渠道配置或继续轮询渠道。 +func TestQueryClosedPaymentReturnsLocalStatus(t *testing.T) { + db := setupPaymentTestDB(t) + repo := NewRepository(db, nil, nil) + payment := model.PaymentOrder{ + PaymentNo: "PAY20260614CLOSED", + OrderID: 9001, + OrderNo: "ORD20260614CLOSED", + UserID: 8001, + Provider: "lakala", + ThirdOrderID: "PAY20260614CLOSED", + AmountCent: 1000, + BizType: "order_pay", + Status: "closed", + } + if err := db.Create(&payment).Error; err != nil { + t.Fatalf("create payment failed: %v", err) + } + + dto, err := repo.Query(t.Context(), payment.UserID, payment.OrderID) + if err != nil { + t.Fatalf("Query() error = %v", err) + } + if dto.Status != "closed" { + t.Fatalf("status = %q, want closed", dto.Status) + } +} + +// TestApplyChannelStatusKeepsTerminalPayment 验证已关闭支付单不会被渠道普通状态重新刷回支付中。 +func TestApplyChannelStatusKeepsTerminalPayment(t *testing.T) { + db := setupPaymentTestDB(t) + repo := NewRepository(db, nil, nil) + payment := model.PaymentOrder{ + PaymentNo: "PAY20260614TERMINAL", + OrderID: 9002, + OrderNo: "ORD20260614TERMINAL", + UserID: 8002, + Provider: "lakala", + ThirdOrderID: "PAY20260614TERMINAL", + AmountCent: 1000, + BizType: "order_pay", + Status: "closed", + } + if err := db.Create(&payment).Error; err != nil { + t.Fatalf("create payment failed: %v", err) + } + + if err := repo.applyChannelStatus(t.Context(), &payment, "paying", "", map[string]string{"status": "paying"}, channelSourceNotify); err != nil { + t.Fatalf("applyChannelStatus() error = %v", err) + } + + var latest model.PaymentOrder + if err := db.First(&latest, payment.ID).Error; err != nil { + t.Fatalf("find payment failed: %v", err) + } + if latest.Status != "closed" { + t.Fatalf("status = %q, want closed", latest.Status) + } +} diff --git a/backend/internal/modules/paymentconfig/query.go b/backend/internal/modules/paymentconfig/query.go index 0e2bede..2de7566 100644 --- a/backend/internal/modules/paymentconfig/query.go +++ b/backend/internal/modules/paymentconfig/query.go @@ -2,7 +2,9 @@ package paymentconfig import ( "context" + "database/sql/driver" "errors" + "time" "hfb_sys/backend/internal/model" @@ -61,6 +63,9 @@ func (r *Repository) List(ctx context.Context, query ListQuery) ([]ConfigDTO, in } dtos = append(dtos, dto) } + if err := r.applySuccessfulPaymentStats(ctx, dtos); err != nil { + return nil, 0, err + } return dtos, total, nil } @@ -78,6 +83,11 @@ func (r *Repository) FindByID(ctx context.Context, id uint64, includeSecret bool if err != nil { return nil, err } + statDTOs := []ConfigDTO{dto} + if err := r.applySuccessfulPaymentStats(ctx, statDTOs); err != nil { + return nil, err + } + dto = statDTOs[0] if includeSecret { if err := appendAuditLog(r.db.WithContext(ctx), actorID, "payment_config.view_secret", item.ID, meta, map[string]any{ "name": item.Name, @@ -90,6 +100,103 @@ func (r *Repository) FindByID(ctx context.Context, id uint64, includeSecret bool return &dto, nil } +type paymentConfigSuccessStat struct { + ConfigID uint64 + TotalTransactions int64 + TotalAmountCent int64 + LastPaidAt nullableTime +} + +// applySuccessfulPaymentStats 使用真实成功支付流水覆盖配置统计,避免仅打开二维码也被算作成交。 +func (r *Repository) applySuccessfulPaymentStats(ctx context.Context, dtos []ConfigDTO) error { + if len(dtos) == 0 { + return nil + } + ids := make([]uint64, 0, len(dtos)) + indexByID := make(map[uint64]int, len(dtos)) + for idx, dto := range dtos { + ids = append(ids, dto.ID) + indexByID[dto.ID] = idx + dtos[idx].TotalTransactions = 0 + dtos[idx].TotalAmountCent = 0 + dtos[idx].LastUsedAt = nil + } + + var rows []paymentConfigSuccessStat + if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}). + Select("payment_config_id AS config_id, COUNT(*) AS total_transactions, COALESCE(SUM(amount_cent), 0) AS total_amount_cent, MAX(paid_at) AS last_paid_at"). + Where("payment_config_id IN ? AND biz_type = ? AND status = ?", ids, "order_pay", "paid"). + Group("payment_config_id"). + Scan(&rows).Error; err != nil { + return err + } + for _, row := range rows { + idx, ok := indexByID[row.ConfigID] + if !ok { + continue + } + dtos[idx].TotalTransactions = row.TotalTransactions + dtos[idx].TotalAmountCent = row.TotalAmountCent + if row.LastPaidAt.Time != nil { + value := row.LastPaidAt.Time.Format(time.RFC3339) + dtos[idx].LastUsedAt = &value + } + } + return nil +} + +type nullableTime struct { + Time *time.Time +} + +// Value 实现 driver.Valuer,让 GORM 能把 nullableTime 当作普通扫描字段处理。 +func (nt nullableTime) Value() (driver.Value, error) { + if nt.Time == nil { + return nil, nil + } + return *nt.Time, nil +} + +// Scan 兼容 MySQL 的 time.Time 和 SQLite 聚合函数返回的字符串时间。 +func (nt *nullableTime) Scan(value any) error { + switch v := value.(type) { + case nil: + nt.Time = nil + case time.Time: + nt.Time = &v + case []byte: + return nt.scanString(string(v)) + case string: + return nt.scanString(v) + default: + nt.Time = nil + } + return nil +} + +func (nt *nullableTime) scanString(value string) error { + if value == "" { + nt.Time = nil + return nil + } + for _, layout := range []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05.999999999-07:00", + "2006-01-02 15:04:05-07:00", + "2006-01-02 15:04:05.999999999", + "2006-01-02 15:04:05", + } { + parsed, err := time.Parse(layout, value) + if err == nil { + nt.Time = &parsed + return nil + } + } + nt.Time = nil + return nil +} + // FindRuntimeByID 根据配置 ID 查询运行时配置,不写审计日志,供支付单回溯原配置使用。 func (r *Repository) FindRuntimeByID(ctx context.Context, id uint64, includeSecret bool) (*ConfigDTO, error) { var item model.PaymentMerchantConfig diff --git a/backend/internal/modules/paymentconfig/repository_test.go b/backend/internal/modules/paymentconfig/repository_test.go index a5b2de0..5df8642 100644 --- a/backend/internal/modules/paymentconfig/repository_test.go +++ b/backend/internal/modules/paymentconfig/repository_test.go @@ -1,6 +1,15 @@ package paymentconfig -import "testing" +import ( + "testing" + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) func TestImportCreateRequestKeepsOneActiveConfigPerPayWay(t *testing.T) { configs := []ConfigDTO{ @@ -38,3 +47,75 @@ func TestImportCreateRequestPreservesTestingConfig(t *testing.T) { t.Fatalf("status/default = %s/%v, want testing/false", req.Status, req.IsDefault) } } + +// TestListUsesSuccessfulPaymentStats 验证配置页只统计成功支付,不把打开二维码或取消订单算作成交。 +func TestListUsesSuccessfulPaymentStats(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("open db failed: %v", err) + } + if err := db.AutoMigrate(&model.PaymentMerchantConfig{}, &model.PaymentOrder{}); err != nil { + t.Fatalf("migrate failed: %v", err) + } + config := model.PaymentMerchantConfig{ + Name: "拉卡拉微信", + Provider: "lakala", + MerchantID: "M1", + PayWay: "WXZF", + Status: "active", + Environment: "production", + TotalTransactions: 99, + TotalAmountCent: 999999, + } + if err := db.Create(&config).Error; err != nil { + t.Fatalf("create config failed: %v", err) + } + paidAt := time.Date(2026, 6, 14, 23, 36, 7, 0, time.UTC) + payments := []model.PaymentOrder{ + { + PaymentNo: "PAY-SUCCESS", + OrderID: 1, + OrderNo: "ORD-SUCCESS", + UserID: 2, + PaymentConfigID: config.ID, + Provider: "lakala", + MerchantID: "M1", + ThirdOrderID: "PAY-SUCCESS", + AmountCent: 10130, + BizType: "order_pay", + Status: "paid", + PaidAt: &paidAt, + }, + { + PaymentNo: "PAY-CANCELLED", + OrderID: 2, + OrderNo: "ORD-CANCELLED", + UserID: 2, + PaymentConfigID: config.ID, + Provider: "lakala", + MerchantID: "M1", + ThirdOrderID: "PAY-CANCELLED", + AmountCent: 10130, + BizType: "order_pay", + Status: "closed", + }, + } + if err := db.Create(&payments).Error; err != nil { + t.Fatalf("create payments failed: %v", err) + } + repo := NewRepository(db, nil) + + items, total, err := repo.List(t.Context(), ListQuery{Page: 1, PageSize: 20}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if total != 1 || len(items) != 1 { + t.Fatalf("items/total = %d/%d, want 1/1", len(items), total) + } + if items[0].TotalTransactions != 1 || items[0].TotalAmountCent != 10130 { + t.Fatalf("success stats = %d/%d, want 1/10130", items[0].TotalTransactions, items[0].TotalAmountCent) + } + if items[0].LastUsedAt == nil { + t.Fatal("last success pay time should not be nil") + } +} diff --git a/backend/migrations/000005_close_cancelled_order_payments.sql b/backend/migrations/000005_close_cancelled_order_payments.sql new file mode 100644 index 0000000..8dbef89 --- /dev/null +++ b/backend/migrations/000005_close_cancelled_order_payments.sql @@ -0,0 +1,19 @@ +-- +goose Up + +-- 修复历史数据:订单已取消但支付流水仍停留在 created/paying 时,同步关闭支付流水。 +UPDATE payment_orders p +JOIN rental_orders o ON o.id = p.order_id +SET p.status = 'closed', + p.raw_response = JSON_OBJECT( + 'source', 'migration_cancelled_order', + 'reason', '订单已取消,关闭历史未完成支付单' + ), + p.updated_at = CURRENT_TIMESTAMP +WHERE p.biz_type = 'order_pay' + AND p.status IN ('created', 'paying') + AND o.status = 'cancelled'; + +-- +goose Down + +-- 数据修复无法安全反推原状态,回滚时不重新打开已关闭支付流水。 +SELECT 1; diff --git a/frontend/src/features/admin/views/AdminPaymentConfigsView.vue b/frontend/src/features/admin/views/AdminPaymentConfigsView.vue index 8b3ed2c..59aa042 100644 --- a/frontend/src/features/admin/views/AdminPaymentConfigsView.vue +++ b/frontend/src/features/admin/views/AdminPaymentConfigsView.vue @@ -391,15 +391,15 @@ function deleteDisabledReason(row: PaymentConfig) { 默认 - + - 交易: {{ row.total_transactions }} 笔 + 成功: {{ row.total_transactions }} 笔 金额: ¥{{ formatAmount(row.total_amount_cent) }} - + {{ row.last_used_at ? formatDateTime(row.last_used_at, '-') : '-' }}