fix: 修复订单风控死锁bug issues#271
This commit is contained in:
1 parent
35c5fece51
commit
041c0e4037
5 files changed
+168
-15
No files matched your search
@@ -282,6 +282,7 @@ type orderCreateParams struct {
|
||||
ClientIP string
|
||||
RiskIP string
|
||||
RiskPaymentExpireMinutes int
|
||||
RiskCheckResult orderriskcontract.CheckResult
|
||||
IsGuest bool
|
||||
ManualFormData map[string]jsonmap.JSON
|
||||
SkipManualFormCheck bool
|
||||
@@ -542,7 +543,7 @@ func (s *OrderService) createOrder(input orderCreateParams) (*orderdomain.Order,
|
||||
orderStore := tx.Orders()
|
||||
productSKURepo := tx.ProductSKUs()
|
||||
if s.riskControlSvc != nil && !input.SkipRiskControl {
|
||||
if err := s.riskControlSvc.CheckPendingOrderAllowed(buildRiskCheckInput(input, false), orderStore); err != nil {
|
||||
if err := s.riskControlSvc.CheckPendingOrderAllowed(buildRiskCheckInput(input, false), input.RiskCheckResult, orderStore); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -728,6 +729,7 @@ func (s *OrderService) checkOrderRisk(input *orderCreateParams, consumeRateLimit
|
||||
input.ClientIP = strings.TrimSpace(input.ClientIP)
|
||||
input.RiskIP = orderriskcontract.NormalizeRiskIP(input.ClientIP)
|
||||
input.RiskPaymentExpireMinutes = 0
|
||||
input.RiskCheckResult = orderriskcontract.CheckResult{}
|
||||
if s.riskControlSvc == nil || input.SkipRiskControl {
|
||||
return nil
|
||||
}
|
||||
@@ -737,6 +739,7 @@ func (s *OrderService) checkOrderRisk(input *orderCreateParams, consumeRateLimit
|
||||
}
|
||||
input.RiskIP = result.RiskIP
|
||||
input.RiskPaymentExpireMinutes = result.PaymentExpireMinutes
|
||||
input.RiskCheckResult = result
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -40,17 +40,24 @@ func NewService(options Options) *Service {
|
||||
return &Service{settings: options.Settings, rateLimiter: options.RateLimiter}
|
||||
}
|
||||
|
||||
// CheckOrderAllowed 执行不依赖订单事务的风控,并返回后续事务应复用的规范化 IP。
|
||||
// CheckOrderAllowed 在订单事务外读取配置并执行前置风控,返回后续事务应复用的配置与规范化 IP。
|
||||
func (s *Service) CheckOrderAllowed(input orderriskcontract.CheckInput) (orderriskcontract.CheckResult, error) {
|
||||
result := orderriskcontract.CheckResult{RiskIP: orderriskcontract.NormalizeRiskIP(input.ClientIP)}
|
||||
result := orderriskcontract.CheckResult{
|
||||
RiskIP: orderriskcontract.NormalizeRiskIP(input.ClientIP),
|
||||
ConfigSnapshot: settingssecurity.DefaultOrderRiskControlConfig(),
|
||||
}
|
||||
if s == nil || s.settings == nil {
|
||||
return result, nil
|
||||
}
|
||||
cfg, err := s.settings.GetOrderRiskControlConfig()
|
||||
if err != nil {
|
||||
logger.Warnw("risk_control_get_config_error", "error", err)
|
||||
if input.ConsumeRateLimit {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
result.ConfigSnapshot = cfg
|
||||
if !cfg.Enabled {
|
||||
return result, nil
|
||||
}
|
||||
@@ -135,19 +142,20 @@ func aggregateProductQuantities(items []orderriskcontract.OrderItem) map[uint]in
|
||||
}
|
||||
|
||||
// CheckPendingOrderAllowed 必须在订单事务内、创建父订单和锁库存之前调用。
|
||||
func (s *Service) CheckPendingOrderAllowed(input orderriskcontract.CheckInput, gate orderriskcontract.PendingOrderGate) error {
|
||||
if s == nil || s.settings == nil || gate == nil {
|
||||
// prepared 来自事务外的 CheckOrderAllowed;事务内禁止再次读取独立设置仓储。
|
||||
func (s *Service) CheckPendingOrderAllowed(input orderriskcontract.CheckInput, prepared orderriskcontract.CheckResult, gate orderriskcontract.PendingOrderGate) error {
|
||||
if s == nil || gate == nil {
|
||||
return nil
|
||||
}
|
||||
cfg, err := s.settings.GetOrderRiskControlConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := prepared.ConfigSnapshot
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
if input.RiskIP == "" {
|
||||
input.RiskIP = orderriskcontract.NormalizeRiskIP(input.ClientIP)
|
||||
input.RiskIP = prepared.RiskIP
|
||||
if input.RiskIP == "" {
|
||||
input.RiskIP = orderriskcontract.NormalizeRiskIP(input.ClientIP)
|
||||
}
|
||||
}
|
||||
if input.IsGuest {
|
||||
return checkGuestPendingOrder(input, cfg.Guest, gate)
|
||||
|
||||
@@ -2,11 +2,23 @@ package application
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dujiao-next/internal/constants"
|
||||
ordercontract "github.com/dujiao-next/internal/modules/order/contract"
|
||||
orderdomain "github.com/dujiao-next/internal/modules/order/domain"
|
||||
orderstore "github.com/dujiao-next/internal/modules/order/infrastructure/gormstore"
|
||||
orderriskcontract "github.com/dujiao-next/internal/modules/orderrisk/contract"
|
||||
orderriskdomain "github.com/dujiao-next/internal/modules/orderrisk/domain"
|
||||
settingsapp "github.com/dujiao-next/internal/modules/settings/application"
|
||||
settingsstore "github.com/dujiao-next/internal/modules/settings/infrastructure/gormstore"
|
||||
settingssecurity "github.com/dujiao-next/internal/modules/settings/schema/security"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type settingReaderStub struct {
|
||||
@@ -18,6 +30,17 @@ func (s settingReaderStub) GetOrderRiskControlConfig() (settingssecurity.OrderRi
|
||||
return s.config, s.err
|
||||
}
|
||||
|
||||
type countingSettingReader struct {
|
||||
config settingssecurity.OrderRiskControlConfig
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *countingSettingReader) GetOrderRiskControlConfig() (settingssecurity.OrderRiskControlConfig, error) {
|
||||
s.calls++
|
||||
return s.config, s.err
|
||||
}
|
||||
|
||||
type rateLimiterStub struct {
|
||||
calls int
|
||||
input orderriskcontract.CheckInput
|
||||
@@ -79,6 +102,118 @@ func TestCheckOrderAllowed_DisabledByDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPendingOrderAllowedDoesNotReadSettingsInsideSingleConnectionTransaction(t *testing.T) {
|
||||
dsn := filepath.Join(t.TempDir(), "risk-single-connection.db")
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&settingsstore.SettingRecord{},
|
||||
&orderdomain.Order{},
|
||||
&orderdomain.OrderItem{},
|
||||
&orderriskdomain.LockKey{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate risk tables: %v", err)
|
||||
}
|
||||
cfg := testConfig()
|
||||
if err := db.Create(&settingsstore.SettingRecord{
|
||||
Key: constants.SettingKeyOrderRiskControlConfig,
|
||||
ValueJSON: settingssecurity.EncodeOrderRiskControlConfig(cfg),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed enabled risk config: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("get sql db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
|
||||
settings := settingsapp.NewService(settingsstore.New(db))
|
||||
svc := NewService(Options{Settings: settings})
|
||||
orders := orderstore.New(db, "risk-single-connection-secret-with-32-bytes")
|
||||
input := orderriskcontract.CheckInput{
|
||||
IsGuest: true,
|
||||
ClientIP: "1.2.3.4",
|
||||
Items: []orderriskcontract.OrderItem{{ProductID: 7, Quantity: 1}},
|
||||
}
|
||||
prepared, err := svc.CheckOrderAllowed(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare risk check: %v", err)
|
||||
}
|
||||
if !prepared.ConfigSnapshot.Enabled {
|
||||
t.Fatal("expected enabled risk config snapshot")
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- orders.WithinTransaction(func(tx ordercontract.Transaction) error {
|
||||
return svc.CheckPendingOrderAllowed(input, prepared, tx.Orders())
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("pending risk check: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
stats := sqlDB.Stats()
|
||||
// 释放旧实现中等待第二条连接的 goroutine,避免失败测试泄漏。
|
||||
sqlDB.SetMaxOpenConns(2)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
t.Fatalf("pending risk check blocked inside transaction: in_use=%d open=%d wait_count=%d", stats.InUse, stats.OpenConnections, stats.WaitCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingRiskCheckReusesPreparedConfigSnapshot(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
settings := &countingSettingReader{config: cfg}
|
||||
svc := NewService(Options{Settings: settings})
|
||||
input := orderriskcontract.CheckInput{IsGuest: true, ClientIP: "1.2.3.4"}
|
||||
|
||||
prepared, err := svc.CheckOrderAllowed(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare risk check: %v", err)
|
||||
}
|
||||
if err := svc.CheckPendingOrderAllowed(input, prepared, &pendingGateStub{}); err != nil {
|
||||
t.Fatalf("pending risk check: %v", err)
|
||||
}
|
||||
if settings.calls != 1 {
|
||||
t.Fatalf("settings reads=%d, want exactly one before the transaction", settings.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOrderAllowed_SettingsReadFailureFailsClosedOnlyForCreate(t *testing.T) {
|
||||
settingsErr := errors.New("settings unavailable")
|
||||
svc := NewService(Options{Settings: settingReaderStub{err: settingsErr}})
|
||||
|
||||
preview, err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{
|
||||
IsGuest: true,
|
||||
ClientIP: "1.2.3.4",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("preview-style check should preserve fail-open behavior, got %v", err)
|
||||
}
|
||||
if preview.ConfigSnapshot.Enabled {
|
||||
t.Fatal("preview fallback config must keep risk control disabled")
|
||||
}
|
||||
|
||||
_, err = svc.CheckOrderAllowed(orderriskcontract.CheckInput{
|
||||
IsGuest: true,
|
||||
ClientIP: "1.2.3.4",
|
||||
ConsumeRateLimit: true,
|
||||
})
|
||||
if !errors.Is(err, settingsErr) {
|
||||
t.Fatalf("create-style check must fail closed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOrderAllowed_IPBlacklistCanonicalizesAddress(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.Common.IPBlacklist = []string{"1.2.3.4", "10.0.0.0/8"}
|
||||
@@ -161,7 +296,7 @@ func TestCheckPendingOrderAllowed_GuestLocksIPAndCountsOrders(t *testing.T) {
|
||||
IsGuest: true,
|
||||
RiskIP: "1.2.3.4",
|
||||
Items: []orderriskcontract.OrderItem{{ProductID: 1, Quantity: 1}},
|
||||
}, gate)
|
||||
}, orderriskcontract.CheckResult{ConfigSnapshot: cfg}, gate)
|
||||
if !errors.Is(err, orderriskcontract.ErrTooManyPendingOrders) {
|
||||
t.Fatalf("expected guest pending order limit, got %v", err)
|
||||
}
|
||||
@@ -184,7 +319,7 @@ func TestCheckPendingOrderAllowed_GuestCountsPendingProductQuantity(t *testing.T
|
||||
{ProductID: 7, Quantity: 1},
|
||||
{ProductID: 7, Quantity: 1},
|
||||
},
|
||||
}, gate)
|
||||
}, orderriskcontract.CheckResult{ConfigSnapshot: cfg}, gate)
|
||||
if !errors.Is(err, orderriskcontract.ErrPendingProductQuantityLimit) {
|
||||
t.Fatalf("expected pending product quantity limit, got %v", err)
|
||||
}
|
||||
@@ -197,7 +332,11 @@ func TestCheckPendingOrderAllowed_MemberUsesUserAndOptionalIP(t *testing.T) {
|
||||
gate := &pendingGateStub{pendingByUser: 4, pendingMemberIP: 3}
|
||||
svc := NewService(Options{Settings: settingReaderStub{config: cfg}})
|
||||
|
||||
err := svc.CheckPendingOrderAllowed(orderriskcontract.CheckInput{UserID: 9, RiskIP: "1.2.3.4"}, gate)
|
||||
err := svc.CheckPendingOrderAllowed(
|
||||
orderriskcontract.CheckInput{UserID: 9, RiskIP: "1.2.3.4"},
|
||||
orderriskcontract.CheckResult{ConfigSnapshot: cfg},
|
||||
gate,
|
||||
)
|
||||
if !errors.Is(err, orderriskcontract.ErrTooManyPendingOrders) {
|
||||
t.Fatalf("expected member IP pending limit, got %v", err)
|
||||
}
|
||||
|
||||
@@ -23,5 +23,5 @@ type RateLimiter interface {
|
||||
// Controller 是订单上下文调用风控所需的用例端口。
|
||||
type Controller interface {
|
||||
CheckOrderAllowed(input CheckInput) (CheckResult, error)
|
||||
CheckPendingOrderAllowed(input CheckInput, gate PendingOrderGate) error
|
||||
CheckPendingOrderAllowed(input CheckInput, prepared CheckResult, gate PendingOrderGate) error
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package contract
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
settingssecurity "github.com/dujiao-next/internal/modules/settings/schema/security"
|
||||
)
|
||||
|
||||
// OrderItem 是风控所需的最小订单项快照。
|
||||
@@ -22,10 +24,11 @@ type CheckInput struct {
|
||||
Items []OrderItem
|
||||
}
|
||||
|
||||
// CheckResult 返回创建订单后续步骤需要复用的规范化风控上下文。
|
||||
// CheckResult 返回创建订单后续步骤需要复用的不可变风控上下文。
|
||||
type CheckResult struct {
|
||||
RiskIP string
|
||||
PaymentExpireMinutes int
|
||||
ConfigSnapshot settingssecurity.OrderRiskControlConfig
|
||||
}
|
||||
|
||||
// NormalizeRiskIP 生成游客风控键:IPv4 使用完整地址,IPv6 按 /64 前缀聚合。
|
||||
|
||||
Reference in new issue
Block a user