Files
hfb_sys/backend/internal/modules/payment/repository_integration_test.go
T
yml 6ff7692f3e 支付确认强一致化: 订单推进与标 paid 合并为单事务
confirmPaid 原为「先 ConfirmPaidFromChannel 独立事务推进订单, 再 Updates 标 paid」两步非原子, 崩溃在中间会出现订单已 pending_handoff 但 payment 仍 paying 的不一致窗口, 恢复依赖渠道回调重发而非事务闭环。

改造:
- 新增 ConfirmPaidFromChannelTx(tx, orderID) 让 order 模块共享 payment 模块的外部事务, 订单推进与 payment 标 paid 落进同一事务, 崩溃一致性窗口消除
- NotifyNewConversation 提到事务提交后触发, 避免事务回滚后误发会话通知
- payment_orders 加行锁后 Updates, 防并发回调覆盖写
- 删除未使用的 providerBizNo 死参数

锁顺序全局一致 (order→listing→account→payment), 与 payment_start/refund/Cancel 路径无反向加锁, 无死锁风险。

新增集成测试:
- 正向: 验证 confirmPaid 后 order/listing/account/payment 全部正确推进
- 回滚: payment 更新失败时 order 不残留 pending_handoff, 验证事务原子性

go build/vet/test 通过。
2026-06-14 09:42:26 +08:00

515 lines
14 KiB
Go

package payment
import (
"testing"
"time"
"hfb_sys/backend/internal/model"
ordermodule "hfb_sys/backend/internal/modules/order"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// setupPaymentTestDB 创建测试数据库
func setupPaymentTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("无法创建测试数据库: %v", err)
}
if err := db.AutoMigrate(
&model.PaymentOrder{},
&model.RentalOrder{},
&model.RentalListing{},
&model.GameAccount{},
&model.User{},
&model.ChatConversation{},
&model.ChatParticipant{},
&model.ChatMessage{},
&model.Notification{},
); err != nil {
t.Fatalf("数据库迁移失败: %v", err)
}
return db
}
type payableOrderFixture struct {
Owner model.User
Renter model.User
Account model.GameAccount
Listing model.RentalListing
Order model.RentalOrder
Payment model.PaymentOrder
}
func createPayableOrderFixture(t *testing.T, db *gorm.DB, suffix string, createPayment bool) payableOrderFixture {
t.Helper()
f := payableOrderFixture{
Owner: model.User{Phone: "13800001" + suffix},
Renter: model.User{Phone: "13900001" + suffix},
}
if err := db.Create(&f.Owner).Error; err != nil {
t.Fatalf("create owner failed: %v", err)
}
if err := db.Create(&f.Renter).Error; err != nil {
t.Fatalf("create renter failed: %v", err)
}
f.Account = model.GameAccount{
OwnerID: f.Owner.ID,
ServerRegion: "国服",
LoginPlatform: "steam",
Title: "测试账号",
Status: "published",
}
if err := db.Create(&f.Account).Error; err != nil {
t.Fatalf("create account failed: %v", err)
}
f.Listing = model.RentalListing{
ListingNo: "LST20260614" + suffix,
OwnerID: f.Owner.ID,
AccountID: f.Account.ID,
Status: "published",
ReviewStatus: "approved",
InTransaction: true,
PriceCent: 1000,
}
if err := db.Create(&f.Listing).Error; err != nil {
t.Fatalf("create listing failed: %v", err)
}
f.Order = model.RentalOrder{
OrderNo: "ORD20260614" + suffix,
ListingID: f.Listing.ID,
AccountID: f.Account.ID,
OwnerID: f.Owner.ID,
RenterID: f.Renter.ID,
RentAmountCent: 1000,
Status: "pending_payment",
HandoffStatus: "none",
}
if err := db.Create(&f.Order).Error; err != nil {
t.Fatalf("create order failed: %v", err)
}
f.Payment = model.PaymentOrder{
PaymentNo: "PAY20260614" + suffix,
OrderID: f.Order.ID,
OrderNo: f.Order.OrderNo,
UserID: f.Renter.ID,
Provider: "mock",
ThirdOrderID: "PAY20260614" + suffix,
ProviderOrderID: "MOCKPAY20260614" + suffix,
AmountCent: 1000,
BizType: "order_pay",
Status: "paying",
}
if createPayment {
if err := db.Create(&f.Payment).Error; err != nil {
t.Fatalf("create payment failed: %v", err)
}
}
return f
}
// TestCanReusePaymentWithSameProvider 测试相同渠道可复用
func TestCanReusePaymentWithSameProvider(t *testing.T) {
payment := model.PaymentOrder{
Status: "paying",
Provider: "lakala",
MerchantID: "M123",
}
config := runtimePaymentConfig{
Provider: "lakala",
MerchantID: "M123",
}
if !canReuseOrderPayment(payment, config) {
t.Fatal("should reuse payment with same provider and merchant")
}
}
// TestCannotReusePaymentWithDifferentProvider 测试不同渠道不可复用
func TestCannotReusePaymentWithDifferentProvider(t *testing.T) {
payment := model.PaymentOrder{
Status: "paying",
Provider: "lakala",
MerchantID: "M123",
}
config := runtimePaymentConfig{
Provider: "leshua", // 不同渠道
MerchantID: "M456",
}
if canReuseOrderPayment(payment, config) {
t.Fatal("should not reuse payment with different provider")
}
}
// TestPaymentStatusTransitions 测试支付单状态转换
func TestPaymentStatusTransitionsValid(t *testing.T) {
validTransitions := map[string][]string{
"pending": {"paying", "closed"},
"paying": {"paid", "failed", "closed"},
"paid": {"refunding", "refunded"},
"failed": {}, // 终态
"closed": {}, // 终态
"refunded": {}, // 终态
}
for from, toList := range validTransitions {
if from == "" {
t.Fatal("payment status should not be empty")
}
for _, to := range toList {
if to == "" {
t.Fatal("transition target should not be empty")
}
}
}
}
// TestRefundBizTypeCoverage 测试退款业务类型覆盖
func TestRefundBizTypeCoverage(t *testing.T) {
expected := []string{
"cancel_refund",
"admin_close_refund",
"admin_refund",
"checkout_refund",
"deposit_refund",
"rent_refund",
"arbitration_refund",
}
if len(refundBizTypes) != len(expected) {
t.Fatalf("refundBizTypes count = %d, want %d", len(refundBizTypes), len(expected))
}
// 验证每个预期类型都存在
typeMap := make(map[string]bool)
for _, bizType := range refundBizTypes {
typeMap[bizType] = true
}
for _, exp := range expected {
if !typeMap[exp] {
t.Fatalf("missing refund biz type: %s", exp)
}
}
}
// TestPaymentAmountValidation 测试支付金额验证
func TestPaymentAmountMustBePositive(t *testing.T) {
testCases := []struct {
name string
amount int64
wantValid bool
}{
{"正数金额", 10000, true},
{"零金额", 0, false},
{"负数金额", -1000, false},
{"最小金额", 1, true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
isValid := tc.amount > 0
if isValid != tc.wantValid {
t.Fatalf("amount %d validation = %v, want %v", tc.amount, isValid, tc.wantValid)
}
})
}
}
// TestPaymentProviderValidation 测试支付渠道验证
func TestPaymentProviderMustNotBeEmpty(t *testing.T) {
testCases := []struct {
name string
provider string
wantValid bool
}{
{"lakala 渠道", "lakala", true},
{"leshua 渠道", "leshua", true},
{"mock 渠道", "mock", true},
{"空渠道", "", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
isValid := tc.provider != ""
if isValid != tc.wantValid {
t.Fatalf("provider %q validation = %v, want %v", tc.provider, isValid, tc.wantValid)
}
})
}
}
// TestRuntimeConfigIsMockMode 测试运行时配置 mock 模式
func TestRuntimeConfigIsMockModeTrue(t *testing.T) {
config := runtimePaymentConfig{
Provider: "mock",
}
if !config.isMockMode() {
t.Fatal("should be mock mode when provider is 'mock'")
}
}
func TestRuntimeConfigIsMockModeFalse(t *testing.T) {
providers := []string{"lakala", "leshua", "alipay", "wechat"}
for _, provider := range providers {
config := runtimePaymentConfig{
Provider: provider,
}
if config.isMockMode() {
t.Fatalf("should not be mock mode when provider is %q", provider)
}
}
}
// TestChannelSourceValidation 测试渠道来源验证
func TestChannelSourceMustBeValid(t *testing.T) {
validSources := []string{
channelSourceCreate,
channelSourceQuery,
channelSourceNotify,
channelSourceMock,
}
expectedValues := []string{"create", "query", "notify", "mock"}
for i, source := range validSources {
if source != expectedValues[i] {
t.Fatalf("channelSource[%d] = %q, want %q", i, source, expectedValues[i])
}
}
}
// TestPaymentOrderFields 测试支付单字段完整性
func TestPaymentOrderRequiredFields(t *testing.T) {
payment := model.PaymentOrder{
PaymentNo: "PAY123456",
OrderNo: "ORD123456",
OrderID: 100,
UserID: 1,
AmountCent: 10000,
Status: "paying",
Provider: "lakala",
MerchantID: "M123",
BizType: "order_pay",
}
// 验证必填字段
if payment.PaymentNo == "" {
t.Fatal("PaymentNo should not be empty")
}
if payment.OrderNo == "" {
t.Fatal("OrderNo should not be empty")
}
if payment.OrderID == 0 {
t.Fatal("OrderID should not be zero")
}
if payment.UserID == 0 {
t.Fatal("UserID should not be zero")
}
if payment.AmountCent <= 0 {
t.Fatal("AmountCent should be positive")
}
if payment.Status == "" {
t.Fatal("Status should not be empty")
}
if payment.Provider == "" {
t.Fatal("Provider should not be empty")
}
}
// TestRefundAmountValidation 测试退款金额验证
func TestRefundAmountMustNotExceedOriginal(t *testing.T) {
testCases := []struct {
name string
originalAmount int64
refundAmount int64
wantValid bool
}{
{"部分退款", 10000, 5000, true},
{"全额退款", 10000, 10000, true},
{"超额退款", 10000, 15000, false},
{"零退款", 10000, 0, false},
{"负数退款", 10000, -1000, false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
isValid := tc.refundAmount > 0 && tc.refundAmount <= tc.originalAmount
if isValid != tc.wantValid {
t.Fatalf("refund %d from %d validation = %v, want %v",
tc.refundAmount, tc.originalAmount, isValid, tc.wantValid)
}
})
}
}
// TestPaymentNotifyResultStructure 测试支付回调结果结构
func TestNotifyResultHasRequiredFields(t *testing.T) {
result := NotifyResult{
OK: true,
Message: "payment success",
}
if result.Message == "" {
t.Fatal("NotifyResult.Message should not be empty")
}
}
func TestStartRefundDoesNotCreateNewOrderWhenFailedRefundExists(t *testing.T) {
db := setupPaymentTestDB(t)
repo := NewRepository(db, nil, nil)
order := model.RentalOrder{
ID: 1001,
OrderNo: "ORD202606140001",
RenterID: 11,
RentAmountCent: 800,
DepositAmountCent: 200,
RefundStatus: "failed",
RefundAmountCent: 1000,
Status: "closed",
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("create order failed: %v", err)
}
original := model.PaymentOrder{
PaymentNo: "PAY202606140001",
OrderID: order.ID,
OrderNo: order.OrderNo,
UserID: order.RenterID,
Provider: "mock",
ThirdOrderID: "PAY202606140001",
ProviderOrderID: "MOCKPAY202606140001",
AmountCent: 1000,
BizType: "order_pay",
Status: "paid",
}
if err := db.Create(&original).Error; err != nil {
t.Fatalf("create original payment failed: %v", err)
}
existingRefund := model.PaymentOrder{
PaymentNo: "PAY202606140002",
OrderID: order.ID,
OrderNo: order.OrderNo,
UserID: order.RenterID,
Provider: "mock",
ThirdOrderID: "REF202606140002",
AmountCent: 1000,
BizType: "admin_refund",
Status: "failed",
}
if err := db.Create(&existingRefund).Error; err != nil {
t.Fatalf("create existing refund failed: %v", err)
}
dto, err := repo.StartRefund(t.Context(), order.ID, 1000, "admin_refund", "后台人工退款")
if err != nil {
t.Fatalf("StartRefund() error = %v", err)
}
if dto.ID != existingRefund.ID {
t.Fatalf("StartRefund() returned payment id %d, want existing %d", dto.ID, existingRefund.ID)
}
var count int64
if err := db.Model(&model.PaymentOrder{}).
Where("order_id = ? AND biz_type = ?", order.ID, "admin_refund").
Count(&count).Error; err != nil {
t.Fatalf("count refund orders failed: %v", err)
}
if count != 1 {
t.Fatalf("refund order count = %d, want 1", count)
}
}
func TestConfirmPaidUpdatesOrderAndPaymentInOnePath(t *testing.T) {
db := setupPaymentTestDB(t)
orderRepo := ordermodule.NewRepository(db)
repo := NewRepository(db, nil, orderRepo)
fixture := createPayableOrderFixture(t, db, "0010", true)
paidAt := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
err := repo.confirmPaid(t.Context(), &fixture.Payment, "paid", paidAt, map[string]string{
"provider_order_id": "PROVIDER202606140010",
}, channelSourceNotify)
if err != nil {
t.Fatalf("confirmPaid() error = %v", err)
}
var latestPayment model.PaymentOrder
if err := db.First(&latestPayment, fixture.Payment.ID).Error; err != nil {
t.Fatalf("find payment failed: %v", err)
}
if latestPayment.Status != "paid" {
t.Fatalf("payment status = %q, want paid", latestPayment.Status)
}
if latestPayment.ProviderOrderID != "PROVIDER202606140010" {
t.Fatalf("provider order id = %q, want provider response", latestPayment.ProviderOrderID)
}
var latestOrder model.RentalOrder
if err := db.First(&latestOrder, fixture.Order.ID).Error; err != nil {
t.Fatalf("find order failed: %v", err)
}
if latestOrder.Status != "pending_handoff" || latestOrder.HandoffStatus != "pending_owner" {
t.Fatalf("order status = %q/%q, want pending_handoff/pending_owner", latestOrder.Status, latestOrder.HandoffStatus)
}
var latestListing model.RentalListing
if err := db.First(&latestListing, fixture.Listing.ID).Error; err != nil {
t.Fatalf("find listing failed: %v", err)
}
var latestAccount model.GameAccount
if err := db.First(&latestAccount, fixture.Account.ID).Error; err != nil {
t.Fatalf("find account failed: %v", err)
}
if latestListing.Status != "rented" || latestAccount.Status != "rented" {
t.Fatalf("asset status = %q/%q, want rented/rented", latestListing.Status, latestAccount.Status)
}
}
func TestConfirmPaidRollsBackOrderWhenPaymentUpdateFails(t *testing.T) {
db := setupPaymentTestDB(t)
orderRepo := ordermodule.NewRepository(db)
repo := NewRepository(db, nil, orderRepo)
fixture := createPayableOrderFixture(t, db, "0011", false)
fixture.Payment.ID = 999999
paidAt := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
err := repo.confirmPaid(t.Context(), &fixture.Payment, "paid", paidAt, map[string]string{
"provider_order_id": "PROVIDER202606140011",
}, channelSourceNotify)
if err == nil {
t.Fatal("confirmPaid() error = nil, want payment lookup failure")
}
var latestOrder model.RentalOrder
if findErr := db.First(&latestOrder, fixture.Order.ID).Error; findErr != nil {
t.Fatalf("find order failed: %v", findErr)
}
if latestOrder.Status != "pending_payment" || latestOrder.HandoffStatus != "none" {
t.Fatalf("order status = %q/%q, want rollback to pending_payment/none", latestOrder.Status, latestOrder.HandoffStatus)
}
var latestListing model.RentalListing
if findErr := db.First(&latestListing, fixture.Listing.ID).Error; findErr != nil {
t.Fatalf("find listing failed: %v", findErr)
}
var latestAccount model.GameAccount
if findErr := db.First(&latestAccount, fixture.Account.ID).Error; findErr != nil {
t.Fatalf("find account failed: %v", findErr)
}
if latestListing.Status != "published" || latestAccount.Status != "published" {
t.Fatalf("asset status = %q/%q, want rollback to published/published", latestListing.Status, latestAccount.Status)
}
}