Files
lijiaoqiao/supply-api/internal/domain/settlement_race_test.go
Your Name da385ee744 fix: P0-02 修复提现竞态条件
使用 SELECT ... FOR UPDATE SKIP LOCKED 实现原子化提现创建

问题:
- HasPendingOrProcessingWithdraw 和 CreateInTx 分开调用导致竞态
- 两个并发请求可能同时通过检查并创建提现

解决方案:
- 新增 CreateWithdrawTx 方法,先锁定 pending 记录再检查插入
- 使用 FOR UPDATE SKIP LOCKED 防止并发插入

涉及文件:
- internal/repository/settlement.go: 新增 CreateWithdrawTx
- internal/adapter/adapter.go: 实现 CreateWithdrawTx
- internal/domain/settlement.go: 使用 CreateWithdrawTx
- internal/storage/store.go: 实现内存存储版本
- sql/postgresql/settlement_withdraw_constraint_v1.sql: 文档说明

测试: go test -short ./... 通过
2026-04-09 22:16:08 +08:00

210 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package domain
import (
"context"
"errors"
"sync"
"testing"
"time"
)
// TestSettlementService_Withdraw_ConcurrentRequests_RaceCondition
// TDD: 验证提现操作在并发请求下的竞态条件
// 问题: HasPendingOrProcessingWithdraw 和 CreateInTx 不在同一事务中
func TestSettlementService_Withdraw_ConcurrentRequests_RaceCondition(t *testing.T) {
store := newMockConcurrentSettlementStore()
earningStore := newMockEarningStore()
auditStore := &mockAuditStoreForSettlement{}
smsVerifier := &mockSMSVerifierForSettlement{verifyResult: true}
// 设置余额充足
store.balances[1001] = 10000.0
svc := NewSettlementServiceWithSMS(store, earningStore, auditStore, smsVerifier)
req := &WithdrawRequest{
Amount: 1000,
SMSCode: "123456",
PaymentMethod: PaymentMethodBank,
PaymentAccount: "1234567890",
}
// 并发发起10个提现请求
const numGoroutines = 10
var wg sync.WaitGroup
wg.Add(numGoroutines)
successCount := 0
var mu sync.Mutex
for i := 0; i < numGoroutines; i++ {
go func() {
defer wg.Done()
result, err := svc.Withdraw(context.Background(), 1001, req)
if err == nil && result != nil {
mu.Lock()
successCount++
mu.Unlock()
}
}()
}
wg.Wait()
// 验证应该只有0或1个成功因为有pending check
// 但由于竞态条件,可能有多个成功
t.Logf("成功数量: %d (预期: 1)", successCount)
// 这个测试在有竞态条件时会 flaky
// 修复后成功数量应该是1
if successCount > 1 {
t.Errorf("发现竞态条件: %d个请求同时成功 (预期最多1个)", successCount)
}
}
// mockConcurrentSettlementStore 支持并发的Mock存储
// 模拟真实数据库行为:检查和插入分开时会有竞态条件
type mockConcurrentSettlementStore struct {
settlements map[int64]*Settlement
balances map[int64]float64
nextID int64
mu sync.Mutex
}
func newMockConcurrentSettlementStore() *mockConcurrentSettlementStore {
return &mockConcurrentSettlementStore{
settlements: make(map[int64]*Settlement),
balances: make(map[int64]float64),
nextID: 1,
}
}
func (m *mockConcurrentSettlementStore) Create(ctx context.Context, s *Settlement) error {
m.mu.Lock()
defer m.mu.Unlock()
s.ID = m.nextID
m.nextID++
m.settlements[s.ID] = s
return nil
}
// CreateWithdrawTx 原子化提现创建(带锁)
// 这个实现会先锁定检查,然后原子性插入,模拟真实数据库的 FOR UPDATE SKIP LOCKED
func (m *mockConcurrentSettlementStore) CreateWithdrawTx(ctx context.Context, s *Settlement) error {
// Step 1: 锁定检查 - 模拟 SELECT ... FOR UPDATE SKIP LOCKED
m.mu.Lock()
var hasPending bool
for _, existing := range m.settlements {
if existing.SupplierID == s.SupplierID &&
(existing.Status == SettlementStatusPending || existing.Status == SettlementStatusProcessing) {
hasPending = true
break
}
}
if hasPending {
m.mu.Unlock()
return errors.New("already has pending or processing withdrawal")
}
// Step 2: 原子插入(在锁内完成)
s.ID = m.nextID
m.nextID++
s.CreatedAt = time.Now()
s.UpdatedAt = time.Now()
m.settlements[s.ID] = s
m.mu.Unlock()
return nil
}
// CreateInTx 模拟真实事务行为
// 注意这个实现与旧版DBSettlementStore有相同的竞态问题检查和插入分开
func (m *mockConcurrentSettlementStore) CreateInTx(ctx context.Context, s *Settlement) error {
// 首先检查(模拟 HasPendingOrProcessingWithdraw
m.mu.Lock()
var hasPending bool
for _, existing := range m.settlements {
if existing.SupplierID == s.SupplierID && (existing.Status == SettlementStatusPending || existing.Status == SettlementStatusProcessing) {
hasPending = true
break
}
}
m.mu.Unlock()
if hasPending {
return errors.New("already has pending withdraw")
}
// 模拟其他事务在这里插入的时间窗口
// 提交检查和插入之间有延迟
// 现在执行插入
m.mu.Lock()
defer m.mu.Unlock()
s.ID = m.nextID
m.nextID++
s.CreatedAt = time.Now()
s.UpdatedAt = time.Now()
m.settlements[s.ID] = s
return nil
}
func (m *mockConcurrentSettlementStore) GetByID(ctx context.Context, supplierID, id int64) (*Settlement, error) {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.settlements[id]; ok && s.SupplierID == supplierID {
return s, nil
}
return nil, errors.New("settlement not found")
}
func (m *mockConcurrentSettlementStore) Update(ctx context.Context, s *Settlement, expectedVersion int) error {
m.mu.Lock()
defer m.mu.Unlock()
if s.Version != expectedVersion {
return errors.New("concurrency conflict")
}
m.settlements[s.ID] = s
return nil
}
func (m *mockConcurrentSettlementStore) List(ctx context.Context, supplierID int64) ([]*Settlement, error) {
m.mu.Lock()
defer m.mu.Unlock()
var result []*Settlement
for _, s := range m.settlements {
if s.SupplierID == supplierID {
result = append(result, s)
}
}
return result, nil
}
func (m *mockConcurrentSettlementStore) GetWithdrawableBalance(ctx context.Context, supplierID int64) (float64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if balance, ok := m.balances[supplierID]; ok {
return balance, nil
}
return 0, nil
}
func (m *mockConcurrentSettlementStore) HasPendingOrProcessingWithdraw(ctx context.Context, supplierID int64) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
for _, s := range m.settlements {
if s.SupplierID == supplierID && (s.Status == SettlementStatusPending || s.Status == SettlementStatusProcessing) {
return true, nil
}
}
return false, nil
}