diff --git a/backend/internal/e2e/rental_flow_test.go b/backend/internal/e2e/rental_flow_test.go new file mode 100644 index 0000000..619f376 --- /dev/null +++ b/backend/internal/e2e/rental_flow_test.go @@ -0,0 +1,541 @@ +package e2e + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" + "time" + + mysqldriver "github.com/go-sql-driver/mysql" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "hfb_sys/backend/internal/auditlog" + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/adminfinance" + "hfb_sys/backend/internal/modules/dispute" + "hfb_sys/backend/internal/modules/listing" + "hfb_sys/backend/internal/modules/order" + "hfb_sys/backend/internal/modules/payment" + "hfb_sys/backend/internal/modules/paymentaccount" + "hfb_sys/backend/internal/modules/paymentconfig" + "hfb_sys/backend/internal/modules/wallet" + "hfb_sys/backend/internal/modules/withdrawal" + "hfb_sys/backend/pkg/crypto" +) + +func TestRentalFullFlowWithMockPayment(t *testing.T) { + db := openE2EDB(t) + + owner, renter, adminID := seedUsers(t, db) + services := newFlowServices(db) + seedMockPaymentConfig(t, services.paymentConfig, adminID) + + account := createOwnerPaymentAccount(t, services.paymentAccount, owner.ID) + listingDTO := createListingUnderReview(t, services.listing, owner.ID) + adjusted := adjustAndApproveListing(t, services.listing, adminID, listingDTO.ID) + if adjusted.PriceCent != 38300 { + t.Fatalf("调价后商品价格 = %d, want 38300", adjusted.PriceCent) + } + + orderDTO := mustCreateOrder(t, services.order, renter.ID, adjusted.ID) + paymentDTO, err := services.payment.Start(renter.ID, orderDTO.ID, payment.StartPaymentRequest{}, "127.0.0.1") + if err != nil { + t.Fatalf("启动 mock 支付失败: %v", err) + } + assertEqual(t, "支付状态", paymentDTO.Status, "paid") + assertEqual(t, "支付金额", paymentDTO.AmountCent, int64(53300)) + + paidOrder, err := services.order.FindAdmin(orderDTO.ID) + if err != nil { + t.Fatalf("读取已支付订单失败: %v", err) + } + assertEqual(t, "支付后订单状态", paidOrder.Status, "pending_handoff") + assertEqual(t, "支付后交接状态", paidOrder.HandoffStatus, "pending_owner") + + if _, err := services.order.SubmitHandoff(owner.ID, orderDTO.ID, order.SubmitHandoffRequest{Content: "账号:demo,密码:demo-pass"}); err != nil { + t.Fatalf("号主交接失败: %v", err) + } + if err := services.order.ConfirmReceive(renter.ID, orderDTO.ID); err != nil { + t.Fatalf("租客确认收号失败: %v", err) + } + + if _, err := services.order.SubmitCheckout(renter.ID, orderDTO.ID, order.SubmitCheckoutRequest{ + Content: "租客发起结账", + ConsumableAmountCent: 700, + CoinConsumedM: 90, + }); err != nil { + t.Fatalf("租客发起结账失败: %v", err) + } + countered, err := services.order.CounterCheckout(owner.ID, orderDTO.ID, order.CounterCheckoutRequest{ + ConsumableAmountCent: 1200, + CoinConsumedM: 100, + DepositDeductAmountCent: 3000, + Reason: "补扣消耗和押金赔付", + }) + if err != nil { + t.Fatalf("号主反价失败: %v", err) + } + assertEqual(t, "反价状态", countered.Status, "countered") + if err := services.order.AcceptCheckout(renter.ID, orderDTO.ID); err != nil { + t.Fatalf("租客接受反价失败: %v", err) + } + + completed, err := services.order.FindAdmin(orderDTO.ID) + if err != nil { + t.Fatalf("读取完成订单失败: %v", err) + } + assertEqual(t, "完成后订单状态", completed.Status, "completed") + assertEqual(t, "完成后结算状态", completed.SettlementStatus, "settled") + if completed.Checkout == nil { + t.Fatal("完成订单缺少结账记录") + } + assertEqual(t, "结账状态", completed.Checkout.Status, "accepted") + assertEqual(t, "实际结账租金", *completed.Checkout.RentAmountCent, int64(27500)) + assertEqual(t, "号主结算收入", *completed.Checkout.OwnerIncomeAmountCent, int64(27500)) + assertEqual(t, "平台收入", *completed.Checkout.PlatformFeeCent, int64(3000)) + + assertFinanceDashboard(t, services.finance, orderDTO.OrderNo) + assertWalletAndWithdrawal(t, services.wallet, services.withdrawal, owner.ID, adminID, account.ID) +} + +func TestArbitrationReleaseDepositDoesNotRepublishListing(t *testing.T) { + db := openE2EDB(t) + + owner, renter, adminID := seedUsers(t, db) + services := newFlowServices(db) + seedMockPaymentConfig(t, services.paymentConfig, adminID) + + listingDTO := createListingUnderReview(t, services.listing, owner.ID) + approved := adjustAndApproveListing(t, services.listing, adminID, listingDTO.ID) + orderDTO := mustCreateOrder(t, services.order, renter.ID, approved.ID) + if _, err := services.payment.Start(renter.ID, orderDTO.ID, payment.StartPaymentRequest{}, "127.0.0.1"); err != nil { + t.Fatalf("启动 mock 支付失败: %v", err) + } + if _, err := services.order.SubmitHandoff(owner.ID, orderDTO.ID, order.SubmitHandoffRequest{Content: "账号:demo,密码:demo-pass"}); err != nil { + t.Fatalf("号主交接失败: %v", err) + } + if err := services.order.ConfirmReceive(renter.ID, orderDTO.ID); err != nil { + t.Fatalf("租客确认收号失败: %v", err) + } + if _, err := services.order.SubmitCheckout(renter.ID, orderDTO.ID, order.SubmitCheckoutRequest{ + Content: "租客发起结账", + ConsumableAmountCent: 700, + CoinConsumedM: 90, + }); err != nil { + t.Fatalf("租客发起结账失败: %v", err) + } + if _, err := services.order.CounterCheckout(owner.ID, orderDTO.ID, order.CounterCheckoutRequest{ + ConsumableAmountCent: 1200, + CoinConsumedM: 100, + DepositDeductAmountCent: 3000, + Reason: "补扣消耗和押金赔付", + }); err != nil { + t.Fatalf("号主反价失败: %v", err) + } + + disputeDTO, err := services.dispute.Create(renter.ID, orderDTO.ID, dispute.CreateRequest{ + Type: "checkout_amount", + Description: "不同意扣押金,申请客服仲裁", + }) + if err != nil { + t.Fatalf("创建结账争议失败: %v", err) + } + assertEqual(t, "争议类型", disputeDTO.Type, "checkout_dispute") + resolved, err := services.dispute.Arbitrate(adminID, disputeDTO.ID, dispute.ArbitrateRequest{ + Result: "release_deposit", + Remark: "确认无押金扣除,退回押金", + }, auditlog.Meta{IP: "127.0.0.1", RequestID: "e2e-arbitrate"}) + if err != nil { + t.Fatalf("仲裁失败: %v", err) + } + assertEqual(t, "争议状态", resolved.Status, "resolved") + + completed, err := services.order.FindAdmin(orderDTO.ID) + if err != nil { + t.Fatalf("读取仲裁后订单失败: %v", err) + } + assertEqual(t, "仲裁后订单状态", completed.Status, "completed") + assertEqual(t, "仲裁后结算状态", completed.SettlementStatus, "arbitrated") + + var orderRow model.RentalOrder + if err := db.First(&orderRow, orderDTO.ID).Error; err != nil { + t.Fatalf("读取仲裁后订单模型失败: %v", err) + } + assertEqual(t, "仲裁退款状态", orderRow.RefundStatus, "refunded") + assertEqual(t, "仲裁退款金额", orderRow.RefundAmountCent, int64(15000)) + + walletAccount, err := services.wallet.Account(owner.ID) + if err != nil { + t.Fatalf("读取号主钱包失败: %v", err) + } + assertEqual(t, "仲裁后号主可用余额", walletAccount.AvailableBalanceCent, int64(35300)) + + var listingRow model.RentalListing + if err := db.First(&listingRow, approved.ID).Error; err != nil { + t.Fatalf("读取仲裁后商品失败: %v", err) + } + var accountRow model.GameAccount + if err := db.First(&accountRow, approved.AccountID).Error; err != nil { + t.Fatalf("读取仲裁后账号失败: %v", err) + } + assertEqual(t, "仲裁后商品状态", listingRow.Status, "offline") + assertEqual(t, "仲裁后账号状态", accountRow.Status, "offline") +} + +type flowServices struct { + listing *listing.Service + order *order.Service + payment *payment.Service + paymentConfig *paymentconfig.Service + paymentAccount *paymentaccount.Service + dispute *dispute.Service + wallet *wallet.Service + withdrawal *withdrawal.Service + finance *adminfinance.Service +} + +func newFlowServices(db *gorm.DB) flowServices { + listingRepo := listing.NewRepository(db) + orderRepo := order.NewRepository(db) + walletRepo := wallet.NewRepository(db) + configRepo := paymentconfig.NewRepository(db, &paymentconfig.MockEncryptor{}) + paymentRepo := payment.NewRepository(db, configRepo, orderRepo, walletRepo) + disputeRepo := dispute.NewRepository(db) + orderRepo.SetRefundFunc(func(orderID uint64, refundAmountCent int64, bizType string, remark string) (string, error) { + refund, err := paymentRepo.StartRefund(orderID, refundAmountCent, bizType, remark) + if err != nil { + return "", err + } + return refund.Status, nil + }) + disputeRepo.SetRefundFunc(func(orderID uint64, refundAmountCent int64, bizType string, remark string) (string, error) { + refund, err := paymentRepo.StartRefund(orderID, refundAmountCent, bizType, remark) + if err != nil { + return "", err + } + return refund.Status, nil + }) + + return flowServices{ + listing: listing.NewService(listingRepo, fixedConfig{"listing.review_required": "true"}), + order: order.NewService(orderRepo), + payment: payment.NewService(paymentRepo, "development"), + paymentConfig: paymentconfig.NewService(configRepo), + paymentAccount: paymentaccount.NewService(paymentaccount.NewRepository(db)), + dispute: dispute.NewService(disputeRepo), + wallet: wallet.NewService(walletRepo), + withdrawal: withdrawal.NewService(withdrawal.NewRepository(db, walletRepo)), + finance: adminfinance.NewService(adminfinance.NewRepository(db)), + } +} + +type fixedConfig map[string]string + +func (c fixedConfig) FindValue(key string) (string, error) { + return c[key], nil +} + +func openE2EDB(t *testing.T) *gorm.DB { + t.Helper() + + rawDSN := os.Getenv("HFB_E2E_DSN") + if rawDSN == "" { + t.Skip("设置 HFB_E2E_DSN 后运行完整租号链路 e2e 测试") + } + cfg, err := mysqldriver.ParseDSN(rawDSN) + if err != nil { + t.Fatalf("解析 HFB_E2E_DSN 失败: %v", err) + } + dbName := cfg.DBName + assertSafeTestDBName(t, dbName) + + serverCfg := cfg.Clone() + serverCfg.DBName = "" + serverDB, err := sql.Open("mysql", serverCfg.FormatDSN()) + if err != nil { + t.Fatalf("连接 MySQL server 失败: %v", err) + } + defer serverDB.Close() + if _, err := serverDB.Exec("DROP DATABASE IF EXISTS `" + dbName + "`"); err != nil { + t.Fatalf("删除测试库失败: %v", err) + } + if _, err := serverDB.Exec("CREATE DATABASE `" + dbName + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil { + t.Fatalf("创建测试库失败: %v", err) + } + + cfg.ParseTime = true + cfg.MultiStatements = true + sqlDB, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + t.Fatalf("连接测试库失败: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + applyInitMigration(t, sqlDB) + + gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB}), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("打开 gorm 连接失败: %v", err) + } + return gormDB +} + +func assertSafeTestDBName(t *testing.T, dbName string) { + t.Helper() + if dbName == "" { + t.Fatal("HFB_E2E_DSN 必须包含数据库名") + } + if !regexp.MustCompile(`^[A-Za-z0-9_]+$`).MatchString(dbName) { + t.Fatalf("测试库名只允许字母、数字、下划线: %q", dbName) + } + lower := strings.ToLower(dbName) + if !strings.Contains(lower, "test") && !strings.Contains(lower, "e2e") { + t.Fatalf("拒绝重置非测试库 %q,库名需包含 test 或 e2e", dbName) + } +} + +func applyInitMigration(t *testing.T, db *sql.DB) { + t.Helper() + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("无法定位当前测试文件") + } + migrationPath := filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations", "000001_init.sql") + raw, err := os.ReadFile(migrationPath) + if err != nil { + t.Fatalf("读取初始化迁移失败: %v", err) + } + if _, err := db.Exec(string(raw)); err != nil { + t.Fatalf("执行初始化迁移失败: %v", err) + } +} + +func seedUsers(t *testing.T, db *gorm.DB) (model.User, model.User, uint64) { + t.Helper() + now := time.Now() + ownerName, renterName := "张三", "李四" + owner := model.User{Phone: "13900000001", Nickname: "测试号主", RealnameStatus: "verified", RiskStatus: "normal", CreditScore: 100, Status: "active"} + renter := model.User{Phone: "13900000002", Nickname: "测试租客", RealnameStatus: "verified", RiskStatus: "normal", CreditScore: 100, Status: "active"} + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("创建号主失败: %v", err) + } + if err := db.Create(&renter).Error; err != nil { + t.Fatalf("创建租客失败: %v", err) + } + seedRealname(t, db, owner.ID, ownerName, now) + seedRealname(t, db, renter.ID, renterName, now) + + admin := model.AdminUser{Username: "e2e_admin", PasswordHash: "noop", Nickname: "测试管理员", Status: "active"} + if err := db.Create(&admin).Error; err != nil { + t.Fatalf("创建管理员失败: %v", err) + } + return owner, renter, admin.ID +} + +func seedRealname(t *testing.T, db *gorm.DB, userID uint64, name string, verifiedAt time.Time) { + t.Helper() + encryptedName, err := crypto.Encrypt(name) + if err != nil { + t.Fatalf("加密实名姓名失败: %v", err) + } + row := model.UserRealname{ + UserID: userID, + Provider: "e2e", + ProviderOrderNo: fmt.Sprintf("RN%d", userID), + Status: "verified", + MaskedName: string([]rune(name)[0]) + "*", + EncryptedName: encryptedName, + MaskedIDNo: "110***********001", + VerifiedAt: &verifiedAt, + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("创建实名记录失败: %v", err) + } +} + +func seedMockPaymentConfig(t *testing.T, service *paymentconfig.Service, adminID uint64) { + t.Helper() + _, err := service.Create(paymentconfig.CreateRequest{ + Name: "E2E Mock 支付", + Provider: "mock", + MerchantID: "mock-e2e", + IsDefault: true, + Status: "active", + Environment: "sandbox", + BusinessTags: []string{"order_pay", "wallet_recharge"}, + }, adminID, auditlog.Meta{IP: "127.0.0.1", RequestID: "e2e-payment-config"}) + if err != nil { + t.Fatalf("创建 mock 支付配置失败: %v", err) + } +} + +func createOwnerPaymentAccount(t *testing.T, service *paymentaccount.Service, ownerID uint64) *paymentaccount.PaymentAccountDTO { + t.Helper() + account, err := service.Create(ownerID, paymentaccount.CreatePaymentAccountRequest{ + AccountType: "alipay", + AccountName: "张三", + AccountNo: "owner@example.com", + }) + if err != nil { + t.Fatalf("创建号主收款账号失败: %v", err) + } + return account +} + +func createListingUnderReview(t *testing.T, service *listing.Service, ownerID uint64) *listing.ListingDTO { + t.Helper() + dto, err := service.Create(ownerID, listing.CreateRequest{ + Title: "E2E 烽火地带账号", + Description: "用于完整链路测试", + ServerRegion: "烽火地带", + LoginPlatform: "QQ", + RankLevel: "钻石", + HafCoinAmount: 100000000, + PriceCent: 35300, + DepositAmountCent: 15000, + ScreenshotURLS: []string{"https://example.com/e2e-account.png"}, + AssetSummary: map[string]any{ + "fire_level": 50, + "price_breakdown": map[string]any{ + "seller_coin_base_price": 233, + "seller_total_price": 353, + "seller_ratio": 42.9, + "consumable_price": 120, + }, + }, + AgreedVirtualAssetSale: true, + AgreedSellerAgreement: true, + }) + if err != nil { + t.Fatalf("创建待审核商品失败: %v", err) + } + assertEqual(t, "商品审核状态", dto.ReviewStatus, "pending") + return dto +} + +func adjustAndApproveListing(t *testing.T, service *listing.Service, adminID uint64, listingID uint64) *listing.ListingDTO { + t.Helper() + adjusted, err := service.AdjustReviewPrice(adminID, listingID, listing.AdminPriceAdjustRequest{ + BuyerTotalPriceCent: 38300, + Reason: "E2E 审核调价", + }, auditlog.Meta{IP: "127.0.0.1", RequestID: "e2e-adjust-price"}) + if err != nil { + t.Fatalf("审核调价失败: %v", err) + } + approved, err := service.Approve(adjusted.ID) + if err != nil { + t.Fatalf("审核通过失败: %v", err) + } + assertEqual(t, "审核通过商品状态", approved.Status, "published") + return approved +} + +func mustCreateOrder(t *testing.T, service *order.Service, renterID uint64, listingID uint64) *order.OrderDTO { + t.Helper() + dto, err := service.Create(renterID, order.CreateRequest{ListingID: listingID}) + if err != nil { + t.Fatalf("创建订单失败: %v", err) + } + assertEqual(t, "新订单状态", dto.Status, "pending_payment") + assertEqual(t, "订单租金", *dto.RentAmountCent, int64(38300)) + assertEqual(t, "订单押金", dto.DepositAmountCent, int64(15000)) + return dto +} + +func assertFinanceDashboard(t *testing.T, service *adminfinance.Service, orderNo string) { + t.Helper() + now := time.Now() + query := adminfinance.DashboardQuery{ + StartDate: now.Add(-time.Hour), + EndDate: now.Add(time.Hour), + } + dashboard, err := service.Dashboard(query) + if err != nil { + t.Fatalf("读取财务看板失败: %v", err) + } + assertEqual(t, "财务总流水", dashboard.Summary.TotalFlowAmountCent, int64(53300)) + assertEqual(t, "财务已退款", dashboard.Summary.TotalRefundAmountCent, int64(22800)) + assertEqual(t, "财务平台收入", dashboard.Summary.PlatformIncomeAmountCent, int64(3000)) + assertEqual(t, "财务号主应收", dashboard.Summary.OwnerShouldIncomeAmountCent, int64(27500)) + assertEqual(t, "财务号主钱包入账", dashboard.Summary.OwnerWalletIncomeAmountCent, int64(27500)) + assertEqual(t, "财务结算订单数", dashboard.Summary.SettledOrderCount, int64(1)) + + details, err := service.Details(adminfinance.DetailQuery{ + OrderNo: orderNo, + DateType: "settled", + StartDate: query.StartDate, + EndDate: query.EndDate, + Page: 1, + PageSize: 10, + }) + if err != nil { + t.Fatalf("读取财务明细失败: %v", err) + } + assertEqual(t, "财务明细数量", details.Total, int64(1)) + items, ok := details.Items.([]adminfinance.FinanceDetailDTO) + if !ok || len(items) != 1 { + t.Fatalf("财务明细类型异常: %#v", details.Items) + } + assertEqual(t, "明细订单号", items[0].OrderNo, orderNo) + assertEqual(t, "明细平台净收入", items[0].PlatformNetAmountCent, int64(3000)) +} + +func assertWalletAndWithdrawal(t *testing.T, walletService *wallet.Service, withdrawalService *withdrawal.Service, ownerID uint64, adminID uint64, paymentAccountID uint64) { + t.Helper() + accountBefore, err := walletService.Account(ownerID) + if err != nil { + t.Fatalf("读取号主钱包失败: %v", err) + } + assertEqual(t, "提现前可用余额", accountBefore.AvailableBalanceCent, int64(27500)) + + req, err := withdrawalService.Create(ownerID, withdrawal.CreateWithdrawalRequest{ + PaymentAccountID: paymentAccountID, + AmountCent: 10000, + }) + if err != nil { + t.Fatalf("创建提现失败: %v", err) + } + assertEqual(t, "提现申请金额", req.AmountCent, int64(10000)) + assertEqual(t, "提现申请状态", req.Status, "pending") + + reviewed, err := withdrawalService.Review(adminID, req.ID, withdrawal.ReviewWithdrawalRequest{ + Approved: true, + Remark: "E2E 审核通过", + }) + if err != nil { + t.Fatalf("审核提现失败: %v", err) + } + assertEqual(t, "提现审核后状态", reviewed.Status, "processing") + + paid, err := withdrawalService.ConfirmPayment(adminID, req.ID, withdrawal.ConfirmPaymentRequest{ + PaymentProofURL: "https://example.com/proof.png", + Remark: "E2E 已打款", + }) + if err != nil { + t.Fatalf("确认提现打款失败: %v", err) + } + assertEqual(t, "提现完成状态", paid.Status, "completed") + assertEqual(t, "提现到账金额", paid.ActualAmountCent, int64(10000)) + + accountAfter, err := walletService.Account(ownerID) + if err != nil { + t.Fatalf("读取提现后钱包失败: %v", err) + } + assertEqual(t, "提现后可用余额", accountAfter.AvailableBalanceCent, int64(17500)) + assertEqual(t, "提现后冻结余额", accountAfter.FrozenBalanceCent, int64(0)) +} + +func assertEqual[T comparable](t *testing.T, name string, got T, want T) { + t.Helper() + if got != want { + t.Fatalf("%s = %v, want %v", name, got, want) + } +} diff --git a/backend/internal/modules/dispute/repository.go b/backend/internal/modules/dispute/repository.go index 281c218..df06252 100644 --- a/backend/internal/modules/dispute/repository.go +++ b/backend/internal/modules/dispute/repository.go @@ -235,15 +235,17 @@ func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest, if order.HandoffStatus != "cancelled" && order.HandoffStatus != "returned" { order.HandoffStatus = "arbitrated" } - if req.Result == "order_close" { - listing.Status = "offline" - account.Status = "offline" - } else if req.Result == "mark_abnormal" { + if req.Result == "mark_abnormal" { listing.Status = "abnormal" + listing.InTransaction = false + listing.PublishedAt = nil account.Status = "abnormal" } else { - listing.Status = "published" - account.Status = "published" + // 仲裁会终结当前订单,账号统一归档下架,避免已结案账号重新出现在公开列表。 + listing.Status = "offline" + listing.InTransaction = false + listing.PublishedAt = nil + account.Status = "offline" } if err := wallet.AppendEntries(tx, settlement.Entries...); err != nil { return err