fix: resolve 5 audit findings in channel/credits/scheduling

P0-1: Credits degraded response retry + fail-open
- Add isAntigravityDegradedResponse() to detect transient API failures
- Retry up to 3 times with exponential backoff (500ms/1s/2s)
- Invalidate singleflight cache between retries
- Fail-open after exhausting retries instead of 5h circuit break

P1-1: Fix channel restriction pre-check timing conflict
- Swap checkClaudeCodeRestriction before checkChannelPricingRestriction
- Ensures channel restriction is checked against final fallback groupID

P1-2: Add interval pricing validation (frontend + backend)
- Backend: ValidateIntervals() with boundary, price, overlap checks
- Frontend: validateIntervals() with Chinese error messages
- Rules: MinTokens>=0, MaxTokens>MinTokens, prices>=0, no overlap

P2: Fix cross-platform same-model pricing/mapping override
- Store cache keys using original platform instead of group platform
- Lookup across matching platforms (antigravity→anthropic→gemini)
- Prevents anthropic/gemini same-name models from overwriting each other
This commit is contained in:
erio
2026-04-02 20:28:04 +08:00
parent 6d3ea64a35
commit 71f61bbc47
12 changed files with 1028 additions and 48 deletions

View File

@@ -1401,6 +1401,32 @@ func TestCreate_DuplicateModel(t *testing.T) {
require.Contains(t, err.Error(), "claude-opus-4")
}
func TestCreate_InvalidPricingIntervals(t *testing.T) {
repo := &mockChannelRepository{
existsByNameFn: func(_ context.Context, _ string) (bool, error) {
return false, nil
},
}
svc := newTestChannelService(repo)
_, err := svc.Create(context.Background(), &CreateChannelInput{
Name: "new-channel",
ModelPricing: []ChannelModelPricing{
{
Platform: "anthropic",
Models: []string{"claude-opus-4"},
Intervals: []PricingInterval{
{MinTokens: 0, MaxTokens: testPtrInt(2000), InputPrice: testPtrFloat64(1e-6)},
{MinTokens: 1000, MaxTokens: testPtrInt(3000), InputPrice: testPtrFloat64(2e-6)},
},
},
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "INVALID_PRICING_INTERVALS")
require.Contains(t, err.Error(), "overlap")
}
func TestCreate_DefaultBillingModelSource(t *testing.T) {
var capturedChannel *Channel
repo := &mockChannelRepository{
@@ -1592,6 +1618,37 @@ func TestUpdate_DuplicateModel(t *testing.T) {
require.Contains(t, err.Error(), "claude-opus-4")
}
func TestUpdate_InvalidPricingIntervals(t *testing.T) {
existing := &Channel{
ID: 1,
Name: "original",
Status: StatusActive,
}
repo := &mockChannelRepository{
getByIDFn: func(_ context.Context, _ int64) (*Channel, error) {
return existing.Clone(), nil
},
}
svc := newTestChannelService(repo)
invalidPricing := []ChannelModelPricing{
{
Platform: "anthropic",
Models: []string{"claude-opus-4"},
Intervals: []PricingInterval{
{MinTokens: 0, MaxTokens: nil, InputPrice: testPtrFloat64(1e-6)},
{MinTokens: 2000, MaxTokens: testPtrInt(4000), InputPrice: testPtrFloat64(2e-6)},
},
},
}
_, err := svc.Update(context.Background(), 1, &UpdateChannelInput{
ModelPricing: &invalidPricing,
})
require.Error(t, err)
require.Contains(t, err.Error(), "INVALID_PRICING_INTERVALS")
require.Contains(t, err.Error(), "unbounded")
}
func TestUpdate_InvalidatesChannelCache(t *testing.T) {
existing := &Channel{
ID: 1,
@@ -1984,3 +2041,144 @@ func TestResolveChannelMapping_AntigravityCrossPlatform(t *testing.T) {
require.Equal(t, "claude-opus-4-6", result.MappedModel)
require.Equal(t, int64(1), result.ChannelID)
}
// ===========================================================================
// 11. Antigravity cross-platform same-name model — no overwrite
// ===========================================================================
func TestGetChannelModelPricing_AntigravitySameModelDifferentPlatforms(t *testing.T) {
// anthropic 和 gemini 都定义了同名模型 "shared-model",价格不同。
// antigravity 分组应能分别查到各自的定价,而不是后者覆盖前者。
ch := Channel{
ID: 1,
Status: StatusActive,
GroupIDs: []int64{10},
ModelPricing: []ChannelModelPricing{
{ID: 200, Platform: PlatformAnthropic, Models: []string{"shared-model"}, InputPrice: testPtrFloat64(10e-6)},
{ID: 201, Platform: PlatformGemini, Models: []string{"shared-model"}, InputPrice: testPtrFloat64(5e-6)},
},
}
repo := makeStandardRepo(ch, map[int64]string{10: PlatformAntigravity})
svc := newTestChannelService(repo)
// antigravity 分组查找 "shared-model":应命中第一个匹配(按 matchingPlatforms 顺序 antigravity→anthropic→gemini
result := svc.GetChannelModelPricing(context.Background(), 10, "shared-model")
require.NotNil(t, result, "antigravity group should find pricing for shared-model")
// 第一个匹配应该是 anthropicmatchingPlatforms 返回 [antigravity, anthropic, gemini]
require.Equal(t, int64(200), result.ID)
require.InDelta(t, 10e-6, *result.InputPrice, 1e-12)
}
func TestGetChannelModelPricing_AntigravityOnlyGeminiPricing(t *testing.T) {
// 只有 gemini 平台定义了模型 "gemini-model"。
// antigravity 分组应能查到 gemini 的定价。
ch := Channel{
ID: 1,
Status: StatusActive,
GroupIDs: []int64{10},
ModelPricing: []ChannelModelPricing{
{ID: 300, Platform: PlatformGemini, Models: []string{"gemini-model"}, InputPrice: testPtrFloat64(2e-6)},
},
}
repo := makeStandardRepo(ch, map[int64]string{10: PlatformAntigravity})
svc := newTestChannelService(repo)
result := svc.GetChannelModelPricing(context.Background(), 10, "gemini-model")
require.NotNil(t, result, "antigravity group should find gemini pricing")
require.Equal(t, int64(300), result.ID)
require.InDelta(t, 2e-6, *result.InputPrice, 1e-12)
}
func TestGetChannelModelPricing_AntigravityWildcardCrossPlatformNoOverwrite(t *testing.T) {
// anthropic 和 gemini 都有 "shared-*" 通配符定价,价格不同。
// antigravity 分组查找 "shared-model" 应命中第一个匹配而非被覆盖。
ch := Channel{
ID: 1,
Status: StatusActive,
GroupIDs: []int64{10},
ModelPricing: []ChannelModelPricing{
{ID: 400, Platform: PlatformAnthropic, Models: []string{"shared-*"}, InputPrice: testPtrFloat64(10e-6)},
{ID: 401, Platform: PlatformGemini, Models: []string{"shared-*"}, InputPrice: testPtrFloat64(5e-6)},
},
}
repo := makeStandardRepo(ch, map[int64]string{10: PlatformAntigravity})
svc := newTestChannelService(repo)
result := svc.GetChannelModelPricing(context.Background(), 10, "shared-model")
require.NotNil(t, result, "antigravity group should find wildcard pricing for shared-model")
// 两个通配符都存在,应命中 anthropic 的matchingPlatforms 顺序)
require.Equal(t, int64(400), result.ID)
require.InDelta(t, 10e-6, *result.InputPrice, 1e-12)
}
func TestResolveChannelMapping_AntigravitySameModelDifferentPlatforms(t *testing.T) {
// anthropic 和 gemini 都定义了同名模型映射 "alias" → 不同目标。
// antigravity 分组应命中 anthropic 的映射(按 matchingPlatforms 顺序)。
ch := Channel{
ID: 1,
Status: StatusActive,
GroupIDs: []int64{10},
ModelMapping: map[string]map[string]string{
PlatformAnthropic: {"alias": "anthropic-target"},
PlatformGemini: {"alias": "gemini-target"},
},
}
repo := makeStandardRepo(ch, map[int64]string{10: PlatformAntigravity})
svc := newTestChannelService(repo)
result := svc.ResolveChannelMapping(context.Background(), 10, "alias")
require.True(t, result.Mapped)
require.Equal(t, "anthropic-target", result.MappedModel)
}
func TestCheckRestricted_AntigravitySameModelDifferentPlatforms(t *testing.T) {
// anthropic 和 gemini 都定义了同名模型 "shared-model"。
// antigravity 分组启用了 RestrictModels"shared-model" 应不被限制。
ch := Channel{
ID: 1,
Status: StatusActive,
RestrictModels: true,
GroupIDs: []int64{10},
ModelPricing: []ChannelModelPricing{
{ID: 500, Platform: PlatformAnthropic, Models: []string{"shared-model"}, InputPrice: testPtrFloat64(10e-6)},
{ID: 501, Platform: PlatformGemini, Models: []string{"shared-model"}, InputPrice: testPtrFloat64(5e-6)},
},
}
repo := makeStandardRepo(ch, map[int64]string{10: PlatformAntigravity})
svc := newTestChannelService(repo)
restricted := svc.IsModelRestricted(context.Background(), 10, "shared-model")
require.False(t, restricted, "shared-model should not be restricted for antigravity")
// 未定义的模型应被限制
restricted = svc.IsModelRestricted(context.Background(), 10, "unknown-model")
require.True(t, restricted, "unknown-model should be restricted for antigravity")
}
func TestGetChannelModelPricing_NonAntigravityUnaffected(t *testing.T) {
// 确保非 antigravity 平台的行为不受影响。
// anthropic 分组只能看到 anthropic 的定价,看不到 gemini 的。
ch := Channel{
ID: 1,
Status: StatusActive,
GroupIDs: []int64{10, 20},
ModelPricing: []ChannelModelPricing{
{ID: 600, Platform: PlatformAnthropic, Models: []string{"shared-model"}, InputPrice: testPtrFloat64(10e-6)},
{ID: 601, Platform: PlatformGemini, Models: []string{"shared-model"}, InputPrice: testPtrFloat64(5e-6)},
},
}
repo := makeStandardRepo(ch, map[int64]string{10: PlatformAnthropic, 20: PlatformGemini})
svc := newTestChannelService(repo)
// anthropic 分组应该只看到 anthropic 的定价
result := svc.GetChannelModelPricing(context.Background(), 10, "shared-model")
require.NotNil(t, result)
require.Equal(t, int64(600), result.ID)
require.InDelta(t, 10e-6, *result.InputPrice, 1e-12)
// gemini 分组应该只看到 gemini 的定价
result = svc.GetChannelModelPricing(context.Background(), 20, "shared-model")
require.NotNil(t, result)
require.Equal(t, int64(601), result.ID)
require.InDelta(t, 5e-6, *result.InputPrice, 1e-12)
}