mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6c80e8aa8 | ||
|
|
8aef24f3a6 | ||
|
|
1922c271c8 | ||
|
|
b3261c94ea | ||
|
|
625c691b63 | ||
|
|
f08f9111e5 | ||
|
|
57a2e7184b | ||
|
|
0df861f450 | ||
|
|
7d67eb083a | ||
|
|
1cdab6f858 | ||
|
|
9be516448e | ||
|
|
8ba2fbd786 | ||
|
|
f9fac7acbe |
@@ -28,6 +28,23 @@ jobs:
|
||||
working-directory: backend
|
||||
run: make test-integration
|
||||
|
||||
# 插件化改造回归安全网:特征化/不变量测试作为独立命名的合并门禁
|
||||
# (这些测试也包含在 test-unit 中,独立 job 是为了回归信号清晰可辨)
|
||||
# 详见 .claude/plugin-refactor/INVARIANTS.md
|
||||
invariants:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: backend/go.mod
|
||||
check-latest: false
|
||||
cache: true
|
||||
cache-dependency-path: backend/go.sum
|
||||
- name: Regression invariants (plugin-refactor safety net)
|
||||
working-directory: backend
|
||||
run: make test-invariants
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -133,6 +133,11 @@ docs/*
|
||||
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
|
||||
!docs/legal/
|
||||
!docs/legal/*.md
|
||||
!docs/plugin-architecture/
|
||||
!docs/plugin-architecture/*.md
|
||||
!backend/scripts/
|
||||
backend/scripts/*
|
||||
!backend/scripts/bench-baseline.sh
|
||||
.serena/
|
||||
.codex/
|
||||
frontend/coverage/
|
||||
|
||||
+15
-2
@@ -1,4 +1,4 @@
|
||||
.PHONY: build generate test test-unit test-integration test-e2e
|
||||
.PHONY: build generate test test-unit test-integration test-e2e test-invariants new-module
|
||||
|
||||
VERSION ?= $(shell tr -d '\r\n' < ./cmd/server/VERSION)
|
||||
LDFLAGS ?= -s -w -X main.Version=$(VERSION)
|
||||
@@ -20,8 +20,21 @@ test-unit:
|
||||
test-integration:
|
||||
go test -tags=integration ./...
|
||||
|
||||
# 插件化改造回归安全网(特征化/不变量测试,详见 .claude/plugin-refactor/INVARIANTS.md)
|
||||
test-invariants:
|
||||
go test -tags=unit -count=1 -run 'Characterization|Invariant' ./internal/...
|
||||
|
||||
# 生成插件模块骨架:make new-module ID=job.foo
|
||||
# (生成后按提示在 internal/modules/standard/imports.go 手工加 import 行)
|
||||
new-module:
|
||||
ifndef ID
|
||||
$(error ID is required: make new-module ID=job.foo)
|
||||
endif
|
||||
go run ./tools/newmodule -id "$(ID)"
|
||||
|
||||
# scripts/e2e-test.sh 已不存在,test-e2e 与 test-e2e-local 等价(env 驱动的真实服务 e2e)
|
||||
test-e2e:
|
||||
./scripts/e2e-test.sh
|
||||
go test -tags=e2e -v -timeout=300s ./internal/integration/...
|
||||
|
||||
test-e2e-local:
|
||||
go test -tags=e2e -v -timeout=300s ./internal/integration/...
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
_ "github.com/Wei-Shaw/sub2api/ent/runtime"
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||
_ "github.com/Wei-Shaw/sub2api/internal/modules/standard" // 插件模块插装清单(唯一插装点)
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/setup"
|
||||
@@ -154,6 +155,21 @@ func runMainServer() {
|
||||
}
|
||||
defer app.Cleanup()
|
||||
|
||||
// 启动插件模块:Build(实例化 → Provision → Validate)+ Start(按稳定序)。
|
||||
// 任一失败立即中止启动(fail-fast)。注意:log.Fatalf 经 os.Exit 退出,
|
||||
// defer 的 app.Cleanup() 不会执行;而部分后台服务在 Wire 构造期已自启动并
|
||||
// 持有 Redis leader 锁,必须先显式 Cleanup 再退出,避免锁滞留到 TTL 过期。
|
||||
// ⚠ 不要把下面的 log.Fatalf 改成 return:那会让上面 defer 的 Cleanup 与
|
||||
// 此处显式 Cleanup 双触发,而 Cleanup 各步骤并未统一保证幂等(审计 B-2)。
|
||||
if err := app.Runtime.Build(); err != nil {
|
||||
app.Cleanup()
|
||||
log.Fatalf("Failed to build plugin modules: %v", err)
|
||||
}
|
||||
if err := app.Runtime.Start(context.Background()); err != nil {
|
||||
app.Cleanup()
|
||||
log.Fatalf("Failed to start plugin modules: %v", err)
|
||||
}
|
||||
|
||||
// 启动服务器
|
||||
go func() {
|
||||
if err := app.Server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/Wei-Shaw/sub2api/internal/plugin"
|
||||
"github.com/Wei-Shaw/sub2api/internal/repository"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
@@ -24,7 +25,10 @@ import (
|
||||
)
|
||||
|
||||
type Application struct {
|
||||
Server *http.Server
|
||||
Server *http.Server
|
||||
// Runtime 是插件模块生命周期驱动器:main 在 HTTP server 启动前
|
||||
// 调用 Build + Start,关闭时由 Cleanup 序列执行 Stop。
|
||||
Runtime *plugin.Runtime
|
||||
Cleanup func()
|
||||
}
|
||||
|
||||
@@ -43,6 +47,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
// Server layer ProviderSet
|
||||
server.ProviderSet,
|
||||
|
||||
// Plugin kernel ProviderSet (Host + module Runtime)
|
||||
plugin.ProviderSet,
|
||||
|
||||
// Privacy client factory for OpenAI training opt-out
|
||||
providePrivacyClientFactory,
|
||||
|
||||
@@ -53,7 +60,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
provideCleanup,
|
||||
|
||||
// Application struct
|
||||
wire.Struct(new(Application), "Server", "Cleanup"),
|
||||
wire.Struct(new(Application), "Server", "Runtime", "Cleanup"),
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
@@ -100,6 +107,7 @@ func provideCleanup(
|
||||
paymentOrderExpiry *service.PaymentOrderExpiryService,
|
||||
channelMonitorRunner *service.ChannelMonitorRunner,
|
||||
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
|
||||
moduleRuntime *plugin.Runtime,
|
||||
) func() {
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -112,6 +120,14 @@ func provideCleanup(
|
||||
|
||||
// 应用层清理步骤可并行执行,基础设施资源(Redis/Ent)最后按顺序关闭。
|
||||
parallelSteps := []cleanupStep{
|
||||
// 插件模块 Stop 属于业务服务关闭组:模块停止时可能仍需写 DB/Redis,
|
||||
// 必须先于基础设施(Redis/Ent)关闭执行。
|
||||
{"PluginModuleRuntime", func() error {
|
||||
if moduleRuntime == nil {
|
||||
return nil
|
||||
}
|
||||
return moduleRuntime.Stop(ctx)
|
||||
}},
|
||||
{"OpsScheduledReportService", func() error {
|
||||
if opsScheduledReport != nil {
|
||||
opsScheduledReport.Stop()
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/Wei-Shaw/sub2api/internal/plugin"
|
||||
"github.com/Wei-Shaw/sub2api/internal/repository"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
@@ -27,6 +28,7 @@ import (
|
||||
import (
|
||||
_ "embed"
|
||||
_ "github.com/Wei-Shaw/sub2api/ent/runtime"
|
||||
_ "github.com/Wei-Shaw/sub2api/internal/modules/standard"
|
||||
)
|
||||
|
||||
// Injectors from wire.go:
|
||||
@@ -241,12 +243,21 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
|
||||
affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService)
|
||||
complianceHandler := admin.NewComplianceHandler(settingService)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler, complianceHandler)
|
||||
pluginConfig, err := plugin.ProvideModuleConfig(configConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host := plugin.ProvideHost(pluginConfig, client, redisClient)
|
||||
runtime := plugin.ProvideModuleRuntime(host, pluginConfig)
|
||||
moduleHandler := admin.NewModuleHandler(runtime)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler, complianceHandler, moduleHandler)
|
||||
gatewayplatformRegistry := handler.ProvideGatewayPlatformRegistry(gatewayService, antigravityGatewayService)
|
||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||
chain := handler.ProvideGatewayHookChain(contentModerationService)
|
||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService)
|
||||
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, configConfig)
|
||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, gatewayplatformRegistry, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, chain, userMessageQueueService, configConfig, settingService)
|
||||
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, chain, configConfig)
|
||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
|
||||
totpHandler := handler.NewTotpHandler(totpService)
|
||||
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService)
|
||||
@@ -273,9 +284,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db)
|
||||
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
|
||||
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, runtime)
|
||||
application := &Application{
|
||||
Server: httpServer,
|
||||
Runtime: runtime,
|
||||
Cleanup: v,
|
||||
}
|
||||
return application, nil
|
||||
@@ -284,7 +296,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
// wire.go:
|
||||
|
||||
type Application struct {
|
||||
Server *http.Server
|
||||
Server *http.Server
|
||||
// Runtime 是插件模块生命周期驱动器:main 在 HTTP server 启动前
|
||||
// 调用 Build + Start,关闭时由 Cleanup 序列执行 Stop。
|
||||
Runtime *plugin.Runtime
|
||||
Cleanup func()
|
||||
}
|
||||
|
||||
@@ -330,6 +345,7 @@ func provideCleanup(
|
||||
paymentOrderExpiry *service.PaymentOrderExpiryService,
|
||||
channelMonitorRunner *service.ChannelMonitorRunner,
|
||||
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
|
||||
moduleRuntime *plugin.Runtime,
|
||||
) func() {
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -341,6 +357,13 @@ func provideCleanup(
|
||||
}
|
||||
|
||||
parallelSteps := []cleanupStep{
|
||||
|
||||
{"PluginModuleRuntime", func() error {
|
||||
if moduleRuntime == nil {
|
||||
return nil
|
||||
}
|
||||
return moduleRuntime.Stop(ctx)
|
||||
}},
|
||||
{"OpsScheduledReportService", func() error {
|
||||
if opsScheduledReport != nil {
|
||||
opsScheduledReport.Stop()
|
||||
|
||||
@@ -80,6 +80,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
nil, // paymentOrderExpiry
|
||||
nil, // channelMonitorRunner
|
||||
nil, // quotaFlusher
|
||||
nil, // moduleRuntime
|
||||
)
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
|
||||
+1
-1
@@ -119,7 +119,7 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mdelapenya/tlscert v0.2.0 // indirect
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.1.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
|
||||
@@ -93,6 +93,15 @@ type Config struct {
|
||||
Gemini GeminiConfig `mapstructure:"gemini"`
|
||||
Update UpdateConfig `mapstructure:"update"`
|
||||
Idempotency IdempotencyConfig `mapstructure:"idempotency"`
|
||||
|
||||
// Modules 是插件模块配置子树(modules:)的原始内容。
|
||||
// 顶层 key 为模块 ID 字面量(如 "job.hello"),value 为该模块的原始配置
|
||||
// (含可选 enabled 键),语义解析由 internal/plugin 包负责。
|
||||
//
|
||||
// 注意:该字段不走 viper.Unmarshal(mapstructure:"-"),而是在 load() 中通过
|
||||
// viper.Get 手工提取——Unmarshal 基于 AllSettings 重建嵌套结构,会把含点的
|
||||
// 模块 ID key 错误拆分为多级嵌套。缺省(未配置)时为空 map,行为与现状完全等价。
|
||||
Modules map[string]map[string]any `mapstructure:"-" yaml:"-"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
@@ -1397,6 +1406,11 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
|
||||
if err := viper.Unmarshal(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal config error: %w", err)
|
||||
}
|
||||
modules, err := normalizeModulesSubtree(viper.Get("modules"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse modules config error: %w", err)
|
||||
}
|
||||
cfg.Modules = modules
|
||||
if cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs == 0 {
|
||||
cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs = 15000
|
||||
}
|
||||
@@ -1530,6 +1544,33 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// normalizeModulesSubtree 将 viper 读取的 modules: 原始子树规整为
|
||||
// map[模块ID]map[配置键]值。raw 为 nil(未配置)时返回空 map(零行为影响);
|
||||
// 子树或某个模块项不是映射时返回错误。空模块项(如 "job.hello:" 后无内容)
|
||||
// 规整为空 map,表示"提及但无私有配置"。
|
||||
func normalizeModulesSubtree(raw any) (map[string]map[string]any, error) {
|
||||
result := make(map[string]map[string]any)
|
||||
if raw == nil {
|
||||
return result, nil
|
||||
}
|
||||
top, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("modules: expected a mapping, got %T", raw)
|
||||
}
|
||||
for key, value := range top {
|
||||
if value == nil {
|
||||
result[key] = map[string]any{}
|
||||
continue
|
||||
}
|
||||
entry, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("modules.%s: expected a mapping, got %T", key, value)
|
||||
}
|
||||
result[key] = entry
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func setDefaults() {
|
||||
viper.SetDefault("run_mode", RunModeStandard)
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestLoadModulesSubtreeDefaultEmpty 验证不配置 modules: 子树时,
|
||||
// cfg.Modules 为空(非 nil)map,解析结果与现状完全等价。
|
||||
func TestLoadModulesSubtreeDefaultEmpty(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg.Modules)
|
||||
require.Empty(t, cfg.Modules)
|
||||
}
|
||||
|
||||
// TestLoadModulesSubtreeParsing 验证 modules: 子树解析:
|
||||
// 含点的模块 ID 必须作为完整 key 保留(不被 viper 按 "." 拆分为多级嵌套)。
|
||||
func TestLoadModulesSubtreeParsing(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
configPath := filepath.Join(tempDir, "config.yaml")
|
||||
yaml := `
|
||||
modules:
|
||||
job.hello:
|
||||
enabled: true
|
||||
greeting: "hi"
|
||||
payment.provider.stripe:
|
||||
enabled: false
|
||||
job.bare:
|
||||
`
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(yaml), 0o644))
|
||||
t.Setenv("DATA_DIR", tempDir)
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, cfg.Modules, 3)
|
||||
|
||||
require.Equal(t, map[string]any{"enabled": true, "greeting": "hi"}, cfg.Modules["job.hello"])
|
||||
require.Equal(t, map[string]any{"enabled": false}, cfg.Modules["payment.provider.stripe"])
|
||||
// 空模块项规整为空 map(提及但无私有配置)。
|
||||
require.NotNil(t, cfg.Modules["job.bare"])
|
||||
require.Empty(t, cfg.Modules["job.bare"])
|
||||
}
|
||||
|
||||
// TestLoadModulesSubtreeInvalidShape 验证 modules 子树形状非法时报错。
|
||||
func TestLoadModulesSubtreeInvalidShape(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
configPath := filepath.Join(tempDir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(configPath, []byte("modules:\n job.hello: true\n"), 0o644))
|
||||
t.Setenv("DATA_DIR", tempDir)
|
||||
|
||||
_, err := Load()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "modules")
|
||||
}
|
||||
|
||||
// TestNormalizeModulesSubtree 直接覆盖规整函数的边界分支。
|
||||
func TestNormalizeModulesSubtree(t *testing.T) {
|
||||
t.Run("nil input", func(t *testing.T) {
|
||||
out, err := normalizeModulesSubtree(nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out)
|
||||
require.Empty(t, out)
|
||||
})
|
||||
t.Run("non-map top level", func(t *testing.T) {
|
||||
_, err := normalizeModulesSubtree([]any{"x"})
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("non-map entry", func(t *testing.T) {
|
||||
_, err := normalizeModulesSubtree(map[string]any{"job.hello": 42})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "job.hello")
|
||||
})
|
||||
t.Run("nil entry becomes empty map", func(t *testing.T) {
|
||||
out, err := normalizeModulesSubtree(map[string]any{"job.hello": nil})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out["job.hello"])
|
||||
require.Empty(t, out["job.hello"])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package gatewayhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Chain 按固定顺序执行 pre-flight 钩子。
|
||||
//
|
||||
// 执行语义(SEAM-DESIGN 裁决 H 链层三件必补):
|
||||
// 1. 每钩子 recover():panic 记 error 日志后按 fail-open 继续下一钩子;
|
||||
// 2. 钩子返回 error 默认 fail-open:记日志后继续下一钩子(与内容审核现状一致);
|
||||
// 3. 首个 Blocked Decision 即短路返回;非 Blocked 的 Decision 视为放行并继续。
|
||||
//
|
||||
// Run 永不向调用方返回 error:返回 nil 即放行。
|
||||
type Chain struct {
|
||||
hooks []PreFlightHook
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewChain 构造钩子链。log 传 nil 时延迟回退到进程全局 logger;
|
||||
// nil 钩子被忽略。
|
||||
func NewChain(log *zap.Logger, hooks ...PreFlightHook) *Chain {
|
||||
filtered := make([]PreFlightHook, 0, len(hooks))
|
||||
for _, hook := range hooks {
|
||||
if hook != nil {
|
||||
filtered = append(filtered, hook)
|
||||
}
|
||||
}
|
||||
return &Chain{hooks: filtered, log: log}
|
||||
}
|
||||
|
||||
// IsEmpty 报告链上是否没有任何钩子(nil 链视为空链)。
|
||||
// 调用侧可据此零开销跳过钩子请求的构造。
|
||||
func (c *Chain) IsEmpty() bool {
|
||||
return c == nil || len(c.hooks) == 0
|
||||
}
|
||||
|
||||
// Run 按序执行钩子,返回首个 Blocked Decision;全部放行(或链为空)返回 nil。
|
||||
func (c *Chain) Run(ctx context.Context, req *Request) *Decision {
|
||||
if c.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
for _, hook := range c.hooks {
|
||||
if decision := c.runHook(ctx, hook, req); decision != nil && decision.Blocked {
|
||||
return decision
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runHook 执行单个钩子并隔离其 panic / error(均按 fail-open 降级为放行)。
|
||||
func (c *Chain) runHook(ctx context.Context, hook PreFlightHook, req *Request) (decision *Decision) {
|
||||
hookID := ""
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
c.logger().Error("gatewayhook.hook_panic",
|
||||
zap.String("hook_id", hookID),
|
||||
zap.Any("panic_value", r),
|
||||
zap.Stack("stack"))
|
||||
decision = nil // fail-open:panic 钩子视为放行
|
||||
}
|
||||
}()
|
||||
hookID = hook.HookID()
|
||||
d, err := hook.CheckPreFlight(ctx, req)
|
||||
if err != nil {
|
||||
c.logger().Warn("gatewayhook.hook_error",
|
||||
zap.String("hook_id", hookID),
|
||||
zap.Error(err))
|
||||
return nil // fail-open:钩子自身故障视为放行
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (c *Chain) logger() *zap.Logger {
|
||||
if c != nil && c.log != nil {
|
||||
return c.log
|
||||
}
|
||||
return logger.L()
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package gatewayhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
// stubHook 是可编程的测试钩子,记录自己是否被执行。
|
||||
type stubHook struct {
|
||||
id string
|
||||
decision *Decision
|
||||
err error
|
||||
panicVal any
|
||||
calls *[]string
|
||||
}
|
||||
|
||||
func (h *stubHook) HookID() string { return h.id }
|
||||
|
||||
func (h *stubHook) CheckPreFlight(ctx context.Context, req *Request) (*Decision, error) {
|
||||
if h.calls != nil {
|
||||
*h.calls = append(*h.calls, h.id)
|
||||
}
|
||||
if h.panicVal != nil {
|
||||
panic(h.panicVal)
|
||||
}
|
||||
return h.decision, h.err
|
||||
}
|
||||
|
||||
func TestChainRun_ExecutesHooksInOrder(t *testing.T) {
|
||||
var calls []string
|
||||
chain := NewChain(zap.NewNop(),
|
||||
&stubHook{id: "first", calls: &calls},
|
||||
&stubHook{id: "second", calls: &calls},
|
||||
&stubHook{id: "third", calls: &calls},
|
||||
)
|
||||
|
||||
decision := chain.Run(context.Background(), &Request{})
|
||||
|
||||
require.Nil(t, decision, "全部放行时 Run 必须返回 nil")
|
||||
require.Equal(t, []string{"first", "second", "third"}, calls, "钩子必须按注册顺序执行")
|
||||
}
|
||||
|
||||
func TestChainRun_FirstBlockedShortCircuits(t *testing.T) {
|
||||
var calls []string
|
||||
blocked := &Decision{Blocked: true, StatusCode: 403, ErrorType: "content_policy_violation", Message: "blocked"}
|
||||
chain := NewChain(zap.NewNop(),
|
||||
&stubHook{id: "pass", calls: &calls},
|
||||
&stubHook{id: "block", decision: blocked, calls: &calls},
|
||||
&stubHook{id: "never", calls: &calls},
|
||||
)
|
||||
|
||||
decision := chain.Run(context.Background(), &Request{})
|
||||
|
||||
require.Same(t, blocked, decision, "必须原样返回首个 Blocked Decision")
|
||||
require.Equal(t, []string{"pass", "block"}, calls, "Blocked 之后的钩子不得执行")
|
||||
}
|
||||
|
||||
func TestChainRun_NonBlockedDecisionContinues(t *testing.T) {
|
||||
var calls []string
|
||||
chain := NewChain(zap.NewNop(),
|
||||
&stubHook{id: "flag-only", decision: &Decision{Blocked: false, Message: "flagged"}, calls: &calls},
|
||||
&stubHook{id: "after", calls: &calls},
|
||||
)
|
||||
|
||||
decision := chain.Run(context.Background(), &Request{})
|
||||
|
||||
require.Nil(t, decision, "非 Blocked 的 Decision 视为放行")
|
||||
require.Equal(t, []string{"flag-only", "after"}, calls)
|
||||
}
|
||||
|
||||
func TestChainRun_PanicIsolatedAndFailOpen(t *testing.T) {
|
||||
core, logs := observer.New(zap.ErrorLevel)
|
||||
var calls []string
|
||||
blocked := &Decision{Blocked: true, StatusCode: 403, Message: "blocked"}
|
||||
chain := NewChain(zap.New(core),
|
||||
&stubHook{id: "boomer", panicVal: "boom", calls: &calls},
|
||||
&stubHook{id: "blocker", decision: blocked, calls: &calls},
|
||||
)
|
||||
|
||||
decision := chain.Run(context.Background(), &Request{})
|
||||
|
||||
require.Same(t, blocked, decision, "panic 钩子 fail-open 后链必须继续执行后续钩子")
|
||||
require.Equal(t, []string{"boomer", "blocker"}, calls)
|
||||
|
||||
entries := logs.FilterMessage("gatewayhook.hook_panic").All()
|
||||
require.Len(t, entries, 1, "panic 必须记一条 error 日志")
|
||||
fields := entries[0].ContextMap()
|
||||
require.Equal(t, "boomer", fields["hook_id"])
|
||||
require.Equal(t, "boom", fields["panic_value"])
|
||||
|
||||
// 仅 panic 钩子的链:整体放行。
|
||||
onlyPanic := NewChain(zap.New(core), &stubHook{id: "boomer2", panicVal: errors.New("kaboom")})
|
||||
require.Nil(t, onlyPanic.Run(context.Background(), &Request{}))
|
||||
}
|
||||
|
||||
func TestChainRun_HookErrorFailOpen(t *testing.T) {
|
||||
core, logs := observer.New(zap.WarnLevel)
|
||||
var calls []string
|
||||
chain := NewChain(zap.New(core),
|
||||
// 即使 error 钩子同时返回了 Blocked Decision,error 优先按 fail-open 丢弃。
|
||||
&stubHook{id: "broken", decision: &Decision{Blocked: true}, err: errors.New("hook exploded"), calls: &calls},
|
||||
&stubHook{id: "after", calls: &calls},
|
||||
)
|
||||
|
||||
decision := chain.Run(context.Background(), &Request{})
|
||||
|
||||
require.Nil(t, decision, "钩子 error 必须 fail-open 放行")
|
||||
require.Equal(t, []string{"broken", "after"}, calls, "error 钩子之后链必须继续")
|
||||
|
||||
entries := logs.FilterMessage("gatewayhook.hook_error").All()
|
||||
require.Len(t, entries, 1)
|
||||
require.Equal(t, "broken", entries[0].ContextMap()["hook_id"])
|
||||
}
|
||||
|
||||
func TestChainRun_EmptyChain(t *testing.T) {
|
||||
require.Nil(t, NewChain(zap.NewNop()).Run(context.Background(), &Request{}))
|
||||
require.True(t, NewChain(zap.NewNop()).IsEmpty())
|
||||
|
||||
var nilChain *Chain
|
||||
require.True(t, nilChain.IsEmpty(), "nil 链视为空链")
|
||||
require.Nil(t, nilChain.Run(context.Background(), &Request{}), "nil 链 Run 必须安全放行")
|
||||
|
||||
// nil 钩子在构造时被过滤。
|
||||
require.True(t, NewChain(zap.NewNop(), nil, nil).IsEmpty())
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Package gatewayhook 定义网关 pre-flight 钩子链(Phase-2 SEAM-DESIGN 裁决 H)。
|
||||
//
|
||||
// 设计要点:
|
||||
// - 独立包:不依赖 internal/handler 与 internal/server/middleware(CallerInfo
|
||||
// 即为与 middleware.AuthSubject 解耦的最小身份视图);
|
||||
// - 链只产 Decision,错误格式化留在各调用点(5 种 HTTP 拦截格式差异由
|
||||
// 特征化测试锁定,与协议常量无映射关系);
|
||||
// - 核心钩子(如内容审核)由 Wire 装配;gateway.hook.* 命名空间的模块收集
|
||||
// 机制留待 Phase-3 与平台 Provider 需求合并设计,本包不做 Runtime 收集。
|
||||
package gatewayhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
// CallerInfo 是调用方身份的最小视图(与 middleware.AuthSubject 解耦)。
|
||||
type CallerInfo struct {
|
||||
// UserID 为认证后的用户 ID。
|
||||
UserID int64
|
||||
// GroupID 为 API Key 所属分组 ID(可能为 nil)。
|
||||
GroupID *int64
|
||||
// KeyID 为平台 API Key ID。
|
||||
KeyID int64
|
||||
}
|
||||
|
||||
// RequestHeaders 是入站请求头的只读访问器(不暴露 gin.Context / http.Header 本体)。
|
||||
type RequestHeaders struct {
|
||||
header http.Header
|
||||
}
|
||||
|
||||
// NewRequestHeaders 包装 http.Header 为只读访问器;传 nil 得到空访问器。
|
||||
func NewRequestHeaders(header http.Header) RequestHeaders {
|
||||
return RequestHeaders{header: header}
|
||||
}
|
||||
|
||||
// Get 返回指定 key 的首个 header 值(语义同 http.Header.Get)。
|
||||
func (r RequestHeaders) Get(key string) string {
|
||||
if r.header == nil {
|
||||
return ""
|
||||
}
|
||||
return r.header.Get(key)
|
||||
}
|
||||
|
||||
// Values 返回指定 key 的全部 header 值(语义同 http.Header.Values)。
|
||||
func (r RequestHeaders) Values(key string) []string {
|
||||
if r.header == nil {
|
||||
return nil
|
||||
}
|
||||
return r.header.Values(key)
|
||||
}
|
||||
|
||||
// Request 是 pre-flight 钩子的协议无关只读入参。
|
||||
type Request struct {
|
||||
// Protocol 复用 service.ContentModerationProtocol* 现有常量值
|
||||
// (如 "anthropic_messages" / "openai_chat_completions"),禁止新造枚举。
|
||||
Protocol string
|
||||
// Model 为请求的模型名(未做 TrimSpace,由钩子按需规范化)。
|
||||
Model string
|
||||
// Body 为送检请求体。各调用点保留自己的入参表达式
|
||||
// (如 images 调用点传 parsed.ModerationBody()),不强行统一。
|
||||
Body []byte
|
||||
// Caller 为调用方最小身份视图。
|
||||
Caller CallerInfo
|
||||
// APIKey 为完整平台 API Key 上下文(audit 类钩子需要;可能为 nil)。
|
||||
APIKey *service.APIKey
|
||||
// Headers 为入站请求头的只读访问器。
|
||||
Headers RequestHeaders
|
||||
// Path 为入站端点路径(调用侧按 inbound endpoint 归一化后传入)。
|
||||
Path string
|
||||
}
|
||||
|
||||
// Decision 是钩子的协议无关决策;nil 表示放行。
|
||||
// ErrorType 对 gemini 调用点无效(googleError 格式化器不消费该字段)。
|
||||
type Decision struct {
|
||||
Blocked bool
|
||||
StatusCode int
|
||||
ErrorType string
|
||||
Message string
|
||||
}
|
||||
|
||||
// PreFlightHook 是网关转发前的拦截钩子。
|
||||
//
|
||||
// 契约(链层强制,见 chain.go):
|
||||
// - 返回 (nil, nil) 表示放行;
|
||||
// - 返回 Blocked Decision 表示拦截(链短路返回);
|
||||
// - 返回 error 表示钩子自身故障,链层按 fail-open 处理(记日志后继续下一钩子);
|
||||
// - 钩子内 panic 同样被链层隔离并按 fail-open 降级。
|
||||
type PreFlightHook interface {
|
||||
// HookID 返回钩子的稳定标识(用于日志与排障)。
|
||||
HookID() string
|
||||
// CheckPreFlight 对请求执行转发前检查。
|
||||
CheckPreFlight(ctx context.Context, req *Request) (*Decision, error)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package gatewayplatform
|
||||
|
||||
// 本文件的三个 adapter 仅包装现有 service 的 Forward 入口(实现零移动)。
|
||||
//
|
||||
// 错误透传铁律(SEAM-DESIGN 裁决 / T4 特征化锁定):底层返回的 error 一律
|
||||
// 原样 return,禁止任何包裹——handler 对 *service.BetaBlockedError /
|
||||
// *service.PromptTooLongError / *service.UpstreamFailoverError 的 errors.As
|
||||
// 断言链依赖错误原值。
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// geminiForwardAction 是 /v1/messages gemini 分支传给 ForwardGemini 的 action
|
||||
// 常量(SEAM-DESIGN 裁决:硬编码 "generateContent" + 测试锁定;该参数仅决定
|
||||
// 客户端响应形态合法性,上游恒为 streamGenerateContent)。
|
||||
const geminiForwardAction = "generateContent"
|
||||
|
||||
// AnthropicProvider 包装 GatewayService.Forward(Claude 协议主路径;
|
||||
// :794 条件不命中侧,含 antigravity 平台的 APIKey 账号)。
|
||||
type AnthropicProvider struct {
|
||||
gateway *service.GatewayService
|
||||
}
|
||||
|
||||
// NewAnthropicProvider 构造 anthropic 平台 Provider。
|
||||
func NewAnthropicProvider(gateway *service.GatewayService) *AnthropicProvider {
|
||||
return &AnthropicProvider{gateway: gateway}
|
||||
}
|
||||
|
||||
// Platform 返回 service.PlatformAnthropic。
|
||||
func (p *AnthropicProvider) Platform() string { return service.PlatformAnthropic }
|
||||
|
||||
// Forward 转发到 GatewayService.Forward;error 原样透传。
|
||||
func (p *AnthropicProvider) Forward(ctx context.Context, c *gin.Context, account *service.Account, req *ForwardRequest) (*service.ForwardResult, error) {
|
||||
return p.gateway.Forward(ctx, c, account, req.Parsed)
|
||||
}
|
||||
|
||||
// AntigravityProvider 包装 AntigravityGatewayService.Forward(Claude 协议 →
|
||||
// antigravity 上游;:794 条件命中侧:Platform == Antigravity && Type != APIKey)。
|
||||
type AntigravityProvider struct {
|
||||
antigravity *service.AntigravityGatewayService
|
||||
}
|
||||
|
||||
// NewAntigravityProvider 构造 antigravity 平台 Provider。
|
||||
func NewAntigravityProvider(antigravity *service.AntigravityGatewayService) *AntigravityProvider {
|
||||
return &AntigravityProvider{antigravity: antigravity}
|
||||
}
|
||||
|
||||
// Platform 返回 service.PlatformAntigravity。
|
||||
func (p *AntigravityProvider) Platform() string { return service.PlatformAntigravity }
|
||||
|
||||
// Forward 转发到 AntigravityGatewayService.Forward;error 原样透传。
|
||||
func (p *AntigravityProvider) Forward(ctx context.Context, c *gin.Context, account *service.Account, req *ForwardRequest) (*service.ForwardResult, error) {
|
||||
return p.antigravity.Forward(ctx, c, account, req.Body, req.IsStickySession)
|
||||
}
|
||||
|
||||
// GeminiProvider 包装 AntigravityGatewayService.ForwardGemini(gemini 平台
|
||||
// 分组下的 antigravity 账号,:444 命中侧;gemini 平台的非 antigravity 账号
|
||||
// 走 GeminiMessagesCompatService,映射逻辑保留在调用点)。
|
||||
type GeminiProvider struct {
|
||||
antigravity *service.AntigravityGatewayService
|
||||
}
|
||||
|
||||
// NewGeminiProvider 构造 gemini 平台 Provider。
|
||||
func NewGeminiProvider(antigravity *service.AntigravityGatewayService) *GeminiProvider {
|
||||
return &GeminiProvider{antigravity: antigravity}
|
||||
}
|
||||
|
||||
// Platform 返回 service.PlatformGemini。
|
||||
func (p *GeminiProvider) Platform() string { return service.PlatformGemini }
|
||||
|
||||
// Forward 转发到 AntigravityGatewayService.ForwardGemini;error 原样透传。
|
||||
// model/stream 取自 req.Parsed(与原调用点 reqModel/reqStream 同源),
|
||||
// 粘性会话维度经 WithForwardGeminiSession 透传。
|
||||
func (p *GeminiProvider) Forward(ctx context.Context, c *gin.Context, account *service.Account, req *ForwardRequest) (*service.ForwardResult, error) {
|
||||
return p.antigravity.ForwardGemini(
|
||||
ctx,
|
||||
c,
|
||||
account,
|
||||
req.Parsed.Model,
|
||||
geminiForwardAction,
|
||||
req.Parsed.Stream,
|
||||
req.Body,
|
||||
req.IsStickySession,
|
||||
service.WithForwardGeminiSession(req.SessionGroupID, req.SessionKey),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Package gatewayplatform 定义 /v1/messages 平台 Forward 分发的 Provider 接缝
|
||||
// (Phase-3 SEAM-DESIGN v2 裁决记录)。
|
||||
//
|
||||
// 设计要点:
|
||||
// - Provider v1 接口仅 Platform/Forward 两方法(DefaultModels / FallbackModel /
|
||||
// InvalidateToken 等经评审裁决不进接口);
|
||||
// - adapter 实现零移动:仅包装现有 service 的 Forward 入口;底层 error 一律
|
||||
// 原样透传(handler 对 BetaBlockedError / PromptTooLongError 等的
|
||||
// errors.As 断言链依赖错误原值,禁止任何包裹);
|
||||
// - Registry 构造期注册、运行期并发只读;平台 → Provider 的映射逻辑
|
||||
// (如 :794 的 Type != APIKey 条件)保留在调用点,registry 只做查找;
|
||||
// - OpenAI 走独立 OpenAIGatewayHandler 与独立计费管线,不进本注册表;
|
||||
// 禁止统一跨平台返回类型(OpenAIForwardResult 载荷计费字段会丢失)。
|
||||
package gatewayplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ForwardRequest 是平台 Forward 的统一入参面。字段集为两处分发点现有入参的
|
||||
// 并集 {Parsed, Body, IsStickySession, SessionGroupID, SessionKey},不为未接入
|
||||
// 平台虚构维度;各调用点保留自己的入参表达式,按各 Provider 的消费面填充。
|
||||
type ForwardRequest struct {
|
||||
// Parsed 为已解析的网关请求(anthropic / gemini Provider 消费;
|
||||
// gemini Provider 读取 Parsed.Model 与 Parsed.Stream)。
|
||||
Parsed *service.ParsedRequest
|
||||
// Body 为转发请求体原文(antigravity / gemini Provider 消费)。
|
||||
Body []byte
|
||||
// IsStickySession 表示请求已命中粘性会话绑定(antigravity / gemini
|
||||
// Provider 消费)。
|
||||
IsStickySession bool
|
||||
// SessionGroupID 与 SessionKey 为粘性会话维度(仅 gemini Provider 消费,
|
||||
// 经 WithForwardGeminiSession 透传到模型限流切换时的粘性绑定清除)。
|
||||
SessionGroupID int64
|
||||
SessionKey string
|
||||
}
|
||||
|
||||
// Provider 是单一平台的 Forward 入口。
|
||||
//
|
||||
// 契约:
|
||||
// - Platform 返回 service.Platform* 现有常量,作为 Registry 的查找键;
|
||||
// - Forward 将请求转发到对应平台 service,error 必须原样透传(禁止包裹)。
|
||||
type Provider interface {
|
||||
// Platform 返回该 Provider 服务的平台标识(service.Platform* 常量)。
|
||||
Platform() string
|
||||
// Forward 执行平台转发。
|
||||
Forward(ctx context.Context, c *gin.Context, account *service.Account, req *ForwardRequest) (*service.ForwardResult, error)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package gatewayplatform
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Registry 是 platform → Provider 的只读查找表:构造期一次性注册,
|
||||
// 构造完成后并发只读(无锁)。
|
||||
type Registry struct {
|
||||
providers map[string]Provider
|
||||
}
|
||||
|
||||
// NewRegistry 构造注册表。providers 中出现 nil Provider、空 Platform() 或
|
||||
// 重复平台时 panic——装配错误必须在启动期(Wire 装配)暴露,而非请求期。
|
||||
func NewRegistry(providers ...Provider) *Registry {
|
||||
m := make(map[string]Provider, len(providers))
|
||||
for _, p := range providers {
|
||||
if p == nil {
|
||||
panic("gatewayplatform: nil provider")
|
||||
}
|
||||
platform := p.Platform()
|
||||
if platform == "" {
|
||||
panic("gatewayplatform: provider with empty platform")
|
||||
}
|
||||
if _, dup := m[platform]; dup {
|
||||
panic(fmt.Sprintf("gatewayplatform: duplicate provider for platform %q", platform))
|
||||
}
|
||||
m[platform] = p
|
||||
}
|
||||
return &Registry{providers: m}
|
||||
}
|
||||
|
||||
// Get 返回平台对应的 Provider;未注册(或 nil Registry)返回 nil。
|
||||
// 调用点须保证所查平台已在装配期注册(Wire 注册全集,miss 属编程错误)。
|
||||
func (r *Registry) Get(platform string) Provider {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return r.providers[platform]
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package gatewayplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// stubProvider 是注册表测试用的最小 Provider 实现。
|
||||
type stubProvider struct {
|
||||
platform string
|
||||
}
|
||||
|
||||
func (s *stubProvider) Platform() string { return s.platform }
|
||||
|
||||
func (s *stubProvider) Forward(_ context.Context, _ *gin.Context, _ *service.Account, _ *ForwardRequest) (*service.ForwardResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestRegistry_GetReturnsRegisteredProvider(t *testing.T) {
|
||||
anthropic := &stubProvider{platform: service.PlatformAnthropic}
|
||||
antigravity := &stubProvider{platform: service.PlatformAntigravity}
|
||||
gemini := &stubProvider{platform: service.PlatformGemini}
|
||||
|
||||
registry := NewRegistry(anthropic, antigravity, gemini)
|
||||
|
||||
require.Same(t, Provider(anthropic), registry.Get(service.PlatformAnthropic))
|
||||
require.Same(t, Provider(antigravity), registry.Get(service.PlatformAntigravity))
|
||||
require.Same(t, Provider(gemini), registry.Get(service.PlatformGemini))
|
||||
}
|
||||
|
||||
func TestRegistry_GetUnregisteredPlatformReturnsNil(t *testing.T) {
|
||||
registry := NewRegistry(&stubProvider{platform: service.PlatformAnthropic})
|
||||
|
||||
require.Nil(t, registry.Get(service.PlatformOpenAI), "未注册平台必须返回 nil")
|
||||
require.Nil(t, registry.Get(""), "空平台必须返回 nil")
|
||||
|
||||
var nilRegistry *Registry
|
||||
require.Nil(t, nilRegistry.Get(service.PlatformAnthropic), "nil Registry 必须返回 nil 而非 panic")
|
||||
}
|
||||
|
||||
func TestNewRegistry_DuplicatePlatformPanics(t *testing.T) {
|
||||
require.PanicsWithValue(t,
|
||||
`gatewayplatform: duplicate provider for platform "anthropic"`,
|
||||
func() {
|
||||
NewRegistry(
|
||||
&stubProvider{platform: service.PlatformAnthropic},
|
||||
&stubProvider{platform: service.PlatformAnthropic},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewRegistry_NilProviderPanics(t *testing.T) {
|
||||
require.PanicsWithValue(t, "gatewayplatform: nil provider", func() {
|
||||
NewRegistry(&stubProvider{platform: service.PlatformAnthropic}, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewRegistry_EmptyPlatformPanics(t *testing.T) {
|
||||
require.PanicsWithValue(t, "gatewayplatform: provider with empty platform", func() {
|
||||
NewRegistry(&stubProvider{platform: ""})
|
||||
})
|
||||
}
|
||||
|
||||
// TestRegistry_ConcurrentGet 验证构造完成后的注册表可被并发只读访问
|
||||
// (配合 -race 检测数据竞争)。
|
||||
func TestRegistry_ConcurrentGet(t *testing.T) {
|
||||
registry := NewRegistry(
|
||||
&stubProvider{platform: service.PlatformAnthropic},
|
||||
&stubProvider{platform: service.PlatformAntigravity},
|
||||
&stubProvider{platform: service.PlatformGemini},
|
||||
)
|
||||
|
||||
platforms := []string{
|
||||
service.PlatformAnthropic,
|
||||
service.PlatformAntigravity,
|
||||
service.PlatformGemini,
|
||||
service.PlatformOpenAI, // miss 路径同样并发安全
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 32; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 200; j++ {
|
||||
platform := platforms[(i+j)%len(platforms)]
|
||||
got := registry.Get(platform)
|
||||
if platform == service.PlatformOpenAI {
|
||||
require.Nil(t, got)
|
||||
} else {
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, platform, got.Platform())
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestAdapters_PlatformIdentity 锁定三个 adapter 的注册键 = service.Platform* 常量
|
||||
// (Registry 查找键与 :444/:794 调用点的查找表达式一致)。
|
||||
func TestAdapters_PlatformIdentity(t *testing.T) {
|
||||
require.Equal(t, service.PlatformAnthropic, NewAnthropicProvider(nil).Platform())
|
||||
require.Equal(t, service.PlatformAntigravity, NewAntigravityProvider(nil).Platform())
|
||||
require.Equal(t, service.PlatformGemini, NewGeminiProvider(nil).Platform())
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/plugin"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// moduleStatusSource 提供插件模块状态快照能力(*plugin.Runtime 实现该接口,测试可注入桩)。
|
||||
type moduleStatusSource interface {
|
||||
Snapshot() []plugin.ModuleStatus
|
||||
}
|
||||
|
||||
// ModuleHandler handles plugin module observability operations (read-only)
|
||||
type ModuleHandler struct {
|
||||
runtime moduleStatusSource
|
||||
}
|
||||
|
||||
// NewModuleHandler creates a new ModuleHandler
|
||||
func NewModuleHandler(runtime *plugin.Runtime) *ModuleHandler {
|
||||
return &ModuleHandler{runtime: runtime}
|
||||
}
|
||||
|
||||
// ModuleStatusItem 是单个插件模块状态的响应条目
|
||||
type ModuleStatusItem struct {
|
||||
ID string `json:"id"`
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
State string `json:"state"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// List 返回全部已注册插件模块的状态清单(含未启用模块),按模块 ID 字典序排序
|
||||
// GET /api/v1/admin/modules
|
||||
func (h *ModuleHandler) List(c *gin.Context) {
|
||||
statuses := h.runtime.Snapshot()
|
||||
modules := make([]ModuleStatusItem, 0, len(statuses))
|
||||
for _, status := range statuses {
|
||||
errText := status.Err
|
||||
if errText != "" {
|
||||
// 错误文本可能包含敏感信息(如上游返回的凭据片段),输出前统一脱敏。
|
||||
errText = logredact.RedactText(errText)
|
||||
}
|
||||
modules = append(modules, ModuleStatusItem{
|
||||
ID: string(status.ID),
|
||||
Namespace: status.ID.Namespace(),
|
||||
Name: status.ID.Name(),
|
||||
Enabled: status.Enabled,
|
||||
State: string(status.State),
|
||||
Error: errText,
|
||||
})
|
||||
}
|
||||
sort.Slice(modules, func(i, j int) bool { return modules[i].ID < modules[j].ID })
|
||||
response.Success(c, gin.H{"modules": modules})
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/plugin"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type moduleStatusSourceStub struct {
|
||||
statuses []plugin.ModuleStatus
|
||||
}
|
||||
|
||||
func (s *moduleStatusSourceStub) Snapshot() []plugin.ModuleStatus {
|
||||
return s.statuses
|
||||
}
|
||||
|
||||
type moduleListEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
Modules []struct {
|
||||
ID string `json:"id"`
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
State string `json:"state"`
|
||||
Error string `json:"error"`
|
||||
} `json:"modules"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func performModuleList(t *testing.T, source moduleStatusSource) (*httptest.ResponseRecorder, moduleListEnvelope) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
handler := &ModuleHandler{runtime: source}
|
||||
router.GET("/admin/modules", handler.List)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/modules", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
var envelope moduleListEnvelope
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &envelope))
|
||||
return w, envelope
|
||||
}
|
||||
|
||||
func TestModuleHandlerListSerializesSnapshot(t *testing.T) {
|
||||
stub := &moduleStatusSourceStub{statuses: []plugin.ModuleStatus{
|
||||
{ID: "gateway.platform.anthropic", Enabled: true, State: plugin.StateRunning},
|
||||
{ID: "job.hello", Enabled: false, State: plugin.StateRegistered},
|
||||
}}
|
||||
|
||||
w, envelope := performModuleList(t, stub)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Equal(t, 0, envelope.Code)
|
||||
require.Equal(t, "success", envelope.Message)
|
||||
require.Len(t, envelope.Data.Modules, 2)
|
||||
|
||||
first := envelope.Data.Modules[0]
|
||||
require.Equal(t, "gateway.platform.anthropic", first.ID)
|
||||
require.Equal(t, "gateway.platform", first.Namespace)
|
||||
require.Equal(t, "anthropic", first.Name)
|
||||
require.True(t, first.Enabled)
|
||||
require.Equal(t, "running", first.State)
|
||||
require.Empty(t, first.Error)
|
||||
|
||||
// 未启用模块也在列:state=registered、enabled=false
|
||||
second := envelope.Data.Modules[1]
|
||||
require.Equal(t, "job.hello", second.ID)
|
||||
require.Equal(t, "job", second.Namespace)
|
||||
require.Equal(t, "hello", second.Name)
|
||||
require.False(t, second.Enabled)
|
||||
require.Equal(t, "registered", second.State)
|
||||
require.Empty(t, second.Error)
|
||||
|
||||
// snake_case 字段恒定输出(含空 error)
|
||||
require.Contains(t, w.Body.String(), `"error":""`)
|
||||
require.Contains(t, w.Body.String(), `"state":"registered"`)
|
||||
}
|
||||
|
||||
func TestModuleHandlerListSortsByID(t *testing.T) {
|
||||
stub := &moduleStatusSourceStub{statuses: []plugin.ModuleStatus{
|
||||
{ID: "job.zeta", Enabled: true, State: plugin.StateRunning},
|
||||
{ID: "gateway.alpha", Enabled: true, State: plugin.StateRunning},
|
||||
{ID: "job.alpha", Enabled: false, State: plugin.StateRegistered},
|
||||
}}
|
||||
|
||||
_, envelope := performModuleList(t, stub)
|
||||
|
||||
ids := make([]string, 0, len(envelope.Data.Modules))
|
||||
for _, m := range envelope.Data.Modules {
|
||||
ids = append(ids, m.ID)
|
||||
}
|
||||
require.Equal(t, []string{"gateway.alpha", "job.alpha", "job.zeta"}, ids)
|
||||
}
|
||||
|
||||
func TestModuleHandlerListRedactsErrorText(t *testing.T) {
|
||||
stub := &moduleStatusSourceStub{statuses: []plugin.ModuleStatus{
|
||||
{ID: "job.bad", Enabled: true, State: plugin.StateErrored, Err: "provision failed: access_token=ya29.secret-value"},
|
||||
}}
|
||||
|
||||
_, envelope := performModuleList(t, stub)
|
||||
|
||||
require.Len(t, envelope.Data.Modules, 1)
|
||||
errText := envelope.Data.Modules[0].Error
|
||||
require.Contains(t, errText, "provision failed")
|
||||
require.NotContains(t, errText, "ya29.secret-value")
|
||||
}
|
||||
|
||||
func TestModuleHandlerListEmptySnapshot(t *testing.T) {
|
||||
w, envelope := performModuleList(t, &moduleStatusSourceStub{})
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.NotNil(t, envelope.Data.Modules)
|
||||
require.Empty(t, envelope.Data.Modules)
|
||||
require.Contains(t, w.Body.String(), `"modules":[]`)
|
||||
}
|
||||
|
||||
type moduleHandlerFakeModule struct {
|
||||
id plugin.ModuleID
|
||||
enabledByDefault bool
|
||||
}
|
||||
|
||||
func (m *moduleHandlerFakeModule) ModuleInfo() plugin.ModuleInfo {
|
||||
id := m.id
|
||||
enabled := m.enabledByDefault
|
||||
return plugin.ModuleInfo{
|
||||
ID: id,
|
||||
New: func() plugin.Module { return &moduleHandlerFakeModule{id: id, enabledByDefault: enabled} },
|
||||
EnabledByDefault: enabled,
|
||||
}
|
||||
}
|
||||
|
||||
// TestModuleHandlerListWithRuntimeSnapshot 用真实 Runtime(隔离注册表)验证
|
||||
// 接口返回与 Runtime.Snapshot 一致:enabled 模块为 running,
|
||||
// disabled 模块也在列且 state=registered、enabled=false。
|
||||
func TestModuleHandlerListWithRuntimeSnapshot(t *testing.T) {
|
||||
registry := plugin.NewRegistry()
|
||||
registry.RegisterModule(&moduleHandlerFakeModule{id: "job.enabled", enabledByDefault: true})
|
||||
registry.RegisterModule(&moduleHandlerFakeModule{id: "job.disabled", enabledByDefault: false})
|
||||
|
||||
runtime := plugin.NewRuntimeWithRegistry(nil, nil, registry)
|
||||
require.NoError(t, runtime.Build())
|
||||
require.NoError(t, runtime.Start(context.Background()))
|
||||
t.Cleanup(func() { _ = runtime.Stop(context.Background()) })
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/admin/modules", NewModuleHandler(runtime).List)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/modules", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var envelope moduleListEnvelope
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &envelope))
|
||||
require.Len(t, envelope.Data.Modules, 2)
|
||||
|
||||
disabled := envelope.Data.Modules[0]
|
||||
require.Equal(t, "job.disabled", disabled.ID)
|
||||
require.False(t, disabled.Enabled)
|
||||
require.Equal(t, "registered", disabled.State)
|
||||
|
||||
enabled := envelope.Data.Modules[1]
|
||||
require.Equal(t, "job.enabled", enabled.ID)
|
||||
require.True(t, enabled.Enabled)
|
||||
require.Equal(t, "running", enabled.State)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
@@ -12,19 +11,10 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (h *GatewayHandler) checkContentModeration(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *service.ContentModerationDecision {
|
||||
if h == nil || h.contentModerationService == nil {
|
||||
return nil
|
||||
}
|
||||
return runContentModeration(c, reqLog, h.contentModerationService, apiKey, subject, protocol, model, body)
|
||||
}
|
||||
|
||||
func contentModerationStatus(decision *service.ContentModerationDecision) int {
|
||||
if decision == nil || decision.StatusCode < 400 || decision.StatusCode > 599 {
|
||||
return http.StatusForbidden
|
||||
}
|
||||
return decision.StatusCode
|
||||
}
|
||||
// 本文件保留 WebSocket 调用点(openai_gateway_handler.go ResponsesWebSocket 的
|
||||
// turn-1 首帧与 turn≥2 每消息审核)仍在使用的内容审核 helper。
|
||||
// HTTP 调用点已迁移至 pre-flight 钩子链(见 gateway_preflight.go,SEAM-DESIGN 裁决 H:
|
||||
// WS 两点因每消息语义 + WS 帧错误格式排除在链改造之外,保持现状)。
|
||||
|
||||
func contentModerationErrorCode(decision *service.ContentModerationDecision) string {
|
||||
return "content_policy_violation"
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/Wei-Shaw/sub2api/internal/gatewayhook"
|
||||
"github.com/Wei-Shaw/sub2api/internal/gatewayplatform"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
@@ -41,13 +43,14 @@ type GatewayHandler struct {
|
||||
gatewayService *service.GatewayService
|
||||
geminiCompatService *service.GeminiMessagesCompatService
|
||||
antigravityGatewayService *service.AntigravityGatewayService
|
||||
platformRegistry *gatewayplatform.Registry
|
||||
userService *service.UserService
|
||||
billingCacheService *service.BillingCacheService
|
||||
usageService *service.UsageService
|
||||
apiKeyService *service.APIKeyService
|
||||
usageRecordWorkerPool *service.UsageRecordWorkerPool
|
||||
errorPassthroughService *service.ErrorPassthroughService
|
||||
contentModerationService *service.ContentModerationService
|
||||
preFlightHooks *gatewayhook.Chain
|
||||
concurrencyHelper *ConcurrencyHelper
|
||||
userMsgQueueHelper *UserMsgQueueHelper
|
||||
maxAccountSwitches int
|
||||
@@ -61,6 +64,7 @@ func NewGatewayHandler(
|
||||
gatewayService *service.GatewayService,
|
||||
geminiCompatService *service.GeminiMessagesCompatService,
|
||||
antigravityGatewayService *service.AntigravityGatewayService,
|
||||
platformRegistry *gatewayplatform.Registry,
|
||||
userService *service.UserService,
|
||||
concurrencyService *service.ConcurrencyService,
|
||||
billingCacheService *service.BillingCacheService,
|
||||
@@ -68,7 +72,7 @@ func NewGatewayHandler(
|
||||
apiKeyService *service.APIKeyService,
|
||||
usageRecordWorkerPool *service.UsageRecordWorkerPool,
|
||||
errorPassthroughService *service.ErrorPassthroughService,
|
||||
contentModerationService *service.ContentModerationService,
|
||||
preFlightHooks *gatewayhook.Chain,
|
||||
userMsgQueueService *service.UserMessageQueueService,
|
||||
cfg *config.Config,
|
||||
settingService *service.SettingService,
|
||||
@@ -96,13 +100,14 @@ func NewGatewayHandler(
|
||||
gatewayService: gatewayService,
|
||||
geminiCompatService: geminiCompatService,
|
||||
antigravityGatewayService: antigravityGatewayService,
|
||||
platformRegistry: platformRegistry,
|
||||
userService: userService,
|
||||
billingCacheService: billingCacheService,
|
||||
usageService: usageService,
|
||||
apiKeyService: apiKeyService,
|
||||
usageRecordWorkerPool: usageRecordWorkerPool,
|
||||
errorPassthroughService: errorPassthroughService,
|
||||
contentModerationService: contentModerationService,
|
||||
preFlightHooks: preFlightHooks,
|
||||
concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatClaude, pingInterval),
|
||||
userMsgQueueHelper: umqHelper,
|
||||
maxAccountSwitches: maxAccountSwitches,
|
||||
@@ -195,8 +200,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && decision.Blocked {
|
||||
h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
|
||||
if decision := h.runPreFlightHooks(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && decision.Blocked {
|
||||
h.errorResponse(c, preFlightStatus(decision), preFlightErrorCode(decision), decision.Message)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -441,16 +446,20 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
// 记录 Forward 前已写入字节数,Forward 后若增加则说明 SSE 内容已发,禁止 failover
|
||||
writerSizeBeforeForward := c.Writer.Size()
|
||||
if account.Platform == service.PlatformAntigravity {
|
||||
result, err = h.antigravityGatewayService.ForwardGemini(
|
||||
// gemini provider → AntigravityGatewayService.ForwardGemini:
|
||||
// model/stream 经 Parsed 透传(与原 reqModel/reqStream 同源),
|
||||
// action 由 adapter 硬编码 "generateContent"。
|
||||
result, err = h.platformRegistry.Get(service.PlatformGemini).Forward(
|
||||
requestCtx,
|
||||
c,
|
||||
account,
|
||||
reqModel,
|
||||
"generateContent",
|
||||
reqStream,
|
||||
body,
|
||||
hasBoundSession,
|
||||
service.WithForwardGeminiSession(derefGroupID(apiKey.GroupID), sessionKey),
|
||||
&gatewayplatform.ForwardRequest{
|
||||
Parsed: parsedReq,
|
||||
Body: body,
|
||||
IsStickySession: hasBoundSession,
|
||||
SessionGroupID: derefGroupID(apiKey.GroupID),
|
||||
SessionKey: sessionKey,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
result, err = h.geminiCompatService.Forward(requestCtx, c, account, body)
|
||||
@@ -790,10 +799,17 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
}
|
||||
// 记录 Forward 前已写入字节数,Forward 后若增加则说明 SSE 内容已发,禁止 failover
|
||||
writerSizeBeforeForward := c.Writer.Size()
|
||||
// 平台 → Provider 的映射逻辑保留在调用点:antigravity 平台的
|
||||
// APIKey 账号走 anthropic provider(Claude 协议直连上游)。
|
||||
forwardReq := &gatewayplatform.ForwardRequest{
|
||||
Parsed: attemptParsedReq,
|
||||
Body: attemptBody,
|
||||
IsStickySession: hasBoundSession,
|
||||
}
|
||||
if account.Platform == service.PlatformAntigravity && account.Type != service.AccountTypeAPIKey {
|
||||
result, err = h.antigravityGatewayService.Forward(requestCtx, c, account, attemptBody, hasBoundSession)
|
||||
result, err = h.platformRegistry.Get(service.PlatformAntigravity).Forward(requestCtx, c, account, forwardReq)
|
||||
} else {
|
||||
result, err = h.gatewayService.Forward(requestCtx, c, account, attemptParsedReq)
|
||||
result, err = h.platformRegistry.Get(service.PlatformAnthropic).Forward(requestCtx, c, account, forwardReq)
|
||||
}
|
||||
|
||||
// 兜底释放串行锁(正常情况已通过回调提前释放)
|
||||
@@ -1134,29 +1150,7 @@ func customModelsListAllowsModel(availablePatterns []string, model string) bool
|
||||
}
|
||||
|
||||
func defaultModelIDsForPlatform(platform string) []string {
|
||||
switch platform {
|
||||
case service.PlatformOpenAI:
|
||||
return openai.DefaultModelIDs()
|
||||
case service.PlatformGemini:
|
||||
ids := make([]string, 0, len(geminicli.DefaultModels))
|
||||
for _, model := range geminicli.DefaultModels {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return ids
|
||||
case service.PlatformAntigravity:
|
||||
models := antigravity.DefaultModels()
|
||||
ids := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return ids
|
||||
default:
|
||||
ids := make([]string, 0, len(claude.DefaultModels))
|
||||
for _, model := range claude.DefaultModels {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
return service.DefaultModelIDsForPlatform(platform)
|
||||
}
|
||||
|
||||
// AntigravityModels 返回 Antigravity 支持的全部模型
|
||||
|
||||
@@ -95,8 +95,8 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, reqModel, body); decision != nil && decision.Blocked {
|
||||
h.chatCompletionsErrorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
|
||||
if decision := h.runPreFlightHooks(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, reqModel, body); decision != nil && decision.Blocked {
|
||||
h.chatCompletionsErrorResponse(c, preFlightStatus(decision), preFlightErrorCode(decision), decision.Message)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -104,8 +104,8 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, body); decision != nil && decision.Blocked {
|
||||
h.responsesErrorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
|
||||
if decision := h.runPreFlightHooks(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, body); decision != nil && decision.Blocked {
|
||||
h.responsesErrorResponse(c, preFlightStatus(decision), preFlightErrorCode(decision), decision.Message)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
//go:build unit
|
||||
|
||||
// Phase-0 TASK-002 特征化测试:网关拦截链路(INVARIANTS I-7.1 / I-7.2)。
|
||||
// 通过完整的 GatewayHandler.Messages / CountTokens 入口驱动,断言客户端最终
|
||||
// 收到的状态码与错误体:
|
||||
// - I-7.1 内容审核拦截:403 + content_policy_violation 错误体;审核服务失败 = 放行(fail-open);
|
||||
// - I-7.2 Claude Code 版本检查:低版本 400 + 升级提示;/count_tokens 路径豁免。
|
||||
//
|
||||
// 复用 gateway_handler_warmup_intercept_unit_test.go 的 newTestGatewayHandler 夹具:
|
||||
// antigravity 账号 intercept_warmup_requests=true 时,Warmup 请求在转发上游前被
|
||||
// mock 拦截返回 200,使"请求被放行"成为可观测结果(无需真实上游)。
|
||||
//
|
||||
// 注意:Claude Code 版本上下限走 service 包内全局 60s TTL 缓存
|
||||
// (setting_service.go versionBoundsCache)。本文件所有版本测试固定使用同一组
|
||||
// 上下限(min=1.5.0,无 max),避免同进程内缓存交叉污染。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// passCharSettingRepo 是 service.SettingRepository 的内存实现。
|
||||
type passCharSettingRepo struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (r *passCharSettingRepo) Get(_ context.Context, key string) (*service.Setting, error) {
|
||||
if v, ok := r.values[key]; ok {
|
||||
return &service.Setting{Key: key, Value: v}, nil
|
||||
}
|
||||
return nil, service.ErrSettingNotFound
|
||||
}
|
||||
|
||||
func (r *passCharSettingRepo) GetValue(_ context.Context, key string) (string, error) {
|
||||
if v, ok := r.values[key]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return "", service.ErrSettingNotFound
|
||||
}
|
||||
|
||||
func (r *passCharSettingRepo) Set(_ context.Context, key, value string) error {
|
||||
if r.values == nil {
|
||||
r.values = map[string]string{}
|
||||
}
|
||||
r.values[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *passCharSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
for _, key := range keys {
|
||||
if v, ok := r.values[key]; ok {
|
||||
out[key] = v
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *passCharSettingRepo) SetMultiple(_ context.Context, settings map[string]string) error {
|
||||
for k, v := range settings {
|
||||
_ = r.Set(context.Background(), k, v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *passCharSettingRepo) GetAll(_ context.Context) (map[string]string, error) {
|
||||
out := make(map[string]string, len(r.values))
|
||||
for k, v := range r.values {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *passCharSettingRepo) Delete(_ context.Context, key string) error {
|
||||
delete(r.values, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// passCharModerationRepo 是 service.ContentModerationRepository 的空操作实现。
|
||||
type passCharModerationRepo struct{}
|
||||
|
||||
func (r *passCharModerationRepo) CreateLog(context.Context, *service.ContentModerationLog) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *passCharModerationRepo) ListLogs(context.Context, service.ContentModerationLogFilter) ([]service.ContentModerationLog, *pagination.PaginationResult, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (r *passCharModerationRepo) CountFlaggedByUserSince(context.Context, int64, time.Time) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (r *passCharModerationRepo) CleanupExpiredLogs(context.Context, time.Time, time.Time) (*service.ContentModerationCleanupResult, error) {
|
||||
return &service.ContentModerationCleanupResult{}, nil
|
||||
}
|
||||
|
||||
// passCharInterceptFixture 构造完整的 Messages 链路夹具(拦截预热账号 + 上下文注入)。
|
||||
func passCharInterceptFixture(t *testing.T) (*GatewayHandler, func()) {
|
||||
t.Helper()
|
||||
|
||||
groupID := int64(7001)
|
||||
accountID := int64(7101)
|
||||
|
||||
group := &service.Group{
|
||||
ID: groupID,
|
||||
Hydrated: true,
|
||||
Platform: service.PlatformAnthropic,
|
||||
Status: service.StatusActive,
|
||||
}
|
||||
account := &service.Account{
|
||||
ID: accountID,
|
||||
Name: "pass-char-intercept",
|
||||
Platform: service.PlatformAntigravity,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "tok_char",
|
||||
"intercept_warmup_requests": true,
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"mixed_scheduling": true,
|
||||
},
|
||||
Concurrency: 1,
|
||||
Priority: 1,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
AccountGroups: []service.AccountGroup{{AccountID: accountID, GroupID: groupID}},
|
||||
}
|
||||
return newTestGatewayHandler(t, group, []*service.Account{account})
|
||||
}
|
||||
|
||||
// passCharNewMessagesContext 构造带认证上下文的 /v1/messages 请求。
|
||||
func passCharNewMessagesContext(t *testing.T, path string, body []byte) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(7001)
|
||||
group := &service.Group{
|
||||
ID: groupID,
|
||||
Hydrated: true,
|
||||
Platform: service.PlatformAnthropic,
|
||||
Status: service.StatusActive,
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxkey.Group, group))
|
||||
c.Request = req
|
||||
|
||||
apiKey := &service.APIKey{
|
||||
ID: 7301,
|
||||
UserID: 7401,
|
||||
GroupID: &groupID,
|
||||
Status: service.StatusActive,
|
||||
User: &service.User{
|
||||
ID: 7401,
|
||||
Concurrency: 10,
|
||||
Balance: 100,
|
||||
},
|
||||
Group: group,
|
||||
}
|
||||
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 10})
|
||||
return c, rec
|
||||
}
|
||||
|
||||
// passCharWarmupBody 返回触发预热拦截的最小请求体。
|
||||
func passCharWarmupBody() []byte {
|
||||
return []byte(`{
|
||||
"model": "claude-sonnet-4-5",
|
||||
"max_tokens": 256,
|
||||
"messages": [{"role":"user","content":[{"type":"text","text":"Warmup"}]}]
|
||||
}`)
|
||||
}
|
||||
|
||||
// passCharClaudeCodeWarmupBody 返回能通过 Claude Code 客户端校验且触发预热拦截的请求体。
|
||||
func passCharClaudeCodeWarmupBody() []byte {
|
||||
deviceID := strings.Repeat("a", 64)
|
||||
return []byte(`{
|
||||
"model": "claude-sonnet-4-5",
|
||||
"max_tokens": 256,
|
||||
"system": [{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude."}],
|
||||
"metadata": {"user_id": "user_` + deviceID + `_account__session_01234567-89ab-cdef-0123-456789abcdef"},
|
||||
"messages": [{"role":"user","content":[{"type":"text","text":"Warmup"}]}]
|
||||
}`)
|
||||
}
|
||||
|
||||
// passCharSetClaudeCodeHeaders 设置 Claude Code 客户端校验所需的请求头。
|
||||
func passCharSetClaudeCodeHeaders(c *gin.Context, ua string) {
|
||||
c.Request.Header.Set("User-Agent", ua)
|
||||
c.Request.Header.Set("X-App", "cli")
|
||||
c.Request.Header.Set("anthropic-beta", "claude-code-20250219")
|
||||
c.Request.Header.Set("anthropic-version", "2023-06-01")
|
||||
}
|
||||
|
||||
// passCharModerationService 构造指向 mock 审核 API 的内容审核服务。
|
||||
func passCharModerationService(t *testing.T, moderationBaseURL string) *service.ContentModerationService {
|
||||
t.Helper()
|
||||
cfgJSON, err := json.Marshal(map[string]any{
|
||||
"enabled": true,
|
||||
"mode": "pre_block",
|
||||
"base_url": moderationBaseURL,
|
||||
"api_keys": []string{"sk-audit"},
|
||||
"sample_rate": 100,
|
||||
"all_groups": true,
|
||||
"auto_ban_enabled": false,
|
||||
"email_on_hit": false,
|
||||
"retry_count": 0,
|
||||
"timeout_ms": 2000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
settingRepo := &passCharSettingRepo{values: map[string]string{
|
||||
service.SettingKeyRiskControlEnabled: "true",
|
||||
service.SettingKeyContentModerationConfig: string(cfgJSON),
|
||||
}}
|
||||
return service.NewContentModerationService(settingRepo, &passCharModerationRepo{}, nil, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// passCharRequireWarmupMock 断言响应为预热拦截 mock(请求被放行并完成)。
|
||||
func passCharRequireWarmupMock(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, "msg_mock_warmup", resp["id"])
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_ContentModerationBlock 固化 I-7.1 拦截侧:
|
||||
// 审核 API 判定命中(pre_block 模式)时,客户端收到 403 +
|
||||
// {"type":"error","error":{"type":"content_policy_violation","message":<配置的拦截文案>}}。
|
||||
func TestGatewayCharacterization_ContentModerationBlock(t *testing.T) {
|
||||
moderationSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"results":[{"flagged":true,"category_scores":{"sexual":0.99}}]}`))
|
||||
}))
|
||||
defer moderationSrv.Close()
|
||||
|
||||
h, cleanup := passCharInterceptFixture(t)
|
||||
defer cleanup()
|
||||
h.preFlightHooks = ProvideGatewayHookChain(passCharModerationService(t, moderationSrv.URL))
|
||||
|
||||
c, rec := passCharNewMessagesContext(t, "/v1/messages", passCharWarmupBody())
|
||||
h.Messages(c)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, rec.Code, "内容审核命中应返回 403")
|
||||
require.JSONEq(t,
|
||||
`{"type":"error","error":{"type":"content_policy_violation","message":"内容审计命中风险规则,请调整输入后重试"}}`,
|
||||
rec.Body.String())
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_ContentModerationFailOpen 固化 I-7.1 fail-open 侧:
|
||||
// 审核 API 自身失败(HTTP 500)时请求必须放行——客户端收到正常业务响应
|
||||
// (此处为预热拦截 mock 200),而不是 403。
|
||||
func TestGatewayCharacterization_ContentModerationFailOpen(t *testing.T) {
|
||||
moderationSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "moderation backend exploded", http.StatusInternalServerError)
|
||||
}))
|
||||
defer moderationSrv.Close()
|
||||
|
||||
h, cleanup := passCharInterceptFixture(t)
|
||||
defer cleanup()
|
||||
h.preFlightHooks = ProvideGatewayHookChain(passCharModerationService(t, moderationSrv.URL))
|
||||
|
||||
c, rec := passCharNewMessagesContext(t, "/v1/messages", passCharWarmupBody())
|
||||
h.Messages(c)
|
||||
|
||||
passCharRequireWarmupMock(t, rec)
|
||||
}
|
||||
|
||||
// passCharVersionBoundSettingService 返回固定 min=1.5.0(无 max)的 SettingService。
|
||||
// 注意:版本上下限有进程内全局 60s 缓存,本文件所有版本测试统一使用该配置。
|
||||
func passCharVersionBoundSettingService() *service.SettingService {
|
||||
repo := &passCharSettingRepo{values: map[string]string{
|
||||
service.SettingKeyMinClaudeCodeVersion: "1.5.0",
|
||||
}}
|
||||
return service.NewSettingService(repo, &config.Config{})
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_ClaudeCodeVersionCheck 固化 I-7.2 主路径:
|
||||
// Claude Code 客户端(UA claude-cli/x.y.z + 完整客户端特征)在 /v1/messages 上:
|
||||
// - CLI 版本低于最低要求 → 400 invalid_request_error + 升级提示;
|
||||
// - CLI 版本满足要求 → 请求放行(预热拦截 mock 200)。
|
||||
func TestGatewayCharacterization_ClaudeCodeVersionCheck(t *testing.T) {
|
||||
t.Run("低版本_400升级提示", func(t *testing.T) {
|
||||
h, cleanup := passCharInterceptFixture(t)
|
||||
defer cleanup()
|
||||
h.settingService = passCharVersionBoundSettingService()
|
||||
|
||||
c, rec := passCharNewMessagesContext(t, "/v1/messages", passCharClaudeCodeWarmupBody())
|
||||
passCharSetClaudeCodeHeaders(c, "claude-cli/1.0.0 (external)")
|
||||
h.Messages(c)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
var resp struct {
|
||||
Type string `json:"type"`
|
||||
Error struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, "error", resp.Type)
|
||||
require.Equal(t, "invalid_request_error", resp.Error.Type)
|
||||
require.Contains(t, resp.Error.Message, "Your Claude Code version (1.0.0) is below the minimum required version (1.5.0)")
|
||||
require.Contains(t, resp.Error.Message, "npm update -g @anthropic-ai/claude-code", "错误信息应包含升级指引")
|
||||
})
|
||||
|
||||
t.Run("版本达标_请求放行", func(t *testing.T) {
|
||||
h, cleanup := passCharInterceptFixture(t)
|
||||
defer cleanup()
|
||||
h.settingService = passCharVersionBoundSettingService()
|
||||
|
||||
c, rec := passCharNewMessagesContext(t, "/v1/messages", passCharClaudeCodeWarmupBody())
|
||||
passCharSetClaudeCodeHeaders(c, "claude-cli/2.0.0 (external)")
|
||||
h.Messages(c)
|
||||
|
||||
passCharRequireWarmupMock(t, rec)
|
||||
})
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_ClaudeCodeVersionCheck_CountTokensExempt 固化 I-7.2 豁免侧:
|
||||
// /v1/messages/count_tokens 不做版本检查——低版本 Claude Code CLI 不会收到版本 400,
|
||||
// 请求继续走 count_tokens 业务(antigravity 账号当前返回 404 not_found_error)。
|
||||
func TestGatewayCharacterization_ClaudeCodeVersionCheck_CountTokensExempt(t *testing.T) {
|
||||
h, cleanup := passCharInterceptFixture(t)
|
||||
defer cleanup()
|
||||
h.settingService = passCharVersionBoundSettingService()
|
||||
|
||||
body := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
|
||||
c, rec := passCharNewMessagesContext(t, "/v1/messages/count_tokens", body)
|
||||
passCharSetClaudeCodeHeaders(c, "claude-cli/1.0.0 (external)")
|
||||
h.CountTokens(c)
|
||||
|
||||
var resp struct {
|
||||
Type string `json:"type"`
|
||||
Error struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.NotContains(t, resp.Error.Message, "below the minimum required version",
|
||||
"count_tokens 必须豁免版本检查")
|
||||
|
||||
// 固化当前实际行为:antigravity 账号不支持 count_tokens,返回 404 not_found_error。
|
||||
require.Equal(t, http.StatusNotFound, rec.Code)
|
||||
require.Equal(t, "not_found_error", resp.Error.Type)
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
//go:build unit
|
||||
|
||||
// Phase-2 TASK-001 特征化测试:按"格式类"锁定内容审核拦截后的客户端可观测输出
|
||||
// (钩子链改造 TASK-003 的实施前置 gate,调用点清单与格式类定义见
|
||||
// issues/plugin-refactor/phase-2_pilots/CALLSITE-INVENTORY.md)。
|
||||
//
|
||||
// 覆盖(已有测试不重复):
|
||||
// - B 格式 chat_completions(无顶层 type) —— gateway_handler_chat_completions.go:98
|
||||
// - C 格式 responses(error.code 字符串字段) —— gateway_handler_responses.go:107
|
||||
// - D 格式 gemini googleError(code int + status 串)—— gemini_v1beta_handler.go:190
|
||||
// - A' 格式 openai 网关 anthropic(顶层 type:"error")—— openai_gateway_handler.go:676(格式化 :934)
|
||||
// - B' 格式 openai images(与 B 字节级同形,归并覆盖 openai_gateway_handler.go:244 / openai_chat_completions.go:88,
|
||||
// 三点共用 openai_gateway_handler.go:1920 同一格式化函数)—— openai_images.go:89
|
||||
// - fail-open 非 anthropic 协议(chat_completions 审核服务 500 → 放行至账号调度阶段)
|
||||
// - WS turn-2 每消息审核 close-error 路径 —— openai_gateway_handler.go:1426
|
||||
//
|
||||
// 特征化纪律:所有断言均为先驱动真实链路跑出实际行为后固化,不写应然值。
|
||||
// 复用 gateway_intercept_characterization_test.go 的 passChar* 夹具与
|
||||
// openai_gateway_handler_test.go 的 WS 测试基建;新增辅助统一 p2Char 前缀。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
coderws "github.com/coder/websocket"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// p2CharFlagAllModerationServer 返回对任意输入都判定命中的 mock 审核 API。
|
||||
func p2CharFlagAllModerationServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"results":[{"flagged":true,"category_scores":{"sexual":0.99}}]}`))
|
||||
}))
|
||||
}
|
||||
|
||||
// p2CharNewContext 构造带认证上下文的网关请求(任意路径/分组/请求体)。
|
||||
func p2CharNewContext(t *testing.T, path string, group *service.Group, body []byte) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxkey.Group, group))
|
||||
c.Request = req
|
||||
|
||||
apiKey := &service.APIKey{
|
||||
ID: 7311,
|
||||
UserID: 7411,
|
||||
GroupID: &group.ID,
|
||||
Status: service.StatusActive,
|
||||
User: &service.User{
|
||||
ID: 7411,
|
||||
Concurrency: 10,
|
||||
Balance: 100,
|
||||
},
|
||||
Group: group,
|
||||
}
|
||||
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 10})
|
||||
return c, rec
|
||||
}
|
||||
|
||||
// p2CharAnthropicGroup 返回 anthropic 平台分组(GatewayHandler 系端点用)。
|
||||
func p2CharAnthropicGroup() *service.Group {
|
||||
return &service.Group{
|
||||
ID: 7001,
|
||||
Hydrated: true,
|
||||
Platform: service.PlatformAnthropic,
|
||||
Status: service.StatusActive,
|
||||
}
|
||||
}
|
||||
|
||||
// TestP2Characterization_ModerationBlock_ChatCompletionsFormat 锁定 B 格式:
|
||||
// GatewayHandler.ChatCompletions(/v1/chat/completions)审核命中时返回 403 +
|
||||
// {"error":{"type":"content_policy_violation","message":<拦截文案>}}——无顶层 type 字段。
|
||||
func TestP2Characterization_ModerationBlock_ChatCompletionsFormat(t *testing.T) {
|
||||
moderationSrv := p2CharFlagAllModerationServer(t)
|
||||
defer moderationSrv.Close()
|
||||
|
||||
group := p2CharAnthropicGroup()
|
||||
h, cleanup := newTestGatewayHandler(t, group, nil)
|
||||
defer cleanup()
|
||||
h.preFlightHooks = ProvideGatewayHookChain(passCharModerationService(t, moderationSrv.URL))
|
||||
|
||||
body := []byte(`{"model":"claude-sonnet-4-5","stream":false,"messages":[{"role":"user","content":"bad words"}]}`)
|
||||
c, rec := p2CharNewContext(t, "/v1/chat/completions", group, body)
|
||||
h.ChatCompletions(c)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
require.JSONEq(t,
|
||||
`{"error":{"type":"content_policy_violation","message":"内容审计命中风险规则,请调整输入后重试"}}`,
|
||||
rec.Body.String())
|
||||
// 关键差异点:chat_completions 格式没有顶层 type:"error"(区别于 anthropic 格式)。
|
||||
require.False(t, gjson.GetBytes(rec.Body.Bytes(), "type").Exists(),
|
||||
"chat_completions 拦截体不应有顶层 type 字段")
|
||||
}
|
||||
|
||||
// TestP2Characterization_ModerationBlock_ResponsesFormat 锁定 C 格式:
|
||||
// GatewayHandler.Responses(/v1/responses)审核命中时返回 403 +
|
||||
// {"error":{"code":"content_policy_violation","message":<拦截文案>}}——
|
||||
// error 内是 code 字段(字符串)而非 type 字段。
|
||||
func TestP2Characterization_ModerationBlock_ResponsesFormat(t *testing.T) {
|
||||
moderationSrv := p2CharFlagAllModerationServer(t)
|
||||
defer moderationSrv.Close()
|
||||
|
||||
group := p2CharAnthropicGroup()
|
||||
h, cleanup := newTestGatewayHandler(t, group, nil)
|
||||
defer cleanup()
|
||||
h.preFlightHooks = ProvideGatewayHookChain(passCharModerationService(t, moderationSrv.URL))
|
||||
|
||||
body := []byte(`{"model":"claude-sonnet-4-5","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"bad words"}]}]}`)
|
||||
c, rec := p2CharNewContext(t, "/v1/responses", group, body)
|
||||
h.Responses(c)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
require.JSONEq(t,
|
||||
`{"error":{"code":"content_policy_violation","message":"内容审计命中风险规则,请调整输入后重试"}}`,
|
||||
rec.Body.String())
|
||||
// 关键差异点:responses 格式用 error.code(字符串),无 error.type、无顶层 type。
|
||||
require.False(t, gjson.GetBytes(rec.Body.Bytes(), "type").Exists())
|
||||
require.False(t, gjson.GetBytes(rec.Body.Bytes(), "error.type").Exists(),
|
||||
"responses 拦截体 error 内不应有 type 字段(用 code)")
|
||||
require.Equal(t, gjson.String, gjson.GetBytes(rec.Body.Bytes(), "error.code").Type,
|
||||
"responses 拦截体 error.code 应为字符串")
|
||||
}
|
||||
|
||||
// TestP2Characterization_ModerationBlock_GeminiGoogleErrorFormat 锁定 D 格式:
|
||||
// GatewayHandler.GeminiV1BetaModels(/v1beta/models/{model}:{action})审核命中时返回 403 +
|
||||
// {"error":{"code":403,"message":<拦截文案>,"status":"PERMISSION_DENIED"}}。
|
||||
// 注意:该调用点不消费 contentModerationErrorCode(content_policy_violation 被丢弃)。
|
||||
func TestP2Characterization_ModerationBlock_GeminiGoogleErrorFormat(t *testing.T) {
|
||||
moderationSrv := p2CharFlagAllModerationServer(t)
|
||||
defer moderationSrv.Close()
|
||||
|
||||
group := &service.Group{
|
||||
ID: 7002,
|
||||
Hydrated: true,
|
||||
Platform: service.PlatformGemini,
|
||||
Status: service.StatusActive,
|
||||
}
|
||||
h, cleanup := newTestGatewayHandler(t, group, nil)
|
||||
defer cleanup()
|
||||
h.preFlightHooks = ProvideGatewayHookChain(passCharModerationService(t, moderationSrv.URL))
|
||||
|
||||
body := []byte(`{"contents":[{"role":"user","parts":[{"text":"bad words"}]}]}`)
|
||||
c, rec := p2CharNewContext(t, "/v1beta/models/gemini-2.5-pro:generateContent", group, body)
|
||||
c.Params = gin.Params{{Key: "modelAction", Value: "/gemini-2.5-pro:generateContent"}}
|
||||
h.GeminiV1BetaModels(c)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
require.JSONEq(t,
|
||||
`{"error":{"code":403,"message":"内容审计命中风险规则,请调整输入后重试","status":"PERMISSION_DENIED"}}`,
|
||||
rec.Body.String())
|
||||
// 关键差异点:googleError 三字段——code 是 int HTTP 状态码、status 是 google 状态串;
|
||||
// 无顶层 type、无 error.type,审核错误码 content_policy_violation 不出现。
|
||||
parsed := gjson.ParseBytes(rec.Body.Bytes())
|
||||
require.False(t, parsed.Get("type").Exists(), "googleError 不应有顶层 type 字段")
|
||||
require.False(t, parsed.Get("error.type").Exists(), "googleError 的 error 内不应有 type 字段")
|
||||
require.Equal(t, gjson.Number, parsed.Get("error.code").Type, "googleError 的 error.code 应为数字")
|
||||
require.NotContains(t, rec.Body.String(), "content_policy_violation",
|
||||
"gemini 调用点不消费 contentModerationErrorCode")
|
||||
}
|
||||
|
||||
// p2CharOpenAIGatewayHandler 构造满足 ensureResponsesDependencies 的最小 OpenAIGatewayHandler。
|
||||
// 审核拦截发生在账号调度/转发之前,故各 service 用零值即可。
|
||||
// HTTP 调用点走 preFlightHooks 链;contentModerationService 同时装配以镜像生产接线
|
||||
// (WS 调用点仍直接消费该字段)。
|
||||
func p2CharOpenAIGatewayHandler(t *testing.T, moderationBaseURL string) *OpenAIGatewayHandler {
|
||||
t.Helper()
|
||||
moderationSvc := passCharModerationService(t, moderationBaseURL)
|
||||
return &OpenAIGatewayHandler{
|
||||
gatewayService: &service.OpenAIGatewayService{},
|
||||
billingCacheService: &service.BillingCacheService{},
|
||||
apiKeyService: &service.APIKeyService{},
|
||||
contentModerationService: moderationSvc,
|
||||
preFlightHooks: ProvideGatewayHookChain(moderationSvc),
|
||||
concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(&concurrencyCacheMock{}), SSEPingFormatNone, time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
// TestP2Characterization_ModerationBlock_OpenAIGatewayAnthropicFormat 锁定 A' 格式:
|
||||
// OpenAIGatewayHandler.Messages(openai 平台分组的 /v1/messages dispatch)审核命中时返回 403 +
|
||||
// {"type":"error","error":{"type":"content_policy_violation","message":<拦截文案>}}
|
||||
//(openai_gateway_handler.go:934 anthropicErrorResponse 一族,与 anthropic 主格式字节级同形)。
|
||||
func TestP2Characterization_ModerationBlock_OpenAIGatewayAnthropicFormat(t *testing.T) {
|
||||
moderationSrv := p2CharFlagAllModerationServer(t)
|
||||
defer moderationSrv.Close()
|
||||
|
||||
group := &service.Group{
|
||||
ID: 7003,
|
||||
Hydrated: true,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Status: service.StatusActive,
|
||||
AllowMessagesDispatch: true,
|
||||
}
|
||||
h := p2CharOpenAIGatewayHandler(t, moderationSrv.URL)
|
||||
|
||||
body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":64,"messages":[{"role":"user","content":[{"type":"text","text":"bad words"}]}]}`)
|
||||
c, rec := p2CharNewContext(t, "/v1/messages", group, body)
|
||||
h.Messages(c)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
require.JSONEq(t,
|
||||
`{"type":"error","error":{"type":"content_policy_violation","message":"内容审计命中风险规则,请调整输入后重试"}}`,
|
||||
rec.Body.String())
|
||||
// 关键差异点:openai 网关 anthropic 格式保留顶层 type:"error"。
|
||||
require.Equal(t, "error", gjson.GetBytes(rec.Body.Bytes(), "type").String())
|
||||
}
|
||||
|
||||
// TestP2Characterization_ModerationBlock_OpenAIImagesFormat 锁定 B' 格式:
|
||||
// OpenAIGatewayHandler.Images(/v1/images/generations,审核入参为 parsed.ModerationBody())
|
||||
// 审核命中时返回 403 + {"error":{"type":"content_policy_violation","message":<拦截文案>}}。
|
||||
// 该格式与 B(chat_completions)字节级同形;openai 网关 /v1/responses(openai_gateway_handler.go:244)
|
||||
// 与 /v1/chat/completions(openai_chat_completions.go:88)共用同一格式化函数
|
||||
//(openai_gateway_handler.go:1920),由本测试归并覆盖(见 CALLSITE-INVENTORY.md)。
|
||||
func TestP2Characterization_ModerationBlock_OpenAIImagesFormat(t *testing.T) {
|
||||
moderationSrv := p2CharFlagAllModerationServer(t)
|
||||
defer moderationSrv.Close()
|
||||
|
||||
group := &service.Group{
|
||||
ID: 7004,
|
||||
Hydrated: true,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Status: service.StatusActive,
|
||||
AllowImageGeneration: true,
|
||||
}
|
||||
h := p2CharOpenAIGatewayHandler(t, moderationSrv.URL)
|
||||
|
||||
body := []byte(`{"model":"gpt-image-1","prompt":"bad words"}`)
|
||||
c, rec := p2CharNewContext(t, "/v1/images/generations", group, body)
|
||||
h.Images(c)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
require.JSONEq(t,
|
||||
`{"error":{"type":"content_policy_violation","message":"内容审计命中风险规则,请调整输入后重试"}}`,
|
||||
rec.Body.String())
|
||||
require.False(t, gjson.GetBytes(rec.Body.Bytes(), "type").Exists(),
|
||||
"openai 网关通用错误格式不应有顶层 type 字段")
|
||||
}
|
||||
|
||||
// TestP2Characterization_ModerationFailOpen_ChatCompletionsFormat 锁定非 anthropic 协议的
|
||||
// fail-open 行为:chat_completions 链路上审核 API 自身故障(HTTP 500)时请求必须放行——
|
||||
// 请求穿过审核 gate 继续进入账号调度阶段(夹具无可用账号,故观测到 503 No available accounts,
|
||||
// 而不是 403 content_policy_violation)。
|
||||
func TestP2Characterization_ModerationFailOpen_ChatCompletionsFormat(t *testing.T) {
|
||||
moderationSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "moderation backend exploded", http.StatusInternalServerError)
|
||||
}))
|
||||
defer moderationSrv.Close()
|
||||
|
||||
group := p2CharAnthropicGroup()
|
||||
h, cleanup := newTestGatewayHandler(t, group, nil)
|
||||
defer cleanup()
|
||||
h.preFlightHooks = ProvideGatewayHookChain(passCharModerationService(t, moderationSrv.URL))
|
||||
|
||||
body := []byte(`{"model":"claude-sonnet-4-5","stream":false,"messages":[{"role":"user","content":"bad words"}]}`)
|
||||
c, rec := p2CharNewContext(t, "/v1/chat/completions", group, body)
|
||||
h.ChatCompletions(c)
|
||||
|
||||
// fail-open:未被审核拦截(非 403 + content_policy_violation),
|
||||
// 请求推进到账号调度阶段(夹具无账号 → 503 api_error)。
|
||||
require.Equal(t, http.StatusServiceUnavailable, rec.Code)
|
||||
require.NotContains(t, rec.Body.String(), "content_policy_violation")
|
||||
require.Equal(t, "api_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
|
||||
require.Contains(t, gjson.GetBytes(rec.Body.Bytes(), "error.message").String(), "No available accounts")
|
||||
}
|
||||
|
||||
// p2CharConditionalModerationServer 返回仅当审核请求中含 marker 才判定命中的 mock 审核 API。
|
||||
func p2CharConditionalModerationServer(t *testing.T, marker string) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.Contains(string(raw), marker) {
|
||||
_, _ = w.Write([]byte(`{"results":[{"flagged":true,"category_scores":{"sexual":0.99}}]}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"results":[{"flagged":false,"category_scores":{"sexual":0.0}}]}`))
|
||||
}))
|
||||
}
|
||||
|
||||
// TestP2Characterization_OpenAIResponsesWSTurn2ModerationCloseError 锁定 E 格式 turn-2 路径
|
||||
// (openai_gateway_handler.go:1426,OpenAIWSIngressHooks.BeforeRequest 的 turn≥2 每消息审核):
|
||||
// turn-1 干净消息正常转发并完成;turn-2 消息审核命中后客户端先收到
|
||||
// writeContentModerationWSError 错误帧,随后连接以 close(1008 StatusPolicyViolation,
|
||||
// reason=<拦截文案>) 关闭,且 turn-2 消息不会到达上游。
|
||||
// 复用 TestOpenAIResponsesWebSocket_ContentModerationBlocksFirstFrame 的 WS 基建
|
||||
//(newOpenAIWSHandlerTestServer 路由形态 + passthrough 上游夹具)。
|
||||
func TestP2Characterization_OpenAIResponsesWSTurn2ModerationCloseError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
const turn2Marker = "p2char-turn2-bad"
|
||||
moderationSrv := p2CharConditionalModerationServer(t, turn2Marker)
|
||||
defer moderationSrv.Close()
|
||||
|
||||
upstreamFrames := make(chan []byte, 4)
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.CloseNow() }()
|
||||
for {
|
||||
readCtx, cancelRead := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
_, payload, readErr := conn.Read(readCtx)
|
||||
cancelRead()
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
upstreamFrames <- payload
|
||||
writeCtx, cancelWrite := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
writeErr := conn.Write(writeCtx, coderws.MessageText, []byte(
|
||||
`{"type":"response.completed","response":{"id":"resp_p2char_turn1","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`,
|
||||
))
|
||||
cancelWrite()
|
||||
if writeErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
account := service.Account{
|
||||
ID: 9911,
|
||||
Name: "p2char-ws-turn2",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-p2char",
|
||||
"base_url": upstreamServer.URL,
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"openai_apikey_responses_websockets_v2_enabled": true,
|
||||
"openai_apikey_responses_websockets_v2_mode": service.OpenAIWSIngressModePassthrough,
|
||||
},
|
||||
}
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.RunMode = config.RunModeSimple
|
||||
cfg.Default.RateMultiplier = 1
|
||||
cfg.Security.URLAllowlist.Enabled = false
|
||||
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
|
||||
cfg.Gateway.OpenAIWS.Enabled = true
|
||||
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
|
||||
cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
|
||||
cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true
|
||||
cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
|
||||
cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5
|
||||
cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
|
||||
|
||||
accountRepo := &openAIWSUsageHandlerAccountRepoStub{account: account}
|
||||
usageRepo := &openAIWSUsageHandlerUsageLogRepoStub{created: make(chan *service.UsageLog, 4)}
|
||||
billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
|
||||
gatewaySvc := service.NewOpenAIGatewayService(
|
||||
accountRepo,
|
||||
usageRepo,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
cfg,
|
||||
nil,
|
||||
nil,
|
||||
service.NewBillingService(cfg, nil),
|
||||
nil,
|
||||
billingCacheSvc,
|
||||
nil,
|
||||
&service.DeferredService{},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
cache := &concurrencyCacheMock{
|
||||
acquireUserSlotFn: func(ctx context.Context, userID int64, maxConcurrency int, requestID string) (bool, error) {
|
||||
return true, nil
|
||||
},
|
||||
acquireAccountSlotFn: func(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
|
||||
return true, nil
|
||||
},
|
||||
}
|
||||
h := &OpenAIGatewayHandler{
|
||||
gatewayService: gatewaySvc,
|
||||
billingCacheService: billingCacheSvc,
|
||||
apiKeyService: &service.APIKeyService{},
|
||||
contentModerationService: passCharModerationService(t, moderationSrv.URL),
|
||||
concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(cache), SSEPingFormatNone, time.Second),
|
||||
}
|
||||
wsServer := newOpenAIWSHandlerTestServer(t, h, middleware.AuthSubject{UserID: 1, Concurrency: 1})
|
||||
defer wsServer.Close()
|
||||
|
||||
dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
clientConn, _, err := coderws.Dial(
|
||||
dialCtx,
|
||||
"ws"+strings.TrimPrefix(wsServer.URL, "http")+"/openai/v1/responses",
|
||||
&coderws.DialOptions{CompressionMode: coderws.CompressionContextTakeover},
|
||||
)
|
||||
cancelDial()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = clientConn.CloseNow() }()
|
||||
|
||||
// turn-1:干净消息正常转发,收到上游 response.completed。
|
||||
writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(
|
||||
`{"type":"response.create","model":"gpt-5.1","stream":false,"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"clean prompt"}]}]}`,
|
||||
))
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
|
||||
readCtx, cancelRead := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
_, turn1Event, err := clientConn.Read(readCtx)
|
||||
cancelRead()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "response.completed", gjson.GetBytes(turn1Event, "type").String())
|
||||
|
||||
select {
|
||||
case <-upstreamFrames:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("等待上游收到 turn-1 帧超时")
|
||||
}
|
||||
|
||||
// turn-2:命中审核的消息被 BeforeRequest 拦截。
|
||||
writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second)
|
||||
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(
|
||||
`{"type":"response.create","model":"gpt-5.1","stream":false,"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"`+turn2Marker+`"}]}]}`,
|
||||
))
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
|
||||
// 实际行为:先收到 writeContentModerationWSError 错误帧,再收到 close(1008) 关闭。
|
||||
readCtx, cancelRead = context.WithTimeout(context.Background(), 5*time.Second)
|
||||
_, errFrame, readErr := clientConn.Read(readCtx)
|
||||
cancelRead()
|
||||
require.NoError(t, readErr, "turn-2 拦截后应先收到错误帧")
|
||||
require.JSONEq(t,
|
||||
`{"event_id":"evt_content_moderation_blocked","type":"error","error":{"type":"invalid_request_error","code":"content_policy_violation","message":"内容审计命中风险规则,请调整输入后重试"}}`,
|
||||
string(errFrame))
|
||||
|
||||
readCtx, cancelRead = context.WithTimeout(context.Background(), 5*time.Second)
|
||||
_, _, closeReadErr := clientConn.Read(readCtx)
|
||||
cancelRead()
|
||||
require.Error(t, closeReadErr)
|
||||
var closeErr coderws.CloseError
|
||||
require.ErrorAs(t, closeReadErr, &closeErr)
|
||||
require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
|
||||
require.Equal(t, "内容审计命中风险规则,请调整输入后重试", closeErr.Reason)
|
||||
|
||||
// turn-2 消息不得到达上游。
|
||||
select {
|
||||
case frame := <-upstreamFrames:
|
||||
t.Fatalf("turn-2 被拦截的消息不应转发到上游,实际收到: %s", frame)
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/gatewayplatform"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
// ProvideGatewayPlatformRegistry 装配 /v1/messages 平台 Forward 分发注册表
|
||||
// (Phase-3 SEAM-DESIGN 裁决:anthropic / antigravity / gemini 三 adapter;
|
||||
// OpenAI 走独立 OpenAIGatewayHandler 与独立计费管线,不进本注册表)。
|
||||
func ProvideGatewayPlatformRegistry(
|
||||
gatewayService *service.GatewayService,
|
||||
antigravityGatewayService *service.AntigravityGatewayService,
|
||||
) *gatewayplatform.Registry {
|
||||
return gatewayplatform.NewRegistry(
|
||||
gatewayplatform.NewAnthropicProvider(gatewayService),
|
||||
gatewayplatform.NewAntigravityProvider(antigravityGatewayService),
|
||||
gatewayplatform.NewGeminiProvider(antigravityGatewayService),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
//go:build unit
|
||||
|
||||
// Phase-3 TASK-001 前置特征化测试:/v1/messages 两处平台 Forward 分发点
|
||||
// (gateway_handler.go :444 与 :794)的路由与参数透传锁定,作为 TASK-002
|
||||
// Provider 接缝改造(registry 替换分发点)的等价性 gate。
|
||||
//
|
||||
// 固化内容(对应 SEAM-DESIGN.md 裁决记录的 T1-T4):
|
||||
// - T1 :794 路由矩阵:antigravity 平台分组下,
|
||||
// {OAuth/Upstream(Type != APIKey)账号 → AntigravityGatewayService.Forward,
|
||||
// APIKey 账号 → GatewayService.Forward};通过两个独立的上游记录桩区分命中路径;
|
||||
// - T2 :444 路由:gemini 平台分组 + antigravity 账号 → ForwardGemini,
|
||||
// 参数透传可观测面:model(v1internal 包裹体 model 字段=映射后模型)、
|
||||
// 上游 action 恒为 streamGenerateContent、stream(客户端响应形态
|
||||
// SSE/收集 JSON)、body 原文进入包裹体;
|
||||
// - T3 session 选项透传:WithForwardGeminiSession(groupID, "gemini:"+sessionHash)
|
||||
// 在模型限流切换路径上经 clearStickySession 落到
|
||||
// GatewayCache.DeleteSessionAccountID(groupID, sessionKey),参数逐一断言;
|
||||
// 粘性绑定账号被优先选中(sessionBoundAccountID → 调度命中);
|
||||
// - T4 错误链:GatewayService.Forward 返回 *BetaBlockedError → handler errors.As
|
||||
// 命中 → 400 + invalid_request_error + 策略消息;AntigravityGatewayService.Forward
|
||||
// 返回 *PromptTooLongError(无兜底分组)→ errors.As 命中 → WriteMappedClaudeError
|
||||
// 输出。改造后 adapter 若包裹/吞掉这两类错误,本组测试必红。
|
||||
//
|
||||
// mock 方式取舍说明:GatewayHandler 的 service 字段均为具体类型
|
||||
// (*service.GatewayService / *service.AntigravityGatewayService),无法以接口桩
|
||||
// 替换记录调用参数;故采用真实 service + 注入 HTTPUpstream/GatewayCache 记录桩,
|
||||
// 通过上游收到的请求特征(URL/包裹体)与缓存调用参数区分路径并断言透传。
|
||||
// isStickySession 实参本身在 handler 层无外部可观测面(其效果 ForceCacheBilling
|
||||
// 已由 service 层既有测试 TestAntigravityGatewayService_Forward_StickySessionForceCacheBilling /
|
||||
// _ForwardGemini_StickySessionForceCacheBilling 锁定);handler 层锁定其数据来源
|
||||
// hasBoundSession 的判定链:粘性绑定命中 → 绑定账号被优先调度(见 T3 用例)。
|
||||
//
|
||||
// 复用同包既有夹具:fakeSchedulerCache / fakeGroupRepo
|
||||
// (gateway_handler_warmup_intercept_unit_test.go)、schedInvNewCountingCache
|
||||
// (scheduling_invariants_failover_test.go)、passCharSettingRepo
|
||||
// (gateway_intercept_characterization_test.go)。
|
||||
// 本文件新增的包级辅助类型/函数一律带 p3Char 前缀。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// p3Char 夹具
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// p3CharUpstreamCall 记录一次上游调用的可观测特征。
|
||||
type p3CharUpstreamCall struct {
|
||||
AccountID int64
|
||||
Method string
|
||||
URL string
|
||||
Header http.Header
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// p3CharUpstream 是可编程的 HTTPUpstream 记录桩:按 accountID 返回脚本化响应,
|
||||
// 并记录每次调用的完整请求特征(URL/Header/Body)。
|
||||
type p3CharUpstream struct {
|
||||
mu sync.Mutex
|
||||
respond func(accountID int64) (*http.Response, error)
|
||||
calls []p3CharUpstreamCall
|
||||
}
|
||||
|
||||
var _ service.HTTPUpstream = (*p3CharUpstream)(nil)
|
||||
|
||||
func (u *p3CharUpstream) Do(req *http.Request, _ string, accountID int64, _ int) (*http.Response, error) {
|
||||
call := p3CharUpstreamCall{AccountID: accountID}
|
||||
if req != nil {
|
||||
call.Method = req.Method
|
||||
call.URL = req.URL.String()
|
||||
call.Header = req.Header.Clone()
|
||||
if req.Body != nil {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
_ = req.Body.Close()
|
||||
call.Body = body
|
||||
}
|
||||
}
|
||||
u.mu.Lock()
|
||||
u.calls = append(u.calls, call)
|
||||
respond := u.respond
|
||||
u.mu.Unlock()
|
||||
|
||||
if respond == nil {
|
||||
return nil, fmt.Errorf("p3CharUpstream: unexpected upstream call (account=%d url=%s)", accountID, call.URL)
|
||||
}
|
||||
return respond(accountID)
|
||||
}
|
||||
|
||||
func (u *p3CharUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return u.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
func (u *p3CharUpstream) recordedCalls() []p3CharUpstreamCall {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
return append([]p3CharUpstreamCall(nil), u.calls...)
|
||||
}
|
||||
|
||||
// p3CharSessionCall 记录一次粘性会话缓存调用的参数。
|
||||
type p3CharSessionCall struct {
|
||||
GroupID int64
|
||||
SessionHash string
|
||||
AccountID int64
|
||||
}
|
||||
|
||||
// p3CharStickyCache 是 service.GatewayCache 的内存实现,记录所有调用参数。
|
||||
// 同一实例同时注入 GatewayService(粘性查询/绑定)与 AntigravityGatewayService
|
||||
// (模型限流时清除粘性绑定),与生产共用同一 Redis 缓存的接线一致。
|
||||
type p3CharStickyCache struct {
|
||||
mu sync.Mutex
|
||||
bound map[string]int64
|
||||
getCalls []p3CharSessionCall
|
||||
setCalls []p3CharSessionCall
|
||||
deleteCalls []p3CharSessionCall
|
||||
}
|
||||
|
||||
var _ service.GatewayCache = (*p3CharStickyCache)(nil)
|
||||
|
||||
func p3CharNewStickyCache() *p3CharStickyCache {
|
||||
return &p3CharStickyCache{bound: make(map[string]int64)}
|
||||
}
|
||||
|
||||
func p3CharSessionKey(groupID int64, sessionHash string) string {
|
||||
return fmt.Sprintf("%d|%s", groupID, sessionHash)
|
||||
}
|
||||
|
||||
func (c *p3CharStickyCache) bind(groupID int64, sessionHash string, accountID int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.bound[p3CharSessionKey(groupID, sessionHash)] = accountID
|
||||
}
|
||||
|
||||
func (c *p3CharStickyCache) GetSessionAccountID(_ context.Context, groupID int64, sessionHash string) (int64, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.getCalls = append(c.getCalls, p3CharSessionCall{GroupID: groupID, SessionHash: sessionHash})
|
||||
return c.bound[p3CharSessionKey(groupID, sessionHash)], nil
|
||||
}
|
||||
|
||||
func (c *p3CharStickyCache) SetSessionAccountID(_ context.Context, groupID int64, sessionHash string, accountID int64, _ time.Duration) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.setCalls = append(c.setCalls, p3CharSessionCall{GroupID: groupID, SessionHash: sessionHash, AccountID: accountID})
|
||||
c.bound[p3CharSessionKey(groupID, sessionHash)] = accountID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *p3CharStickyCache) RefreshSessionTTL(_ context.Context, _ int64, _ string, _ time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *p3CharStickyCache) DeleteSessionAccountID(_ context.Context, groupID int64, sessionHash string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.deleteCalls = append(c.deleteCalls, p3CharSessionCall{GroupID: groupID, SessionHash: sessionHash})
|
||||
delete(c.bound, p3CharSessionKey(groupID, sessionHash))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *p3CharStickyCache) recordedDeleteCalls() []p3CharSessionCall {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]p3CharSessionCall(nil), c.deleteCalls...)
|
||||
}
|
||||
|
||||
func (c *p3CharStickyCache) recordedGetCalls() []p3CharSessionCall {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]p3CharSessionCall(nil), c.getCalls...)
|
||||
}
|
||||
|
||||
// p3CharGroup 构造指定平台的测试分组。
|
||||
func p3CharGroup(groupID int64, platform string) *service.Group {
|
||||
return &service.Group{
|
||||
ID: groupID,
|
||||
Hydrated: true,
|
||||
Platform: platform,
|
||||
Status: service.StatusActive,
|
||||
}
|
||||
}
|
||||
|
||||
// p3CharAntigravityAccount 构造 antigravity 账号夹具。
|
||||
func p3CharAntigravityAccount(id, groupID int64, accountType string, creds, extra map[string]any) *service.Account {
|
||||
credentials := map[string]any{"access_token": fmt.Sprintf("ag-token-%d", id)}
|
||||
for k, v := range creds {
|
||||
credentials[k] = v
|
||||
}
|
||||
return &service.Account{
|
||||
ID: id,
|
||||
Name: fmt.Sprintf("p3-char-ag-%d", id),
|
||||
Platform: service.PlatformAntigravity,
|
||||
Type: accountType,
|
||||
Credentials: credentials,
|
||||
Extra: extra,
|
||||
Concurrency: 5,
|
||||
Priority: 1,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID}},
|
||||
}
|
||||
}
|
||||
|
||||
// p3CharHandlerEnv 聚合一套完整 GatewayHandler 测试环境。
|
||||
type p3CharHandlerEnv struct {
|
||||
handler *GatewayHandler
|
||||
gwUpstream *p3CharUpstream // GatewayService.Forward 路径的上游
|
||||
agUpstream *p3CharUpstream // AntigravityGatewayService.Forward/ForwardGemini 路径的上游
|
||||
stickyCache *p3CharStickyCache
|
||||
cleanup func()
|
||||
}
|
||||
|
||||
// p3CharNewHandler 构造完整 GatewayHandler:真实 GatewayService 与
|
||||
// AntigravityGatewayService,各自注入独立上游记录桩;共享粘性会话缓存桩。
|
||||
// settingValues 写入两个 service 共享的 SettingService 内存仓库(beta policy 等)。
|
||||
func p3CharNewHandler(t *testing.T, group *service.Group, accounts []*service.Account, settingValues map[string]string) *p3CharHandlerEnv {
|
||||
t.Helper()
|
||||
t.Setenv("SUB2API_DEBUG_GATEWAY_BODY", "")
|
||||
|
||||
gwUpstream := &p3CharUpstream{}
|
||||
agUpstream := &p3CharUpstream{}
|
||||
stickyCache := p3CharNewStickyCache()
|
||||
|
||||
schedulerSnapshot := service.NewSchedulerSnapshotService(&fakeSchedulerCache{accounts: accounts}, nil, nil, nil, nil)
|
||||
concurrencySvc := service.NewConcurrencyService(schedInvNewCountingCache())
|
||||
settingSvc := service.NewSettingService(&passCharSettingRepo{values: settingValues}, &config.Config{})
|
||||
|
||||
gwSvc := service.NewGatewayService(
|
||||
nil, // accountRepo
|
||||
&fakeGroupRepo{group: group},
|
||||
nil, nil, nil, nil, nil, // usageLogRepo / usageBillingRepo / userRepo / userSubRepo / userGroupRateRepo
|
||||
stickyCache,
|
||||
&config.Config{},
|
||||
schedulerSnapshot,
|
||||
concurrencySvc,
|
||||
nil, // billingService
|
||||
&service.RateLimitService{}, // 零值:错误码仅记录日志
|
||||
nil, // billingCacheService
|
||||
nil, // identityService
|
||||
gwUpstream,
|
||||
&service.DeferredService{},
|
||||
nil, nil, nil, nil, // claudeTokenProvider / sessionLimitCache / rpmCache / digestStore
|
||||
settingSvc,
|
||||
nil, nil, nil, nil, nil, // tlsFPProfileService / channelService / resolver / balanceNotifyService / userPlatformQuotaRepo
|
||||
)
|
||||
|
||||
agSvc := service.NewAntigravityGatewayService(
|
||||
nil, // accountRepo(模型限流写库不在锁定面,nil → 仅走 handleError 兜底分支)
|
||||
stickyCache,
|
||||
schedulerSnapshot,
|
||||
&service.AntigravityTokenProvider{},
|
||||
nil, // rateLimitService
|
||||
agUpstream,
|
||||
settingSvc,
|
||||
nil, // internal500Cache
|
||||
)
|
||||
|
||||
billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil,
|
||||
&config.Config{RunMode: config.RunModeSimple}, nil)
|
||||
|
||||
h := NewGatewayHandler(
|
||||
gwSvc,
|
||||
nil, // geminiCompatService(:444 非 antigravity 分支误入会 panic → 用例显式失败)
|
||||
agSvc,
|
||||
ProvideGatewayPlatformRegistry(gwSvc, agSvc), // 与生产 Wire 装配同构的平台分发注册表
|
||||
nil, // userService
|
||||
concurrencySvc,
|
||||
billingCacheSvc,
|
||||
nil, nil, nil, nil, nil, nil, // usageService / apiKeyService / workerPool / errorPassthrough / preFlightHooks / userMsgQueue
|
||||
nil, // cfg
|
||||
nil, // settingService(handler 级版本检查等不在锁定面)
|
||||
)
|
||||
return &p3CharHandlerEnv{
|
||||
handler: h,
|
||||
gwUpstream: gwUpstream,
|
||||
agUpstream: agUpstream,
|
||||
stickyCache: stickyCache,
|
||||
cleanup: func() { billingCacheSvc.Stop() },
|
||||
}
|
||||
}
|
||||
|
||||
// p3CharMessagesContext 构造带认证上下文的 /v1/messages 请求(可附加 header)。
|
||||
func p3CharMessagesContext(t *testing.T, group *service.Group, body []byte, headers map[string]string) (*gin.Context, *httptest.ResponseRecorder, context.CancelFunc) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), ctxkey.Group, group)
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
c.Request = req.WithContext(ctx)
|
||||
|
||||
apiKey := &service.APIKey{
|
||||
ID: 8501,
|
||||
UserID: 8601,
|
||||
GroupID: &group.ID,
|
||||
Status: service.StatusActive,
|
||||
User: &service.User{
|
||||
ID: 8601,
|
||||
Concurrency: 10,
|
||||
Balance: 100,
|
||||
},
|
||||
Group: group,
|
||||
}
|
||||
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 10})
|
||||
return c, rec, cancel
|
||||
}
|
||||
|
||||
// p3CharSessionID 是 metadata.user_id 中的 session 段(36 字符 UUID),
|
||||
// 用于产生确定性的 sessionHash(GenerateSessionHash 最高优先级来源)。
|
||||
const p3CharSessionID = "12345678-1234-1234-1234-123456789abc"
|
||||
|
||||
// p3CharMetadataUserID 构造 legacy 格式 metadata.user_id。
|
||||
func p3CharMetadataUserID() string {
|
||||
return "user_" + strings.Repeat("a", 64) + "_account__session_" + p3CharSessionID
|
||||
}
|
||||
|
||||
// p3CharClaudeBody 构造 Claude 协议请求体(:794 路径)。
|
||||
func p3CharClaudeBody(stream bool) []byte {
|
||||
return []byte(fmt.Sprintf(`{
|
||||
"model": "claude-sonnet-4-5",
|
||||
"max_tokens": 64,
|
||||
"stream": %t,
|
||||
"metadata": {"user_id": %q},
|
||||
"messages": [{"role":"user","content":"p3 dispatch probe"}]
|
||||
}`, stream, p3CharMetadataUserID()))
|
||||
}
|
||||
|
||||
// p3CharGeminiLoopBody 构造同时满足 /v1/messages 解析(model/stream/metadata)
|
||||
// 与 ForwardGemini 原文透传(contents)的混合请求体(:444 路径将原始 body
|
||||
// 原样传给 ForwardGemini)。
|
||||
func p3CharGeminiLoopBody(stream bool) []byte {
|
||||
return []byte(fmt.Sprintf(`{
|
||||
"model": "gemini-2.5-flash",
|
||||
"stream": %t,
|
||||
"metadata": {"user_id": %q},
|
||||
"contents": [{"role":"user","parts":[{"text":"p3 gemini dispatch probe"}]}]
|
||||
}`, stream, p3CharMetadataUserID()))
|
||||
}
|
||||
|
||||
// p3CharAntigravitySSEResponse 构造 v1internal 包裹的上游流式成功响应。
|
||||
func p3CharAntigravitySSEResponse() *http.Response {
|
||||
sse := "data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":3}}}\n\n"
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
"X-Request-Id": []string{"rid-p3-ag"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(sse)),
|
||||
}
|
||||
}
|
||||
|
||||
// p3CharRateLimit429Response 构造触发"模型限流 + 切换账号"的 429 响应
|
||||
// (RATE_LIMIT_EXCEEDED + retryDelay 60s ≥ 7s 阈值,无智能重试等待)。
|
||||
func p3CharRateLimit429Response() *http.Response {
|
||||
body := `{
|
||||
"error": {
|
||||
"code": 429,
|
||||
"message": "rate limited",
|
||||
"status": "RESOURCE_EXHAUSTED",
|
||||
"details": [
|
||||
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "RATE_LIMIT_EXCEEDED", "metadata": {"model": "gemini-3-pro-high"}},
|
||||
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "60s"}
|
||||
]
|
||||
}
|
||||
}`
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T1 :794 路由矩阵
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestPlatformDispatchCharacterization_AntigravityNonAPIKeyRoutesToAntigravityForward
|
||||
// 固化 :794 分支条件 `Platform == Antigravity && Type != APIKey` 的"命中"侧:
|
||||
// OAuth 与 Upstream 两种非 APIKey 账号均走 AntigravityGatewayService.Forward
|
||||
// (上游收到 antigravity v1internal 请求 / 上游透传请求),GatewayService 上游零调用。
|
||||
func TestPlatformDispatchCharacterization_AntigravityNonAPIKeyRoutesToAntigravityForward(t *testing.T) {
|
||||
t.Run("OAuth账号走antigravity转发", func(t *testing.T) {
|
||||
group := p3CharGroup(9501, service.PlatformAntigravity)
|
||||
account := p3CharAntigravityAccount(9511, group.ID, service.AccountTypeOAuth, map[string]any{
|
||||
"model_mapping": map[string]any{"claude-sonnet-4-5": "gemini-3-pro-high"},
|
||||
}, nil)
|
||||
|
||||
env := p3CharNewHandler(t, group, []*service.Account{account}, nil)
|
||||
defer env.cleanup()
|
||||
env.agUpstream.respond = func(int64) (*http.Response, error) {
|
||||
return p3CharAntigravitySSEResponse(), nil
|
||||
}
|
||||
|
||||
c, rec, cancel := p3CharMessagesContext(t, group, p3CharClaudeBody(false), nil)
|
||||
defer cancel()
|
||||
|
||||
env.handler.Messages(c)
|
||||
|
||||
agCalls := env.agUpstream.recordedCalls()
|
||||
require.Len(t, agCalls, 1, "antigravity 上游应被调用恰好一次")
|
||||
require.Empty(t, env.gwUpstream.recordedCalls(), "GatewayService 上游不应被调用")
|
||||
|
||||
// Claude→Gemini 转换 + v1internal 包裹:URL 恒为 streamGenerateContent,
|
||||
// 包裹体 model = 账号映射后的最终模型
|
||||
require.Contains(t, agCalls[0].URL, "/v1internal:streamGenerateContent?alt=sse")
|
||||
require.Equal(t, "gemini-3-pro-high", gjson.GetBytes(agCalls[0].Body, "model").String())
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
})
|
||||
|
||||
t.Run("Upstream账号走antigravity转发", func(t *testing.T) {
|
||||
group := p3CharGroup(9502, service.PlatformAntigravity)
|
||||
account := p3CharAntigravityAccount(9512, group.ID, service.AccountTypeUpstream, map[string]any{
|
||||
"api_key": "ag-upstream-key",
|
||||
"base_url": "https://ag-upstream.example.com",
|
||||
"model_mapping": map[string]any{"claude-sonnet-4-5": "claude-sonnet-4-5"},
|
||||
}, nil)
|
||||
|
||||
env := p3CharNewHandler(t, group, []*service.Account{account}, nil)
|
||||
defer env.cleanup()
|
||||
env.agUpstream.respond = func(int64) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"id":"msg_p3_up","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":5,"output_tokens":2}}`)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
c, rec, cancel := p3CharMessagesContext(t, group, p3CharClaudeBody(false), nil)
|
||||
defer cancel()
|
||||
|
||||
env.handler.Messages(c)
|
||||
|
||||
agCalls := env.agUpstream.recordedCalls()
|
||||
require.Len(t, agCalls, 1, "antigravity 上游应被调用恰好一次(ForwardUpstream 透传)")
|
||||
require.Empty(t, env.gwUpstream.recordedCalls(), "GatewayService 上游不应被调用")
|
||||
require.Equal(t, "https://ag-upstream.example.com/v1/messages", agCalls[0].URL)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
})
|
||||
}
|
||||
|
||||
// TestPlatformDispatchCharacterization_AntigravityAPIKeyRoutesToGatewayForward
|
||||
// 固化 :794 分支条件的"未命中"侧:antigravity 平台的 APIKey 账号走
|
||||
// GatewayService.Forward(Claude 协议直连上游),antigravity 上游零调用。
|
||||
func TestPlatformDispatchCharacterization_AntigravityAPIKeyRoutesToGatewayForward(t *testing.T) {
|
||||
group := p3CharGroup(9503, service.PlatformAntigravity)
|
||||
account := p3CharAntigravityAccount(9513, group.ID, service.AccountTypeAPIKey, map[string]any{
|
||||
"api_key": "ag-apikey-cred",
|
||||
"model_mapping": map[string]any{"claude-sonnet-4-5": "claude-sonnet-4-5"},
|
||||
}, nil)
|
||||
|
||||
env := p3CharNewHandler(t, group, []*service.Account{account}, nil)
|
||||
defer env.cleanup()
|
||||
env.gwUpstream.respond = func(int64) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"id":"msg_p3_gw","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":5,"output_tokens":2}}`)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
c, rec, cancel := p3CharMessagesContext(t, group, p3CharClaudeBody(false), nil)
|
||||
defer cancel()
|
||||
|
||||
env.handler.Messages(c)
|
||||
|
||||
gwCalls := env.gwUpstream.recordedCalls()
|
||||
require.Len(t, gwCalls, 1, "GatewayService 上游应被调用恰好一次")
|
||||
require.Empty(t, env.agUpstream.recordedCalls(), "antigravity 上游不应被调用")
|
||||
|
||||
// APIKey 账号未配置 base_url 时直连 Anthropic 默认地址,认证走 x-api-key
|
||||
// (setHeaderRaw 以小写原样写入 header map,须用原始 key 读取)
|
||||
require.Equal(t, "https://api.anthropic.com/v1/messages?beta=true", gwCalls[0].URL)
|
||||
require.Equal(t, []string{"ag-apikey-cred"}, gwCalls[0].Header["x-api-key"])
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T2 :444 路由 + 参数透传
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestPlatformDispatchCharacterization_GeminiLoopAntigravityRoutesToForwardGemini
|
||||
// 固化 :444 分支:gemini 平台分组下 antigravity 账号 → ForwardGemini。
|
||||
// 参数可观测面逐一断言:
|
||||
// - model:v1internal 包裹体 model = 账号映射后的模型(请求体 model 经映射进入上游);
|
||||
// - 上游 action 恒为 streamGenerateContent(handler 传入的 action="generateContent"
|
||||
// 仅决定客户端响应形态合法性,上游不变量见 service 层 action 契约测试);
|
||||
// - stream=false → 网关收集上游流式响应,客户端收到单个 JSON(application/json);
|
||||
// - stream=true → SSE 解包透传(text/event-stream);
|
||||
// - body 原文(contents 文本)进入包裹体 request 字段;
|
||||
// - sessionKey 格式 "gemini:"+sessionHash 进入粘性查询。
|
||||
func TestPlatformDispatchCharacterization_GeminiLoopAntigravityRoutesToForwardGemini(t *testing.T) {
|
||||
newEnv := func(t *testing.T, groupID int64) (*p3CharHandlerEnv, *service.Group) {
|
||||
group := p3CharGroup(groupID, service.PlatformGemini)
|
||||
account := p3CharAntigravityAccount(groupID+10, groupID, service.AccountTypeOAuth, map[string]any{
|
||||
"model_mapping": map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"},
|
||||
}, map[string]any{"mixed_scheduling": true})
|
||||
env := p3CharNewHandler(t, group, []*service.Account{account}, nil)
|
||||
env.agUpstream.respond = func(int64) (*http.Response, error) {
|
||||
return p3CharAntigravitySSEResponse(), nil
|
||||
}
|
||||
return env, group
|
||||
}
|
||||
|
||||
t.Run("非流式收集为单个JSON", func(t *testing.T) {
|
||||
env, group := newEnv(t, 9521)
|
||||
defer env.cleanup()
|
||||
|
||||
c, rec, cancel := p3CharMessagesContext(t, group, p3CharGeminiLoopBody(false), nil)
|
||||
defer cancel()
|
||||
|
||||
env.handler.Messages(c)
|
||||
|
||||
agCalls := env.agUpstream.recordedCalls()
|
||||
require.Len(t, agCalls, 1, "antigravity 上游应被调用恰好一次")
|
||||
require.Empty(t, env.gwUpstream.recordedCalls())
|
||||
|
||||
// model 参数:包裹体 model = 映射后模型
|
||||
require.Contains(t, agCalls[0].URL, "/v1internal:streamGenerateContent?alt=sse")
|
||||
require.Equal(t, "gemini-3-pro-high", gjson.GetBytes(agCalls[0].Body, "model").String())
|
||||
// body 透传:原始 contents 文本进入包裹体 request 字段
|
||||
require.Contains(t, gjson.GetBytes(agCalls[0].Body, "request").Raw, "p3 gemini dispatch probe")
|
||||
|
||||
// stream=false:收集上游流式响应为单个 JSON
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "application/json", rec.Header().Get("Content-Type"))
|
||||
require.Equal(t, "ok", gjson.Get(rec.Body.String(), "candidates.0.content.parts.0.text").String())
|
||||
|
||||
// sessionKey 格式:gemini 平台为 "gemini:"+sessionHash
|
||||
getCalls := env.stickyCache.recordedGetCalls()
|
||||
require.NotEmpty(t, getCalls, "粘性会话查询应发生")
|
||||
for _, call := range getCalls {
|
||||
require.Equal(t, group.ID, call.GroupID)
|
||||
require.Equal(t, "gemini:"+p3CharSessionID, call.SessionHash)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("流式SSE解包透传", func(t *testing.T) {
|
||||
env, group := newEnv(t, 9522)
|
||||
defer env.cleanup()
|
||||
|
||||
c, rec, cancel := p3CharMessagesContext(t, group, p3CharGeminiLoopBody(true), nil)
|
||||
defer cancel()
|
||||
|
||||
env.handler.Messages(c)
|
||||
|
||||
agCalls := env.agUpstream.recordedCalls()
|
||||
require.Len(t, agCalls, 1)
|
||||
require.Contains(t, agCalls[0].URL, "/v1internal:streamGenerateContent?alt=sse")
|
||||
|
||||
// stream=true:SSE 解包透传
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "text/event-stream", rec.Header().Get("Content-Type"))
|
||||
require.Contains(t, rec.Body.String(), `data: {"candidates"`)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T3 session 选项透传 + 粘性绑定语义
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestPlatformDispatchCharacterization_GeminiLoopSessionOptionsReachStickyClear
|
||||
// 固化 WithForwardGeminiSession(groupID, sessionKey) 的端到端透传:
|
||||
// 1. 粘性绑定账号被优先调度(首次上游调用命中绑定账号);
|
||||
// 2. 绑定账号 429(RATE_LIMIT_EXCEEDED + 长 retryDelay)触发模型限流切换时,
|
||||
// ForwardGemini 内部以 handler 传入的 (groupID, sessionKey) 调用
|
||||
// GatewayCache.DeleteSessionAccountID 清除粘性绑定——参数逐一断言;
|
||||
// 3. failover 切换第二账号成功,客户端最终 200。
|
||||
//
|
||||
// 此场景同时锁定 isStickySession=hasBoundSession 的端到端透传:绑定命中 →
|
||||
// ForwardGemini(isSticky=true) → 切换信号携带 IsStickySession → handler
|
||||
// fs.ForceCacheBilling → 第二账号成功后 RecordUsage 应用 force_cache_billing
|
||||
// (经结构化日志 sink 断言;isSticky 在 service 内的语义另由 service 层既有
|
||||
// 测试 TestAntigravityGatewayService_ForwardGemini_StickySessionForceCacheBilling 锁定)。
|
||||
func TestPlatformDispatchCharacterization_GeminiLoopSessionOptionsReachStickyClear(t *testing.T) {
|
||||
sink, restoreSink := captureHandlerStructuredLog(t)
|
||||
defer restoreSink()
|
||||
|
||||
group := p3CharGroup(9531, service.PlatformGemini)
|
||||
boundAccount := p3CharAntigravityAccount(9541, group.ID, service.AccountTypeOAuth, map[string]any{
|
||||
"model_mapping": map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"},
|
||||
}, map[string]any{"mixed_scheduling": true})
|
||||
healthyAccount := p3CharAntigravityAccount(9542, group.ID, service.AccountTypeOAuth, map[string]any{
|
||||
"model_mapping": map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"},
|
||||
}, map[string]any{"mixed_scheduling": true})
|
||||
|
||||
env := p3CharNewHandler(t, group, []*service.Account{boundAccount, healthyAccount}, nil)
|
||||
defer env.cleanup()
|
||||
|
||||
sessionKey := "gemini:" + p3CharSessionID
|
||||
env.stickyCache.bind(group.ID, sessionKey, boundAccount.ID)
|
||||
|
||||
env.agUpstream.respond = func(accountID int64) (*http.Response, error) {
|
||||
if accountID == boundAccount.ID {
|
||||
return p3CharRateLimit429Response(), nil
|
||||
}
|
||||
return p3CharAntigravitySSEResponse(), nil
|
||||
}
|
||||
|
||||
c, rec, cancel := p3CharMessagesContext(t, group, p3CharGeminiLoopBody(false), nil)
|
||||
defer cancel()
|
||||
|
||||
env.handler.Messages(c)
|
||||
|
||||
// 1. 粘性绑定账号被优先调度,429 后切换到第二账号
|
||||
agCalls := env.agUpstream.recordedCalls()
|
||||
require.Len(t, agCalls, 2, "绑定账号 1 次 + failover 后第二账号 1 次")
|
||||
require.Equal(t, boundAccount.ID, agCalls[0].AccountID, "首次尝试必须命中粘性绑定账号")
|
||||
require.Equal(t, healthyAccount.ID, agCalls[1].AccountID, "failover 切换到第二账号")
|
||||
|
||||
// 2. WithForwardGeminiSession 的 (groupID, sessionKey) 透传到粘性清除
|
||||
deleteCalls := env.stickyCache.recordedDeleteCalls()
|
||||
require.NotEmpty(t, deleteCalls, "模型限流切换必须清除粘性绑定")
|
||||
for _, call := range deleteCalls {
|
||||
require.Equal(t, group.ID, call.GroupID, "清除粘性绑定的 groupID 必须等于 handler 传入的分组 ID")
|
||||
require.Equal(t, sessionKey, call.SessionHash, "清除粘性绑定的 sessionHash 必须等于 handler 传入的 sessionKey")
|
||||
}
|
||||
|
||||
// 3. failover 成功,客户端 200
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Empty(t, env.gwUpstream.recordedCalls())
|
||||
|
||||
// 4. isStickySession 透传链:hasBoundSession=true → ForwardGemini 切换信号
|
||||
// IsStickySession=true → fs.ForceCacheBilling → RecordUsage 应用
|
||||
// force_cache_billing(input_tokens 转 cache_read)。
|
||||
require.True(t,
|
||||
sink.ContainsMessageAtLevel("force_cache_billing", "info"),
|
||||
"粘性会话切换后第二账号成功,RecordUsage 必须应用 force_cache_billing")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T4 错误链现状锁定
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestErrorChainCharacterization_BetaBlockedErrorReturns400 固化:
|
||||
// GatewayService.Forward 返回 *service.BetaBlockedError 时,handler 的
|
||||
// errors.As 命中 → 400 + invalid_request_error + 策略消息原文;不触发 failover、
|
||||
// 不触上游。改造后 adapter 若包裹该错误且破坏 errors.As 链,本用例必红。
|
||||
func TestErrorChainCharacterization_BetaBlockedErrorReturns400(t *testing.T) {
|
||||
group := p3CharGroup(9551, service.PlatformAnthropic)
|
||||
// anthropic APIKey 账号(未启用透传)→ GatewayService.Forward 主路径会评估 beta policy
|
||||
account := &service.Account{
|
||||
ID: 9561,
|
||||
Name: "p3-char-anthropic-apikey",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "sk-p3-beta"},
|
||||
Concurrency: 5,
|
||||
Priority: 1,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
AccountGroups: []service.AccountGroup{{AccountID: 9561, GroupID: group.ID}},
|
||||
}
|
||||
|
||||
betaPolicy := `{"rules":[{"beta_token":"p3-blocked-beta","action":"block","scope":"all","error_message":"beta blocked by p3 policy"}]}`
|
||||
env := p3CharNewHandler(t, group, []*service.Account{account}, map[string]string{
|
||||
"beta_policy_settings": betaPolicy,
|
||||
})
|
||||
defer env.cleanup()
|
||||
|
||||
c, rec, cancel := p3CharMessagesContext(t, group, p3CharClaudeBody(false), map[string]string{
|
||||
"anthropic-beta": "p3-blocked-beta",
|
||||
})
|
||||
defer cancel()
|
||||
|
||||
env.handler.Messages(c)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.JSONEq(t,
|
||||
`{"type":"error","error":{"type":"invalid_request_error","message":"beta blocked by p3 policy"}}`,
|
||||
rec.Body.String())
|
||||
require.Empty(t, env.gwUpstream.recordedCalls(), "beta policy 拦截发生在触上游之前")
|
||||
require.Empty(t, env.agUpstream.recordedCalls())
|
||||
}
|
||||
|
||||
// TestErrorChainCharacterization_PromptTooLongWritesMappedClaudeError 固化:
|
||||
// AntigravityGatewayService.Forward 返回 *service.PromptTooLongError 且无兜底
|
||||
// 分组时,handler 的 errors.As 命中 → WriteMappedClaudeError 写回上游状态码 +
|
||||
// Claude 风格错误体;不触发 failover(单次上游调用)。
|
||||
func TestErrorChainCharacterization_PromptTooLongWritesMappedClaudeError(t *testing.T) {
|
||||
group := p3CharGroup(9552, service.PlatformAntigravity)
|
||||
account := p3CharAntigravityAccount(9562, group.ID, service.AccountTypeOAuth, map[string]any{
|
||||
"model_mapping": map[string]any{"claude-sonnet-4-5": "gemini-3-pro-high"},
|
||||
}, nil)
|
||||
|
||||
env := p3CharNewHandler(t, group, []*service.Account{account}, nil)
|
||||
defer env.cleanup()
|
||||
env.agUpstream.respond = func(int64) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"X-Request-Id": []string{"rid-p3-ptl"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"Prompt is too long"}}`)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
c, rec, cancel := p3CharMessagesContext(t, group, p3CharClaudeBody(false), nil)
|
||||
defer cancel()
|
||||
|
||||
env.handler.Messages(c)
|
||||
|
||||
require.Len(t, env.agUpstream.recordedCalls(), 1, "prompt too long 不触发 failover")
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, "WriteMappedClaudeError 透传上游状态码")
|
||||
|
||||
var errBody map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &errBody))
|
||||
require.Equal(t, "error", errBody["type"])
|
||||
errObj, ok := errBody["error"].(map[string]any)
|
||||
require.True(t, ok, "错误体必须是 Claude 风格 {type, error:{...}}")
|
||||
require.Contains(t, fmt.Sprintf("%v", errObj["message"]), "Prompt is too long")
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/gatewayhook"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ProvideGatewayHookChain 装配网关 pre-flight 钩子链(Wire 注入的核心钩子;
|
||||
// 裁决 H-C:只建链,不做 gateway.hook.* 命名空间的 Runtime 收集——该机制留待
|
||||
// Phase-3 与平台 Provider 真实需求合并设计)。当前链 = [content_moderation]。
|
||||
func ProvideGatewayHookChain(contentModerationService *service.ContentModerationService) *gatewayhook.Chain {
|
||||
if contentModerationService == nil {
|
||||
return gatewayhook.NewChain(nil)
|
||||
}
|
||||
return gatewayhook.NewChain(nil, &contentModerationPreFlightHook{svc: contentModerationService})
|
||||
}
|
||||
|
||||
// preFlightLoggerKey 在钩子执行期间通过 ctx 传递请求级 logger(reqLog),
|
||||
// 保留其已累积的 component/model/stream 等字段,保证审核日志字段等价。
|
||||
type preFlightLoggerKey struct{}
|
||||
|
||||
func withPreFlightLogger(ctx context.Context, log *zap.Logger) context.Context {
|
||||
if log == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, preFlightLoggerKey{}, log)
|
||||
}
|
||||
|
||||
func preFlightLoggerFromContext(ctx context.Context) *zap.Logger {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
log, _ := ctx.Value(preFlightLoggerKey{}).(*zap.Logger)
|
||||
return log
|
||||
}
|
||||
|
||||
// newGatewayHookRequest 从 gin 上下文构造钩子请求(协议无关只读视图)。
|
||||
// Path 的计算与原 buildContentModerationInput 的 Endpoint 等价:
|
||||
// GetInboundEndpoint 优先,空值回退原始 URL path。
|
||||
func newGatewayHookRequest(c *gin.Context, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *gatewayhook.Request {
|
||||
caller := gatewayhook.CallerInfo{UserID: subject.UserID}
|
||||
if apiKey != nil {
|
||||
caller.KeyID = apiKey.ID
|
||||
caller.GroupID = apiKey.GroupID
|
||||
}
|
||||
path := GetInboundEndpoint(c)
|
||||
if path == "" && c.Request.URL != nil {
|
||||
path = c.Request.URL.Path
|
||||
}
|
||||
return &gatewayhook.Request{
|
||||
Protocol: protocol,
|
||||
Model: model,
|
||||
Body: body,
|
||||
Caller: caller,
|
||||
APIKey: apiKey,
|
||||
Headers: gatewayhook.NewRequestHeaders(c.Request.Header),
|
||||
Path: path,
|
||||
}
|
||||
}
|
||||
|
||||
// runGatewayPreFlight 构造钩子请求并执行 pre-flight 链。
|
||||
// 等价性约束(对应原 runContentModeration 的调用面):
|
||||
// - 链为空(审核服务未装配/测试夹具)时直接放行,且不构造任何对象(零分配);
|
||||
// - c / c.Request 为 nil 时直接放行(与原 helper 的 nil 防御一致)。
|
||||
func runGatewayPreFlight(chain *gatewayhook.Chain, c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *gatewayhook.Decision {
|
||||
if chain.IsEmpty() || c == nil || c.Request == nil {
|
||||
return nil
|
||||
}
|
||||
req := newGatewayHookRequest(c, apiKey, subject, protocol, model, body)
|
||||
return chain.Run(withPreFlightLogger(c.Request.Context(), reqLog), req)
|
||||
}
|
||||
|
||||
// runPreFlightHooks 执行网关 pre-flight 钩子链(替代原 checkContentModeration 的 HTTP 调用面)。
|
||||
func (h *GatewayHandler) runPreFlightHooks(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *gatewayhook.Decision {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
return runGatewayPreFlight(h.preFlightHooks, c, reqLog, apiKey, subject, protocol, model, body)
|
||||
}
|
||||
|
||||
// runPreFlightHooks 执行网关 pre-flight 钩子链(HTTP 调用点专用;
|
||||
// WebSocket 两调用点仍走 checkContentModeration,按裁决排除在链改造之外)。
|
||||
func (h *OpenAIGatewayHandler) runPreFlightHooks(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *gatewayhook.Decision {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
return runGatewayPreFlight(h.preFlightHooks, c, reqLog, apiKey, subject, protocol, model, body)
|
||||
}
|
||||
|
||||
// preFlightStatus 与原 contentModerationStatus 等价:非 4xx/5xx 状态码钳制为 403。
|
||||
func preFlightStatus(decision *gatewayhook.Decision) int {
|
||||
if decision == nil || decision.StatusCode < 400 || decision.StatusCode > 599 {
|
||||
return http.StatusForbidden
|
||||
}
|
||||
return decision.StatusCode
|
||||
}
|
||||
|
||||
// preFlightErrorCode 与原 contentModerationErrorCode 等价:
|
||||
// 拦截错误码固定回退 content_policy_violation(gemini 调用点不消费该值)。
|
||||
func preFlightErrorCode(decision *gatewayhook.Decision) string {
|
||||
if decision == nil || strings.TrimSpace(decision.ErrorType) == "" {
|
||||
return "content_policy_violation"
|
||||
}
|
||||
return decision.ErrorType
|
||||
}
|
||||
|
||||
// contentModerationPreFlightHook 是内容审核的核心钩子 adapter
|
||||
// (裁决 H:moderation 本体依赖面超出 Host 能力,不模块化,仅包装现有 Check)。
|
||||
//
|
||||
// 等价性硬约束(与原 runContentModeration 逐项对应,WS 路径仍直接使用后者):
|
||||
// - gateway_check_start(11 字段)/ gateway_check_done(8 字段)结构化日志
|
||||
// 的事件名、字段名、字段来源逐一保留,且经请求级 logger(reqLog)输出;
|
||||
// - Check 返回 error 时记 content_moderation.check_failed 后放行(fail-open,
|
||||
// 由本钩子自行吞错,不依赖链层的通用 error 降级,保持原日志事件名);
|
||||
// - svc 为 nil 时直接放行。
|
||||
type contentModerationPreFlightHook struct {
|
||||
svc *service.ContentModerationService
|
||||
}
|
||||
|
||||
func (h *contentModerationPreFlightHook) HookID() string { return "content_moderation" }
|
||||
|
||||
func (h *contentModerationPreFlightHook) CheckPreFlight(ctx context.Context, req *gatewayhook.Request) (*gatewayhook.Decision, error) {
|
||||
if h == nil || h.svc == nil || req == nil {
|
||||
return nil, nil
|
||||
}
|
||||
reqLog := preFlightLoggerFromContext(ctx)
|
||||
input := moderationInputFromHookRequest(ctx, req)
|
||||
if reqLog != nil {
|
||||
reqLog.Info("content_moderation.gateway_check_start",
|
||||
zap.String("request_id", input.RequestID),
|
||||
zap.Int64("user_id", input.UserID),
|
||||
zap.Int64("api_key_id", input.APIKeyID),
|
||||
zap.String("api_key_name", input.APIKeyName),
|
||||
zap.Int64p("group_id", input.GroupID),
|
||||
zap.String("group_name", input.GroupName),
|
||||
zap.String("endpoint", input.Endpoint),
|
||||
zap.String("provider", input.Provider),
|
||||
zap.String("protocol", input.Protocol),
|
||||
zap.String("model", input.Model),
|
||||
zap.Int("body_bytes", len(req.Body)),
|
||||
)
|
||||
}
|
||||
decision, err := h.svc.Check(ctx, input)
|
||||
if err != nil {
|
||||
if reqLog != nil {
|
||||
reqLog.Warn("content_moderation.check_failed", zap.Error(err))
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if reqLog != nil && decision != nil {
|
||||
reqLog.Info("content_moderation.gateway_check_done",
|
||||
zap.String("request_id", input.RequestID),
|
||||
zap.Bool("allowed", decision.Allowed),
|
||||
zap.Bool("blocked", decision.Blocked),
|
||||
zap.Bool("flagged", decision.Flagged),
|
||||
zap.String("action", decision.Action),
|
||||
zap.Int("status_code", decision.StatusCode),
|
||||
zap.String("highest_category", decision.HighestCategory),
|
||||
zap.Float64("highest_score", decision.HighestScore),
|
||||
)
|
||||
}
|
||||
if decision == nil || !decision.Blocked {
|
||||
return nil, nil
|
||||
}
|
||||
return &gatewayhook.Decision{
|
||||
Blocked: true,
|
||||
StatusCode: decision.StatusCode,
|
||||
ErrorType: "content_policy_violation",
|
||||
Message: decision.Message,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// moderationInputFromHookRequest 从钩子请求构造审核输入,与 buildContentModerationInput
|
||||
// (WS 路径仍在使用)逐字段等价:
|
||||
// - RequestID 来自 ctx(调用侧传 c.Request.Context());
|
||||
// - Endpoint 即 req.Path(调用侧已按 GetInboundEndpoint + URL.Path 兜底预计算);
|
||||
// - 强制平台覆盖改读 ctxkey.ForcePlatform(middleware.ForcePlatform 同步写入
|
||||
// gin key 与 request context,两者在审核执行点恒一致)。
|
||||
func moderationInputFromHookRequest(ctx context.Context, req *gatewayhook.Request) service.ContentModerationCheckInput {
|
||||
input := service.ContentModerationCheckInput{
|
||||
RequestID: contentModerationRequestID(ctx),
|
||||
UserID: req.Caller.UserID,
|
||||
Endpoint: req.Path,
|
||||
Provider: contentModerationProvider(req.APIKey),
|
||||
Model: strings.TrimSpace(req.Model),
|
||||
Protocol: req.Protocol,
|
||||
Body: req.Body,
|
||||
}
|
||||
if forcedPlatform, ok := ctx.Value(ctxkey.ForcePlatform).(string); ok {
|
||||
input.Provider = strings.TrimSpace(forcedPlatform)
|
||||
}
|
||||
if req.APIKey != nil {
|
||||
input.APIKeyID = req.APIKey.ID
|
||||
input.APIKeyName = req.APIKey.Name
|
||||
if req.APIKey.User != nil {
|
||||
input.UserEmail = req.APIKey.User.Email
|
||||
}
|
||||
if req.APIKey.GroupID != nil {
|
||||
groupID := *req.APIKey.GroupID
|
||||
input.GroupID = &groupID
|
||||
}
|
||||
if req.APIKey.Group != nil {
|
||||
input.GroupName = req.APIKey.Group.Name
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//go:build unit
|
||||
|
||||
// Phase-2 TASK-003 钩子链等价性单测:
|
||||
// - moderationInputFromHookRequest 与 buildContentModerationInput(WS 路径仍用)逐字段等价;
|
||||
// - moderation 核心钩子的 gateway_check_start(11 字段)/ gateway_check_done(8 字段)
|
||||
// 结构化日志契约;
|
||||
// - 空链 / nil 防御路径零分配(moderation 未装配时热路径无新增开销)。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/gatewayhook"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
// preFlightTestGinContext 构造带请求上下文的 gin.Context。
|
||||
// forcePlatform 非空时按 middleware.ForcePlatform 的真实行为同时写入
|
||||
// request context(ctxkey.ForcePlatform)与 gin key。
|
||||
func preFlightTestGinContext(t *testing.T, path string, forcePlatform string) *gin.Context {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader([]byte(`{}`)))
|
||||
ctx := context.WithValue(req.Context(), ctxkey.RequestID, "req-pf-001")
|
||||
if forcePlatform != "" {
|
||||
ctx = context.WithValue(ctx, ctxkey.ForcePlatform, forcePlatform)
|
||||
}
|
||||
c.Request = req.WithContext(ctx)
|
||||
if forcePlatform != "" {
|
||||
c.Set(string(middleware.ContextKeyForcePlatform), forcePlatform)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func preFlightTestAPIKey() *service.APIKey {
|
||||
groupID := int64(9001)
|
||||
return &service.APIKey{
|
||||
ID: 9101,
|
||||
UserID: 9201,
|
||||
Name: "pf-key",
|
||||
GroupID: &groupID,
|
||||
User: &service.User{ID: 9201, Email: "pf@example.com"},
|
||||
Group: &service.Group{ID: groupID, Name: "pf-group", Platform: service.PlatformAnthropic},
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreFlightModerationInput_EquivalentToLegacyBuilder 锁定钩子侧审核输入
|
||||
// 与 WS 路径仍在使用的 buildContentModerationInput 逐字段等价。
|
||||
func TestPreFlightModerationInput_EquivalentToLegacyBuilder(t *testing.T) {
|
||||
subject := middleware.AuthSubject{UserID: 9201, Concurrency: 5}
|
||||
body := []byte(`{"model":"claude-sonnet-4-5"}`)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
forcePlatform string
|
||||
apiKey *service.APIKey
|
||||
model string
|
||||
}{
|
||||
{name: "常规请求", apiKey: preFlightTestAPIKey(), model: " claude-sonnet-4-5 "},
|
||||
{name: "强制平台覆盖provider", forcePlatform: "antigravity", apiKey: preFlightTestAPIKey(), model: "claude-sonnet-4-5"},
|
||||
{name: "nil apiKey防御", apiKey: nil, model: "gpt-5.1"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c := preFlightTestGinContext(t, "/v1/messages", tc.forcePlatform)
|
||||
|
||||
legacy := buildContentModerationInput(c, tc.apiKey, subject, service.ContentModerationProtocolAnthropicMessages, tc.model, body)
|
||||
req := newGatewayHookRequest(c, tc.apiKey, subject, service.ContentModerationProtocolAnthropicMessages, tc.model, body)
|
||||
hookInput := moderationInputFromHookRequest(c.Request.Context(), req)
|
||||
|
||||
require.Equal(t, legacy, hookInput, "钩子侧审核输入必须与原 builder 逐字段等价")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestContentModerationPreFlightHook_CheckLogContract 锁定核心钩子的
|
||||
// gateway_check_start / gateway_check_done 日志字段契约与拦截决策映射。
|
||||
func TestContentModerationPreFlightHook_CheckLogContract(t *testing.T) {
|
||||
moderationSrv := p2CharFlagAllModerationServer(t)
|
||||
defer moderationSrv.Close()
|
||||
|
||||
core, logs := observer.New(zap.InfoLevel)
|
||||
reqLog := zap.New(core)
|
||||
|
||||
hook := &contentModerationPreFlightHook{svc: passCharModerationService(t, moderationSrv.URL)}
|
||||
c := preFlightTestGinContext(t, "/v1/messages", "")
|
||||
apiKey := preFlightTestAPIKey()
|
||||
subject := middleware.AuthSubject{UserID: 9201, Concurrency: 5}
|
||||
body := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"bad words"}]}`)
|
||||
|
||||
req := newGatewayHookRequest(c, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, "claude-sonnet-4-5", body)
|
||||
ctx := withPreFlightLogger(c.Request.Context(), reqLog)
|
||||
|
||||
decision, err := hook.CheckPreFlight(ctx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, decision)
|
||||
require.True(t, decision.Blocked)
|
||||
require.Equal(t, "content_policy_violation", decision.ErrorType)
|
||||
require.NotEmpty(t, decision.Message)
|
||||
|
||||
start := logs.FilterMessage("content_moderation.gateway_check_start").All()
|
||||
require.Len(t, start, 1, "必须记一条 gateway_check_start")
|
||||
startFields := start[0].ContextMap()
|
||||
require.Equal(t, []string{
|
||||
"request_id", "user_id", "api_key_id", "api_key_name", "group_id",
|
||||
"group_name", "endpoint", "provider", "protocol", "model", "body_bytes",
|
||||
}, preFlightLogFieldKeys(start[0]), "gateway_check_start 11 字段的名称与顺序必须保留")
|
||||
require.Equal(t, "req-pf-001", startFields["request_id"])
|
||||
require.Equal(t, int64(9201), startFields["user_id"])
|
||||
require.Equal(t, int64(9101), startFields["api_key_id"])
|
||||
require.Equal(t, "pf-key", startFields["api_key_name"])
|
||||
require.Equal(t, int64(9001), startFields["group_id"])
|
||||
require.Equal(t, "pf-group", startFields["group_name"])
|
||||
require.Equal(t, "/v1/messages", startFields["endpoint"])
|
||||
require.Equal(t, service.PlatformAnthropic, startFields["provider"])
|
||||
require.Equal(t, service.ContentModerationProtocolAnthropicMessages, startFields["protocol"])
|
||||
require.Equal(t, "claude-sonnet-4-5", startFields["model"])
|
||||
require.Equal(t, int64(len(body)), startFields["body_bytes"])
|
||||
|
||||
done := logs.FilterMessage("content_moderation.gateway_check_done").All()
|
||||
require.Len(t, done, 1, "必须记一条 gateway_check_done")
|
||||
require.Equal(t, []string{
|
||||
"request_id", "allowed", "blocked", "flagged", "action",
|
||||
"status_code", "highest_category", "highest_score",
|
||||
}, preFlightLogFieldKeys(done[0]), "gateway_check_done 8 字段的名称与顺序必须保留")
|
||||
doneFields := done[0].ContextMap()
|
||||
require.Equal(t, true, doneFields["blocked"])
|
||||
require.Equal(t, "req-pf-001", doneFields["request_id"])
|
||||
}
|
||||
|
||||
func preFlightLogFieldKeys(entry observer.LoggedEntry) []string {
|
||||
keys := make([]string, 0, len(entry.Context))
|
||||
for _, f := range entry.Context {
|
||||
keys = append(keys, f.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// TestRunGatewayPreFlight_EmptyChainZeroAlloc 锁定审核未装配(空链/nil 链)时
|
||||
// pre-flight 调用面零分配直接放行——热路径无新增开销。
|
||||
func TestRunGatewayPreFlight_EmptyChainZeroAlloc(t *testing.T) {
|
||||
c := preFlightTestGinContext(t, "/v1/messages", "")
|
||||
apiKey := preFlightTestAPIKey()
|
||||
subject := middleware.AuthSubject{UserID: 9201}
|
||||
body := []byte(`{}`)
|
||||
reqLog := zap.NewNop()
|
||||
|
||||
for name, chain := range map[string]*gatewayhook.Chain{
|
||||
"nil链": nil,
|
||||
"空链": ProvideGatewayHookChain(nil),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
require.Nil(t, runGatewayPreFlight(chain, c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, "m", body))
|
||||
allocs := testing.AllocsPerRun(100, func() {
|
||||
_ = runGatewayPreFlight(chain, c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, "m", body)
|
||||
})
|
||||
require.Zero(t, allocs, "空链路径必须零分配")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestContentModerationPreFlightHook_NilGuards 锁定 nil 防御:
|
||||
// svc/req 为 nil 或 ctx 无请求级 logger 时不 panic 且放行。
|
||||
func TestContentModerationPreFlightHook_NilGuards(t *testing.T) {
|
||||
var nilHook *contentModerationPreFlightHook
|
||||
d, err := nilHook.CheckPreFlight(context.Background(), &gatewayhook.Request{})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, d)
|
||||
|
||||
hookNoSvc := &contentModerationPreFlightHook{}
|
||||
d, err = hookNoSvc.CheckPreFlight(context.Background(), &gatewayhook.Request{})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, d)
|
||||
|
||||
moderationSrv := p2CharFlagAllModerationServer(t)
|
||||
defer moderationSrv.Close()
|
||||
hook := &contentModerationPreFlightHook{svc: passCharModerationService(t, moderationSrv.URL)}
|
||||
d, err = hook.CheckPreFlight(context.Background(), nil)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, d)
|
||||
}
|
||||
@@ -187,8 +187,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
setOpsRequestContext(c, modelName, stream)
|
||||
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(stream, false)))
|
||||
|
||||
if decision := h.checkContentModeration(c, reqLog, apiKey, authSubject, service.ContentModerationProtocolGemini, modelName, body); decision != nil && decision.Blocked {
|
||||
googleError(c, contentModerationStatus(decision), decision.Message)
|
||||
if decision := h.runPreFlightHooks(c, reqLog, apiKey, authSubject, service.ContentModerationProtocolGemini, modelName, body); decision != nil && decision.Blocked {
|
||||
googleError(c, preFlightStatus(decision), decision.Message)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ type AdminHandlers struct {
|
||||
Payment *admin.PaymentHandler
|
||||
Affiliate *admin.AffiliateHandler
|
||||
Compliance *admin.ComplianceHandler
|
||||
Module *admin.ModuleHandler
|
||||
}
|
||||
|
||||
// Handlers contains all HTTP handlers
|
||||
|
||||
@@ -85,8 +85,8 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
setOpsRequestContext(c, reqModel, reqStream)
|
||||
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
|
||||
|
||||
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, reqModel, body); decision != nil && decision.Blocked {
|
||||
h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
|
||||
if decision := h.runPreFlightHooks(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, reqModel, body); decision != nil && decision.Blocked {
|
||||
h.errorResponse(c, preFlightStatus(decision), preFlightErrorCode(decision), decision.Message)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/gatewayhook"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
@@ -34,6 +35,7 @@ type OpenAIGatewayHandler struct {
|
||||
usageRecordWorkerPool *service.UsageRecordWorkerPool
|
||||
errorPassthroughService *service.ErrorPassthroughService
|
||||
contentModerationService *service.ContentModerationService
|
||||
preFlightHooks *gatewayhook.Chain
|
||||
concurrencyHelper *ConcurrencyHelper
|
||||
imageLimiter *imageConcurrencyLimiter
|
||||
maxAccountSwitches int
|
||||
@@ -105,6 +107,7 @@ func NewOpenAIGatewayHandler(
|
||||
usageRecordWorkerPool *service.UsageRecordWorkerPool,
|
||||
errorPassthroughService *service.ErrorPassthroughService,
|
||||
contentModerationService *service.ContentModerationService,
|
||||
preFlightHooks *gatewayhook.Chain,
|
||||
cfg *config.Config,
|
||||
) *OpenAIGatewayHandler {
|
||||
pingInterval := time.Duration(0)
|
||||
@@ -122,6 +125,7 @@ func NewOpenAIGatewayHandler(
|
||||
usageRecordWorkerPool: usageRecordWorkerPool,
|
||||
errorPassthroughService: errorPassthroughService,
|
||||
contentModerationService: contentModerationService,
|
||||
preFlightHooks: preFlightHooks,
|
||||
concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatComment, pingInterval),
|
||||
imageLimiter: &imageConcurrencyLimiter{},
|
||||
maxAccountSwitches: maxAccountSwitches,
|
||||
@@ -241,8 +245,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
setOpsRequestContext(c, reqModel, reqStream)
|
||||
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
|
||||
|
||||
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, body); decision != nil && decision.Blocked {
|
||||
h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
|
||||
if decision := h.runPreFlightHooks(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, body); decision != nil && decision.Blocked {
|
||||
h.errorResponse(c, preFlightStatus(decision), preFlightErrorCode(decision), decision.Message)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -673,8 +677,8 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
setOpsRequestContext(c, reqModel, reqStream)
|
||||
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
|
||||
|
||||
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && decision.Blocked {
|
||||
h.anthropicErrorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
|
||||
if decision := h.runPreFlightHooks(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && decision.Blocked {
|
||||
h.anthropicErrorResponse(c, preFlightStatus(decision), preFlightErrorCode(decision), decision.Message)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -86,8 +86,8 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage())
|
||||
return
|
||||
}
|
||||
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIImages, requestModel, parsed.ModerationBody()); decision != nil && decision.Blocked {
|
||||
h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
|
||||
if decision := h.runPreFlightHooks(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIImages, requestModel, parsed.ModerationBody()); decision != nil && decision.Blocked {
|
||||
h.errorResponse(c, preFlightStatus(decision), preFlightErrorCode(decision), decision.Message)
|
||||
return
|
||||
}
|
||||
imageReleaseFunc, acquired := h.acquireImageGenerationSlot(c, streamStarted)
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
//go:build unit
|
||||
|
||||
// Phase-0 TASK-004 failover 不变量测试(INVARIANTS I-5.1 / I-5.2 / I-5.3、I-6.1 部分)。
|
||||
//
|
||||
// 固化内容:
|
||||
// - I-5.1 三平台换号上限默认值(anthropic=10 / gemini=3 / openai=3,双断言:
|
||||
// 构造函数默认值 + 硬编码当前值);anthropic 平台通过完整 Messages 入口驱动
|
||||
// 真实 failover 循环:连续上游 500 时恰好尝试 maxAccountSwitches+1 个账号后
|
||||
// 返回 502 + 映射错误体;
|
||||
// - I-5.2 失败账号进入排除集合不被重选(同一账号不会被尝试两次);
|
||||
// - I-5.3 可重试错误(池模式)先同账号重试 maxSameAccountRetries 次再换号(完整链);
|
||||
// - I-6.1 转发失败路径下账号/用户槽位获取-释放严格配平(配平计数器);
|
||||
// - chat-completions 兼容路径耗尽错误体("All available accounts exhausted")。
|
||||
//
|
||||
// 复用同包既有夹具:fakeSchedulerCache / fakeGroupRepo
|
||||
// (gateway_handler_warmup_intercept_unit_test.go)、mockTempUnscheduler /
|
||||
// newTestFailoverErr(failover_loop_test.go)。
|
||||
// 本文件新增的包级辅助类型/函数一律带 schedInv 前缀。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// schedInv 夹具
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// schedInvUpstream 是固定响应的 HTTPUpstream 桩,记录每次调用的 accountID。
|
||||
type schedInvUpstream struct {
|
||||
mu sync.Mutex
|
||||
status int
|
||||
body string
|
||||
panicOn bool
|
||||
attempts []int64
|
||||
}
|
||||
|
||||
var _ service.HTTPUpstream = (*schedInvUpstream)(nil)
|
||||
|
||||
func (u *schedInvUpstream) Do(req *http.Request, _ string, accountID int64, _ int) (*http.Response, error) {
|
||||
u.mu.Lock()
|
||||
u.attempts = append(u.attempts, accountID)
|
||||
shouldPanic := u.panicOn
|
||||
status := u.status
|
||||
body := u.body
|
||||
u.mu.Unlock()
|
||||
|
||||
if req != nil && req.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
_ = req.Body.Close()
|
||||
}
|
||||
if shouldPanic {
|
||||
panic("schedInv: simulated upstream panic")
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (u *schedInvUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return u.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
func (u *schedInvUpstream) attemptedAccounts() []int64 {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
return append([]int64(nil), u.attempts...)
|
||||
}
|
||||
|
||||
// schedInvCountingCache 是带真实容量语义的 ConcurrencyCache,
|
||||
// 记录账号/用户槽位与等待计数的获取-释放配平。
|
||||
type schedInvCountingCache struct {
|
||||
mu sync.Mutex
|
||||
|
||||
accountHeld map[int64]map[string]struct{}
|
||||
userHeld map[int64]map[string]struct{}
|
||||
|
||||
accountAcquired int
|
||||
accountReleased int
|
||||
// accountReleasedUnknown 记录对未持有 requestID 的重复/无效释放(幂等释放语义)
|
||||
accountReleasedUnknown int
|
||||
userAcquired int
|
||||
userReleased int
|
||||
userReleasedUnknown int
|
||||
|
||||
userWaitInc int
|
||||
userWaitDec int
|
||||
accountWaitInc int
|
||||
accountWaitDec int
|
||||
}
|
||||
|
||||
var _ service.ConcurrencyCache = (*schedInvCountingCache)(nil)
|
||||
|
||||
func schedInvNewCountingCache() *schedInvCountingCache {
|
||||
return &schedInvCountingCache{
|
||||
accountHeld: make(map[int64]map[string]struct{}),
|
||||
userHeld: make(map[int64]map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) AcquireAccountSlot(_ context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
held := c.accountHeld[accountID]
|
||||
if maxConcurrency > 0 && len(held) >= maxConcurrency {
|
||||
return false, nil
|
||||
}
|
||||
if held == nil {
|
||||
held = make(map[string]struct{})
|
||||
c.accountHeld[accountID] = held
|
||||
}
|
||||
held[requestID] = struct{}{}
|
||||
c.accountAcquired++
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) ReleaseAccountSlot(_ context.Context, accountID int64, requestID string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if held := c.accountHeld[accountID]; held != nil {
|
||||
if _, ok := held[requestID]; ok {
|
||||
delete(held, requestID)
|
||||
c.accountReleased++
|
||||
return nil
|
||||
}
|
||||
}
|
||||
c.accountReleasedUnknown++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) GetAccountConcurrency(_ context.Context, accountID int64) (int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return len(c.accountHeld[accountID]), nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) GetAccountConcurrencyBatch(_ context.Context, accountIDs []int64) (map[int64]int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
result := make(map[int64]int, len(accountIDs))
|
||||
for _, id := range accountIDs {
|
||||
result[id] = len(c.accountHeld[id])
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) IncrementAccountWaitCount(_ context.Context, _ int64, _ int) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.accountWaitInc++
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) DecrementAccountWaitCount(_ context.Context, _ int64) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.accountWaitDec++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) GetAccountWaitingCount(_ context.Context, _ int64) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) AcquireUserSlot(_ context.Context, userID int64, maxConcurrency int, requestID string) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
held := c.userHeld[userID]
|
||||
if maxConcurrency > 0 && len(held) >= maxConcurrency {
|
||||
return false, nil
|
||||
}
|
||||
if held == nil {
|
||||
held = make(map[string]struct{})
|
||||
c.userHeld[userID] = held
|
||||
}
|
||||
held[requestID] = struct{}{}
|
||||
c.userAcquired++
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) ReleaseUserSlot(_ context.Context, userID int64, requestID string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if held := c.userHeld[userID]; held != nil {
|
||||
if _, ok := held[requestID]; ok {
|
||||
delete(held, requestID)
|
||||
c.userReleased++
|
||||
return nil
|
||||
}
|
||||
}
|
||||
c.userReleasedUnknown++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) GetUserConcurrency(_ context.Context, userID int64) (int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return len(c.userHeld[userID]), nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) IncrementWaitCount(_ context.Context, _ int64, _ int) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.userWaitInc++
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) DecrementWaitCount(_ context.Context, _ int64) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.userWaitDec++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) GetAccountsLoadBatch(_ context.Context, accounts []service.AccountWithConcurrency) (map[int64]*service.AccountLoadInfo, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
result := make(map[int64]*service.AccountLoadInfo, len(accounts))
|
||||
for _, acc := range accounts {
|
||||
held := len(c.accountHeld[acc.ID])
|
||||
loadRate := 0
|
||||
if acc.MaxConcurrency > 0 {
|
||||
loadRate = held * 100 / acc.MaxConcurrency
|
||||
}
|
||||
result[acc.ID] = &service.AccountLoadInfo{
|
||||
AccountID: acc.ID,
|
||||
CurrentConcurrency: held,
|
||||
LoadRate: loadRate,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) GetUsersLoadBatch(_ context.Context, users []service.UserWithConcurrency) (map[int64]*service.UserLoadInfo, error) {
|
||||
result := make(map[int64]*service.UserLoadInfo, len(users))
|
||||
for _, u := range users {
|
||||
result[u.ID] = &service.UserLoadInfo{UserID: u.ID}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) CleanupExpiredAccountSlots(_ context.Context, _ int64) error { return nil }
|
||||
func (c *schedInvCountingCache) CleanupStaleProcessSlots(_ context.Context, _ string) error { return nil }
|
||||
|
||||
// schedInvRequireBalanced 断言所有槽位与等待计数获取-释放严格配平。
|
||||
func schedInvRequireBalanced(t *testing.T, cc *schedInvCountingCache) {
|
||||
t.Helper()
|
||||
cc.mu.Lock()
|
||||
defer cc.mu.Unlock()
|
||||
for accountID, held := range cc.accountHeld {
|
||||
require.Empty(t, held, "账号 %d 仍有未释放的槽位", accountID)
|
||||
}
|
||||
for userID, held := range cc.userHeld {
|
||||
require.Empty(t, held, "用户 %d 仍有未释放的槽位", userID)
|
||||
}
|
||||
require.Equal(t, cc.accountAcquired, cc.accountReleased, "账号槽位获取/释放必须配平")
|
||||
require.Equal(t, cc.userAcquired, cc.userReleased, "用户槽位获取/释放必须配平")
|
||||
require.Equal(t, cc.userWaitInc, cc.userWaitDec, "用户等待计数增减必须配平")
|
||||
require.Equal(t, cc.accountWaitInc, cc.accountWaitDec, "账号等待计数增减必须配平")
|
||||
}
|
||||
|
||||
// schedInvGroup 构造测试分组。
|
||||
func schedInvGroup(groupID int64) *service.Group {
|
||||
return &service.Group{
|
||||
ID: groupID,
|
||||
Hydrated: true,
|
||||
Platform: service.PlatformAnthropic,
|
||||
Status: service.StatusActive,
|
||||
}
|
||||
}
|
||||
|
||||
// schedInvPassthroughAccount 构造 anthropic API Key 透传账号(Forward 路径不依赖真实上游域名)。
|
||||
func schedInvPassthroughAccount(id, groupID int64, extraCreds map[string]any) *service.Account {
|
||||
creds := map[string]any{"api_key": fmt.Sprintf("sk-sched-inv-%d", id)}
|
||||
for k, v := range extraCreds {
|
||||
creds[k] = v
|
||||
}
|
||||
return &service.Account{
|
||||
ID: id,
|
||||
Name: fmt.Sprintf("sched-inv-acc-%d", id),
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Credentials: creds,
|
||||
Extra: map[string]any{"anthropic_passthrough": true},
|
||||
Concurrency: 5,
|
||||
Priority: 1,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID}},
|
||||
}
|
||||
}
|
||||
|
||||
// schedInvNewHandler 构造完整 GatewayHandler:真实 failover 循环 + 计数并发缓存 + 上游桩。
|
||||
// cfg 传 nil 给 NewGatewayHandler,以固化默认换号上限(anthropic=10 / gemini=3)。
|
||||
func schedInvNewHandler(t *testing.T, group *service.Group, accounts []*service.Account, upstream service.HTTPUpstream, cc *schedInvCountingCache) (*GatewayHandler, func()) {
|
||||
t.Helper()
|
||||
|
||||
// 隔离环境变量:避免本机 SUB2API_DEBUG_GATEWAY_BODY 在测试期间写调试文件。
|
||||
t.Setenv("SUB2API_DEBUG_GATEWAY_BODY", "")
|
||||
|
||||
schedulerSnapshot := service.NewSchedulerSnapshotService(&fakeSchedulerCache{accounts: accounts}, nil, nil, nil, nil)
|
||||
concurrencySvc := service.NewConcurrencyService(cc)
|
||||
|
||||
gwSvc := service.NewGatewayService(
|
||||
nil, // accountRepo(scheduler snapshot 命中,不需要)
|
||||
&fakeGroupRepo{group: group},
|
||||
nil, nil, nil, nil, nil, // usageLogRepo / usageBillingRepo / userRepo / userSubRepo / userGroupRateRepo
|
||||
nil, // cache(粘性会话关闭)
|
||||
// 空 cfg(非 nil):API Key 透传分支会校验默认 base_url,
|
||||
// validateUpstreamBaseURL 在 cfg=nil 时会空指针。
|
||||
&config.Config{},
|
||||
schedulerSnapshot,
|
||||
concurrencySvc,
|
||||
nil, // billingService
|
||||
&service.RateLimitService{}, // rateLimitService(零值:500 仅记录日志,无副作用)
|
||||
nil, // billingCacheService
|
||||
nil, // identityService
|
||||
upstream,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
|
||||
// RunModeSimple 跳过计费检查,避免引入 repo/cache 依赖。
|
||||
billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil,
|
||||
&config.Config{RunMode: config.RunModeSimple}, nil)
|
||||
|
||||
h := NewGatewayHandler(
|
||||
gwSvc,
|
||||
nil, // geminiCompatService
|
||||
nil, // antigravityGatewayService
|
||||
ProvideGatewayPlatformRegistry(gwSvc, nil), // anthropic 账号经 anthropic provider → gwSvc.Forward
|
||||
nil, // userService
|
||||
concurrencySvc,
|
||||
billingCacheSvc,
|
||||
nil, // usageService
|
||||
nil, // apiKeyService
|
||||
nil, // usageRecordWorkerPool
|
||||
nil, // errorPassthroughService
|
||||
nil, // preFlightHooks
|
||||
nil, // userMsgQueueService
|
||||
nil, // cfg → 默认换号上限
|
||||
nil, // settingService
|
||||
)
|
||||
return h, func() { billingCacheSvc.Stop() }
|
||||
}
|
||||
|
||||
// schedInvNewMessagesContext 构造带认证上下文的 /v1/messages 请求。
|
||||
// 返回的 cancel 用于模拟请求结束时的 context 取消(生产环境由 net/http 完成)。
|
||||
func schedInvNewMessagesContext(t *testing.T, group *service.Group, body []byte) (*gin.Context, *httptest.ResponseRecorder, context.CancelFunc) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ctx := context.WithValue(req.Context(), ctxkey.Group, group)
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
c.Request = req.WithContext(ctx)
|
||||
|
||||
apiKey := &service.APIKey{
|
||||
ID: 8301,
|
||||
UserID: 8401,
|
||||
GroupID: &group.ID,
|
||||
Status: service.StatusActive,
|
||||
User: &service.User{
|
||||
ID: 8401,
|
||||
Concurrency: 10,
|
||||
Balance: 100,
|
||||
},
|
||||
Group: group,
|
||||
}
|
||||
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 10})
|
||||
return c, rec, cancel
|
||||
}
|
||||
|
||||
func schedInvMessagesBody() []byte {
|
||||
return []byte(`{
|
||||
"model": "claude-sonnet-4-5",
|
||||
"max_tokens": 256,
|
||||
"messages": [{"role":"user","content":[{"type":"text","text":"scheduling invariants probe"}]}]
|
||||
}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-5.1 三平台换号上限默认值(双断言)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSchedulingInvariant_FailoverSwitchLimit_DefaultValues(t *testing.T) {
|
||||
t.Run("anthropic与gemini默认上限", func(t *testing.T) {
|
||||
h := NewGatewayHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
require.Equal(t, 10, h.maxAccountSwitches, "anthropic 默认换号上限必须为 10")
|
||||
require.Equal(t, 3, h.maxAccountSwitchesGemini, "gemini 默认换号上限必须为 3")
|
||||
})
|
||||
|
||||
t.Run("openai默认上限", func(t *testing.T) {
|
||||
oh := NewOpenAIGatewayHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
require.Equal(t, 3, oh.maxAccountSwitches, "openai 默认换号上限必须为 3")
|
||||
})
|
||||
|
||||
t.Run("配置可覆盖默认上限", func(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.MaxAccountSwitches = 5
|
||||
cfg.Gateway.MaxAccountSwitchesGemini = 2
|
||||
h := NewGatewayHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, cfg, nil)
|
||||
require.Equal(t, 5, h.maxAccountSwitches)
|
||||
require.Equal(t, 2, h.maxAccountSwitchesGemini)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-5.1 + I-5.2 + I-6.1:anthropic 完整 failover 循环
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestSchedulingInvariant_FailoverAnthropic_FullLoopExhaustion 通过完整 Messages
|
||||
// 入口驱动真实 failover 循环:12 个账号 + 恒定上游 500。
|
||||
// 固化语义:
|
||||
// 1. 恰好尝试 maxAccountSwitches+1 = 11 个账号(初次 + 10 次换号);
|
||||
// 2. 失败账号进入排除集合,同一账号不被尝试两次;
|
||||
// 3. 耗尽后客户端收到 502 + {"type":"error","error":{"type":"upstream_error",...}}
|
||||
// (/v1/messages 路径按 mapUpstreamError 映射 500→502;
|
||||
// "server_error/All available accounts exhausted" 错误体属于
|
||||
// chat-completions/responses 兼容路径,见下方独立用例);
|
||||
// 4. 转发失败路径下账号/用户槽位获取-释放严格配平。
|
||||
func TestSchedulingInvariant_FailoverAnthropic_FullLoopExhaustion(t *testing.T) {
|
||||
groupID := int64(9001)
|
||||
group := schedInvGroup(groupID)
|
||||
|
||||
accounts := make([]*service.Account, 0, 12)
|
||||
for i := int64(1); i <= 12; i++ {
|
||||
accounts = append(accounts, schedInvPassthroughAccount(9100+i, groupID, nil))
|
||||
}
|
||||
|
||||
upstream := &schedInvUpstream{
|
||||
status: http.StatusInternalServerError,
|
||||
body: `{"type":"error","error":{"type":"api_error","message":"schedInv upstream boom"}}`,
|
||||
}
|
||||
cc := schedInvNewCountingCache()
|
||||
h, cleanup := schedInvNewHandler(t, group, accounts, upstream, cc)
|
||||
defer cleanup()
|
||||
|
||||
c, rec, cancel := schedInvNewMessagesContext(t, group, schedInvMessagesBody())
|
||||
defer cancel()
|
||||
|
||||
h.Messages(c)
|
||||
|
||||
// 1. 换号次数恰为上限:尝试账号数 = maxAccountSwitches + 1(常量引用 + 硬编码双断言)
|
||||
attempts := upstream.attemptedAccounts()
|
||||
require.Len(t, attempts, h.maxAccountSwitches+1, "尝试账号数必须等于 maxAccountSwitches+1")
|
||||
require.Len(t, attempts, 11, "anthropic 平台:1 次初始尝试 + 10 次换号")
|
||||
|
||||
// 2. 排除集合语义:同一账号不被重选
|
||||
seen := make(map[int64]struct{}, len(attempts))
|
||||
for _, accountID := range attempts {
|
||||
_, dup := seen[accountID]
|
||||
require.False(t, dup, "账号 %d 被尝试了两次:失败账号必须进入排除集合", accountID)
|
||||
seen[accountID] = struct{}{}
|
||||
}
|
||||
|
||||
// 3. 耗尽后错误语义:502 + upstream_error 映射错误体(固化当前实际行为)
|
||||
require.Equal(t, http.StatusBadGateway, rec.Code)
|
||||
require.JSONEq(t,
|
||||
`{"type":"error","error":{"type":"upstream_error","message":"Upstream service temporarily unavailable"}}`,
|
||||
rec.Body.String())
|
||||
|
||||
// 4. I-6.1 转发失败路径:槽位严格配平
|
||||
require.Equal(t, 11, cc.accountAcquired, "每次尝试获取一次账号槽位")
|
||||
require.Equal(t, 1, cc.userAcquired, "整个请求只获取一次用户槽位")
|
||||
schedInvRequireBalanced(t, cc)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-5.3 池模式可重试错误:同账号重试 3 次后换号(完整链)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestSchedulingInvariant_FailoverSameAccountRetry_FullChain 使用池模式账号
|
||||
// (pool_mode_retry_status_codes=[500])驱动完整链路:
|
||||
// 上游恒定 500 → 同账号共尝试 1+maxSameAccountRetries=4 次 → 加入排除集合换号 →
|
||||
// 无其他账号 → 502。同时验证每次重试均独立获取/释放账号槽位(配平)。
|
||||
func TestSchedulingInvariant_FailoverSameAccountRetry_FullChain(t *testing.T) {
|
||||
groupID := int64(9002)
|
||||
group := schedInvGroup(groupID)
|
||||
|
||||
poolAccount := schedInvPassthroughAccount(9201, groupID, map[string]any{
|
||||
"pool_mode": true,
|
||||
"pool_mode_retry_status_codes": []any{float64(500)},
|
||||
})
|
||||
|
||||
upstream := &schedInvUpstream{
|
||||
status: http.StatusInternalServerError,
|
||||
body: `{"type":"error","error":{"type":"api_error","message":"schedInv pool boom"}}`,
|
||||
}
|
||||
cc := schedInvNewCountingCache()
|
||||
h, cleanup := schedInvNewHandler(t, group, []*service.Account{poolAccount}, upstream, cc)
|
||||
defer cleanup()
|
||||
|
||||
c, rec, cancel := schedInvNewMessagesContext(t, group, schedInvMessagesBody())
|
||||
defer cancel()
|
||||
|
||||
h.Messages(c)
|
||||
|
||||
// 同账号共尝试 1 + maxSameAccountRetries 次(常量引用 + 硬编码双断言)
|
||||
attempts := upstream.attemptedAccounts()
|
||||
require.Len(t, attempts, 1+maxSameAccountRetries, "可重试错误:1 次初始 + maxSameAccountRetries 次同账号重试")
|
||||
require.Len(t, attempts, 4)
|
||||
for _, accountID := range attempts {
|
||||
require.Equal(t, poolAccount.ID, accountID, "重试必须发生在同一账号上")
|
||||
}
|
||||
|
||||
// 重试耗尽 + 换号后无可用账号 → 502
|
||||
require.Equal(t, http.StatusBadGateway, rec.Code)
|
||||
require.JSONEq(t,
|
||||
`{"type":"error","error":{"type":"upstream_error","message":"Upstream service temporarily unavailable"}}`,
|
||||
rec.Body.String())
|
||||
|
||||
// 每轮重试独立获取/释放账号槽位
|
||||
require.Equal(t, 4, cc.accountAcquired)
|
||||
schedInvRequireBalanced(t, cc)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-5.1 gemini 换号上限:FailoverState 循环契约
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestSchedulingInvariant_FailoverGemini_SwitchLimitLoopContract 按 Messages
|
||||
// gemini 分支的真实接线(NewFailoverState(h.maxAccountSwitchesGemini, ...))
|
||||
// 驱动 FailoverState:连续上游失败时恰好允许 3+1=4 个账号尝试后耗尽。
|
||||
// (gemini 平台 Forward 的服务层内部 500 重试带秒级退避,完整 e2e 不可在
|
||||
// 单测时间预算内执行,故此处固化 handler 循环契约层语义。)
|
||||
func TestSchedulingInvariant_FailoverGemini_SwitchLimitLoopContract(t *testing.T) {
|
||||
h := NewGatewayHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
require.Equal(t, 3, h.maxAccountSwitchesGemini)
|
||||
|
||||
mock := &mockTempUnscheduler{}
|
||||
fs := NewFailoverState(h.maxAccountSwitchesGemini, false)
|
||||
|
||||
attempts := 0
|
||||
for i := 1; ; i++ {
|
||||
require.LessOrEqual(t, i, 10, "防御:循环不应超过 10 次")
|
||||
attempts++
|
||||
action := fs.HandleFailoverError(context.Background(), mock, int64(i), service.PlatformGemini, newTestFailoverErr(500, false, false))
|
||||
if action == FailoverExhausted {
|
||||
break
|
||||
}
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
}
|
||||
|
||||
require.Equal(t, h.maxAccountSwitchesGemini+1, attempts, "gemini:尝试账号数 = 上限 + 1")
|
||||
require.Equal(t, 4, attempts)
|
||||
require.Len(t, fs.FailedAccountIDs, 4, "所有失败账号都进入排除集合")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// chat-completions 兼容路径耗尽错误体
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestSchedulingInvariant_FailoverExhausted_ChatCompletionsErrorBody 固化
|
||||
// chat-completions 兼容路径的耗尽错误体(INVARIANTS 记录的
|
||||
// "All available accounts exhausted" 属于此路径而非 /v1/messages 路径)。
|
||||
// 注意当前实际行为:lastErr 非空时状态码透传上游状态码(500→500),
|
||||
// lastErr 为空时才回退 502。
|
||||
func TestSchedulingInvariant_FailoverExhausted_ChatCompletionsErrorBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("lastErr存在_状态码透传上游", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
h := &GatewayHandler{}
|
||||
h.handleCCFailoverExhausted(c, &service.UpstreamFailoverError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
ResponseBody: []byte(`{"error":{"message":"boom"}}`),
|
||||
}, false)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, rec.Code,
|
||||
"当前行为:lastErr 存在时透传上游状态码(非固定 502)")
|
||||
require.JSONEq(t,
|
||||
`{"error":{"type":"server_error","message":"All available accounts exhausted"}}`,
|
||||
rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("lastErr为空_回退502", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
h := &GatewayHandler{}
|
||||
h.handleCCFailoverExhausted(c, nil, false)
|
||||
|
||||
require.Equal(t, http.StatusBadGateway, rec.Code)
|
||||
require.JSONEq(t,
|
||||
`{"error":{"type":"server_error","message":"All available accounts exhausted"}}`,
|
||||
rec.Body.String())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
//go:build unit
|
||||
|
||||
// Phase-0 TASK-004 并发槽不变量测试(INVARIANTS I-6.1 / I-6.2)。
|
||||
//
|
||||
// 固化内容:
|
||||
// - I-6.1 用户槽/账号槽获取-释放严格配平:
|
||||
// * 正常路径(上游 200,完整 Messages 链路);
|
||||
// * early-return 路径(预热拦截:选号后转发前直接返回);
|
||||
// * panic 路径(defer 释放用户槽 + context 取消兜底回收账号槽);
|
||||
// * wrapReleaseOnDone 恰好一次语义(显式调用 / context 取消两种触发);
|
||||
// * service 层 AcquireResult.ReleaseFunc 重复调用的当前行为特征化;
|
||||
// - I-6.2 AcquireUserSlotWithWait 等待队列:满 → 等待 → 释放 → 唤醒;
|
||||
// 等待超时返回 ConcurrencyError{IsTimeout}。
|
||||
//
|
||||
// 转发失败路径的配平已由 scheduling_invariants_failover_test.go 的完整循环用例覆盖。
|
||||
// 复用本包 schedInv* 夹具(scheduling_invariants_failover_test.go)。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// schedInvNewGinContext 构造仅含基础请求的 gin 上下文(供 ConcurrencyHelper 使用)。
|
||||
func schedInvNewGinContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
return c, rec
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-6.2 用户槽等待队列:满 → 等待 → 释放 → 唤醒
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSchedulingInvariant_UserSlotWaitQueue_FullWaitReleaseWakeup(t *testing.T) {
|
||||
cc := schedInvNewCountingCache()
|
||||
helper := NewConcurrencyHelper(service.NewConcurrencyService(cc), SSEPingFormatNone, time.Hour)
|
||||
|
||||
const userID = int64(701)
|
||||
|
||||
// 1. 占满唯一槽位
|
||||
c1, _ := schedInvNewGinContext(t)
|
||||
var ss1 bool
|
||||
release1, err := helper.AcquireUserSlotWithWait(c1, userID, 1, false, &ss1)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, release1)
|
||||
|
||||
// 2. 第二个请求进入等待(槽位已满)
|
||||
type waitResult struct {
|
||||
release func()
|
||||
err error
|
||||
}
|
||||
done := make(chan waitResult, 1)
|
||||
go func() {
|
||||
c2, _ := schedInvNewGinContext(t)
|
||||
var ss2 bool
|
||||
release2, err2 := helper.AcquireUserSlotWithWait(c2, userID, 1, false, &ss2)
|
||||
done <- waitResult{release: release2, err: err2}
|
||||
}()
|
||||
|
||||
// 槽位未释放期间,等待者不应返回
|
||||
select {
|
||||
case r := <-done:
|
||||
t.Fatalf("槽位未释放时等待者不应获取成功: err=%v", r.err)
|
||||
case <-time.After(400 * time.Millisecond):
|
||||
}
|
||||
|
||||
// 3. 释放 → 4. 等待者被唤醒并成功获取
|
||||
release1()
|
||||
select {
|
||||
case r := <-done:
|
||||
require.NoError(t, r.err, "释放后等待者必须能获取槽位")
|
||||
require.NotNil(t, r.release)
|
||||
r.release()
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("释放槽位后等待者在 10s 内未被唤醒")
|
||||
}
|
||||
|
||||
require.Equal(t, 2, cc.userAcquired, "两次成功获取")
|
||||
schedInvRequireBalanced(t, cc)
|
||||
}
|
||||
|
||||
// TestSchedulingInvariant_AccountSlotWait_TimeoutReturnsConcurrencyError 固化:
|
||||
// 账号槽等待超时后返回 ConcurrencyError{IsTimeout:true},且不产生未配平的获取。
|
||||
func TestSchedulingInvariant_AccountSlotWait_TimeoutReturnsConcurrencyError(t *testing.T) {
|
||||
cc := schedInvNewCountingCache()
|
||||
svc := service.NewConcurrencyService(cc)
|
||||
helper := NewConcurrencyHelper(svc, SSEPingFormatNone, time.Hour)
|
||||
|
||||
const accountID = int64(801)
|
||||
|
||||
// 占满唯一槽位
|
||||
holder, err := svc.AcquireAccountSlot(context.Background(), accountID, 1)
|
||||
require.NoError(t, err)
|
||||
require.True(t, holder.Acquired)
|
||||
|
||||
c, _ := schedInvNewGinContext(t)
|
||||
var ss bool
|
||||
release, err := helper.AcquireAccountSlotWithWaitTimeout(c, accountID, 1, 300*time.Millisecond, false, &ss)
|
||||
require.Nil(t, release)
|
||||
require.Error(t, err)
|
||||
var concurrencyErr *ConcurrencyError
|
||||
require.ErrorAs(t, err, &concurrencyErr)
|
||||
require.True(t, concurrencyErr.IsTimeout, "等待超时必须返回 IsTimeout 的 ConcurrencyError")
|
||||
require.Equal(t, "account", concurrencyErr.SlotType)
|
||||
|
||||
holder.ReleaseFunc()
|
||||
schedInvRequireBalanced(t, cc)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-6.1 正常路径与 early-return 路径配平(完整 Messages 链路)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestSchedulingInvariant_SlotBalance_NormalSuccessPath 固化:上游 200 正常完成时,
|
||||
// 账号槽与用户槽各获取/释放一次,严格配平。
|
||||
func TestSchedulingInvariant_SlotBalance_NormalSuccessPath(t *testing.T) {
|
||||
groupID := int64(9003)
|
||||
group := schedInvGroup(groupID)
|
||||
account := schedInvPassthroughAccount(9301, groupID, nil)
|
||||
|
||||
upstream := &schedInvUpstream{
|
||||
status: http.StatusOK,
|
||||
body: `{"id":"msg_sched_inv","type":"message","role":"assistant","model":"claude-sonnet-4-5",` +
|
||||
`"content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn",` +
|
||||
`"usage":{"input_tokens":3,"output_tokens":2}}`,
|
||||
}
|
||||
cc := schedInvNewCountingCache()
|
||||
h, cleanup := schedInvNewHandler(t, group, []*service.Account{account}, upstream, cc)
|
||||
defer cleanup()
|
||||
|
||||
c, rec, cancel := schedInvNewMessagesContext(t, group, schedInvMessagesBody())
|
||||
defer cancel()
|
||||
|
||||
h.Messages(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "msg_sched_inv", "上游 200 响应体应透传给客户端")
|
||||
require.Len(t, upstream.attemptedAccounts(), 1)
|
||||
require.Equal(t, 1, cc.accountAcquired)
|
||||
require.Equal(t, 1, cc.userAcquired)
|
||||
schedInvRequireBalanced(t, cc)
|
||||
}
|
||||
|
||||
// TestSchedulingInvariant_SlotBalance_InterceptEarlyReturnPath 固化 early-return 路径:
|
||||
// 预热拦截在选号成功后、转发上游前直接返回 mock 响应,账号槽必须被释放。
|
||||
func TestSchedulingInvariant_SlotBalance_InterceptEarlyReturnPath(t *testing.T) {
|
||||
groupID := int64(9004)
|
||||
group := schedInvGroup(groupID)
|
||||
account := schedInvPassthroughAccount(9401, groupID, map[string]any{
|
||||
"intercept_warmup_requests": true,
|
||||
})
|
||||
|
||||
upstream := &schedInvUpstream{status: http.StatusOK, body: `{}`}
|
||||
cc := schedInvNewCountingCache()
|
||||
h, cleanup := schedInvNewHandler(t, group, []*service.Account{account}, upstream, cc)
|
||||
defer cleanup()
|
||||
|
||||
warmupBody := []byte(`{
|
||||
"model": "claude-sonnet-4-5",
|
||||
"max_tokens": 256,
|
||||
"messages": [{"role":"user","content":[{"type":"text","text":"Warmup"}]}]
|
||||
}`)
|
||||
c, rec, cancel := schedInvNewMessagesContext(t, group, warmupBody)
|
||||
defer cancel()
|
||||
|
||||
h.Messages(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "msg_mock_warmup", "预热请求应被拦截返回 mock 响应")
|
||||
require.Empty(t, upstream.attemptedAccounts(), "early-return 路径不应触达上游")
|
||||
require.Equal(t, 1, cc.accountAcquired, "选号阶段获取过账号槽")
|
||||
schedInvRequireBalanced(t, cc)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-6.1 panic 路径:defer 释放用户槽 + context 取消兜底回收账号槽
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestSchedulingInvariant_SlotBalance_PanicPath 固化当前 panic 语义:
|
||||
// - 用户槽通过 defer userReleaseFunc() 在 panic 展开时立即释放;
|
||||
// - 账号槽的释放调用位于 Forward 之后(非 defer),panic 时依赖
|
||||
// wrapReleaseOnDone 注册的 context.AfterFunc 在请求 context 取消时兜底回收
|
||||
// (生产环境由 net/http 在连接结束时取消请求 context)。
|
||||
func TestSchedulingInvariant_SlotBalance_PanicPath(t *testing.T) {
|
||||
groupID := int64(9005)
|
||||
group := schedInvGroup(groupID)
|
||||
account := schedInvPassthroughAccount(9501, groupID, nil)
|
||||
|
||||
upstream := &schedInvUpstream{status: http.StatusOK, body: `{}`, panicOn: true}
|
||||
cc := schedInvNewCountingCache()
|
||||
h, cleanup := schedInvNewHandler(t, group, []*service.Account{account}, upstream, cc)
|
||||
defer cleanup()
|
||||
|
||||
c, _, cancel := schedInvNewMessagesContext(t, group, schedInvMessagesBody())
|
||||
defer cancel()
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
require.NotNil(t, recover(), "上游 panic 应穿透 Messages(由外层 gin recovery 兜底)")
|
||||
}()
|
||||
h.Messages(c)
|
||||
}()
|
||||
|
||||
// 用户槽:defer 在 panic 展开时已释放
|
||||
cc.mu.Lock()
|
||||
userReleased := cc.userReleased
|
||||
cc.mu.Unlock()
|
||||
require.Equal(t, 1, userReleased, "panic 展开时 defer 必须释放用户槽")
|
||||
|
||||
// 账号槽:panic 跳过了显式释放调用,由请求 context 取消兜底回收
|
||||
cancel()
|
||||
require.Eventually(t, func() bool {
|
||||
cc.mu.Lock()
|
||||
defer cc.mu.Unlock()
|
||||
return cc.accountReleased == cc.accountAcquired
|
||||
}, 5*time.Second, 10*time.Millisecond, "context 取消后账号槽必须被兜底回收")
|
||||
schedInvRequireBalanced(t, cc)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-6.1 wrapReleaseOnDone 恰好一次语义
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSchedulingInvariant_WrapReleaseOnDone_ExactlyOnce(t *testing.T) {
|
||||
t.Run("重复显式调用只释放一次", func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
release := wrapReleaseOnDone(context.Background(), func() { calls.Add(1) })
|
||||
release()
|
||||
release()
|
||||
require.Equal(t, int32(1), calls.Load(), "重复调用不得重复释放")
|
||||
})
|
||||
|
||||
t.Run("context取消自动触发释放", func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
release := wrapReleaseOnDone(ctx, func() { calls.Add(1) })
|
||||
cancel()
|
||||
require.Eventually(t, func() bool { return calls.Load() == 1 },
|
||||
2*time.Second, 5*time.Millisecond, "context 取消必须自动触发释放")
|
||||
// 取消后再显式调用也不重复释放
|
||||
release()
|
||||
require.Equal(t, int32(1), calls.Load())
|
||||
})
|
||||
|
||||
t.Run("nil释放函数返回nil", func(t *testing.T) {
|
||||
require.Nil(t, wrapReleaseOnDone(context.Background(), nil))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// service 层 ReleaseFunc 重复调用特征化
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestSchedulingCharacterization_ServiceReleaseFuncNotOnceGuarded 固化当前实际行为:
|
||||
// ConcurrencyService 返回的 AcquireResult.ReleaseFunc 本身没有 once 保护,
|
||||
// 重复调用会向缓存重复发出释放请求;配平依赖两点:
|
||||
// 1. 缓存释放按 requestID 幂等(Redis ZREM 不存在的成员是 no-op);
|
||||
// 2. handler 层统一经 wrapReleaseOnDone 包装后才暴露。
|
||||
func TestSchedulingCharacterization_ServiceReleaseFuncNotOnceGuarded(t *testing.T) {
|
||||
cc := schedInvNewCountingCache()
|
||||
svc := service.NewConcurrencyService(cc)
|
||||
|
||||
result, err := svc.AcquireAccountSlot(context.Background(), 901, 5)
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.Acquired)
|
||||
|
||||
result.ReleaseFunc()
|
||||
result.ReleaseFunc()
|
||||
|
||||
cc.mu.Lock()
|
||||
defer cc.mu.Unlock()
|
||||
require.Equal(t, 1, cc.accountReleased, "第一次释放生效")
|
||||
require.Equal(t, 1, cc.accountReleasedUnknown,
|
||||
"当前行为:第二次释放仍会调用缓存(按 requestID 幂等,不破坏配平)")
|
||||
require.Empty(t, cc.accountHeld[901])
|
||||
}
|
||||
@@ -40,6 +40,7 @@ func ProvideAdminHandlers(
|
||||
paymentHandler *admin.PaymentHandler,
|
||||
affiliateHandler *admin.AffiliateHandler,
|
||||
complianceHandler *admin.ComplianceHandler,
|
||||
moduleHandler *admin.ModuleHandler,
|
||||
) *AdminHandlers {
|
||||
return &AdminHandlers{
|
||||
Dashboard: dashboardHandler,
|
||||
@@ -73,6 +74,7 @@ func ProvideAdminHandlers(
|
||||
Payment: paymentHandler,
|
||||
Affiliate: affiliateHandler,
|
||||
Compliance: complianceHandler,
|
||||
Module: moduleHandler,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +149,8 @@ var ProviderSet = wire.NewSet(
|
||||
NewSubscriptionHandler,
|
||||
NewAnnouncementHandler,
|
||||
NewChannelMonitorUserHandler,
|
||||
ProvideGatewayHookChain,
|
||||
ProvideGatewayPlatformRegistry,
|
||||
NewGatewayHandler,
|
||||
NewOpenAIGatewayHandler,
|
||||
NewTotpHandler,
|
||||
@@ -187,6 +191,7 @@ var ProviderSet = wire.NewSet(
|
||||
admin.NewPaymentHandler,
|
||||
admin.NewAffiliateHandler,
|
||||
admin.NewComplianceHandler,
|
||||
admin.NewModuleHandler,
|
||||
|
||||
// AdminHandlers and Handlers constructors
|
||||
ProvideAdminHandlers,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package hello
|
||||
|
||||
// Phase-1.5 对抗式审计(2026-06-11)B-1 回归测试:
|
||||
// Stop 传入已取消的 ctx 时,若 worker 实际已干净退出,必须返回 nil
|
||||
// (修复前 select 双臂就绪的随机选取可能把成功停止误报为失败)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/plugin/plugintest"
|
||||
)
|
||||
|
||||
func TestStopWithPreCancelledContextAfterWorkerExit(t *testing.T) {
|
||||
m := new(Module)
|
||||
require.NoError(t, m.Provision(plugintest.NewHost(t)))
|
||||
require.NoError(t, m.Validate())
|
||||
require.NoError(t, m.Start(context.Background()))
|
||||
|
||||
// 让 worker 先行退出并确认退出完成(直接驱动内部 cancel,规避计时竞态)。
|
||||
m.cancel()
|
||||
select {
|
||||
case <-m.done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("worker 未在期限内退出")
|
||||
}
|
||||
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
require.NoError(t, m.Stop(cancelled), "worker 已退出时,即便 ctx 已取消 Stop 也应返回 nil")
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Package hello 实现示例插件模块 job.hello。
|
||||
//
|
||||
// 该模块是插件内核的最小可运行示例:实现全部四个可选生命周期接口
|
||||
// (Provision / Validate / Start / Stop),启动后按配置间隔周期性输出
|
||||
// 一条 debug 日志。默认 disabled——仅在 `modules:` 配置中显式 enabled
|
||||
// 后才会运行,用于验证插件链路连通性,并作为编写新模块的参考实现。
|
||||
package hello
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/plugin"
|
||||
)
|
||||
|
||||
// ID 是 hello 模块的模块 ID。
|
||||
const ID plugin.ModuleID = "job.hello"
|
||||
|
||||
func init() {
|
||||
plugin.RegisterModule(&Module{})
|
||||
}
|
||||
|
||||
// Config 是 hello 模块的私有配置
|
||||
// (`modules:` 子树中 "job.hello" 项除 enabled 外的部分)。
|
||||
type Config struct {
|
||||
// Interval 是周期日志的输出间隔,必须大于 0。
|
||||
Interval time.Duration `mapstructure:"interval"`
|
||||
|
||||
// Greeting 是周期日志输出的内容,不能为空。
|
||||
Greeting string `mapstructure:"greeting"`
|
||||
}
|
||||
|
||||
// defaultConfig 返回 hello 模块的默认配置(用户未配置的字段保持默认值)。
|
||||
func defaultConfig() Config {
|
||||
return Config{
|
||||
Interval: 30 * time.Second,
|
||||
Greeting: "hello from job.hello",
|
||||
}
|
||||
}
|
||||
|
||||
// Module 是 job.hello 模块实例。零值即可用,依赖在 Provision 中装配;
|
||||
// 每次 Runtime.Build 都会通过 ModuleInfo.New 创建全新实例。
|
||||
type Module struct {
|
||||
cfg Config
|
||||
logger *zap.Logger
|
||||
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// 编译期断言:Module 实现 plugin.Module 与全部四个可选生命周期接口。
|
||||
var (
|
||||
_ plugin.Module = (*Module)(nil)
|
||||
_ plugin.Provisioner = (*Module)(nil)
|
||||
_ plugin.Validator = (*Module)(nil)
|
||||
_ plugin.Starter = (*Module)(nil)
|
||||
_ plugin.Stopper = (*Module)(nil)
|
||||
)
|
||||
|
||||
// ModuleInfo 返回模块注册信息。EnabledByDefault 为 false:
|
||||
// 未在 `modules:` 中显式 enabled 时模块不实例化、无任何副作用。
|
||||
func (*Module) ModuleInfo() plugin.ModuleInfo {
|
||||
return plugin.ModuleInfo{
|
||||
ID: ID,
|
||||
New: func() plugin.Module { return new(Module) },
|
||||
EnabledByDefault: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Provision 从宿主获取 logger,并在默认配置之上解码模块私有配置。
|
||||
func (m *Module) Provision(host *plugin.Host) error {
|
||||
m.cfg = defaultConfig()
|
||||
if host != nil {
|
||||
m.logger = host.Logger
|
||||
if host.ConfigOf != nil {
|
||||
if err := host.ConfigOf(ID, &m.cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.logger == nil {
|
||||
m.logger = zap.NewNop()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate 校验模块配置:interval 必须大于 0,greeting 不能为空。
|
||||
func (m *Module) Validate() error {
|
||||
if m.cfg.Interval <= 0 {
|
||||
return fmt.Errorf("hello: interval must be positive, got %s", m.cfg.Interval)
|
||||
}
|
||||
if m.cfg.Greeting == "" {
|
||||
return errors.New("hello: greeting must not be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start 启动周期日志 goroutine。
|
||||
//
|
||||
// 模块持有独立于启动 ctx 的生命周期上下文,由 Stop 负责取消,
|
||||
// 因此模块的运行寿命不受启动流程上下文的影响。
|
||||
func (m *Module) Start(_ context.Context) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m.cancel = cancel
|
||||
m.done = make(chan struct{})
|
||||
go m.run(ctx)
|
||||
m.logger.Info("hello module started",
|
||||
zap.String("module", string(ID)),
|
||||
zap.Duration("interval", m.cfg.Interval))
|
||||
return nil
|
||||
}
|
||||
|
||||
// run 按配置间隔输出周期 debug 日志,直到 ctx 被取消。
|
||||
func (m *Module) run(ctx context.Context) {
|
||||
defer close(m.done)
|
||||
ticker := time.NewTicker(m.cfg.Interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.logger.Debug(m.cfg.Greeting, zap.String("module", string(ID)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 取消周期 goroutine 并等待其退出,尊重 ctx 的 deadline。
|
||||
// 未 Start 过(或 Start 之前失败)时调用为 no-op。
|
||||
func (m *Module) Stop(ctx context.Context) error {
|
||||
if m.cancel == nil {
|
||||
return nil
|
||||
}
|
||||
m.cancel()
|
||||
// 先非阻塞探测 worker 是否已退出:当传入的 ctx 已取消/超时而 worker 其实
|
||||
// 已干净退出时,select 双臂就绪的随机选取可能把成功停止误报为失败(审计 B-1)。
|
||||
select {
|
||||
case <-m.done:
|
||||
m.logger.Info("hello module stopped", zap.String("module", string(ID)))
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-m.done:
|
||||
m.logger.Info("hello module stopped", zap.String("module", string(ID)))
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("hello: stop interrupted while waiting for worker exit: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package hello
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/plugin"
|
||||
"github.com/Wei-Shaw/sub2api/internal/plugin/plugintest"
|
||||
)
|
||||
|
||||
// TestModuleRegisteredInDefaultRegistry 验证 init() 已向包级默认注册表
|
||||
// 注册 job.hello,且默认 disabled(EnabledByDefault=false)。
|
||||
func TestModuleRegisteredInDefaultRegistry(t *testing.T) {
|
||||
info, ok := plugin.GetModule(ID)
|
||||
require.True(t, ok, "job.hello should self-register via init()")
|
||||
require.Equal(t, ID, info.ID)
|
||||
require.False(t, info.EnabledByDefault, "hello module must be disabled by default")
|
||||
require.NotNil(t, info.New)
|
||||
require.IsType(t, &Module{}, info.New())
|
||||
}
|
||||
|
||||
// TestProvisionDefaults 验证未提供私有配置时 Provision 使用默认配置且可通过校验。
|
||||
func TestProvisionDefaults(t *testing.T) {
|
||||
m := new(Module)
|
||||
require.NoError(t, m.Provision(plugintest.NewHost(t)))
|
||||
require.Equal(t, defaultConfig(), m.cfg)
|
||||
require.NoError(t, m.Validate())
|
||||
|
||||
// nil host 防御路径:不 panic、使用默认配置。
|
||||
m2 := new(Module)
|
||||
require.NoError(t, m2.Provision(nil))
|
||||
require.Equal(t, defaultConfig(), m2.cfg)
|
||||
require.NoError(t, m2.Validate())
|
||||
}
|
||||
|
||||
// TestProvisionDecodesPrivateConfig 验证 Provision 通过 host.ConfigOf
|
||||
// 解码模块私有配置(interval 时长字符串、greeting 覆盖)。
|
||||
func TestProvisionDecodesPrivateConfig(t *testing.T) {
|
||||
host := plugintest.NewHost(t, plugintest.WithConfig(map[string]map[string]any{
|
||||
"job.hello": {"enabled": true, "interval": "15ms", "greeting": "hi"},
|
||||
}))
|
||||
|
||||
m := new(Module)
|
||||
require.NoError(t, m.Provision(host))
|
||||
require.Equal(t, 15*time.Millisecond, m.cfg.Interval)
|
||||
require.Equal(t, "hi", m.cfg.Greeting)
|
||||
require.NoError(t, m.Validate())
|
||||
}
|
||||
|
||||
// TestProvisionConfigDecodeError 验证私有配置类型错误时 Provision 返回错误。
|
||||
func TestProvisionConfigDecodeError(t *testing.T) {
|
||||
host := plugintest.NewHost(t, plugintest.WithConfig(map[string]map[string]any{
|
||||
"job.hello": {"interval": "not-a-duration"},
|
||||
}))
|
||||
|
||||
m := new(Module)
|
||||
err := m.Provision(host)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "job.hello")
|
||||
}
|
||||
|
||||
// TestValidateRejectsInvalidConfig 验证非法配置被 Validate 拒绝。
|
||||
func TestValidateRejectsInvalidConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
wantErr string
|
||||
}{
|
||||
{"zero interval", Config{Interval: 0, Greeting: "hi"}, "interval must be positive"},
|
||||
{"negative interval", Config{Interval: -time.Second, Greeting: "hi"}, "interval must be positive"},
|
||||
{"empty greeting", Config{Interval: time.Second, Greeting: ""}, "greeting must not be empty"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := &Module{cfg: tt.cfg}
|
||||
err := m.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartPeriodicLogAndStop 验证 Start 后周期 debug 日志持续出现,
|
||||
// Stop 后 goroutine 优雅退出。
|
||||
func TestStartPeriodicLogAndStop(t *testing.T) {
|
||||
logOpt, logs := plugintest.WithObservedLogger()
|
||||
host := plugintest.NewHost(t, logOpt, plugintest.WithConfig(map[string]map[string]any{
|
||||
"job.hello": {"interval": "5ms"},
|
||||
}))
|
||||
|
||||
m := new(Module)
|
||||
require.NoError(t, m.Provision(host))
|
||||
require.NoError(t, m.Validate())
|
||||
require.NoError(t, m.Start(context.Background()))
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return logs.FilterMessage(m.cfg.Greeting).Len() >= 2
|
||||
}, 2*time.Second, 5*time.Millisecond, "periodic debug log should appear repeatedly")
|
||||
|
||||
require.NoError(t, m.Stop(context.Background()))
|
||||
select {
|
||||
case <-m.done:
|
||||
// goroutine 已退出。
|
||||
default:
|
||||
t.Fatal("worker goroutine should have exited after Stop")
|
||||
}
|
||||
require.Equal(t, 1, logs.FilterMessage("hello module stopped").Len())
|
||||
}
|
||||
|
||||
// TestStopWithoutStartIsNoop 验证未 Start 时 Stop 为 no-op。
|
||||
func TestStopWithoutStartIsNoop(t *testing.T) {
|
||||
m := new(Module)
|
||||
require.NoError(t, m.Provision(plugintest.NewHost(t)))
|
||||
require.NoError(t, m.Stop(context.Background()))
|
||||
}
|
||||
|
||||
// TestRuntimeDefaultConfigIsNoop 验证缺省配置(modules: 子树为空)下,
|
||||
// hello 模块保持 disabled:Build/Start/Stop 全程 no-op,无任何副作用。
|
||||
func TestRuntimeDefaultConfigIsNoop(t *testing.T) {
|
||||
logOpt, logs := plugintest.WithObservedLogger()
|
||||
rt := plugintest.RunLifecycle(t, &Module{}, plugintest.NewHost(t, logOpt), nil)
|
||||
|
||||
require.NoError(t, rt.Stop(context.Background()))
|
||||
|
||||
snap := rt.Snapshot()
|
||||
require.Len(t, snap, 1)
|
||||
require.Equal(t, ID, snap[0].ID)
|
||||
require.False(t, snap[0].Enabled)
|
||||
require.Equal(t, plugin.StateRegistered, snap[0].State)
|
||||
require.Zero(t, logs.Len(), "disabled module must not produce any module logs")
|
||||
}
|
||||
|
||||
// TestRuntimeEnabledHelloLifecycle 验证启用 job.hello 后完整生命周期:
|
||||
// Build(Provision/Validate)→ Start(周期日志出现)→ Stop(优雅退出)。
|
||||
func TestRuntimeEnabledHelloLifecycle(t *testing.T) {
|
||||
logOpt, logs := plugintest.WithObservedLogger()
|
||||
rt := plugintest.RunLifecycle(t, &Module{}, plugintest.NewHost(t, logOpt), map[string]map[string]any{
|
||||
"job.hello": {"enabled": true, "interval": "5ms"},
|
||||
})
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return logs.FilterMessage(defaultConfig().Greeting).Len() >= 1
|
||||
}, 2*time.Second, 5*time.Millisecond, "periodic debug log should appear after Start")
|
||||
|
||||
require.NoError(t, rt.Stop(context.Background()))
|
||||
snap := rt.Snapshot()
|
||||
require.Len(t, snap, 1)
|
||||
require.True(t, snap[0].Enabled)
|
||||
require.Equal(t, plugin.StateStopped, snap[0].State)
|
||||
}
|
||||
|
||||
// TestRuntimeInvalidHelloConfigAbortsBuild 验证 hello 配置非法时
|
||||
// Validate 失败导致 Build 返回错误(启动中止,fail-fast)。
|
||||
func TestRuntimeInvalidHelloConfigAbortsBuild(t *testing.T) {
|
||||
err := plugintest.BuildExpectingError(t, &Module{}, plugintest.NewHost(t), map[string]map[string]any{
|
||||
"job.hello": {"enabled": true, "interval": "-1s"},
|
||||
})
|
||||
require.Contains(t, err.Error(), `validate module "job.hello"`)
|
||||
require.Contains(t, err.Error(), "interval must be positive")
|
||||
}
|
||||
|
||||
// TestRuntimeIgnoresUnknownConfiguredModule 验证配置中出现格式合法但
|
||||
// 未注册的模块 ID 时,Runtime 按内核语义忽略该项,不影响其他模块。
|
||||
func TestRuntimeIgnoresUnknownConfiguredModule(t *testing.T) {
|
||||
rt := plugintest.RunLifecycle(t, &Module{}, plugintest.NewHost(t), map[string]map[string]any{
|
||||
"job.hello": {"enabled": true, "interval": "5ms"},
|
||||
"job.unknown": {"enabled": true},
|
||||
})
|
||||
|
||||
require.NoError(t, rt.Stop(context.Background()))
|
||||
|
||||
snap := rt.Snapshot()
|
||||
require.Len(t, snap, 1, "snapshot should only contain registered modules")
|
||||
require.Equal(t, ID, snap[0].ID)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Package standard 是插件模块的唯一插装清单(instrumentation list)。
|
||||
//
|
||||
// 所有需要编译进二进制的内置模块必须且只能在本文件中匿名 import:
|
||||
// 模块包在 init() 期向包级默认注册表自注册,cmd/server 匿名 import
|
||||
// 本包即完成全部内置模块的插装。新增/移除模块只需修改本文件,
|
||||
// 使插装变更集中、可 review。
|
||||
package standard
|
||||
|
||||
import (
|
||||
// job.hello:示例模块(默认 disabled),验证插件链路连通性。
|
||||
_ "github.com/Wei-Shaw/sub2api/internal/modules/hello"
|
||||
)
|
||||
@@ -55,6 +55,12 @@ type airwallexTokenState struct {
|
||||
|
||||
var airwallexAccessTokens sync.Map
|
||||
|
||||
func init() {
|
||||
register(payment.TypeAirwallex, func(instanceID string, config map[string]string) (payment.Provider, error) {
|
||||
return NewAirwallex(instanceID, config)
|
||||
})
|
||||
}
|
||||
|
||||
func NewAirwallex(instanceID string, config map[string]string) (*Airwallex, error) {
|
||||
for _, k := range []string{"clientId", "apiKey", "webhookSecret", "apiBase"} {
|
||||
if strings.TrimSpace(config[k]) == "" {
|
||||
|
||||
@@ -48,6 +48,12 @@ type Alipay struct {
|
||||
client *alipay.Client
|
||||
}
|
||||
|
||||
func init() {
|
||||
register(payment.TypeAlipay, func(instanceID string, config map[string]string) (payment.Provider, error) {
|
||||
return NewAlipay(instanceID, config)
|
||||
})
|
||||
}
|
||||
|
||||
// NewAlipay creates a new Alipay provider instance.
|
||||
func NewAlipay(instanceID string, config map[string]string) (*Alipay, error) {
|
||||
required := []string{"appId", "privateKey"}
|
||||
|
||||
@@ -41,6 +41,12 @@ type EasyPay struct {
|
||||
|
||||
// NewEasyPay creates a new EasyPay provider.
|
||||
// config keys: pid, pkey, apiBase, notifyUrl, returnUrl, cid, cidAlipay, cidWxpay
|
||||
func init() {
|
||||
register(payment.TypeEasyPay, func(instanceID string, config map[string]string) (payment.Provider, error) {
|
||||
return NewEasyPay(instanceID, config)
|
||||
})
|
||||
}
|
||||
|
||||
func NewEasyPay(instanceID string, config map[string]string) (*EasyPay, error) {
|
||||
for _, k := range []string{"pid", "pkey", "apiBase", "notifyUrl", "returnUrl"} {
|
||||
if strings.TrimSpace(config[k]) == "" {
|
||||
|
||||
@@ -7,19 +7,13 @@ import (
|
||||
)
|
||||
|
||||
// CreateProvider creates a Provider from a provider key, instance ID and decrypted config.
|
||||
// Constructor errors (including *infraerrors.ApplicationError from wxpay) are returned
|
||||
// as-is, never wrapped: the "_validate_" path relies on the structured error for
|
||||
// frontend i18n (see service/payment_config_providers.go).
|
||||
func CreateProvider(providerKey string, instanceID string, config map[string]string) (payment.Provider, error) {
|
||||
switch providerKey {
|
||||
case payment.TypeEasyPay:
|
||||
return NewEasyPay(instanceID, config)
|
||||
case payment.TypeAlipay:
|
||||
return NewAlipay(instanceID, config)
|
||||
case payment.TypeWxpay:
|
||||
return NewWxpay(instanceID, config)
|
||||
case payment.TypeStripe:
|
||||
return NewStripe(instanceID, config)
|
||||
case payment.TypeAirwallex:
|
||||
return NewAirwallex(instanceID, config)
|
||||
default:
|
||||
fn, ok := constructors[providerKey]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown provider key: %s", providerKey)
|
||||
}
|
||||
return fn(instanceID, config)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
)
|
||||
|
||||
// ConstructorFunc builds a payment.Provider from an instance ID and decrypted config.
|
||||
type ConstructorFunc func(instanceID string, config map[string]string) (payment.Provider, error)
|
||||
|
||||
// constructors is the package-private provider constructor registry.
|
||||
// It is populated exclusively by each provider file's init() via register()
|
||||
// and is read-only afterwards, so concurrent CreateProvider calls
|
||||
// (e.g. RefreshProviders' clear-and-reload loop) are safe and side-effect free.
|
||||
var constructors = map[string]ConstructorFunc{}
|
||||
|
||||
// register adds a provider constructor under the given key.
|
||||
// A duplicate key panics: that is an init-time instrumentation error
|
||||
// which must surface as early as possible.
|
||||
func register(key string, fn ConstructorFunc) {
|
||||
if _, exists := constructors[key]; exists {
|
||||
panic(fmt.Sprintf("payment/provider: register: provider key %q is already registered (duplicate registration)", key))
|
||||
}
|
||||
constructors[key] = fn
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
)
|
||||
|
||||
// TestCreateProviderUnknownKeyMessage 锁定 unknown-key 错误文案(等价性硬约束 B2):
|
||||
// 必须与改造前 factory.go switch default 分支的文案逐字节一致。
|
||||
func TestCreateProviderUnknownKeyMessage(t *testing.T) {
|
||||
prov, err := CreateProvider("nope", "inst-1", map[string]string{})
|
||||
if prov != nil {
|
||||
t.Fatalf("expected nil provider for unknown key, got %T", prov)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown provider key")
|
||||
}
|
||||
const want = "unknown provider key: nope"
|
||||
if got := err.Error(); got != want {
|
||||
t.Fatalf("unknown-key error message changed:\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryContainsExactlyAllProviderKeys 验证 5 个 provider 的 init 自注册全部生效,
|
||||
// 且没有意外多注册的 key。
|
||||
func TestRegistryContainsExactlyAllProviderKeys(t *testing.T) {
|
||||
want := []string{
|
||||
payment.TypeEasyPay,
|
||||
payment.TypeAlipay,
|
||||
payment.TypeWxpay,
|
||||
payment.TypeStripe,
|
||||
payment.TypeAirwallex,
|
||||
}
|
||||
if len(constructors) != len(want) {
|
||||
t.Fatalf("expected %d registered provider keys, got %d: %v", len(want), len(constructors), registeredKeys())
|
||||
}
|
||||
for _, key := range want {
|
||||
if _, ok := constructors[key]; !ok {
|
||||
t.Errorf("provider key %q not registered", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func registeredKeys() []string {
|
||||
keys := make([]string, 0, len(constructors))
|
||||
for k := range constructors {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// TestCreateProviderDispatchesToConstructors 验证每个已注册 key 都能分发到对应构造器:
|
||||
// 空 config 下返回的错误必须是各 provider 自身的校验错误(原样透传,等价性硬约束 B1),
|
||||
// 而不是 unknown-key 错误或任何包裹后的错误。
|
||||
func TestCreateProviderDispatchesToConstructors(t *testing.T) {
|
||||
cases := []struct {
|
||||
key string
|
||||
wantErr string
|
||||
}{
|
||||
{payment.TypeEasyPay, "easypay config missing required key: pid"},
|
||||
{payment.TypeAlipay, "alipay config missing required key: appId"},
|
||||
{payment.TypeStripe, "stripe config missing required key: secretKey"},
|
||||
{payment.TypeAirwallex, "airwallex config missing required key: clientId"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, err := CreateProvider(tc.key, "_validate_", map[string]string{})
|
||||
if err == nil {
|
||||
t.Errorf("key %q: expected constructor validation error, got nil", tc.key)
|
||||
continue
|
||||
}
|
||||
if got := err.Error(); got != tc.wantErr {
|
||||
t.Errorf("key %q: constructor error changed:\n got: %q\nwant: %q", tc.key, got, tc.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateProviderPassesThroughApplicationError 验证 wxpay 构造器返回的结构化
|
||||
// *infraerrors.ApplicationError 被原样透传不包裹(等价性硬约束 B1):
|
||||
// "_validate_" 路径依赖该结构化错误做前端 i18n(service/payment_config_providers.go)。
|
||||
func TestCreateProviderPassesThroughApplicationError(t *testing.T) {
|
||||
_, err := CreateProvider(payment.TypeWxpay, "_validate_", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected wxpay constructor validation error, got nil")
|
||||
}
|
||||
appErr, ok := err.(*infraerrors.ApplicationError)
|
||||
if !ok {
|
||||
var as *infraerrors.ApplicationError
|
||||
if errors.As(err, &as) {
|
||||
t.Fatalf("ApplicationError was wrapped instead of passed through as-is: %T", err)
|
||||
}
|
||||
t.Fatalf("expected *infraerrors.ApplicationError, got %T: %v", err, err)
|
||||
}
|
||||
if appErr.Reason != "WXPAY_CONFIG_MISSING_KEY" {
|
||||
t.Errorf("unexpected reason: got %q, want %q", appErr.Reason, "WXPAY_CONFIG_MISSING_KEY")
|
||||
}
|
||||
if appErr.Metadata["key"] != "appId" {
|
||||
t.Errorf("unexpected metadata key: got %q, want %q", appErr.Metadata["key"], "appId")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateProviderSuccessPath 验证命中 key 且 config 合法时返回可用的 Provider 实例。
|
||||
func TestCreateProviderSuccessPath(t *testing.T) {
|
||||
prov, err := CreateProvider(payment.TypeStripe, "inst-42", map[string]string{"secretKey": "sk_test_123"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
if prov == nil {
|
||||
t.Fatal("expected non-nil provider")
|
||||
}
|
||||
if got := prov.ProviderKey(); got != payment.TypeStripe {
|
||||
t.Fatalf("unexpected provider key: got %q, want %q", got, payment.TypeStripe)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterDuplicateKeyPanics 验证重复注册同一 key 在 init 期立即 panic,
|
||||
// 使插装错误尽早暴露。复用已注册的 stripe key,panic 发生在写入之前,
|
||||
// 不会污染包级注册表状态(等价性硬约束 B3)。
|
||||
func TestRegisterDuplicateKeyPanics(t *testing.T) {
|
||||
before := len(constructors)
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatal("expected panic on duplicate registration")
|
||||
}
|
||||
msg := fmt.Sprint(r)
|
||||
if !strings.Contains(msg, fmt.Sprintf("%q", payment.TypeStripe)) {
|
||||
t.Fatalf("panic message should contain the duplicate key %q, got: %s", payment.TypeStripe, msg)
|
||||
}
|
||||
if len(constructors) != before {
|
||||
t.Fatalf("duplicate registration mutated registry state: %d -> %d", before, len(constructors))
|
||||
}
|
||||
}()
|
||||
register(payment.TypeStripe, func(string, map[string]string) (payment.Provider, error) {
|
||||
return nil, nil
|
||||
})
|
||||
}
|
||||
@@ -28,6 +28,12 @@ type Stripe struct {
|
||||
sc *stripe.Client
|
||||
}
|
||||
|
||||
func init() {
|
||||
register(payment.TypeStripe, func(instanceID string, config map[string]string) (payment.Provider, error) {
|
||||
return NewStripe(instanceID, config)
|
||||
})
|
||||
}
|
||||
|
||||
// NewStripe creates a new Stripe provider instance.
|
||||
func NewStripe(instanceID string, config map[string]string) (*Stripe, error) {
|
||||
if config["secretKey"] == "" {
|
||||
|
||||
@@ -82,6 +82,12 @@ type Wxpay struct {
|
||||
|
||||
const wxpayAPIv3KeyLength = 32
|
||||
|
||||
func init() {
|
||||
register(payment.TypeWxpay, func(instanceID string, config map[string]string) (payment.Provider, error) {
|
||||
return NewWxpay(instanceID, config)
|
||||
})
|
||||
}
|
||||
|
||||
func NewWxpay(instanceID string, config map[string]string) (*Wxpay, error) {
|
||||
// All fields are required. Platform-certificate mode is intentionally unsupported —
|
||||
// WeChat has been migrating all merchants to the pubkey verifier since 2024-10,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package plugin
|
||||
|
||||
// Phase-1.5 对抗式审计(2026-06-11)发现的 P2 问题回归测试:
|
||||
// A-1: 失败的 Build 不得被静默重跑(会对已 Provision 实例不 Stop 即覆盖)
|
||||
// C-1: enabled 空值(YAML null)按"未显式配置"处理,走模块默认值
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestRuntimeBuildFailureBlocksRetry 固化审计 A-1 修复:
|
||||
// Build 失败后,二次 Build 与 Start 均被拒绝,且不重新驱动任何生命周期。
|
||||
func TestRuntimeBuildFailureBlocksRetry(t *testing.T) {
|
||||
log := &lifecycleLog{}
|
||||
okMod := &fakeModule{id: "job.a", enabledByDefault: true, log: log}
|
||||
badMod := &fakeModule{id: "job.b", enabledByDefault: true, log: log, provisionErr: errors.New("boom")}
|
||||
rt := newTestRuntime(t, Config{}, okMod, badMod)
|
||||
|
||||
require.Error(t, rt.Build(), "首次 Build 应因 job.b Provision 失败而报错")
|
||||
firstLog := log.snapshot()
|
||||
|
||||
err := rt.Build()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cannot be rebuilt", "失败后的二次 Build 应被明确拒绝")
|
||||
require.Equal(t, firstLog, log.snapshot(), "二次 Build 不得重新 Provision 任何模块")
|
||||
|
||||
require.Error(t, rt.Start(context.Background()), "Build 未成功时 Start 应被拒绝")
|
||||
}
|
||||
|
||||
// TestRuntimeBuildSuccessThenRebuildStillRejected 确认成功路径的原有 guard 语义不变。
|
||||
func TestRuntimeBuildSuccessThenRebuildStillRejected(t *testing.T) {
|
||||
rt := newTestRuntime(t, Config{}, &plainModule{id: "job.ok"})
|
||||
require.NoError(t, rt.Build())
|
||||
err := rt.Build()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "already built")
|
||||
}
|
||||
|
||||
// TestParseConfigEnabledNilTreatedAsUnset 固化审计 C-1 修复:
|
||||
// `enabled:`(YAML 空值 → nil)等价于未配置,三态语义回落到模块默认值,
|
||||
// 且不影响同条目下私有配置的解析。
|
||||
func TestParseConfigEnabledNilTreatedAsUnset(t *testing.T) {
|
||||
cfg, err := ParseConfig(map[string]map[string]any{
|
||||
"job.hello": {"enabled": nil, "greeting": "hi"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
mc, ok := cfg["job.hello"]
|
||||
require.True(t, ok)
|
||||
require.Nil(t, mc.Enabled, "enabled 空值应视为未显式配置")
|
||||
require.Equal(t, "hi", mc.Raw["greeting"], "私有配置不受 enabled 空值影响")
|
||||
|
||||
require.True(t, cfg.enabledFor(ModuleInfo{ID: "job.hello", EnabledByDefault: true}))
|
||||
require.False(t, cfg.enabledFor(ModuleInfo{ID: "job.hello", EnabledByDefault: false}))
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
)
|
||||
|
||||
// enabledKey 是每个模块配置项中由内核保留解释的键,其余键全部属于模块私有配置。
|
||||
const enabledKey = "enabled"
|
||||
|
||||
// ModuleConfig 是单个模块在 `modules:` 配置子树中的配置项。
|
||||
type ModuleConfig struct {
|
||||
// Enabled 是模块的显式启停开关;nil 表示未显式配置,
|
||||
// 由模块注册时声明的 ModuleInfo.EnabledByDefault 决定。
|
||||
Enabled *bool
|
||||
|
||||
// Raw 是模块的私有配置(去掉 enabled 键之后的剩余部分),
|
||||
// 由 Config.Of 解码到模块自定义的配置 struct,内核不解释其内容。
|
||||
Raw map[string]any
|
||||
}
|
||||
|
||||
// Config 是 `modules:` 配置子树的解析结果:模块 ID → 模块配置。
|
||||
//
|
||||
// 缺省(子树不存在)时为空 map,所有模块按各自默认 enabled 值运行,
|
||||
// 行为与未引入插件内核前完全一致。
|
||||
type Config map[ModuleID]ModuleConfig
|
||||
|
||||
// ParseConfig 将全局配置中 `modules:` 子树的原始映射解析为 Config。
|
||||
//
|
||||
// raw 的顶层 key 必须是合法的模块 ID 字面量(如 "job.hello",YAML 中带点的
|
||||
// 完整 key),value 为该模块的配置映射;其中 enabled 键被内核解释为启停开关
|
||||
// (接受 bool 或可解析为 bool 的字符串),其余键原样保留为模块私有配置。
|
||||
//
|
||||
// 允许出现"格式合法但未注册"的模块 ID(例如不同编译变体的配置共用),
|
||||
// 这类配置项会被 Runtime 忽略;但 ID 格式非法或 enabled 类型错误会返回错误,
|
||||
// 以便尽早暴露配置笔误。
|
||||
//
|
||||
// 注意:全局配置经 Viper 加载时,所有 key(含模块 ID 与私有配置键)会被
|
||||
// 静默小写化——配置文件里写 "Job.Hello" 实际到达这里时已是 "job.hello"。
|
||||
// 因此模块 ID 与私有配置 struct 的 mapstructure 标签必须全小写。
|
||||
func ParseConfig(raw map[string]map[string]any) (Config, error) {
|
||||
cfg := make(Config, len(raw))
|
||||
for key, entry := range raw {
|
||||
id := ModuleID(key)
|
||||
if err := id.validate(); err != nil {
|
||||
return nil, fmt.Errorf("plugin config: invalid module ID in modules config: %w", err)
|
||||
}
|
||||
mc := ModuleConfig{Raw: make(map[string]any, len(entry))}
|
||||
for k, v := range entry {
|
||||
if k == enabledKey {
|
||||
// YAML 中写空的 `enabled:`(值为 null)按"未显式配置"处理,
|
||||
// 走模块默认值——与 Enabled=nil 的三态语义一致(审计 C-1)。
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
b, err := toBool(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin config: module %q: invalid enabled value: %w", id, err)
|
||||
}
|
||||
mc.Enabled = &b
|
||||
continue
|
||||
}
|
||||
mc.Raw[k] = v
|
||||
}
|
||||
cfg[id] = mc
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Of 将模块 id 的 raw 私有配置解码到 out(必须是指向配置 struct 的指针)。
|
||||
//
|
||||
// 解码行为与项目全局配置(Viper 默认解码器)保持一致:
|
||||
// mapstructure 标签、弱类型转换、字符串到 time.Duration / 逗号分隔切片的钩子。
|
||||
// 模块未配置或私有配置为空时不修改 out(模块应自带默认值并返回 nil);
|
||||
// 类型不匹配等解码失败时返回包含模块 ID 的错误。
|
||||
func (c Config) Of(id ModuleID, out any) error {
|
||||
mc, ok := c[id]
|
||||
if !ok || len(mc.Raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
||||
Result: out,
|
||||
WeaklyTypedInput: true,
|
||||
DecodeHook: mapstructure.ComposeDecodeHookFunc(
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
mapstructure.StringToSliceHookFunc(","),
|
||||
),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugin config: build decoder for module %q: %w", id, err)
|
||||
}
|
||||
if err := decoder.Decode(mc.Raw); err != nil {
|
||||
return fmt.Errorf("plugin config: decode config for module %q: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enabledFor 解析模块的最终启停状态:显式配置优先,否则用注册时声明的默认值。
|
||||
func (c Config) enabledFor(info ModuleInfo) bool {
|
||||
if mc, ok := c[info.ID]; ok && mc.Enabled != nil {
|
||||
return *mc.Enabled
|
||||
}
|
||||
return info.EnabledByDefault
|
||||
}
|
||||
|
||||
// toBool 将 enabled 配置值弱类型转换为 bool(接受 bool 与 strconv.ParseBool 可解析的字符串)。
|
||||
func toBool(v any) (bool, error) {
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
return val, nil
|
||||
case string:
|
||||
b, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("cannot parse %q as bool", val)
|
||||
}
|
||||
return b, nil
|
||||
default:
|
||||
return false, fmt.Errorf("expected bool, got %T", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseConfigEnabledTriState(t *testing.T) {
|
||||
cfg, err := ParseConfig(map[string]map[string]any{
|
||||
"job.on": {"enabled": true},
|
||||
"job.off": {"enabled": false},
|
||||
"job.unset": {"greeting": "hi"},
|
||||
"job.strbool": {"enabled": "true"}, // 弱类型:字符串 bool(如来自环境变量)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, cfg["job.on"].Enabled)
|
||||
require.True(t, *cfg["job.on"].Enabled)
|
||||
|
||||
require.NotNil(t, cfg["job.off"].Enabled)
|
||||
require.False(t, *cfg["job.off"].Enabled)
|
||||
|
||||
require.Nil(t, cfg["job.unset"].Enabled, "未显式配置 enabled 时必须为 nil(用模块默认值)")
|
||||
|
||||
require.NotNil(t, cfg["job.strbool"].Enabled)
|
||||
require.True(t, *cfg["job.strbool"].Enabled)
|
||||
}
|
||||
|
||||
func TestParseConfigSeparatesEnabledFromRaw(t *testing.T) {
|
||||
cfg, err := ParseConfig(map[string]map[string]any{
|
||||
"job.hello": {"enabled": true, "greeting": "hi", "interval": "5s"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
mc := cfg["job.hello"]
|
||||
require.Equal(t, map[string]any{"greeting": "hi", "interval": "5s"}, mc.Raw,
|
||||
"enabled 键属于内核,不得混入模块私有配置")
|
||||
}
|
||||
|
||||
func TestParseConfigErrors(t *testing.T) {
|
||||
t.Run("invalid module ID", func(t *testing.T) {
|
||||
_, err := ParseConfig(map[string]map[string]any{"Job.Hello": {}})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "Job.Hello")
|
||||
})
|
||||
t.Run("invalid enabled type", func(t *testing.T) {
|
||||
_, err := ParseConfig(map[string]map[string]any{"job.hello": {"enabled": 123}})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "job.hello")
|
||||
})
|
||||
t.Run("invalid enabled string", func(t *testing.T) {
|
||||
_, err := ParseConfig(map[string]map[string]any{"job.hello": {"enabled": "not-a-bool"}})
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("unregistered but valid ID is allowed", func(t *testing.T) {
|
||||
cfg, err := ParseConfig(map[string]map[string]any{"future.module": {"enabled": true}})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, cfg, ModuleID("future.module"))
|
||||
})
|
||||
}
|
||||
|
||||
type helloModuleConfig struct {
|
||||
Greeting string `mapstructure:"greeting"`
|
||||
Interval time.Duration `mapstructure:"interval"`
|
||||
Targets []string `mapstructure:"targets"`
|
||||
Limit int `mapstructure:"limit"`
|
||||
}
|
||||
|
||||
func TestConfigOfDecodesRawIntoStruct(t *testing.T) {
|
||||
cfg, err := ParseConfig(map[string]map[string]any{
|
||||
"job.hello": {
|
||||
"enabled": true,
|
||||
"greeting": "hi",
|
||||
"interval": "5s", // 字符串 → time.Duration(与 viper 默认钩子一致)
|
||||
"targets": "a,b,c", // 逗号分隔 → 切片(与 viper 默认钩子一致)
|
||||
"limit": "42", // 弱类型:字符串 → int
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var out helloModuleConfig
|
||||
require.NoError(t, cfg.Of("job.hello", &out))
|
||||
require.Equal(t, "hi", out.Greeting)
|
||||
require.Equal(t, 5*time.Second, out.Interval)
|
||||
require.Equal(t, []string{"a", "b", "c"}, out.Targets)
|
||||
require.Equal(t, 42, out.Limit)
|
||||
}
|
||||
|
||||
func TestConfigOfMissingModuleLeavesOutUntouched(t *testing.T) {
|
||||
cfg := Config{}
|
||||
out := helloModuleConfig{Greeting: "default-value"}
|
||||
require.NoError(t, cfg.Of("job.absent", &out))
|
||||
require.Equal(t, "default-value", out.Greeting, "模块未配置时不得修改 out(保留模块默认值)")
|
||||
}
|
||||
|
||||
func TestConfigOfTypeErrorContainsModuleID(t *testing.T) {
|
||||
cfg, err := ParseConfig(map[string]map[string]any{
|
||||
"job.hello": {"limit": "not-an-int"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var out helloModuleConfig
|
||||
err = cfg.Of("job.hello", &out)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "job.hello", "解码错误必须包含模块 ID")
|
||||
}
|
||||
|
||||
func TestConfigEnabledFor(t *testing.T) {
|
||||
on, off := true, false
|
||||
cfg := Config{
|
||||
"job.explicit-on": {Enabled: &on},
|
||||
"job.explicit-off": {Enabled: &off},
|
||||
"job.mentioned": {},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
info ModuleInfo
|
||||
want bool
|
||||
}{
|
||||
{"explicit on overrides default off", ModuleInfo{ID: "job.explicit-on", EnabledByDefault: false}, true},
|
||||
{"explicit off overrides default on", ModuleInfo{ID: "job.explicit-off", EnabledByDefault: true}, false},
|
||||
{"mentioned without enabled uses default true", ModuleInfo{ID: "job.mentioned", EnabledByDefault: true}, true},
|
||||
{"mentioned without enabled uses default false", ModuleInfo{ID: "job.mentioned", EnabledByDefault: false}, false},
|
||||
{"absent uses default true", ModuleInfo{ID: "job.absent", EnabledByDefault: true}, true},
|
||||
{"absent uses default false", ModuleInfo{ID: "job.absent", EnabledByDefault: false}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, cfg.enabledFor(tt.info))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
ent "github.com/Wei-Shaw/sub2api/ent"
|
||||
)
|
||||
|
||||
// Host 是模块可使用的宿主能力面(纯数据结构,由宿主装配后注入 Provision)。
|
||||
//
|
||||
// 边界纪律:Host 只暴露 ports 级基础能力,禁止暴露任何具体 Service;
|
||||
// 本阶段仅 Logger / ConfigOf / DB / Redis 四项,后续每新增一项
|
||||
// 必须在插件化 ROADMAP 决策记录中登记,防止 Host 膨胀为上帝对象。
|
||||
type Host struct {
|
||||
// Logger 是宿主提供的结构化日志器。模块应通过它输出日志,
|
||||
// 而不是自建全局 logger。
|
||||
Logger *zap.Logger
|
||||
|
||||
// ConfigOf 将 `modules:` 配置子树中 id 对应的 raw 私有配置解码到 out
|
||||
// (out 必须是指向模块自定义配置 struct 的指针)。
|
||||
// 模块未配置时不修改 out(模块应自带默认值);解码类型错误时返回错误。
|
||||
//
|
||||
// 该字段通常由宿主装配为 Config.Of;若装配时留空,
|
||||
// NewRuntime 会默认绑定到 Runtime 自身持有的 Config。
|
||||
ConfigOf func(id ModuleID, out any) error
|
||||
|
||||
// DB 是 Ent ORM 客户端(数据库访问能力)。
|
||||
DB *ent.Client
|
||||
|
||||
// Redis 是 go-redis 客户端(缓存/队列能力)。
|
||||
// 使用 UniversalClient 接口以兼容单机与集群部署形态。
|
||||
Redis redis.UniversalClient
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Package plugin 实现 Caddy 式进程内插件内核。
|
||||
//
|
||||
// 内核由四部分组成:
|
||||
// - 模块注册表(registry.go):命名空间化的模块目录,模块包在 init() 期通过
|
||||
// RegisterModule 自注册,编译期插装清单(internal/modules/standard)决定哪些模块被编译进来;
|
||||
// - 配置子树(config.go):来自全局配置 `modules:` 的每模块配置(enabled + raw 私有配置);
|
||||
// - 宿主能力面(host.go):模块可使用的全部宿主能力,只暴露 ports 级接口;
|
||||
// - 生命周期驱动器(runtime.go):按注册表稳定序实例化 enabled 模块并驱动
|
||||
// Provision → Validate → Start → Stop(逆序)生命周期。
|
||||
//
|
||||
// 注册表只是"目录",模块的实例化与生命周期统一由 Runtime 驱动。
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ModuleID 是模块的全局唯一标识,采用点分层级命名,
|
||||
// 形如 "job.hello"、"gateway.platform.anthropic"。
|
||||
//
|
||||
// 最后一段为模块名(Name),之前的部分为命名空间(Namespace)。
|
||||
// 每一段只允许 [a-z0-9_-] 字符且不能为空。
|
||||
type ModuleID string
|
||||
|
||||
// Namespace 返回模块 ID 的命名空间部分(去掉最后一段)。
|
||||
//
|
||||
// 例如 "gateway.platform.anthropic" 的命名空间为 "gateway.platform";
|
||||
// 单段 ID(如 "hello")的命名空间为空字符串。
|
||||
func (id ModuleID) Namespace() string {
|
||||
lastDot := strings.LastIndex(string(id), ".")
|
||||
if lastDot < 0 {
|
||||
return ""
|
||||
}
|
||||
return string(id)[:lastDot]
|
||||
}
|
||||
|
||||
// Name 返回模块 ID 的最后一段,即模块名。
|
||||
//
|
||||
// 例如 "gateway.platform.anthropic" 的模块名为 "anthropic"。
|
||||
func (id ModuleID) Name() string {
|
||||
lastDot := strings.LastIndex(string(id), ".")
|
||||
if lastDot < 0 {
|
||||
return string(id)
|
||||
}
|
||||
return string(id)[lastDot+1:]
|
||||
}
|
||||
|
||||
// validate 校验模块 ID 格式:非空、点分、各段为非空的 [a-z0-9_-]+。
|
||||
// 校验规则有意保持宽松,不做长度等额外限制。
|
||||
func (id ModuleID) validate() error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("module ID is empty")
|
||||
}
|
||||
for _, segment := range strings.Split(string(id), ".") {
|
||||
if segment == "" {
|
||||
return fmt.Errorf("module ID %q contains an empty segment", id)
|
||||
}
|
||||
for _, r := range segment {
|
||||
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_' && r != '-' {
|
||||
return fmt.Errorf("module ID %q contains invalid character %q (allowed: [a-z0-9_-] per segment)", id, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ModuleInfo 描述一个模块的注册信息(模块目录中的一条记录)。
|
||||
type ModuleInfo struct {
|
||||
// ID 是模块的全局唯一标识,见 ModuleID。
|
||||
ID ModuleID
|
||||
|
||||
// New 返回该模块的一个新实例。
|
||||
// 每次 Runtime.Build 都会通过 New 创建全新实例,模块不应依赖包级可变状态。
|
||||
New func() Module
|
||||
|
||||
// EnabledByDefault 声明该模块在 `modules:` 配置未显式设置 enabled 时的默认启停状态。
|
||||
//
|
||||
// 设计说明(默认 enabled 的声明方式):默认值由模块在注册时通过本字段自行声明,
|
||||
// 而不是由内核统一约定。这样"缺省配置 = 内置模块全部按各自默认 enabled 值运行"
|
||||
// 的语义完全由各模块的注册信息决定;零值 false 意味着新模块默认不启用,
|
||||
// 保证引入新模块不会在未配置时改变现有行为(与 Phase-1 零行为变更要求一致)。
|
||||
EnabledByDefault bool
|
||||
}
|
||||
|
||||
// Module 是所有插件模块必须实现的最小接口。
|
||||
//
|
||||
// 模块通常是一个 struct,在所属包的 init() 中调用 RegisterModule 完成自注册。
|
||||
// 除 Module 外,模块可按需实现以下可选生命周期接口:
|
||||
// Provisioner、Validator、Starter、Stopper。
|
||||
type Module interface {
|
||||
// ModuleInfo 返回模块的注册信息。该方法必须无副作用且可在零值实例上调用。
|
||||
ModuleInfo() ModuleInfo
|
||||
}
|
||||
|
||||
// Provisioner 是可选生命周期接口:在模块实例化后、校验前装配依赖。
|
||||
//
|
||||
// Provision 在 Runtime.Build 阶段被调用,模块应在此从 Host 获取所需能力
|
||||
// (Logger / ConfigOf / DB / Redis)并完成自身初始化。失败将中止启动。
|
||||
type Provisioner interface {
|
||||
Provision(host *Host) error
|
||||
}
|
||||
|
||||
// Validator 是可选生命周期接口:在 Provision 之后校验模块配置与状态。
|
||||
//
|
||||
// Validate 在 Runtime.Build 阶段被调用,失败将中止启动。
|
||||
type Validator interface {
|
||||
Validate() error
|
||||
}
|
||||
|
||||
// Starter 是可选生命周期接口:启动模块的后台工作(goroutine、监听等)。
|
||||
//
|
||||
// Start 在 Runtime.Start 阶段按注册表稳定序被调用,失败将中止启动并
|
||||
// 触发对已启动模块的逆序 Stop 回滚。
|
||||
//
|
||||
// 契约:实现必须快速返回——长任务应自行启动 goroutine(参照 hello 模块)。
|
||||
// Runtime 在持锁状态下调用 Start/Stop,阻塞的实现会同时阻塞 Snapshot
|
||||
// (即 admin 模块可观测接口)与其他生命周期操作。
|
||||
type Starter interface {
|
||||
Start(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Stopper 是可选生命周期接口:停止模块并释放资源。
|
||||
//
|
||||
// Stop 在 Runtime.Stop 阶段按注册表稳定序的逆序被调用,
|
||||
// 实现必须尊重 ctx 的 deadline(与进程优雅关闭超时协同)。
|
||||
//
|
||||
// 契约:与 Start 相同,实现必须快速返回(等待自身 goroutine 退出时
|
||||
// 用 select 配合 ctx.Done(),参照 hello 模块),原因见 Starter 注释。
|
||||
type Stopper interface {
|
||||
Stop(ctx context.Context) error
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestModuleIDNamespaceAndName(t *testing.T) {
|
||||
tests := []struct {
|
||||
id ModuleID
|
||||
namespace string
|
||||
name string
|
||||
}{
|
||||
{id: "job.hello", namespace: "job", name: "hello"},
|
||||
{id: "gateway.platform.anthropic", namespace: "gateway.platform", name: "anthropic"},
|
||||
{id: "hello", namespace: "", name: "hello"},
|
||||
{id: "payment.provider.stripe-checkout", namespace: "payment.provider", name: "stripe-checkout"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.id), func(t *testing.T) {
|
||||
require.Equal(t, tt.namespace, tt.id.Namespace())
|
||||
require.Equal(t, tt.name, tt.id.Name())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleIDValidate(t *testing.T) {
|
||||
valid := []ModuleID{
|
||||
"hello",
|
||||
"job.hello",
|
||||
"gateway.platform.anthropic",
|
||||
"a.b-c.d_e",
|
||||
"ns.mod123",
|
||||
}
|
||||
for _, id := range valid {
|
||||
require.NoError(t, id.validate(), "id %q should be valid", id)
|
||||
}
|
||||
|
||||
invalid := []ModuleID{
|
||||
"",
|
||||
".",
|
||||
".hello",
|
||||
"hello.",
|
||||
"job..hello",
|
||||
"Job.hello", // 大写
|
||||
"job.hello!", // 非法字符
|
||||
"job. hello", // 空格
|
||||
"job.héllo", // 非 ASCII
|
||||
"job.hello.World", // 末段大写
|
||||
}
|
||||
for _, id := range invalid {
|
||||
require.Error(t, id.validate(), "id %q should be invalid", id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Package plugintest 提供插件模块的测试夹具(test fixtures)。
|
||||
//
|
||||
// 本包是模块作者的测试入口:NewHost 一行构造可按需附带 miniredis、
|
||||
// observed logger、配置子树的宿主能力面;RunLifecycle 一行驱动
|
||||
// "隔离注册 → 解析配置 → Build → Start → t.Cleanup 自动 Stop" 的完整
|
||||
// 生命周期。命名与用法风格对齐标准库 net/http/httptest。
|
||||
//
|
||||
// 使用约束:本包 import "testing",仅供 _test.go 文件 import,
|
||||
// 禁止进入任何生产代码的依赖面。该约束以本声明 + 代码评审人工约定保证
|
||||
// (未配置 depguard 规则;若被非 _test 文件 import,testing 包会被
|
||||
// 连带编入生产二进制,评审时一票否决)。
|
||||
//
|
||||
// 夹具纪律:夹具自身绝不吞错——Build/Start/Stop 或配置解析失败一律经
|
||||
// tb.Fatalf / tb.Errorf 报告,且错误信息包含被测模块 ID。
|
||||
package plugintest
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/plugin"
|
||||
)
|
||||
|
||||
// Option 定制 NewHost 构造的 Host。Option 在 NewHost 内部被依次应用,
|
||||
// tb 即传给 NewHost 的测试句柄;Option 内部的失败一律经 tb 报告(不吞错)。
|
||||
type Option func(tb testing.TB, h *plugin.Host)
|
||||
|
||||
// NewHost 构造模块测试用的宿主能力面,默认零依赖即可使用:
|
||||
// nop logger、空 ConfigOf(对任何模块 ID 都不报错、不修改 out)、
|
||||
// nil DB、nil Redis。按需叠加 WithRedis / WithConfig / WithObservedLogger。
|
||||
//
|
||||
// Host 是纯数据结构:本包未覆盖的少见场景(如注入 enttest 客户端、
|
||||
// 自定义 logger)可在 NewHost 返回后直接对字段赋值,无需新增 Option。
|
||||
func NewHost(tb testing.TB, opts ...Option) *plugin.Host {
|
||||
tb.Helper()
|
||||
host := &plugin.Host{
|
||||
Logger: zap.NewNop(),
|
||||
ConfigOf: plugin.Config{}.Of,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(tb, host)
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// WithRedis 为 Host 附带一个由 miniredis 支撑的 go-redis 客户端。
|
||||
//
|
||||
// miniredis 实例与 redis 客户端的生命周期均挂在 tb.Cleanup 上自动清理;
|
||||
// tb 通常就是传给 NewHost 的同一个测试句柄。
|
||||
func WithRedis(tb testing.TB) Option {
|
||||
return func(_ testing.TB, h *plugin.Host) {
|
||||
tb.Helper()
|
||||
mr := miniredis.RunT(tb)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
tb.Cleanup(func() {
|
||||
if err := rdb.Close(); err != nil {
|
||||
tb.Errorf("plugintest: close redis client: %v", err)
|
||||
}
|
||||
})
|
||||
h.Redis = rdb
|
||||
}
|
||||
}
|
||||
|
||||
// WithConfig 将 Host.ConfigOf 绑定为 raw(与全局配置 `modules:` 子树同形状)
|
||||
// 的解析结果,供直接调用 Provision 等不经 Runtime 的测试读取模块私有配置。
|
||||
// raw 解析失败(模块 ID 格式非法、enabled 类型错误)立即 tb.Fatal。
|
||||
//
|
||||
// 注意:经 RunLifecycle / BuildOnly 驱动生命周期时,模块配置以其 raw 参数
|
||||
// 为唯一来源(会覆盖本 Option 设置的 ConfigOf),两者不要混用。
|
||||
func WithConfig(raw map[string]map[string]any) Option {
|
||||
return func(tb testing.TB, h *plugin.Host) {
|
||||
tb.Helper()
|
||||
cfg, err := plugin.ParseConfig(raw)
|
||||
if err != nil {
|
||||
tb.Fatalf("plugintest: parse modules config: %v", err)
|
||||
}
|
||||
h.ConfigOf = cfg.Of
|
||||
}
|
||||
}
|
||||
|
||||
// WithObservedLogger 将 Host.Logger 替换为 debug 级别的 zap observer logger,
|
||||
// 并把可断言日志记录的句柄作为第二返回值一并返回:
|
||||
//
|
||||
// logOpt, logs := plugintest.WithObservedLogger()
|
||||
// host := plugintest.NewHost(t, logOpt)
|
||||
// // ... 驱动模块 ...
|
||||
// require.Equal(t, 1, logs.FilterMessage("hello").Len())
|
||||
//
|
||||
// 设计说明:observer 句柄随 Option 直接返回,而非提供 Logs(host) 式的查表
|
||||
// 函数——调用方一行解构即可拿到句柄,包内也无需维护 Host → observer 的
|
||||
// 映射状态(与 PHASE_PLAN 草案中 Logs(t) 形态的差异已在任务交付说明登记)。
|
||||
func WithObservedLogger() (Option, *observer.ObservedLogs) {
|
||||
core, logs := observer.New(zapcore.DebugLevel)
|
||||
logger := zap.New(core)
|
||||
return func(_ testing.TB, h *plugin.Host) {
|
||||
h.Logger = logger
|
||||
}, logs
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package plugintest_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/plugin"
|
||||
"github.com/Wei-Shaw/sub2api/internal/plugin/plugintest"
|
||||
)
|
||||
|
||||
// fixtureConfig 是 fakeModule 的私有配置,用于验证 raw 配置经 host.ConfigOf 抵达模块。
|
||||
type fixtureConfig struct {
|
||||
Greeting string `mapstructure:"greeting"`
|
||||
}
|
||||
|
||||
// fakeModule 是自测用模块:New 返回自身以便直接断言生命周期调用计数,
|
||||
// 可按需注入各阶段错误。生命周期由 Runtime 在测试 goroutine 上同步驱动
|
||||
// (cleanup 与 t.Run 完成均有 happens-before 保证),计数无需加锁。
|
||||
type fakeModule struct {
|
||||
id plugin.ModuleID
|
||||
enabledByDefault bool
|
||||
|
||||
provisionErr error
|
||||
validateErr error
|
||||
startErr error
|
||||
stopErr error
|
||||
|
||||
provisions int
|
||||
validates int
|
||||
starts int
|
||||
stops int
|
||||
host *plugin.Host
|
||||
cfg fixtureConfig
|
||||
}
|
||||
|
||||
func (m *fakeModule) ModuleInfo() plugin.ModuleInfo {
|
||||
return plugin.ModuleInfo{
|
||||
ID: m.id,
|
||||
EnabledByDefault: m.enabledByDefault,
|
||||
New: func() plugin.Module { return m },
|
||||
}
|
||||
}
|
||||
|
||||
func (m *fakeModule) Provision(host *plugin.Host) error {
|
||||
m.provisions++
|
||||
m.host = host
|
||||
if host != nil && host.ConfigOf != nil {
|
||||
if err := host.ConfigOf(m.id, &m.cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return m.provisionErr
|
||||
}
|
||||
|
||||
func (m *fakeModule) Validate() error { m.validates++; return m.validateErr }
|
||||
|
||||
func (m *fakeModule) Start(context.Context) error { m.starts++; return m.startErr }
|
||||
|
||||
func (m *fakeModule) Stop(context.Context) error { m.stops++; return m.stopErr }
|
||||
|
||||
// errFatalCalled 是 recordingTB.Fatalf 用于模拟"立即中止"语义的 panic 哨兵。
|
||||
var errFatalCalled = errors.New("recordingTB: Fatalf called")
|
||||
|
||||
// recordingTB 包装真实 *testing.T,捕获夹具的 Fatalf / Errorf / Logf / Cleanup
|
||||
// 调用,用于验证夹具在错误路径上不吞错。Fatalf 记录信息后 panic(errFatalCalled)
|
||||
// 模拟真实 t.Fatalf 的中止语义,由 expectFatal 捕获恢复。
|
||||
type recordingTB struct {
|
||||
testing.TB
|
||||
fatals []string
|
||||
errs []string
|
||||
logLines []string
|
||||
cleanups []func()
|
||||
}
|
||||
|
||||
func (r *recordingTB) Helper() {}
|
||||
|
||||
func (r *recordingTB) Logf(format string, args ...any) {
|
||||
r.logLines = append(r.logLines, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (r *recordingTB) Errorf(format string, args ...any) {
|
||||
r.errs = append(r.errs, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (r *recordingTB) Fatalf(format string, args ...any) {
|
||||
r.fatals = append(r.fatals, fmt.Sprintf(format, args...))
|
||||
panic(errFatalCalled)
|
||||
}
|
||||
|
||||
func (r *recordingTB) Cleanup(f func()) { r.cleanups = append(r.cleanups, f) }
|
||||
|
||||
// runCleanups 按注册逆序(与真实 testing 一致)执行捕获的 cleanup。
|
||||
func (r *recordingTB) runCleanups() {
|
||||
for i := len(r.cleanups) - 1; i >= 0; i-- {
|
||||
r.cleanups[i]()
|
||||
}
|
||||
}
|
||||
|
||||
// expectFatal 执行 fn 并断言它经 recordingTB.Fatalf 中止,返回最后一条 fatal 信息。
|
||||
func expectFatal(t *testing.T, rec *recordingTB, fn func()) (msg string) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if r != errFatalCalled {
|
||||
panic(r) // 非哨兵 panic 原样上抛
|
||||
}
|
||||
require.NotEmpty(t, rec.fatals)
|
||||
msg = rec.fatals[len(rec.fatals)-1]
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
t.Fatal("expected the fixture to call Fatalf, but it returned normally")
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestNewHostDefaults(t *testing.T) {
|
||||
host := plugintest.NewHost(t)
|
||||
require.NotNil(t, host.Logger, "默认应提供 nop logger 而非 nil")
|
||||
require.NotNil(t, host.ConfigOf, "默认应提供空 ConfigOf 而非 nil")
|
||||
require.Nil(t, host.DB)
|
||||
require.Nil(t, host.Redis)
|
||||
|
||||
// 空 ConfigOf:对任意模块 ID 不报错、不修改 out(模块默认值得以保留)。
|
||||
cfg := fixtureConfig{Greeting: "default"}
|
||||
require.NoError(t, host.ConfigOf("job.anything", &cfg))
|
||||
require.Equal(t, "default", cfg.Greeting)
|
||||
}
|
||||
|
||||
func TestWithRedis(t *testing.T) {
|
||||
host := plugintest.NewHost(t, plugintest.WithRedis(t))
|
||||
require.NotNil(t, host.Redis)
|
||||
|
||||
ctx := context.Background()
|
||||
require.NoError(t, host.Redis.Set(ctx, "k", "v", 0).Err())
|
||||
got, err := host.Redis.Get(ctx, "k").Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "v", got)
|
||||
}
|
||||
|
||||
func TestWithConfig(t *testing.T) {
|
||||
host := plugintest.NewHost(t, plugintest.WithConfig(map[string]map[string]any{
|
||||
"job.fixture": {"enabled": true, "greeting": "hi"},
|
||||
}))
|
||||
|
||||
var cfg fixtureConfig
|
||||
require.NoError(t, host.ConfigOf("job.fixture", &cfg))
|
||||
require.Equal(t, "hi", cfg.Greeting, "私有配置应可经 ConfigOf 解码(enabled 键被内核剥离)")
|
||||
}
|
||||
|
||||
func TestWithConfigParseFailureFatals(t *testing.T) {
|
||||
rec := &recordingTB{TB: t}
|
||||
msg := expectFatal(t, rec, func() {
|
||||
plugintest.NewHost(rec, plugintest.WithConfig(map[string]map[string]any{
|
||||
"INVALID ID": {},
|
||||
}))
|
||||
})
|
||||
require.Contains(t, msg, "parse modules config")
|
||||
}
|
||||
|
||||
func TestWithObservedLogger(t *testing.T) {
|
||||
logOpt, logs := plugintest.WithObservedLogger()
|
||||
host := plugintest.NewHost(t, logOpt)
|
||||
|
||||
host.Logger.Debug("observed-ping")
|
||||
require.Equal(t, 1, logs.FilterMessage("observed-ping").Len())
|
||||
}
|
||||
|
||||
func TestRunLifecycleSuccessAndAutoStop(t *testing.T) {
|
||||
m := &fakeModule{id: "job.fixture"}
|
||||
t.Run("lifecycle", func(t *testing.T) {
|
||||
rt := plugintest.RunLifecycle(t, m, plugintest.NewHost(t), map[string]map[string]any{
|
||||
"job.fixture": {"enabled": true, "greeting": "hi"},
|
||||
})
|
||||
|
||||
require.Equal(t, 1, m.provisions)
|
||||
require.Equal(t, 1, m.validates)
|
||||
require.Equal(t, 1, m.starts)
|
||||
require.Zero(t, m.stops)
|
||||
require.Equal(t, "hi", m.cfg.Greeting, "raw 配置应经 host.ConfigOf 抵达模块")
|
||||
require.NotNil(t, m.host)
|
||||
|
||||
snap := rt.Snapshot()
|
||||
require.Len(t, snap, 1, "隔离注册表应只含被测模块")
|
||||
require.Equal(t, plugin.StateRunning, snap[0].State)
|
||||
})
|
||||
require.Equal(t, 1, m.stops, "t.Cleanup 应自动 Stop 模块")
|
||||
}
|
||||
|
||||
func TestRunLifecycleRawOverridesHostConfig(t *testing.T) {
|
||||
m := &fakeModule{id: "job.fixture"}
|
||||
host := plugintest.NewHost(t, plugintest.WithConfig(map[string]map[string]any{
|
||||
"job.fixture": {"greeting": "from-with-config"},
|
||||
}))
|
||||
plugintest.RunLifecycle(t, m, host, map[string]map[string]any{
|
||||
"job.fixture": {"enabled": true, "greeting": "from-raw"},
|
||||
})
|
||||
require.Equal(t, "from-raw", m.cfg.Greeting,
|
||||
"RunLifecycle 的 raw 是配置唯一来源,应覆盖 WithConfig 的旧绑定")
|
||||
}
|
||||
|
||||
func TestRunLifecycleNilHostUsesDefaultHost(t *testing.T) {
|
||||
m := &fakeModule{id: "job.fixture"}
|
||||
plugintest.RunLifecycle(t, m, nil, map[string]map[string]any{
|
||||
"job.fixture": {"enabled": true},
|
||||
})
|
||||
require.NotNil(t, m.host, "nil host 应被替换为默认 Host")
|
||||
require.NotNil(t, m.host.Logger)
|
||||
require.NotNil(t, m.host.ConfigOf)
|
||||
}
|
||||
|
||||
func TestRunLifecycleExplicitStopThenCleanupIsIdempotent(t *testing.T) {
|
||||
m := &fakeModule{id: "job.fixture"}
|
||||
t.Run("lifecycle", func(t *testing.T) {
|
||||
rt := plugintest.RunLifecycle(t, m, nil, map[string]map[string]any{
|
||||
"job.fixture": {"enabled": true},
|
||||
})
|
||||
require.NoError(t, rt.Stop(context.Background()))
|
||||
require.Equal(t, 1, m.stops)
|
||||
})
|
||||
require.Equal(t, 1, m.stops, "测试已显式 Stop 时,cleanup 的二次 Stop 应为 no-op")
|
||||
}
|
||||
|
||||
func TestRunLifecycleDisabledModuleIsNoop(t *testing.T) {
|
||||
m := &fakeModule{id: "job.fixture"} // EnabledByDefault=false 且 raw 未显式 enabled
|
||||
rt := plugintest.RunLifecycle(t, m, nil, nil)
|
||||
|
||||
require.Zero(t, m.provisions, "disabled 模块不应经历任何生命周期(语义与生产一致,不强制 enable)")
|
||||
snap := rt.Snapshot()
|
||||
require.Len(t, snap, 1)
|
||||
require.False(t, snap[0].Enabled)
|
||||
require.Equal(t, plugin.StateRegistered, snap[0].State)
|
||||
}
|
||||
|
||||
func TestBuildOnlyDoesNotStart(t *testing.T) {
|
||||
m := &fakeModule{id: "job.fixture"}
|
||||
t.Run("build", func(t *testing.T) {
|
||||
rt := plugintest.BuildOnly(t, m, nil, map[string]map[string]any{
|
||||
"job.fixture": {"enabled": true},
|
||||
})
|
||||
require.Equal(t, 1, m.provisions)
|
||||
require.Equal(t, 1, m.validates)
|
||||
require.Zero(t, m.starts)
|
||||
require.Equal(t, plugin.StateProvisioned, rt.Snapshot()[0].State)
|
||||
})
|
||||
require.Zero(t, m.stops, "未 Start 的模块在 cleanup 兜底 Stop 中不应被调用 Stop")
|
||||
}
|
||||
|
||||
func TestBuildOnlyLogsDisabledHint(t *testing.T) {
|
||||
rec := &recordingTB{TB: t}
|
||||
m := &fakeModule{id: "job.fixture"}
|
||||
plugintest.BuildOnly(rec, m, nil, nil)
|
||||
|
||||
require.NotEmpty(t, rec.logLines, "disabled 模块应输出提示日志")
|
||||
require.Contains(t, rec.logLines[0], "disabled")
|
||||
require.Contains(t, rec.logLines[0], `"job.fixture"`)
|
||||
rec.runCleanups()
|
||||
require.Empty(t, rec.errs)
|
||||
}
|
||||
|
||||
func TestRunLifecycleBuildFailureFatalsWithModuleID(t *testing.T) {
|
||||
rec := &recordingTB{TB: t}
|
||||
m := &fakeModule{id: "job.fixture", enabledByDefault: true, provisionErr: errors.New("provision boom")}
|
||||
msg := expectFatal(t, rec, func() {
|
||||
plugintest.RunLifecycle(rec, m, nil, nil)
|
||||
})
|
||||
require.Contains(t, msg, `"job.fixture"`, "Build 失败信息必须包含模块 ID")
|
||||
require.Contains(t, msg, "provision boom")
|
||||
}
|
||||
|
||||
func TestRunLifecycleStartFailureFatalsWithModuleID(t *testing.T) {
|
||||
rec := &recordingTB{TB: t}
|
||||
m := &fakeModule{id: "job.fixture", enabledByDefault: true, startErr: errors.New("start boom")}
|
||||
msg := expectFatal(t, rec, func() {
|
||||
plugintest.RunLifecycle(rec, m, nil, nil)
|
||||
})
|
||||
require.Contains(t, msg, `"job.fixture"`, "Start 失败信息必须包含模块 ID")
|
||||
require.Contains(t, msg, "start boom")
|
||||
|
||||
// Start 失败的模块未进入 running,cleanup 兜底 Stop 应为无错误的 no-op。
|
||||
rec.runCleanups()
|
||||
require.Empty(t, rec.errs)
|
||||
require.Zero(t, m.stops)
|
||||
}
|
||||
|
||||
func TestCleanupStopFailureReportedWithModuleID(t *testing.T) {
|
||||
rec := &recordingTB{TB: t}
|
||||
m := &fakeModule{id: "job.fixture", enabledByDefault: true, stopErr: errors.New("stop boom")}
|
||||
plugintest.RunLifecycle(rec, m, nil, nil)
|
||||
require.Empty(t, rec.fatals)
|
||||
require.NotEmpty(t, rec.cleanups)
|
||||
|
||||
rec.runCleanups()
|
||||
require.Len(t, rec.errs, 1, "cleanup Stop 失败必须经 Errorf 报告(不吞错)")
|
||||
require.Contains(t, rec.errs[0], `"job.fixture"`)
|
||||
require.Contains(t, rec.errs[0], "stop boom")
|
||||
}
|
||||
|
||||
func TestBuildExpectingErrorReturnsBuildError(t *testing.T) {
|
||||
m := &fakeModule{id: "job.fixture", enabledByDefault: true, validateErr: errors.New("validate boom")}
|
||||
err := plugintest.BuildExpectingError(t, m, nil, nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), `"job.fixture"`)
|
||||
require.Contains(t, err.Error(), "validate boom")
|
||||
}
|
||||
|
||||
func TestBuildExpectingErrorFatalsOnUnexpectedSuccess(t *testing.T) {
|
||||
rec := &recordingTB{TB: t}
|
||||
m := &fakeModule{id: "job.fixture", enabledByDefault: true}
|
||||
msg := expectFatal(t, rec, func() {
|
||||
_ = plugintest.BuildExpectingError(rec, m, nil, nil)
|
||||
})
|
||||
require.Contains(t, msg, "expected Build")
|
||||
require.Contains(t, msg, `"job.fixture"`)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package plugintest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/plugin"
|
||||
)
|
||||
|
||||
// stopTimeout 是 t.Cleanup 自动 Stop 的超时上限:模块 Stop 悬挂时,
|
||||
// 测试以包含模块 ID 的明确错误失败,而不是吊死整个 go test 进程。
|
||||
const stopTimeout = 10 * time.Second
|
||||
|
||||
// RunLifecycle 驱动单个模块的完整生命周期:在隔离注册表中注册 m →
|
||||
// 解析 raw 配置 → Build(Provision / Validate)→ Start。任一步失败立即
|
||||
// tb.Fatal(错误信息含模块 ID)。测试结束时 t.Cleanup 自动 Stop 并断言无
|
||||
// 错误;测试中途已显式 rt.Stop 时,清理阶段的二次 Stop 为 no-op
|
||||
// (Runtime.Stop 只停 running 模块,幂等)。
|
||||
//
|
||||
// 语义说明:
|
||||
// - raw 与全局配置 `modules:` 子树同形状,是模块配置的唯一来源
|
||||
// (host 上已有的 ConfigOf——包括 WithConfig 设置的——会被重绑定为
|
||||
// raw 的解析结果);nil 表示空配置;
|
||||
// - enabled 语义与生产完全一致、不做任何强制:raw 未显式 enabled 且模块
|
||||
// 默认 disabled 时,Build / Start 全程 no-op(此时会 tb.Logf 提示,
|
||||
// 便于排查"忘了写 enabled: true");
|
||||
// - host 为 nil 时等价于 NewHost(tb) 的默认 Host;
|
||||
// - Runtime 经 ModuleInfo().New 创建实例驱动生命周期——除非模块的 New
|
||||
// 返回自身(fake 模块常如此),传入的 m 仅用于注册,生命周期效果应
|
||||
// 通过日志(WithObservedLogger)与返回 Runtime 的 Snapshot 断言,
|
||||
// 而不是 m 的内部字段。
|
||||
//
|
||||
// 需要断言"Build 应失败"用 BuildExpectingError;更复杂的场景(Start 失败
|
||||
// 回滚、多模块编排等)直接使用内核 API(plugin.NewRegistry +
|
||||
// plugin.NewRuntimeWithRegistry),内核 API 保持完全公开。
|
||||
func RunLifecycle(tb testing.TB, m plugin.Module, host *plugin.Host, raw map[string]map[string]any) *plugin.Runtime {
|
||||
tb.Helper()
|
||||
rt, id := buildOnly(tb, m, host, raw)
|
||||
if err := rt.Start(context.Background()); err != nil {
|
||||
tb.Fatalf("plugintest: start module %q: %v", id, err)
|
||||
}
|
||||
return rt
|
||||
}
|
||||
|
||||
// BuildOnly 与 RunLifecycle 相同但停在 Build 之后(不 Start),用于只验证
|
||||
// Provision / Validate 行为的测试。返回的 Runtime 可由测试自行 Start;
|
||||
// 无论是否 Start,t.Cleanup 都会执行兜底 Stop(未启动时为 no-op)并断言无错误。
|
||||
func BuildOnly(tb testing.TB, m plugin.Module, host *plugin.Host, raw map[string]map[string]any) *plugin.Runtime {
|
||||
tb.Helper()
|
||||
rt, _ := buildOnly(tb, m, host, raw)
|
||||
return rt
|
||||
}
|
||||
|
||||
// BuildExpectingError 与 BuildOnly 同样装配 Runtime,但期望 Build 失败:
|
||||
// Build 意外成功时 tb.Fatal;失败时返回该错误供测试断言内容
|
||||
// (内核保证 Build 错误信息包含失败模块 ID)。
|
||||
//
|
||||
// 设计说明:单独提供本变体而不是给 RunLifecycle 加 expectError 参数,
|
||||
// 是为了让成功 / 失败两种期望在调用处一目了然,且失败路径不注册多余的
|
||||
// cleanup(Build 失败后无 running 模块,无需 Stop)。raw 解析失败仍然
|
||||
// tb.Fatal——配置字面量笔误属于测试自身缺陷,不属于"期望中的 Build 失败"。
|
||||
// "Start 应失败"的场景本包不提供夹具(hello 与内核测试均无此样板复用需求,
|
||||
// 避免为假设需求预铺 API),请直接使用内核 API。
|
||||
func BuildExpectingError(tb testing.TB, m plugin.Module, host *plugin.Host, raw map[string]map[string]any) error {
|
||||
tb.Helper()
|
||||
rt, id := newRuntime(tb, m, host, raw)
|
||||
err := rt.Build()
|
||||
if err == nil {
|
||||
tb.Fatalf("plugintest: expected Build of module %q to fail, but it succeeded", id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// buildOnly 是 RunLifecycle / BuildOnly 的共享实现:装配 Runtime、执行 Build、
|
||||
// 输出 disabled 提示并注册 t.Cleanup 兜底 Stop。
|
||||
func buildOnly(tb testing.TB, m plugin.Module, host *plugin.Host, raw map[string]map[string]any) (*plugin.Runtime, plugin.ModuleID) {
|
||||
tb.Helper()
|
||||
rt, id := newRuntime(tb, m, host, raw)
|
||||
if err := rt.Build(); err != nil {
|
||||
tb.Fatalf("plugintest: build module %q: %v", id, err)
|
||||
}
|
||||
for _, status := range rt.Snapshot() {
|
||||
if status.ID == id && !status.Enabled {
|
||||
tb.Logf("plugintest: module %q resolved to disabled under the given config; its lifecycle is a no-op (set \"enabled\": true in raw to run it)", id)
|
||||
}
|
||||
}
|
||||
tb.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), stopTimeout)
|
||||
defer cancel()
|
||||
if err := rt.Stop(ctx); err != nil {
|
||||
tb.Errorf("plugintest: stop module %q during cleanup: %v", id, err)
|
||||
}
|
||||
})
|
||||
return rt, id
|
||||
}
|
||||
|
||||
// newRuntime 完成各夹具入口共享的装配:隔离注册表注册 m、解析 raw 配置、
|
||||
// 将 host.ConfigOf 重绑定为解析结果、创建 Runtime。
|
||||
func newRuntime(tb testing.TB, m plugin.Module, host *plugin.Host, raw map[string]map[string]any) (*plugin.Runtime, plugin.ModuleID) {
|
||||
tb.Helper()
|
||||
if m == nil {
|
||||
tb.Fatalf("plugintest: module under test is nil")
|
||||
}
|
||||
id := m.ModuleInfo().ID
|
||||
cfg, err := plugin.ParseConfig(raw)
|
||||
if err != nil {
|
||||
tb.Fatalf("plugintest: parse modules config for module %q: %v", id, err)
|
||||
}
|
||||
if host == nil {
|
||||
host = NewHost(tb)
|
||||
}
|
||||
// raw 是生命周期夹具的唯一配置来源:统一重绑定 ConfigOf,避免 host 上的
|
||||
// 旧绑定(NewHost 默认空配置 / WithConfig)与 Runtime 实际持有的 cfg 不一致。
|
||||
host.ConfigOf = cfg.Of
|
||||
|
||||
registry := plugin.NewRegistry()
|
||||
registry.RegisterModule(m) // 注册非法(ID 格式等)时按内核语义 panic,测试直接失败
|
||||
return plugin.NewRuntimeWithRegistry(host, cfg, registry), id
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Registry 是模块注册表:从 ModuleID 到 ModuleInfo 的并发安全目录。
|
||||
//
|
||||
// 注册表只负责"有哪些模块",不负责实例化与生命周期(那是 Runtime 的职责)。
|
||||
// 常规代码使用包级默认注册表(RegisterModule 等包级函数);
|
||||
// 测试可通过 NewRegistry 创建相互隔离的实例。
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
modules map[ModuleID]ModuleInfo
|
||||
}
|
||||
|
||||
// NewRegistry 创建一个空的、与包级默认注册表相互隔离的注册表实例。
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{modules: make(map[ModuleID]ModuleInfo)}
|
||||
}
|
||||
|
||||
// RegisterModule 将模块注册进本注册表。
|
||||
//
|
||||
// 与包级 RegisterModule 相同的 panic 语义:模块为 nil、ID 为空、ID 格式非法、
|
||||
// New 为 nil 或 ID 重复时 panic。注册错误属于编译期插装错误,应尽早暴露。
|
||||
func (r *Registry) RegisterModule(m Module) {
|
||||
if m == nil {
|
||||
panic("plugin: RegisterModule called with nil module")
|
||||
}
|
||||
info := m.ModuleInfo()
|
||||
if err := info.ID.validate(); err != nil {
|
||||
panic(fmt.Sprintf("plugin: RegisterModule: invalid module ID: %v", err))
|
||||
}
|
||||
if info.New == nil {
|
||||
panic(fmt.Sprintf("plugin: RegisterModule: module %q has a nil New function", info.ID))
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, dup := r.modules[info.ID]; dup {
|
||||
panic(fmt.Sprintf("plugin: RegisterModule: module %q is already registered (duplicate registration; check the instrumentation list in internal/modules/standard)", info.ID))
|
||||
}
|
||||
r.modules[info.ID] = info
|
||||
}
|
||||
|
||||
// GetModule 按 ID 查询模块注册信息,第二个返回值表示是否存在。
|
||||
func (r *Registry) GetModule(id ModuleID) (ModuleInfo, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
info, ok := r.modules[id]
|
||||
return info, ok
|
||||
}
|
||||
|
||||
// GetModulesInNamespace 返回命名空间恰好为 ns 的全部模块,按 ID 字典序稳定排序。
|
||||
//
|
||||
// 匹配为精确匹配(非前缀匹配):ns 为 "gateway.platform" 时返回
|
||||
// "gateway.platform.anthropic" 等直接子模块,但不包含 "gateway.platform.x.y"。
|
||||
// ns 为空字符串时返回所有单段 ID 的顶层模块。
|
||||
func (r *Registry) GetModulesInNamespace(ns string) []ModuleInfo {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
var result []ModuleInfo
|
||||
for id, info := range r.modules {
|
||||
if id.Namespace() == ns {
|
||||
result = append(result, info)
|
||||
}
|
||||
}
|
||||
sortModuleInfos(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// Modules 返回注册表中的全部模块,按 ID 字典序稳定排序。
|
||||
// Runtime 以该顺序作为模块生命周期的确定性顺序。
|
||||
func (r *Registry) Modules() []ModuleInfo {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
result := make([]ModuleInfo, 0, len(r.modules))
|
||||
for _, info := range r.modules {
|
||||
result = append(result, info)
|
||||
}
|
||||
sortModuleInfos(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// sortModuleInfos 按 ID 字典序原地排序,保证遍历顺序的确定性。
|
||||
func sortModuleInfos(infos []ModuleInfo) {
|
||||
sort.Slice(infos, func(i, j int) bool { return infos[i].ID < infos[j].ID })
|
||||
}
|
||||
|
||||
// defaultRegistry 是包级默认注册表,模块包在 init() 期向其自注册。
|
||||
var defaultRegistry = NewRegistry()
|
||||
|
||||
// RegisterModule 将模块注册进包级默认注册表,必须在 init() 期调用。
|
||||
//
|
||||
// 模块为 nil、ID 为空、ID 格式非法、New 为 nil 或 ID 重复时 panic:
|
||||
// 注册错误属于编译期插装错误(插装清单 import 错误、模块 ID 冲突等),
|
||||
// 必须在进程启动最早期暴露,而不是延迟到运行时。
|
||||
func RegisterModule(m Module) {
|
||||
defaultRegistry.RegisterModule(m)
|
||||
}
|
||||
|
||||
// GetModule 按 ID 在包级默认注册表中查询模块注册信息。
|
||||
func GetModule(id ModuleID) (ModuleInfo, bool) {
|
||||
return defaultRegistry.GetModule(id)
|
||||
}
|
||||
|
||||
// GetModulesInNamespace 在包级默认注册表中查询命名空间恰好为 ns 的全部模块,
|
||||
// 按 ID 字典序稳定排序。匹配语义见 (*Registry).GetModulesInNamespace。
|
||||
func GetModulesInNamespace(ns string) []ModuleInfo {
|
||||
return defaultRegistry.GetModulesInNamespace(ns)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// stubModule 是注册表测试用的最小模块实现。
|
||||
type stubModule struct {
|
||||
info ModuleInfo
|
||||
}
|
||||
|
||||
func (m *stubModule) ModuleInfo() ModuleInfo { return m.info }
|
||||
|
||||
// newStubModule 构造一个 ID 为 id 的最小可注册模块。
|
||||
func newStubModule(id ModuleID) *stubModule {
|
||||
m := &stubModule{}
|
||||
m.info = ModuleInfo{
|
||||
ID: id,
|
||||
New: func() Module { return &stubModule{info: m.info} },
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestRegistryRegisterAndGet(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
reg.RegisterModule(newStubModule("job.hello"))
|
||||
|
||||
info, ok := reg.GetModule("job.hello")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, ModuleID("job.hello"), info.ID)
|
||||
require.NotNil(t, info.New)
|
||||
|
||||
_, ok = reg.GetModule("job.missing")
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
func TestRegistryDuplicateIDPanics(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
reg.RegisterModule(newStubModule("job.hello"))
|
||||
|
||||
require.PanicsWithValue(t,
|
||||
`plugin: RegisterModule: module "job.hello" is already registered (duplicate registration; check the instrumentation list in internal/modules/standard)`,
|
||||
func() { reg.RegisterModule(newStubModule("job.hello")) },
|
||||
)
|
||||
}
|
||||
|
||||
func TestRegistryInvalidRegistrationPanics(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
|
||||
t.Run("nil module", func(t *testing.T) {
|
||||
require.Panics(t, func() { reg.RegisterModule(nil) })
|
||||
})
|
||||
t.Run("empty ID", func(t *testing.T) {
|
||||
require.Panics(t, func() { reg.RegisterModule(newStubModule("")) })
|
||||
})
|
||||
t.Run("invalid ID format", func(t *testing.T) {
|
||||
// panic 信息必须包含非法 ID,便于排查插装清单。
|
||||
defer func() {
|
||||
r := recover()
|
||||
require.NotNil(t, r)
|
||||
require.Contains(t, fmt.Sprint(r), "Job.Hello")
|
||||
}()
|
||||
reg.RegisterModule(newStubModule("Job.Hello"))
|
||||
})
|
||||
t.Run("nil New func", func(t *testing.T) {
|
||||
require.Panics(t, func() {
|
||||
reg.RegisterModule(&stubModule{info: ModuleInfo{ID: "job.no-new"}})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegistryGetModulesInNamespace(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
// 故意乱序注册,验证返回结果按 ID 字典序稳定排序。
|
||||
for _, id := range []ModuleID{
|
||||
"job.zeta",
|
||||
"job.alpha",
|
||||
"job.middle",
|
||||
"gateway.platform.anthropic",
|
||||
"gateway.platform.openai",
|
||||
"toplevel",
|
||||
} {
|
||||
reg.RegisterModule(newStubModule(id))
|
||||
}
|
||||
|
||||
ids := func(infos []ModuleInfo) []ModuleID {
|
||||
out := make([]ModuleID, 0, len(infos))
|
||||
for _, info := range infos {
|
||||
out = append(out, info.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
require.Equal(t, []ModuleID{"job.alpha", "job.middle", "job.zeta"}, ids(reg.GetModulesInNamespace("job")))
|
||||
// 精确匹配:ns "gateway" 不包含 "gateway.platform.*"。
|
||||
require.Empty(t, reg.GetModulesInNamespace("gateway"))
|
||||
require.Equal(t, []ModuleID{"gateway.platform.anthropic", "gateway.platform.openai"},
|
||||
ids(reg.GetModulesInNamespace("gateway.platform")))
|
||||
// 空命名空间返回单段顶层模块。
|
||||
require.Equal(t, []ModuleID{"toplevel"}, ids(reg.GetModulesInNamespace("")))
|
||||
|
||||
// 多次调用结果稳定。
|
||||
require.Equal(t, ids(reg.GetModulesInNamespace("job")), ids(reg.GetModulesInNamespace("job")))
|
||||
}
|
||||
|
||||
func TestRegistryModulesSorted(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
for _, id := range []ModuleID{"c.mod", "a.mod", "b.mod"} {
|
||||
reg.RegisterModule(newStubModule(id))
|
||||
}
|
||||
all := reg.Modules()
|
||||
require.Len(t, all, 3)
|
||||
require.Equal(t, ModuleID("a.mod"), all[0].ID)
|
||||
require.Equal(t, ModuleID("b.mod"), all[1].ID)
|
||||
require.Equal(t, ModuleID("c.mod"), all[2].ID)
|
||||
}
|
||||
|
||||
func TestRegistryIsolation(t *testing.T) {
|
||||
regA := NewRegistry()
|
||||
regB := NewRegistry()
|
||||
regA.RegisterModule(newStubModule("job.only-in-a"))
|
||||
|
||||
_, ok := regA.GetModule("job.only-in-a")
|
||||
require.True(t, ok)
|
||||
_, ok = regB.GetModule("job.only-in-a")
|
||||
require.False(t, ok, "isolated registry must not see other registry's modules")
|
||||
// 隔离实例也不影响包级默认注册表。
|
||||
_, ok = GetModule("job.only-in-a")
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
func TestPackageLevelFunctionsDelegateToDefaultRegistry(t *testing.T) {
|
||||
// 使用唯一 ID,避免与同进程其他测试冲突(包级默认注册表是进程级状态,
|
||||
// 无法清理)。注册仅做一次:`go test -count>1` 同进程重跑时探针已存在,
|
||||
// 直接复用——否则会撞重复注册 panic(终审第二路审计发现的测试幂等性问题)。
|
||||
const id = ModuleID("test_default_registry.probe")
|
||||
if _, registered := GetModule(id); !registered {
|
||||
RegisterModule(newStubModule(id))
|
||||
}
|
||||
|
||||
info, ok := GetModule(id)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, id, info.ID)
|
||||
|
||||
infos := GetModulesInNamespace("test_default_registry")
|
||||
require.Len(t, infos, 1)
|
||||
require.Equal(t, id, infos[0].ID)
|
||||
}
|
||||
|
||||
func TestRegistryConcurrentRegistration(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
const n = 64
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
reg.RegisterModule(newStubModule(ModuleID(fmt.Sprintf("concurrent.mod-%03d", i))))
|
||||
}(i)
|
||||
}
|
||||
// 并发读不应与写竞争(-race 验证)。
|
||||
for i := 0; i < 8; i++ {
|
||||
go func() {
|
||||
_ = reg.GetModulesInNamespace("concurrent")
|
||||
_, _ = reg.GetModule("concurrent.mod-000")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
all := reg.Modules()
|
||||
require.Len(t, all, n)
|
||||
for i := 1; i < len(all); i++ {
|
||||
require.Less(t, all[i-1].ID, all[i].ID, "Modules() must be sorted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// State 是模块在 Runtime 生命周期中的状态。
|
||||
type State string
|
||||
|
||||
// 模块生命周期状态。状态机:
|
||||
//
|
||||
// registered ──Build──▶ provisioned ──Start──▶ running ──Stop──▶ stopped
|
||||
// │(disabled 模块停留在 registered) 任一阶段失败 ──▶ errored
|
||||
const (
|
||||
// StateRegistered 模块已注册但未实例化(disabled 模块始终处于该状态)。
|
||||
StateRegistered State = "registered"
|
||||
// StateProvisioned 模块已实例化并通过 Provision/Validate,等待启动。
|
||||
StateProvisioned State = "provisioned"
|
||||
// StateRunning 模块已启动(未实现 Starter 的 enabled 模块在 Start 阶段也进入该状态)。
|
||||
StateRunning State = "running"
|
||||
// StateStopped 模块已正常停止。
|
||||
StateStopped State = "stopped"
|
||||
// StateErrored 模块在某个生命周期阶段失败,错误见 ModuleStatus.Err。
|
||||
StateErrored State = "errored"
|
||||
)
|
||||
|
||||
// ModuleStatus 是单个模块的状态快照(admin 可观测用)。
|
||||
type ModuleStatus struct {
|
||||
// ID 模块 ID。
|
||||
ID ModuleID
|
||||
// Enabled 模块最终的启停状态(显式配置优先,否则为注册声明的默认值)。
|
||||
Enabled bool
|
||||
// State 模块当前生命周期状态。
|
||||
State State
|
||||
// Err 模块最近一次生命周期失败的错误信息,无错误时为空字符串。
|
||||
Err string
|
||||
}
|
||||
|
||||
// moduleRecord 是 Runtime 内部维护的单模块运行时记录。
|
||||
type moduleRecord struct {
|
||||
info ModuleInfo
|
||||
enabled bool
|
||||
instance Module
|
||||
state State
|
||||
err error
|
||||
}
|
||||
|
||||
// Runtime 是模块生命周期驱动器:按注册表稳定序(ID 字典序)实例化 enabled
|
||||
// 模块并驱动 Provision → Validate → Start → Stop(逆序)生命周期。
|
||||
//
|
||||
// 使用方式(单次使用,不支持 Stop 后重新 Start):
|
||||
//
|
||||
// rt := plugin.NewRuntime(host, cfg)
|
||||
// if err := rt.Build(); err != nil { ... } // 启动中止
|
||||
// if err := rt.Start(ctx); err != nil { ... } // 已自动逆序回滚
|
||||
// defer rt.Stop(shutdownCtx) // 接入既有 cleanup 序列
|
||||
//
|
||||
// 所有方法并发安全。
|
||||
type Runtime struct {
|
||||
mu sync.Mutex
|
||||
host *Host
|
||||
config Config
|
||||
registry *Registry
|
||||
logger *zap.Logger
|
||||
|
||||
built bool
|
||||
buildAttempted bool
|
||||
started bool
|
||||
records []*moduleRecord
|
||||
}
|
||||
|
||||
// NewRuntime 创建基于包级默认注册表的 Runtime。
|
||||
//
|
||||
// host 为 nil 时使用空 Host;若 host.ConfigOf 未装配,则默认绑定为 cfg.Of,
|
||||
// 使模块在 Provision 中总能读取到自己的私有配置。
|
||||
func NewRuntime(host *Host, cfg Config) *Runtime {
|
||||
return NewRuntimeWithRegistry(host, cfg, defaultRegistry)
|
||||
}
|
||||
|
||||
// NewRuntimeWithRegistry 创建基于指定注册表的 Runtime(测试隔离用)。
|
||||
// 其余语义与 NewRuntime 一致。
|
||||
func NewRuntimeWithRegistry(host *Host, cfg Config, registry *Registry) *Runtime {
|
||||
if host == nil {
|
||||
host = &Host{}
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = Config{}
|
||||
}
|
||||
if registry == nil {
|
||||
registry = defaultRegistry
|
||||
}
|
||||
if host.ConfigOf == nil {
|
||||
host.ConfigOf = cfg.Of
|
||||
}
|
||||
logger := host.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &Runtime{
|
||||
host: host,
|
||||
config: cfg,
|
||||
registry: registry,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Build 按注册表稳定序实例化全部 enabled 模块并依次执行 Provision、Validate。
|
||||
//
|
||||
// 任一模块失败立即返回包含模块 ID 的错误(启动中止),不会继续处理后续模块;
|
||||
// 已成功 Provision 的模块保持 provisioned 状态,由进程启动失败路径整体退出。
|
||||
// Build 只能调用一次:无论成败,第二次调用一律拒绝——失败的 Build 不可重试
|
||||
// (重试会对已 Provision 的旧实例不 Stop 即覆盖,造成资源泄漏),应整体重启进程。
|
||||
func (r *Runtime) Build() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.buildAttempted {
|
||||
if r.built {
|
||||
return errors.New("plugin: runtime already built")
|
||||
}
|
||||
return errors.New("plugin: previous build failed; runtime cannot be rebuilt, restart the process")
|
||||
}
|
||||
r.buildAttempted = true
|
||||
|
||||
// 先为全部已注册模块建立记录(含 disabled),保证失败后 Snapshot 仍是完整视图。
|
||||
infos := r.registry.Modules() // 已按 ID 字典序稳定排序
|
||||
r.records = make([]*moduleRecord, 0, len(infos))
|
||||
for _, info := range infos {
|
||||
r.records = append(r.records, &moduleRecord{
|
||||
info: info,
|
||||
enabled: r.config.enabledFor(info),
|
||||
state: StateRegistered,
|
||||
})
|
||||
}
|
||||
|
||||
for _, rec := range r.records {
|
||||
if !rec.enabled {
|
||||
continue
|
||||
}
|
||||
info := rec.info
|
||||
rec.instance = info.New()
|
||||
if rec.instance == nil {
|
||||
rec.err = errors.New("module New func returned nil")
|
||||
rec.state = StateErrored
|
||||
return fmt.Errorf("plugin: build module %q: New returned nil instance", info.ID)
|
||||
}
|
||||
if p, ok := rec.instance.(Provisioner); ok {
|
||||
if err := p.Provision(r.host); err != nil {
|
||||
rec.err = err
|
||||
rec.state = StateErrored
|
||||
return fmt.Errorf("plugin: provision module %q: %w", info.ID, err)
|
||||
}
|
||||
}
|
||||
if v, ok := rec.instance.(Validator); ok {
|
||||
if err := v.Validate(); err != nil {
|
||||
rec.err = err
|
||||
rec.state = StateErrored
|
||||
return fmt.Errorf("plugin: validate module %q: %w", info.ID, err)
|
||||
}
|
||||
}
|
||||
rec.state = StateProvisioned
|
||||
r.logger.Debug("plugin module provisioned", zap.String("module", string(info.ID)))
|
||||
}
|
||||
r.built = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start 按注册表稳定序启动全部 enabled 模块(对实现 Starter 的模块调用 Start,
|
||||
// 未实现 Starter 的模块直接视为 running)。
|
||||
//
|
||||
// 任一模块启动失败时:对已进入 running 的模块按逆序执行 Stop 回滚
|
||||
// (回滚沿用传入的 ctx)。返回的错误包含失败模块 ID;若回滚中某些模块的
|
||||
// Stop 也失败,这些错误会一并 errors.Join 进返回值(同时记日志),
|
||||
// 让调用方能看到哪些模块的资源未释放干净。
|
||||
func (r *Runtime) Start(ctx context.Context) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.built {
|
||||
return errors.New("plugin: runtime must be built before start")
|
||||
}
|
||||
if r.started {
|
||||
return errors.New("plugin: runtime already started")
|
||||
}
|
||||
|
||||
startedRecs := make([]*moduleRecord, 0, len(r.records))
|
||||
for _, rec := range r.records {
|
||||
if !rec.enabled || rec.state != StateProvisioned {
|
||||
continue
|
||||
}
|
||||
if s, ok := rec.instance.(Starter); ok {
|
||||
if err := s.Start(ctx); err != nil {
|
||||
rec.err = err
|
||||
rec.state = StateErrored
|
||||
// 逆序回滚已启动模块;回滚失败并入返回错误(见方法注释)。
|
||||
rollbackErrs := r.stopRecordsLocked(ctx, startedRecs)
|
||||
startErr := fmt.Errorf("plugin: start module %q: %w", rec.info.ID, err)
|
||||
if len(rollbackErrs) > 0 {
|
||||
return errors.Join(append([]error{startErr}, rollbackErrs...)...)
|
||||
}
|
||||
return startErr
|
||||
}
|
||||
}
|
||||
rec.state = StateRunning
|
||||
startedRecs = append(startedRecs, rec)
|
||||
r.logger.Info("plugin module started", zap.String("module", string(rec.info.ID)))
|
||||
}
|
||||
r.started = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop 按注册表稳定序的逆序停止全部 running 模块。
|
||||
//
|
||||
// 单个模块 Stop 失败只记日志并继续关闭其余模块(与既有 cleanup 序列的容错
|
||||
// 纪律一致),最终返回 errors.Join 聚合的失败集合(全部成功时为 nil)。
|
||||
// ctx 原样传递给各模块 Stop,模块必须自行尊重其 deadline。
|
||||
func (r *Runtime) Stop(ctx context.Context) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
running := make([]*moduleRecord, 0, len(r.records))
|
||||
for _, rec := range r.records {
|
||||
if rec.state == StateRunning {
|
||||
running = append(running, rec)
|
||||
}
|
||||
}
|
||||
errs := r.stopRecordsLocked(ctx, running)
|
||||
r.started = false
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// stopRecordsLocked 逆序停止 recs 中的模块(recs 为正向顺序),返回失败集合。
|
||||
// 调用方必须已持有 r.mu。
|
||||
func (r *Runtime) stopRecordsLocked(ctx context.Context, recs []*moduleRecord) []error {
|
||||
var errs []error
|
||||
for i := len(recs) - 1; i >= 0; i-- {
|
||||
rec := recs[i]
|
||||
if s, ok := rec.instance.(Stopper); ok {
|
||||
if err := s.Stop(ctx); err != nil {
|
||||
rec.err = err
|
||||
rec.state = StateErrored
|
||||
r.logger.Error("plugin module stop failed",
|
||||
zap.String("module", string(rec.info.ID)), zap.Error(err))
|
||||
errs = append(errs, fmt.Errorf("plugin: stop module %q: %w", rec.info.ID, err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
rec.state = StateStopped
|
||||
r.logger.Info("plugin module stopped", zap.String("module", string(rec.info.ID)))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// Snapshot 返回全部已注册模块的状态快照,按 ID 字典序稳定排序。
|
||||
//
|
||||
// Build 之前调用时,基于注册表与配置即时计算(状态均为 registered);
|
||||
// Build 之后返回 Runtime 维护的实际生命周期状态。
|
||||
func (r *Runtime) Snapshot() []ModuleStatus {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.records == nil {
|
||||
infos := r.registry.Modules()
|
||||
statuses := make([]ModuleStatus, 0, len(infos))
|
||||
for _, info := range infos {
|
||||
statuses = append(statuses, ModuleStatus{
|
||||
ID: info.ID,
|
||||
Enabled: r.config.enabledFor(info),
|
||||
State: StateRegistered,
|
||||
})
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
|
||||
statuses := make([]ModuleStatus, 0, len(r.records))
|
||||
for _, rec := range r.records {
|
||||
status := ModuleStatus{
|
||||
ID: rec.info.ID,
|
||||
Enabled: rec.enabled,
|
||||
State: rec.state,
|
||||
}
|
||||
if rec.err != nil {
|
||||
status.Err = rec.err.Error()
|
||||
}
|
||||
statuses = append(statuses, status)
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// lifecycleLog 记录全部 fake 模块的生命周期调用序列("模块ID:阶段")。
|
||||
type lifecycleLog struct {
|
||||
mu sync.Mutex
|
||||
events []string
|
||||
}
|
||||
|
||||
func (l *lifecycleLog) add(event string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.events = append(l.events, event)
|
||||
}
|
||||
|
||||
func (l *lifecycleLog) snapshot() []string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return append([]string(nil), l.events...)
|
||||
}
|
||||
|
||||
// fakeModule 是实现全部可选生命周期接口的测试模块,按配置注入各阶段错误。
|
||||
type fakeModule struct {
|
||||
id ModuleID
|
||||
enabledByDefault bool
|
||||
log *lifecycleLog
|
||||
|
||||
provisionErr error
|
||||
validateErr error
|
||||
startErr error
|
||||
stopErr error
|
||||
stopFn func(ctx context.Context) error // 非 nil 时覆盖默认 Stop 行为
|
||||
|
||||
host *Host // Provision 时记录,供断言
|
||||
}
|
||||
|
||||
func (m *fakeModule) ModuleInfo() ModuleInfo {
|
||||
return ModuleInfo{
|
||||
ID: m.id,
|
||||
EnabledByDefault: m.enabledByDefault,
|
||||
New: func() Module { return m },
|
||||
}
|
||||
}
|
||||
|
||||
func (m *fakeModule) Provision(host *Host) error {
|
||||
m.host = host
|
||||
m.log.add(string(m.id) + ":provision")
|
||||
return m.provisionErr
|
||||
}
|
||||
|
||||
func (m *fakeModule) Validate() error {
|
||||
m.log.add(string(m.id) + ":validate")
|
||||
return m.validateErr
|
||||
}
|
||||
|
||||
func (m *fakeModule) Start(ctx context.Context) error {
|
||||
m.log.add(string(m.id) + ":start")
|
||||
return m.startErr
|
||||
}
|
||||
|
||||
func (m *fakeModule) Stop(ctx context.Context) error {
|
||||
m.log.add(string(m.id) + ":stop")
|
||||
if m.stopFn != nil {
|
||||
return m.stopFn(ctx)
|
||||
}
|
||||
return m.stopErr
|
||||
}
|
||||
|
||||
// plainModule 只实现 Module(无任何生命周期接口)。
|
||||
type plainModule struct {
|
||||
id ModuleID
|
||||
}
|
||||
|
||||
func (m *plainModule) ModuleInfo() ModuleInfo {
|
||||
return ModuleInfo{
|
||||
ID: m.id,
|
||||
EnabledByDefault: true,
|
||||
New: func() Module { return m },
|
||||
}
|
||||
}
|
||||
|
||||
// newTestRuntime 构造隔离注册表 + Runtime。
|
||||
func newTestRuntime(t *testing.T, cfg Config, mods ...Module) *Runtime {
|
||||
t.Helper()
|
||||
reg := NewRegistry()
|
||||
for _, m := range mods {
|
||||
reg.RegisterModule(m)
|
||||
}
|
||||
return NewRuntimeWithRegistry(&Host{}, cfg, reg)
|
||||
}
|
||||
|
||||
func statusByID(t *testing.T, statuses []ModuleStatus, id ModuleID) ModuleStatus {
|
||||
t.Helper()
|
||||
for _, s := range statuses {
|
||||
if s.ID == id {
|
||||
return s
|
||||
}
|
||||
}
|
||||
t.Fatalf("module %q not found in snapshot", id)
|
||||
return ModuleStatus{}
|
||||
}
|
||||
|
||||
func TestRuntimeBuildLifecycleOrderAndEnabledTriState(t *testing.T) {
|
||||
log := &lifecycleLog{}
|
||||
on, off := true, false
|
||||
cfg := Config{
|
||||
"job.c-default-off-enabled": {Enabled: &on},
|
||||
"job.d-default-on-disabled": {Enabled: &off},
|
||||
}
|
||||
rt := newTestRuntime(t, cfg,
|
||||
// 故意乱序注册,验证 Build 按 ID 字典序执行。
|
||||
&fakeModule{id: "job.b-default-on", enabledByDefault: true, log: log},
|
||||
&fakeModule{id: "job.a-default-on", enabledByDefault: true, log: log},
|
||||
&fakeModule{id: "job.c-default-off-enabled", enabledByDefault: false, log: log},
|
||||
&fakeModule{id: "job.d-default-on-disabled", enabledByDefault: true, log: log},
|
||||
&fakeModule{id: "job.e-default-off", enabledByDefault: false, log: log},
|
||||
)
|
||||
|
||||
require.NoError(t, rt.Build())
|
||||
|
||||
// enabled 三态:默认 on(a、b)、显式 enable 默认 off 的 c、显式 disable 默认 on 的 d、默认 off 的 e。
|
||||
require.Equal(t, []string{
|
||||
"job.a-default-on:provision", "job.a-default-on:validate",
|
||||
"job.b-default-on:provision", "job.b-default-on:validate",
|
||||
"job.c-default-off-enabled:provision", "job.c-default-off-enabled:validate",
|
||||
}, log.snapshot())
|
||||
|
||||
statuses := rt.Snapshot()
|
||||
require.Equal(t, StateProvisioned, statusByID(t, statuses, "job.a-default-on").State)
|
||||
require.Equal(t, StateProvisioned, statusByID(t, statuses, "job.c-default-off-enabled").State)
|
||||
require.False(t, statusByID(t, statuses, "job.d-default-on-disabled").Enabled)
|
||||
require.Equal(t, StateRegistered, statusByID(t, statuses, "job.d-default-on-disabled").State)
|
||||
require.False(t, statusByID(t, statuses, "job.e-default-off").Enabled)
|
||||
require.Equal(t, StateRegistered, statusByID(t, statuses, "job.e-default-off").State)
|
||||
}
|
||||
|
||||
func TestRuntimeBuildProvisionFailureAborts(t *testing.T) {
|
||||
log := &lifecycleLog{}
|
||||
provisionErr := errors.New("provision boom")
|
||||
rt := newTestRuntime(t, Config{},
|
||||
&fakeModule{id: "job.a", enabledByDefault: true, log: log},
|
||||
&fakeModule{id: "job.b", enabledByDefault: true, log: log, provisionErr: provisionErr},
|
||||
&fakeModule{id: "job.c", enabledByDefault: true, log: log},
|
||||
)
|
||||
|
||||
err := rt.Build()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "job.b", "Build 错误必须包含失败模块 ID")
|
||||
require.ErrorIs(t, err, provisionErr)
|
||||
|
||||
// 失败即中止:job.c 不会被 Provision;job.b 的 Validate 不会执行。
|
||||
require.Equal(t, []string{
|
||||
"job.a:provision", "job.a:validate",
|
||||
"job.b:provision",
|
||||
}, log.snapshot())
|
||||
|
||||
statuses := rt.Snapshot()
|
||||
require.Equal(t, StateProvisioned, statusByID(t, statuses, "job.a").State)
|
||||
bStatus := statusByID(t, statuses, "job.b")
|
||||
require.Equal(t, StateErrored, bStatus.State)
|
||||
require.Contains(t, bStatus.Err, "provision boom")
|
||||
require.Equal(t, StateRegistered, statusByID(t, statuses, "job.c").State)
|
||||
|
||||
// Build 失败后 Start 必须被拒绝。
|
||||
require.Error(t, rt.Start(context.Background()))
|
||||
}
|
||||
|
||||
func TestRuntimeBuildValidateFailureAborts(t *testing.T) {
|
||||
log := &lifecycleLog{}
|
||||
rt := newTestRuntime(t, Config{},
|
||||
&fakeModule{id: "job.a", enabledByDefault: true, log: log, validateErr: errors.New("validate boom")},
|
||||
&fakeModule{id: "job.b", enabledByDefault: true, log: log},
|
||||
)
|
||||
|
||||
err := rt.Build()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "job.a")
|
||||
require.Equal(t, []string{"job.a:provision", "job.a:validate"}, log.snapshot())
|
||||
require.Equal(t, StateErrored, statusByID(t, rt.Snapshot(), "job.a").State)
|
||||
}
|
||||
|
||||
func TestRuntimeStartOrderAndStopReverseOrder(t *testing.T) {
|
||||
log := &lifecycleLog{}
|
||||
rt := newTestRuntime(t, Config{},
|
||||
&fakeModule{id: "job.b", enabledByDefault: true, log: log},
|
||||
&fakeModule{id: "job.a", enabledByDefault: true, log: log},
|
||||
&fakeModule{id: "job.c", enabledByDefault: true, log: log},
|
||||
)
|
||||
require.NoError(t, rt.Build())
|
||||
require.NoError(t, rt.Start(context.Background()))
|
||||
|
||||
for _, s := range rt.Snapshot() {
|
||||
require.Equal(t, StateRunning, s.State)
|
||||
}
|
||||
|
||||
require.NoError(t, rt.Stop(context.Background()))
|
||||
|
||||
require.Equal(t, []string{
|
||||
"job.a:provision", "job.a:validate",
|
||||
"job.b:provision", "job.b:validate",
|
||||
"job.c:provision", "job.c:validate",
|
||||
"job.a:start", "job.b:start", "job.c:start",
|
||||
"job.c:stop", "job.b:stop", "job.a:stop", // 逆序关闭
|
||||
}, log.snapshot())
|
||||
|
||||
for _, s := range rt.Snapshot() {
|
||||
require.Equal(t, StateStopped, s.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStartHalfwayFailureRollsBackInReverse(t *testing.T) {
|
||||
log := &lifecycleLog{}
|
||||
startErr := errors.New("start boom")
|
||||
rt := newTestRuntime(t, Config{},
|
||||
&fakeModule{id: "job.a", enabledByDefault: true, log: log},
|
||||
&fakeModule{id: "job.b", enabledByDefault: true, log: log},
|
||||
&fakeModule{id: "job.c", enabledByDefault: true, log: log, startErr: startErr},
|
||||
&fakeModule{id: "job.d", enabledByDefault: true, log: log},
|
||||
)
|
||||
require.NoError(t, rt.Build())
|
||||
|
||||
err := rt.Start(context.Background())
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "job.c", "Start 错误必须包含失败模块 ID")
|
||||
require.ErrorIs(t, err, startErr)
|
||||
|
||||
// a、b 已启动;c 启动失败;回滚必须按 b → a 逆序 Stop;d 不得被 Start。
|
||||
require.Equal(t, []string{
|
||||
"job.a:provision", "job.a:validate",
|
||||
"job.b:provision", "job.b:validate",
|
||||
"job.c:provision", "job.c:validate",
|
||||
"job.d:provision", "job.d:validate",
|
||||
"job.a:start", "job.b:start", "job.c:start",
|
||||
"job.b:stop", "job.a:stop",
|
||||
}, log.snapshot())
|
||||
|
||||
statuses := rt.Snapshot()
|
||||
require.Equal(t, StateStopped, statusByID(t, statuses, "job.a").State)
|
||||
require.Equal(t, StateStopped, statusByID(t, statuses, "job.b").State)
|
||||
cStatus := statusByID(t, statuses, "job.c")
|
||||
require.Equal(t, StateErrored, cStatus.State)
|
||||
require.Contains(t, cStatus.Err, "start boom")
|
||||
require.Equal(t, StateProvisioned, statusByID(t, statuses, "job.d").State)
|
||||
}
|
||||
|
||||
func TestRuntimeStopToleratesSingleFailure(t *testing.T) {
|
||||
log := &lifecycleLog{}
|
||||
stopErr := errors.New("stop boom")
|
||||
rt := newTestRuntime(t, Config{},
|
||||
&fakeModule{id: "job.a", enabledByDefault: true, log: log},
|
||||
&fakeModule{id: "job.b", enabledByDefault: true, log: log, stopErr: stopErr},
|
||||
&fakeModule{id: "job.c", enabledByDefault: true, log: log},
|
||||
)
|
||||
require.NoError(t, rt.Build())
|
||||
require.NoError(t, rt.Start(context.Background()))
|
||||
|
||||
err := rt.Stop(context.Background())
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, stopErr)
|
||||
require.Contains(t, err.Error(), "job.b")
|
||||
|
||||
// 单个失败不中断其余模块关闭:c → b(失败)→ a 全部被调用。
|
||||
stops := log.snapshot()[9:]
|
||||
require.Equal(t, []string{"job.c:stop", "job.b:stop", "job.a:stop"}, stops)
|
||||
|
||||
statuses := rt.Snapshot()
|
||||
require.Equal(t, StateStopped, statusByID(t, statuses, "job.a").State)
|
||||
require.Equal(t, StateErrored, statusByID(t, statuses, "job.b").State)
|
||||
require.Contains(t, statusByID(t, statuses, "job.b").Err, "stop boom")
|
||||
require.Equal(t, StateStopped, statusByID(t, statuses, "job.c").State)
|
||||
}
|
||||
|
||||
func TestRuntimeStopRespectsContextDeadline(t *testing.T) {
|
||||
log := &lifecycleLog{}
|
||||
blocking := &fakeModule{
|
||||
id: "job.blocking", enabledByDefault: true, log: log,
|
||||
stopFn: func(ctx context.Context) error {
|
||||
<-ctx.Done() // 模拟尊重 ctx deadline 的慢关闭
|
||||
return ctx.Err()
|
||||
},
|
||||
}
|
||||
rt := newTestRuntime(t, Config{}, blocking,
|
||||
&fakeModule{id: "job.a", enabledByDefault: true, log: log},
|
||||
)
|
||||
require.NoError(t, rt.Build())
|
||||
require.NoError(t, rt.Start(context.Background()))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
begin := time.Now()
|
||||
err := rt.Stop(ctx)
|
||||
require.Less(t, time.Since(begin), 2*time.Second, "Stop 必须随 ctx deadline 返回,不得无限阻塞")
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
|
||||
// 即使 blocking 超时失败,job.a 仍被关闭(容错继续)。
|
||||
require.Equal(t, StateStopped, statusByID(t, rt.Snapshot(), "job.a").State)
|
||||
}
|
||||
|
||||
func TestRuntimeModuleWithoutLifecycleInterfaces(t *testing.T) {
|
||||
rt := newTestRuntime(t, Config{}, &plainModule{id: "job.plain"})
|
||||
require.NoError(t, rt.Build())
|
||||
require.NoError(t, rt.Start(context.Background()))
|
||||
require.Equal(t, StateRunning, statusByID(t, rt.Snapshot(), "job.plain").State,
|
||||
"未实现 Starter 的 enabled 模块在 Start 后视为 running")
|
||||
require.NoError(t, rt.Stop(context.Background()))
|
||||
require.Equal(t, StateStopped, statusByID(t, rt.Snapshot(), "job.plain").State)
|
||||
}
|
||||
|
||||
func TestRuntimeGuards(t *testing.T) {
|
||||
rt := newTestRuntime(t, Config{})
|
||||
require.Error(t, rt.Start(context.Background()), "未 Build 不得 Start")
|
||||
require.NoError(t, rt.Build())
|
||||
require.Error(t, rt.Build(), "Build 只能调用一次")
|
||||
require.NoError(t, rt.Start(context.Background()))
|
||||
require.Error(t, rt.Start(context.Background()), "Start 只能调用一次")
|
||||
}
|
||||
|
||||
func TestRuntimeSnapshotBeforeBuild(t *testing.T) {
|
||||
log := &lifecycleLog{}
|
||||
off := false
|
||||
rt := newTestRuntime(t, Config{"job.b": {Enabled: &off}},
|
||||
&fakeModule{id: "job.b", enabledByDefault: true, log: log},
|
||||
&fakeModule{id: "job.a", enabledByDefault: true, log: log},
|
||||
)
|
||||
|
||||
statuses := rt.Snapshot()
|
||||
require.Len(t, statuses, 2)
|
||||
require.Equal(t, ModuleID("job.a"), statuses[0].ID, "Snapshot 按 ID 字典序")
|
||||
require.Equal(t, ModuleID("job.b"), statuses[1].ID)
|
||||
for _, s := range statuses {
|
||||
require.Equal(t, StateRegistered, s.State)
|
||||
require.Empty(t, s.Err)
|
||||
}
|
||||
require.True(t, statuses[0].Enabled)
|
||||
require.False(t, statuses[1].Enabled, "Build 前 Snapshot 也要正确解析 enabled 三态")
|
||||
}
|
||||
|
||||
func TestRuntimeHostConfigOfDefaultBinding(t *testing.T) {
|
||||
log := &lifecycleLog{}
|
||||
cfg, err := ParseConfig(map[string]map[string]any{
|
||||
"job.hello": {"enabled": true, "greeting": "from-config"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
mod := &fakeModule{id: "job.hello", log: log}
|
||||
rt := newTestRuntime(t, cfg, mod)
|
||||
require.NoError(t, rt.Build())
|
||||
|
||||
// NewRuntime 未显式装配 ConfigOf 时默认绑定到自身 Config.Of。
|
||||
require.NotNil(t, mod.host)
|
||||
require.NotNil(t, mod.host.ConfigOf)
|
||||
var out helloModuleConfig
|
||||
require.NoError(t, mod.host.ConfigOf("job.hello", &out))
|
||||
require.Equal(t, "from-config", out.Greeting)
|
||||
}
|
||||
|
||||
// 审计 B2 修复的回归测试:Start 半途失败触发回滚时,若已启动模块的回滚 Stop
|
||||
// 也失败,该失败必须并入 Start 的返回错误(而不是只记日志被吞掉)。
|
||||
func TestRuntimeStartRollbackStopFailureJoinedIntoError(t *testing.T) {
|
||||
log := &lifecycleLog{}
|
||||
stopBoom := errors.New("stop-a-boom")
|
||||
rt := newTestRuntime(t, Config{},
|
||||
&fakeModule{id: "job.a", enabledByDefault: true, log: log, stopErr: stopBoom},
|
||||
&fakeModule{id: "job.b", enabledByDefault: true, log: log, startErr: errors.New("start-b-boom")},
|
||||
)
|
||||
require.NoError(t, rt.Build())
|
||||
|
||||
err := rt.Start(context.Background())
|
||||
require.Error(t, err)
|
||||
// 启动失败与回滚失败都必须在返回错误中可见(errors.Join 可穿透)。
|
||||
require.ErrorIs(t, err, stopBoom)
|
||||
require.Contains(t, err.Error(), `start module "job.b"`)
|
||||
require.Contains(t, err.Error(), `stop module "job.a"`)
|
||||
|
||||
statuses := rt.Snapshot()
|
||||
require.Equal(t, StateErrored, statusByID(t, statuses, "job.a").State, "回滚失败的模块应为 errored")
|
||||
require.Equal(t, StateErrored, statusByID(t, statuses, "job.b").State)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"github.com/google/wire"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
ent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
)
|
||||
|
||||
// ProviderSet 提供插件内核的依赖:modules: 配置子树、宿主能力面 Host
|
||||
// 与模块生命周期驱动器 Runtime。
|
||||
//
|
||||
// 边界约定(见 Phase-1 计划):Wire 只负责装配 Host 与 Runtime,
|
||||
// 模块目录由注册表(插装清单 internal/modules/standard)负责,
|
||||
// 模块内部依赖一律经 Host 获取,不进入 Wire graph。
|
||||
var ProviderSet = wire.NewSet(
|
||||
ProvideModuleConfig,
|
||||
ProvideHost,
|
||||
ProvideModuleRuntime,
|
||||
)
|
||||
|
||||
// ProvideModuleConfig 解析全局配置中 `modules:` 子树为插件模块配置。
|
||||
//
|
||||
// 解析失败(模块 ID 格式非法、enabled 类型错误)返回错误,
|
||||
// 使应用启动 fail-fast,配置笔误在启动时即暴露。
|
||||
func ProvideModuleConfig(cfg *config.Config) (Config, error) {
|
||||
return ParseConfig(cfg.Modules)
|
||||
}
|
||||
|
||||
// ProvideHost 装配模块宿主能力面:聚合全局结构化日志器、模块私有配置
|
||||
// 解码能力(ConfigOf 绑定为 modules: 配置子树的 Config.Of)、
|
||||
// Ent 客户端与 Redis 客户端。
|
||||
//
|
||||
// 日志器复用项目全局 logger(main 在依赖装配前已完成 logger.Init)。
|
||||
func ProvideHost(moduleCfg Config, entClient *ent.Client, rdb *redis.Client) *Host {
|
||||
return &Host{
|
||||
Logger: logger.L(),
|
||||
ConfigOf: moduleCfg.Of,
|
||||
DB: entClient,
|
||||
Redis: rdb,
|
||||
}
|
||||
}
|
||||
|
||||
// ProvideModuleRuntime 创建基于包级默认注册表的模块生命周期驱动器。
|
||||
//
|
||||
// 生命周期驱动由 main 负责:HTTP server 启动前 Build + Start,
|
||||
// 关闭时 Stop 接入 cleanup 序列(先于 Redis/Ent 等基础设施关闭)。
|
||||
func ProvideModuleRuntime(host *Host, moduleCfg Config) *Runtime {
|
||||
return NewRuntime(host, moduleCfg)
|
||||
}
|
||||
@@ -103,9 +103,17 @@ func RegisterAdminRoutes(
|
||||
|
||||
// 邀请返利(专属用户管理)
|
||||
registerAffiliateRoutes(admin, h)
|
||||
|
||||
// 插件模块可观测
|
||||
registerModuleRoutes(admin, h)
|
||||
}
|
||||
}
|
||||
|
||||
// registerModuleRoutes 注册插件模块可观测的管理端路由(只读)
|
||||
func registerModuleRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
admin.GET("/modules", h.Admin.Module.List)
|
||||
}
|
||||
|
||||
func registerAdminComplianceRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
compliance := admin.Group("/compliance")
|
||||
{
|
||||
|
||||
@@ -17,13 +17,9 @@ import (
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
||||
"github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/httputil"
|
||||
)
|
||||
@@ -1764,29 +1760,7 @@ func (s *adminServiceImpl) GetGroupModelsListCandidates(ctx context.Context, id
|
||||
}
|
||||
|
||||
func defaultModelsListCandidateIDs(platform string) []string {
|
||||
switch platform {
|
||||
case PlatformOpenAI:
|
||||
return openai.DefaultModelIDs()
|
||||
case PlatformGemini:
|
||||
ids := make([]string, 0, len(geminicli.DefaultModels))
|
||||
for _, model := range geminicli.DefaultModels {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return ids
|
||||
case PlatformAntigravity:
|
||||
models := antigravity.DefaultModels()
|
||||
ids := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return ids
|
||||
default:
|
||||
ids := make([]string, 0, len(claude.DefaultModels))
|
||||
for _, model := range claude.DefaultModels {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
return DefaultModelIDsForPlatform(platform)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//go:build unit
|
||||
|
||||
// Phase-3 TASK-001 前置特征化测试(T2 补充):ForwardGemini 的 action 参数契约。
|
||||
//
|
||||
// handler :444 调用点(以及未来 gemini adapter)以硬编码 "generateContent"
|
||||
// 传入 action;本文件锁定该参数的全部行为面,使 adapter 改写 action 字面量
|
||||
// 时测试必红:
|
||||
// - "generateContent" / "streamGenerateContent" 被接受,且上游 action 恒为
|
||||
// streamGenerateContent(客户端流式与否由独立的 stream 参数决定);
|
||||
// - "countTokens" 短路返回 {"totalTokens":0},不触上游;
|
||||
// - 其他 action → 404 "Unsupported action",不触上游。
|
||||
//
|
||||
// 夹具复用本包既有 p3 系列/passChar 系列(antigravitySettingRepoStub、
|
||||
// queuedHTTPUpstreamStub、passCharAntigravityService/Account)。
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// p3CharGeminiNativeBody 返回 Gemini 原生请求体。
|
||||
func p3CharGeminiNativeBody(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
return []byte(`{"contents":[{"role":"user","parts":[{"text":"action contract probe"}]}]}`)
|
||||
}
|
||||
|
||||
// p3CharGeminiSSESuccess 返回 v1internal 包裹的上游流式成功响应。
|
||||
func p3CharGeminiSSESuccess() *http.Response {
|
||||
sse := "data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":4,\"candidatesTokenCount\":2}}}\n\n"
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(sse))),
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_ForwardGeminiActionContract 锁定 action 参数契约。
|
||||
func TestGatewayCharacterization_ForwardGeminiActionContract(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
newCtx := func(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", bytes.NewReader(p3CharGeminiNativeBody(t)))
|
||||
return c, rec
|
||||
}
|
||||
|
||||
t.Run("generateContent被接受_上游action恒为streamGenerateContent", func(t *testing.T) {
|
||||
upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{p3CharGeminiSSESuccess()}}
|
||||
svc := passCharAntigravityService(upstream)
|
||||
c, _ := newCtx(t)
|
||||
account := passCharAntigravityAccount(9701, map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"})
|
||||
|
||||
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "generateContent", false, p3CharGeminiNativeBody(t), false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, 1, upstream.callCount)
|
||||
})
|
||||
|
||||
t.Run("streamGenerateContent同样被接受_与generateContent上游行为一致", func(t *testing.T) {
|
||||
upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{p3CharGeminiSSESuccess()}}
|
||||
svc := passCharAntigravityService(upstream)
|
||||
c, _ := newCtx(t)
|
||||
account := passCharAntigravityAccount(9702, map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"})
|
||||
|
||||
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "streamGenerateContent", false, p3CharGeminiNativeBody(t), false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, 1, upstream.callCount)
|
||||
})
|
||||
|
||||
t.Run("countTokens短路返回零值不触上游", func(t *testing.T) {
|
||||
upstream := &queuedHTTPUpstreamStub{} // 任何上游调用都会失败
|
||||
svc := passCharAntigravityService(upstream)
|
||||
c, rec := newCtx(t)
|
||||
account := passCharAntigravityAccount(9703, map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"})
|
||||
|
||||
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "countTokens", false, p3CharGeminiNativeBody(t), false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, 0, upstream.callCount, "countTokens 不触上游")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.JSONEq(t, `{"totalTokens":0}`, rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("未知action返回404不触上游", func(t *testing.T) {
|
||||
upstream := &queuedHTTPUpstreamStub{}
|
||||
svc := passCharAntigravityService(upstream)
|
||||
c, rec := newCtx(t)
|
||||
account := passCharAntigravityAccount(9704, map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"})
|
||||
|
||||
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "bogusAction", false, p3CharGeminiNativeBody(t), false)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
require.Equal(t, 0, upstream.callCount)
|
||||
require.Equal(t, http.StatusNotFound, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "Unsupported action")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//go:build unit
|
||||
|
||||
// Phase-0 TASK-002 特征化测试:gemini/antigravity 路径流式/非流式透传(INVARIANTS I-1.6)。
|
||||
// ForwardGemini 的外部可观测语义:
|
||||
// - 流式:上游 v1internal SSE(data: {"response":{...}})被解包后逐事件转发给客户端;
|
||||
// - 非流式:上游流式响应被收集合并为单个 JSON(文本片段拼接)后返回;
|
||||
// - 非 failover 上游错误(如 404):解包后的错误体 + 上游状态码原样返回。
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// passCharAntigravityService 构造最小可运行的 AntigravityGatewayService。
|
||||
func passCharAntigravityService(upstream HTTPUpstream) *AntigravityGatewayService {
|
||||
return &AntigravityGatewayService{
|
||||
settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
|
||||
tokenProvider: &AntigravityTokenProvider{},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
}
|
||||
|
||||
// passCharAntigravityAccount 返回带模型映射的 antigravity OAuth 账号夹具。
|
||||
func passCharAntigravityAccount(id int64, mapping map[string]any) *Account {
|
||||
return &Account{
|
||||
ID: id,
|
||||
Name: "pass-char-antigravity",
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Concurrency: 1,
|
||||
Schedulable: true,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "ag-token",
|
||||
"model_mapping": mapping,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func passCharGeminiBody(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"contents": []map[string]any{
|
||||
{"role": "user", "parts": []map[string]any{{"text": "hello"}}},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return body
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_GeminiStreamUnwrapsV1Internal 固化 I-1.6 流式侧:
|
||||
// 上游 data 行的 v1internal 包裹({"response":{...}})被解包,客户端按事件收到内层 JSON 原文。
|
||||
func TestGatewayCharacterization_GeminiStreamUnwrapsV1Internal(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
innerChunk1 := `{"candidates":[{"content":{"role":"model","parts":[{"text":"Hel"}]}}]}`
|
||||
innerChunk2 := `{"candidates":[{"content":{"role":"model","parts":[{"text":"lo ✓"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":3,"cachedContentTokenCount":2}}`
|
||||
upstreamSSE := "data: {\"response\":" + innerChunk1 + "}\n\n" +
|
||||
"data: {\"response\":" + innerChunk2 + "}\n\n"
|
||||
|
||||
upstream := &httpUpstreamStub{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
"X-Request-Id": []string{"rid-char-gemini-stream"},
|
||||
},
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(upstreamSSE))),
|
||||
}}
|
||||
svc := passCharAntigravityService(upstream)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:streamGenerateContent", bytes.NewReader(passCharGeminiBody(t)))
|
||||
|
||||
account := passCharAntigravityAccount(9201, map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"})
|
||||
|
||||
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "streamGenerateContent", true, passCharGeminiBody(t), false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, result.Stream)
|
||||
|
||||
wantEvents := []string{
|
||||
"data: " + innerChunk1,
|
||||
"data: " + innerChunk2,
|
||||
}
|
||||
require.Equal(t, wantEvents, passCharSplitSSEEvents(rec.Body.String()),
|
||||
"客户端应按事件收到解包后的内层 JSON 原文")
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "text/event-stream", rec.Header().Get("Content-Type"))
|
||||
require.Equal(t, "rid-char-gemini-stream", rec.Header().Get("x-request-id"))
|
||||
|
||||
require.Equal(t, "gemini-2.5-flash", result.Model)
|
||||
require.Equal(t, "gemini-3-pro-high", result.UpstreamModel, "计费模型使用映射后的模型")
|
||||
require.Equal(t, 6, result.Usage.InputTokens, "input = promptTokenCount - cachedContentTokenCount")
|
||||
require.Equal(t, 3, result.Usage.OutputTokens)
|
||||
require.Equal(t, 2, result.Usage.CacheReadInputTokens)
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_GeminiNonStreamCollectsStream 固化 I-1.6 非流式侧:
|
||||
// 客户端请求非流式时,网关收集上游流式 chunk,将文本片段拼接进最后一个含 parts 的
|
||||
// 响应中,作为单个 JSON(HTTP 200, application/json)返回。
|
||||
func TestGatewayCharacterization_GeminiNonStreamCollectsStream(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
upstreamSSE := `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"Hel"}]}}]}}` + "\n\n" +
|
||||
`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"lo ✓"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":3,"cachedContentTokenCount":2}}}` + "\n\n"
|
||||
|
||||
upstream := &httpUpstreamStub{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
"X-Request-Id": []string{"rid-char-gemini-collect"},
|
||||
},
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(upstreamSSE))),
|
||||
}}
|
||||
svc := passCharAntigravityService(upstream)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", bytes.NewReader(passCharGeminiBody(t)))
|
||||
|
||||
account := passCharAntigravityAccount(9202, map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"})
|
||||
|
||||
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "generateContent", false, passCharGeminiBody(t), false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.False(t, result.Stream)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "application/json", rec.Header().Get("Content-Type"))
|
||||
require.Equal(t, "rid-char-gemini-collect", rec.Header().Get("x-request-id"))
|
||||
|
||||
// 合并语义:以最后一个含 parts 的 chunk 为基底,第一个 text part 替换为全部文本片段的拼接。
|
||||
require.JSONEq(t, `{
|
||||
"candidates":[{
|
||||
"content":{"role":"model","parts":[{"text":"Hello ✓"}]},
|
||||
"finishReason":"STOP"
|
||||
}],
|
||||
"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":3,"cachedContentTokenCount":2}
|
||||
}`, rec.Body.String())
|
||||
|
||||
require.Equal(t, 6, result.Usage.InputTokens)
|
||||
require.Equal(t, 3, result.Usage.OutputTokens)
|
||||
require.Equal(t, 2, result.Usage.CacheReadInputTokens)
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_GeminiUpstreamErrorUnwrappedPassthrough 固化 I-1.6 错误侧:
|
||||
// 非 failover 上游错误(404)时,客户端收到上游状态码 + 解包后的错误体原文。
|
||||
func TestGatewayCharacterization_GeminiUpstreamErrorUnwrappedPassthrough(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
innerErr := `{"error":{"code":404,"message":"model not found: gemini-3-pro-high","status":"NOT_FOUND"}}`
|
||||
upstream := &httpUpstreamStub{resp: &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"X-Request-Id": []string{"rid-char-gemini-err"},
|
||||
},
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(`{"response":` + innerErr + `}`))),
|
||||
}}
|
||||
svc := passCharAntigravityService(upstream)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", bytes.NewReader(passCharGeminiBody(t)))
|
||||
|
||||
account := passCharAntigravityAccount(9203, map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"})
|
||||
|
||||
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "generateContent", false, passCharGeminiBody(t), false)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, rec.Code, "非 failover 上游错误状态码原样返回")
|
||||
require.Equal(t, innerErr, rec.Body.String(), "错误体应为解包后的内层 JSON 原文")
|
||||
require.Equal(t, "application/json", rec.Header().Get("Content-Type"))
|
||||
require.Equal(t, "rid-char-gemini-err", rec.Header().Get("x-request-id"))
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//go:build unit
|
||||
|
||||
// TASK-003 计费精度不变量测试(INVARIANTS.md ② 计费精度)。
|
||||
//
|
||||
// 本文件覆盖纯计算层(BillingService)的不变量:
|
||||
// - I-2.2: 5m 与 1h 缓存写入差异化计价(两档金额断言 + 无明细回退 + breakdown 开关门控)
|
||||
// - I-2.4: 倍率叠加顺序 serviceTier → rateMultiplier;rateMultiplier=0 时
|
||||
// ActualCost=0 但 TotalCost 保留
|
||||
//
|
||||
// 所有期望值均为人工核算的固定值(算式在行内注释),容差 1e-10。
|
||||
// 这些是 characterization 测试:固化当前 main 上的实际行为,插件化改造期间
|
||||
// 任何金额漂移都应使其失败。
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// billInvManualPricingService 构造带手工定价表的 BillingService(无动态价格源)。
|
||||
func billInvManualPricingService(pricing *ModelPricing) *BillingService {
|
||||
return &BillingService{
|
||||
cfg: &config.Config{},
|
||||
fallbackPrices: map[string]*ModelPricing{
|
||||
"claude-sonnet-4": pricing,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingInvariant_CacheTier5mVs1h 锁定 I-2.2:cache_creation 分
|
||||
// CacheCreation5mPrice / CacheCreation1hPrice 两档分别计价。
|
||||
// 定价:input $3/MTok, output $15/MTok, cache_read $0.3/MTok,
|
||||
// 5m 写入 $4/MTok, 1h 写入 $5/MTok(SupportsCacheBreakdown=true)。
|
||||
func TestBillingInvariant_CacheTier5mVs1h(t *testing.T) {
|
||||
breakdownPricing := &ModelPricing{
|
||||
InputPricePerToken: 3e-6,
|
||||
OutputPricePerToken: 15e-6,
|
||||
CacheReadPricePerToken: 0.3e-6,
|
||||
CacheCreationPricePerToken: 3.75e-6,
|
||||
SupportsCacheBreakdown: true,
|
||||
CacheCreation5mPrice: 4e-6,
|
||||
CacheCreation1hPrice: 5e-6,
|
||||
}
|
||||
noBreakdownPricing := &ModelPricing{
|
||||
InputPricePerToken: 3e-6,
|
||||
OutputPricePerToken: 15e-6,
|
||||
CacheReadPricePerToken: 0.3e-6,
|
||||
CacheCreationPricePerToken: 3.75e-6,
|
||||
SupportsCacheBreakdown: false,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pricing *ModelPricing
|
||||
tokens UsageTokens
|
||||
// 期望值(固定值 + 1e-10 容差)
|
||||
wantInput float64
|
||||
wantOutput float64
|
||||
wantCacheCreation float64
|
||||
wantCacheRead float64
|
||||
wantTotal float64
|
||||
}{
|
||||
{
|
||||
name: "5m与1h两档分别计价",
|
||||
pricing: breakdownPricing,
|
||||
tokens: UsageTokens{
|
||||
InputTokens: 1000,
|
||||
OutputTokens: 500,
|
||||
// 上游通常同时给出总量与 ephemeral 明细;有明细时按明细计价
|
||||
CacheCreationTokens: 12000,
|
||||
CacheCreation5mTokens: 8000,
|
||||
CacheCreation1hTokens: 4000,
|
||||
CacheReadTokens: 2000,
|
||||
},
|
||||
wantInput: 0.003, // 1000 × $3/MTok = 0.003
|
||||
wantOutput: 0.0075, // 500 × $15/MTok = 0.0075
|
||||
// 8000 × 4e-6 = 0.032; 4000 × 5e-6 = 0.020; 合计 0.052
|
||||
wantCacheCreation: 0.052,
|
||||
wantCacheRead: 0.0006, // 2000 × $0.3/MTok = 0.0006
|
||||
wantTotal: 0.0631, // 0.003+0.0075+0.052+0.0006
|
||||
},
|
||||
{
|
||||
name: "仅1h写入按1h单价",
|
||||
pricing: breakdownPricing,
|
||||
tokens: UsageTokens{
|
||||
CacheCreationTokens: 10000,
|
||||
CacheCreation1hTokens: 10000,
|
||||
},
|
||||
wantCacheCreation: 0.05, // 10000 × 5e-6 = 0.05
|
||||
wantTotal: 0.05,
|
||||
},
|
||||
{
|
||||
name: "无ephemeral明细时全部回退5m单价",
|
||||
pricing: breakdownPricing,
|
||||
tokens: UsageTokens{
|
||||
CacheCreationTokens: 6000, // 5m/1h 明细均为 0
|
||||
},
|
||||
wantCacheCreation: 0.024, // 6000 × 4e-6 = 0.024(回退 5m 档)
|
||||
wantTotal: 0.024,
|
||||
},
|
||||
{
|
||||
name: "breakdown关闭时按标准单价计CacheCreationTokens并忽略5m1h明细",
|
||||
pricing: noBreakdownPricing,
|
||||
tokens: UsageTokens{
|
||||
CacheCreationTokens: 6000,
|
||||
CacheCreation5mTokens: 4000,
|
||||
CacheCreation1hTokens: 2000,
|
||||
},
|
||||
wantCacheCreation: 0.0225, // 6000 × 3.75e-6 = 0.0225(明细被忽略)
|
||||
wantTotal: 0.0225,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc := billInvManualPricingService(tt.pricing)
|
||||
cost, err := svc.CalculateCost("claude-sonnet-4", tt.tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, tt.wantInput, cost.InputCost, 1e-10)
|
||||
require.InDelta(t, tt.wantOutput, cost.OutputCost, 1e-10)
|
||||
require.InDelta(t, tt.wantCacheCreation, cost.CacheCreationCost, 1e-10)
|
||||
require.InDelta(t, tt.wantCacheRead, cost.CacheReadCost, 1e-10)
|
||||
require.InDelta(t, tt.wantTotal, cost.TotalCost, 1e-10)
|
||||
require.InDelta(t, tt.wantTotal, cost.ActualCost, 1e-10) // 倍率 1.0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingInvariant_CacheTierBreakdownGating 锁定 I-2.2 的动态定价门控:
|
||||
// 仅当 LiteLLM 的 1h 单价存在且严格大于 5m 单价时启用两档计费
|
||||
// (防止上游数据错误导致少收费,见 billing_service.go GetModelPricing)。
|
||||
func TestBillingInvariant_CacheTierBreakdownGating(t *testing.T) {
|
||||
tokens := UsageTokens{
|
||||
CacheCreationTokens: 1000,
|
||||
CacheCreation5mTokens: 600,
|
||||
CacheCreation1hTokens: 400,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
price5m, price1h float64
|
||||
wantBreakdown bool
|
||||
wantCacheCreation float64
|
||||
}{
|
||||
{
|
||||
name: "1h大于5m启用两档",
|
||||
price5m: 4e-6, price1h: 5e-6,
|
||||
wantBreakdown: true,
|
||||
// 600 × 4e-6 = 0.0024; 400 × 5e-6 = 0.002; 合计 0.0044
|
||||
wantCacheCreation: 0.0044,
|
||||
},
|
||||
{
|
||||
name: "1h等于5m禁用两档防少收费",
|
||||
price5m: 4e-6, price1h: 4e-6,
|
||||
wantBreakdown: false,
|
||||
// 回退标准单价:1000 × 4e-6 = 0.004
|
||||
wantCacheCreation: 0.004,
|
||||
},
|
||||
{
|
||||
name: "1h缺失禁用两档",
|
||||
price5m: 4e-6, price1h: 0,
|
||||
wantBreakdown: false,
|
||||
// 1000 × 4e-6 = 0.004
|
||||
wantCacheCreation: 0.004,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc := NewBillingService(&config.Config{}, &PricingService{
|
||||
pricingData: map[string]*LiteLLMModelPricing{
|
||||
"tier-gate-model": {
|
||||
InputCostPerToken: 3e-6,
|
||||
OutputCostPerToken: 15e-6,
|
||||
CacheCreationInputTokenCost: tt.price5m,
|
||||
CacheCreationInputTokenCostAbove1hr: tt.price1h,
|
||||
CacheReadInputTokenCost: 0.3e-6,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
pricing, err := svc.GetModelPricing("tier-gate-model")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantBreakdown, pricing.SupportsCacheBreakdown)
|
||||
|
||||
cost, err := svc.CalculateCost("tier-gate-model", tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, tt.wantCacheCreation, cost.CacheCreationCost, 1e-10)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingInvariant_ServiceTierThenRateMultiplier 锁定 I-2.4 的应用顺序:
|
||||
// 各分项成本先应用 serviceTier 倍率(或 priority 显式价),TotalCost 为分项之和
|
||||
// (含 tier、不含 rateMultiplier),最后 ActualCost = TotalCost × rateMultiplier。
|
||||
func TestBillingInvariant_ServiceTierThenRateMultiplier(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
serviceTier string
|
||||
rateMultiplier float64
|
||||
tokens UsageTokens
|
||||
wantInput float64
|
||||
wantOutput float64
|
||||
wantCacheWrite float64
|
||||
wantCacheRead float64
|
||||
wantTotal float64
|
||||
wantActual float64
|
||||
}{
|
||||
{
|
||||
// gpt-5.4 fallback 价:in $2.5/MTok, out $15/MTok, cache_write $2.5/MTok,
|
||||
// cache_read $0.25/MTok;flex 无显式价 → 0.5 倍 tier multiplier。
|
||||
name: "flex_tier后再乘rateMultiplier",
|
||||
model: "gpt-5.4",
|
||||
serviceTier: "flex",
|
||||
rateMultiplier: 1.5,
|
||||
tokens: UsageTokens{InputTokens: 1000, OutputTokens: 500, CacheCreationTokens: 400, CacheReadTokens: 2000},
|
||||
wantInput: 0.00125, // 1000×2.5e-6=0.0025 ×0.5
|
||||
wantOutput: 0.00375, // 500×15e-6=0.0075 ×0.5
|
||||
wantCacheWrite: 0.0005, // 400×2.5e-6=0.001 ×0.5
|
||||
wantCacheRead: 0.00025, // 2000×0.25e-6=0.0005 ×0.5
|
||||
wantTotal: 0.00575, // 分项之和(含 tier、不含 rate)
|
||||
wantActual: 0.008625, // 0.00575 × 1.5
|
||||
},
|
||||
{
|
||||
// gpt-5.4 有显式 priority 价:in $5/MTok, out $30/MTok, cache_read $0.5/MTok。
|
||||
// 注意(characterization):cache_write 无 priority 价,且显式 priority 价
|
||||
// 生效时 tierMultiplier 固定 1.0,因此 cache_write 仍按基础价 $2.5/MTok
|
||||
// 计费、不做 2 倍上浮——与下方"无显式价回退 2 倍"的行为不同。
|
||||
name: "priority显式价后再乘rateMultiplier",
|
||||
model: "gpt-5.4",
|
||||
serviceTier: "priority",
|
||||
rateMultiplier: 2.0,
|
||||
tokens: UsageTokens{InputTokens: 1000, OutputTokens: 500, CacheCreationTokens: 1000, CacheReadTokens: 2000},
|
||||
wantInput: 0.005, // 1000 × 5e-6(priority 价)
|
||||
wantOutput: 0.015, // 500 × 30e-6(priority 价)
|
||||
wantCacheWrite: 0.0025, // 1000 × 2.5e-6(基础价,无 priority 上浮)
|
||||
wantCacheRead: 0.001, // 2000 × 0.5e-6(priority 价)
|
||||
wantTotal: 0.0235,
|
||||
wantActual: 0.047, // 0.0235 × 2.0
|
||||
},
|
||||
{
|
||||
// claude-sonnet-4 fallback 无 priority 显式价 → 回退 2.0 倍 tier multiplier,
|
||||
// 此路径下 cache_write 也参与 2 倍上浮。
|
||||
name: "priority无显式价回退2倍tier后再乘rateMultiplier",
|
||||
model: "claude-sonnet-4",
|
||||
serviceTier: "priority",
|
||||
rateMultiplier: 1.5,
|
||||
tokens: UsageTokens{InputTokens: 1000, OutputTokens: 500, CacheCreationTokens: 2000, CacheReadTokens: 3000},
|
||||
wantInput: 0.006, // 1000×3e-6=0.003 ×2
|
||||
wantOutput: 0.015, // 500×15e-6=0.0075 ×2
|
||||
wantCacheWrite: 0.015, // 2000×3.75e-6=0.0075 ×2
|
||||
wantCacheRead: 0.0018, // 3000×0.3e-6=0.0009 ×2
|
||||
wantTotal: 0.0378,
|
||||
wantActual: 0.0567, // 0.0378 × 1.5
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
cost, err := svc.CalculateCostWithServiceTier(tt.model, tt.tokens, tt.rateMultiplier, tt.serviceTier)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, tt.wantInput, cost.InputCost, 1e-10)
|
||||
require.InDelta(t, tt.wantOutput, cost.OutputCost, 1e-10)
|
||||
require.InDelta(t, tt.wantCacheWrite, cost.CacheCreationCost, 1e-10)
|
||||
require.InDelta(t, tt.wantCacheRead, cost.CacheReadCost, 1e-10)
|
||||
require.InDelta(t, tt.wantTotal, cost.TotalCost, 1e-10)
|
||||
require.InDelta(t, tt.wantActual, cost.ActualCost, 1e-10)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingInvariant_ZeroAndNegativeRateMultiplier 锁定 I-2.4:
|
||||
// rateMultiplier=0(免费账号)时 ActualCost=0 但 TotalCost 保留;
|
||||
// 负数倍率按 0 处理(防缓存/迁移残留导致按 1x 误扣)。
|
||||
func TestBillingInvariant_ZeroAndNegativeRateMultiplier(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
tokens := UsageTokens{InputTokens: 1000, OutputTokens: 500, CacheCreationTokens: 2000, CacheReadTokens: 3000}
|
||||
// claude-sonnet-4: 0.003 + 0.0075 + 2000×3.75e-6(=0.0075) + 3000×0.3e-6(=0.0009) = 0.0189
|
||||
const wantTotal = 0.0189
|
||||
|
||||
for _, multiplier := range []float64{0, -2} {
|
||||
cost, err := svc.CalculateCost("claude-sonnet-4", tokens, multiplier)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, wantTotal, cost.TotalCost, 1e-10, "multiplier=%v 时 TotalCost 应保留原始费用", multiplier)
|
||||
require.InDelta(t, 0.0, cost.ActualCost, 1e-10, "multiplier=%v 时 ActualCost 应为 0", multiplier)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
//go:build unit
|
||||
|
||||
// TASK-003 计费/配额端到端不变量测试(INVARIANTS.md ②③)。
|
||||
//
|
||||
// 本文件通过 GatewayService.RecordUsage 走完整后扣链路,锁定:
|
||||
// - I-2.6: 完整 usage 输入 → 最终扣费金额 + 余额扣减/订阅用量增量二选一分支
|
||||
// (含 image 计价路径与 ImageOutputTokens 独立计价,呼应 I-2.3 端到端联动)
|
||||
// - I-2.2: 5m/1h 两档缓存写入的端到端金额
|
||||
// - I-3.3: API Key quota_used 增量金额(统一命令路径 + legacy 直写路径)
|
||||
// - I-3.4: Account 级配额增量金额 = TotalCost × AccountRateMultiplier
|
||||
// (注意:用 TotalCost 而非 ActualCost,分组倍率不影响账号配额消耗)
|
||||
//
|
||||
// 断言对象为 UsageBillingRepository.Apply 收到的 UsageBillingCommand(生产原子
|
||||
// 扣费路径的唯一输入)与 UsageLog 金额字段。所有期望值均人工核算(算式见注释)。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// billInvAccountRepoStub 捕获 legacy 路径下的账号配额增量。
|
||||
type billInvAccountRepoStub struct {
|
||||
AccountRepository
|
||||
|
||||
quotaIncrCalls int
|
||||
lastQuotaAmount float64
|
||||
}
|
||||
|
||||
func (s *billInvAccountRepoStub) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) error {
|
||||
s.quotaIncrCalls++
|
||||
s.lastQuotaAmount = amount
|
||||
return nil
|
||||
}
|
||||
|
||||
// billInvSubRepoStub 捕获 legacy 路径下的订阅用量增量金额。
|
||||
type billInvSubRepoStub struct {
|
||||
UserSubscriptionRepository
|
||||
|
||||
incrementCalls int
|
||||
lastCost float64
|
||||
}
|
||||
|
||||
func (s *billInvSubRepoStub) IncrementUsage(ctx context.Context, id int64, costUSD float64) error {
|
||||
s.incrementCalls++
|
||||
s.lastCost = costUSD
|
||||
return nil
|
||||
}
|
||||
|
||||
func billInvF64Ptr(v float64) *float64 { return &v }
|
||||
|
||||
// billInvNewGatewayService 构造带可注入 BillingService / 计费仓储的网关服务。
|
||||
func billInvNewGatewayService(
|
||||
billingSvc *BillingService,
|
||||
accountRepo AccountRepository,
|
||||
usageRepo UsageLogRepository,
|
||||
billingRepo UsageBillingRepository,
|
||||
userRepo UserRepository,
|
||||
subRepo UserSubscriptionRepository,
|
||||
) *GatewayService {
|
||||
cfg := &config.Config{}
|
||||
cfg.Default.RateMultiplier = 1.0
|
||||
if billingSvc == nil {
|
||||
billingSvc = NewBillingService(cfg, nil)
|
||||
}
|
||||
svc := NewGatewayService(
|
||||
accountRepo,
|
||||
nil, // groupRepo
|
||||
usageRepo,
|
||||
billingRepo,
|
||||
userRepo,
|
||||
subRepo,
|
||||
nil, // userGroupRateRepo
|
||||
nil, // cache
|
||||
cfg,
|
||||
nil, // schedulerSnapshot
|
||||
nil, // concurrencyService
|
||||
billingSvc,
|
||||
nil, // rateLimitService
|
||||
&BillingCacheService{},
|
||||
nil, // identityService
|
||||
nil, // httpUpstream
|
||||
&DeferredService{},
|
||||
nil, // claudeTokenProvider
|
||||
nil, // sessionLimitCache
|
||||
nil, // rpmCache
|
||||
nil, // digestStore
|
||||
nil, // settingService
|
||||
nil, // tlsFPProfileService
|
||||
nil, // channelService
|
||||
nil, // resolver
|
||||
nil, // balanceNotifyService
|
||||
nil, // userPlatformQuotaRepo
|
||||
)
|
||||
return svc
|
||||
}
|
||||
|
||||
// TestBillingInvariant_EndToEndUsageBillingCommand 表驱动锁定 I-2.6 / I-3.3 / I-3.4:
|
||||
// 每行 = (定价/分组倍率/账号倍率/usage 输入) → (UsageBillingCommand 各项金额 + UsageLog 金额)。
|
||||
func TestBillingInvariant_EndToEndUsageBillingCommand(t *testing.T) {
|
||||
groupID := int64(11)
|
||||
type expected struct {
|
||||
balanceCost float64
|
||||
subscriptionCost float64
|
||||
apiKeyQuotaCost float64
|
||||
apiKeyRateLimitCost float64
|
||||
accountQuotaCost float64
|
||||
subscriptionID *int64
|
||||
logTotal float64
|
||||
logActual float64
|
||||
billingType int8
|
||||
}
|
||||
|
||||
// 公共 usage:claude-sonnet-4 fallback 价(in $3/MTok, out $15/MTok,
|
||||
// cache_write $3.75/MTok, cache_read $0.3/MTok)。
|
||||
// TotalCost = 1000×3e-6 + 500×15e-6 + 2000×3.75e-6 + 3000×0.3e-6
|
||||
// = 0.003 + 0.0075 + 0.0075 + 0.0009 = 0.0189
|
||||
fourDimUsage := ClaudeUsage{
|
||||
InputTokens: 1000,
|
||||
OutputTokens: 500,
|
||||
CacheCreationInputTokens: 2000,
|
||||
CacheReadInputTokens: 3000,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pricingData map[string]*LiteLLMModelPricing // 可选:动态定价
|
||||
model string
|
||||
usage ClaudeUsage
|
||||
imageCount int
|
||||
imageSize string
|
||||
group *Group
|
||||
account *Account
|
||||
apiKeyQuota float64
|
||||
rateLimit5h float64
|
||||
sub *UserSubscription
|
||||
want expected
|
||||
}{
|
||||
{
|
||||
name: "余额模式_四维token组合计价",
|
||||
model: "claude-sonnet-4",
|
||||
usage: fourDimUsage,
|
||||
group: &Group{ID: groupID, Platform: PlatformAnthropic, RateMultiplier: 1.0},
|
||||
account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
// API Key Quota=100 → quota 增量 = ActualCost
|
||||
apiKeyQuota: 100,
|
||||
want: expected{
|
||||
balanceCost: 0.0189, // ActualCost = 0.0189 × 1.0
|
||||
apiKeyQuotaCost: 0.0189, // I-3.3: = ActualCost
|
||||
logTotal: 0.0189,
|
||||
logActual: 0.0189,
|
||||
billingType: BillingTypeBalance,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "余额模式_分组倍率2x只影响Actual不影响Total",
|
||||
model: "claude-sonnet-4",
|
||||
usage: fourDimUsage,
|
||||
group: &Group{ID: groupID, Platform: PlatformAnthropic, RateMultiplier: 2.0},
|
||||
account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
apiKeyQuota: 100,
|
||||
want: expected{
|
||||
balanceCost: 0.0378, // 0.0189 × 2.0
|
||||
apiKeyQuotaCost: 0.0378, // I-3.3: 跟随 ActualCost
|
||||
logTotal: 0.0189, // TotalCost 不含分组倍率
|
||||
logActual: 0.0378,
|
||||
billingType: BillingTypeBalance,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "订阅模式_订阅用量增量替代余额扣减",
|
||||
model: "claude-sonnet-4",
|
||||
usage: fourDimUsage,
|
||||
group: &Group{
|
||||
ID: groupID, Platform: PlatformAnthropic,
|
||||
RateMultiplier: 1.5, SubscriptionType: SubscriptionTypeSubscription,
|
||||
},
|
||||
account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
sub: &UserSubscription{ID: 42},
|
||||
want: expected{
|
||||
balanceCost: 0, // 二选一:订阅模式不扣余额
|
||||
subscriptionCost: 0.02835, // 0.0189 × 1.5
|
||||
subscriptionID: i64p(42),
|
||||
logTotal: 0.0189,
|
||||
logActual: 0.02835,
|
||||
billingType: BillingTypeSubscription,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "订阅模式_免费分组倍率0时订阅增量为0但Total保留",
|
||||
model: "claude-sonnet-4",
|
||||
usage: fourDimUsage,
|
||||
group: &Group{
|
||||
ID: groupID, Platform: PlatformAnthropic,
|
||||
RateMultiplier: 0, SubscriptionType: SubscriptionTypeSubscription,
|
||||
},
|
||||
account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
sub: &UserSubscription{ID: 42},
|
||||
want: expected{
|
||||
balanceCost: 0,
|
||||
subscriptionCost: 0, // ActualCost = 0
|
||||
subscriptionID: i64p(42), // TotalCost > 0 仍记录订阅归属
|
||||
logTotal: 0.0189,
|
||||
logActual: 0,
|
||||
billingType: BillingTypeSubscription,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "账号配额增量用TotalCost乘账号倍率而非ActualCost",
|
||||
model: "claude-sonnet-4",
|
||||
usage: fourDimUsage,
|
||||
group: &Group{ID: groupID, Platform: PlatformAnthropic, RateMultiplier: 2.0},
|
||||
account: &Account{
|
||||
ID: 702, Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{"quota_limit": 100.0},
|
||||
RateMultiplier: billInvF64Ptr(0.5),
|
||||
},
|
||||
want: expected{
|
||||
balanceCost: 0.0378, // 余额按分组倍率 2.0
|
||||
accountQuotaCost: 0.00945, // I-3.4: 0.0189(Total) × 0.5(账号倍率)
|
||||
logTotal: 0.0189,
|
||||
logActual: 0.0378,
|
||||
billingType: BillingTypeBalance,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OAuth账号不计账号配额增量",
|
||||
model: "claude-sonnet-4",
|
||||
usage: fourDimUsage,
|
||||
group: &Group{ID: groupID, Platform: PlatformAnthropic, RateMultiplier: 1.0},
|
||||
account: &Account{
|
||||
ID: 703, Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"quota_limit": 100.0},
|
||||
},
|
||||
want: expected{
|
||||
balanceCost: 0.0189,
|
||||
accountQuotaCost: 0, // 仅 apikey/bedrock 账号计配额
|
||||
logTotal: 0.0189,
|
||||
logActual: 0.0189,
|
||||
billingType: BillingTypeBalance,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "APIKey无限额时quota增量为0",
|
||||
model: "claude-sonnet-4",
|
||||
usage: fourDimUsage,
|
||||
group: &Group{ID: groupID, Platform: PlatformAnthropic, RateMultiplier: 1.0},
|
||||
account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
apiKeyQuota: 0, // Quota=0 表示不限额
|
||||
want: expected{
|
||||
balanceCost: 0.0189,
|
||||
apiKeyQuotaCost: 0,
|
||||
logTotal: 0.0189,
|
||||
logActual: 0.0189,
|
||||
billingType: BillingTypeBalance,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "APIKey限速窗口增量等于ActualCost",
|
||||
model: "claude-sonnet-4",
|
||||
usage: fourDimUsage,
|
||||
group: &Group{ID: groupID, Platform: PlatformAnthropic, RateMultiplier: 1.0},
|
||||
account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
rateLimit5h: 10,
|
||||
want: expected{
|
||||
balanceCost: 0.0189,
|
||||
apiKeyRateLimitCost: 0.0189,
|
||||
logTotal: 0.0189,
|
||||
logActual: 0.0189,
|
||||
billingType: BillingTypeBalance,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "图片按张计价_分组2K价格",
|
||||
model: "gemini-3-pro-image",
|
||||
imageCount: 2,
|
||||
imageSize: "2K",
|
||||
group: &Group{
|
||||
ID: groupID, Platform: PlatformGemini, RateMultiplier: 1.0,
|
||||
ImagePrice2K: billInvF64Ptr(0.19),
|
||||
},
|
||||
account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
want: expected{
|
||||
balanceCost: 0.38, // 2 × $0.19
|
||||
logTotal: 0.38,
|
||||
logActual: 0.38,
|
||||
billingType: BillingTypeBalance,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "图片独立倍率只影响Actual",
|
||||
model: "gemini-3-pro-image",
|
||||
imageCount: 2,
|
||||
imageSize: "2K",
|
||||
group: &Group{
|
||||
ID: groupID, Platform: PlatformGemini, RateMultiplier: 1.0,
|
||||
ImagePrice2K: billInvF64Ptr(0.19),
|
||||
ImageRateIndependent: true,
|
||||
ImageRateMultiplier: 2.0,
|
||||
},
|
||||
account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
want: expected{
|
||||
balanceCost: 0.76, // 0.38 × 2.0(图片独立倍率)
|
||||
logTotal: 0.38,
|
||||
logActual: 0.76,
|
||||
billingType: BillingTypeBalance,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ImageOutputTokens按独立单价从输出中拆分计价",
|
||||
pricingData: map[string]*LiteLLMModelPricing{
|
||||
"img-token-model": {
|
||||
InputCostPerToken: 3e-6,
|
||||
OutputCostPerToken: 15e-6,
|
||||
OutputCostPerImageToken: 30e-6,
|
||||
},
|
||||
},
|
||||
model: "img-token-model",
|
||||
usage: ClaudeUsage{
|
||||
InputTokens: 100,
|
||||
OutputTokens: 200, // 其中 50 为图片输出 token
|
||||
ImageOutputTokens: 50,
|
||||
},
|
||||
group: &Group{ID: groupID, Platform: PlatformGemini, RateMultiplier: 1.0},
|
||||
account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
want: expected{
|
||||
// input: 100×3e-6 = 0.0003
|
||||
// 文本输出: (200-50)×15e-6 = 0.00225
|
||||
// 图片输出: 50×30e-6 = 0.0015
|
||||
// Total = 0.00405
|
||||
balanceCost: 0.00405,
|
||||
logTotal: 0.00405,
|
||||
logActual: 0.00405,
|
||||
billingType: BillingTypeBalance,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
billingRepo := &openAIRecordUsageBillingRepoStub{result: &UsageBillingApplyResult{Applied: true}}
|
||||
var billingSvc *BillingService
|
||||
if tt.pricingData != nil {
|
||||
cfg := &config.Config{}
|
||||
billingSvc = NewBillingService(cfg, &PricingService{pricingData: tt.pricingData})
|
||||
}
|
||||
svc := billInvNewGatewayService(billingSvc, nil, usageRepo, billingRepo,
|
||||
&openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{})
|
||||
quotaSvc := &openAIRecordUsageAPIKeyQuotaStub{}
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &RecordUsageInput{
|
||||
Result: &ForwardResult{
|
||||
RequestID: "bill-inv-" + tt.name,
|
||||
Usage: tt.usage,
|
||||
Model: tt.model,
|
||||
ImageCount: tt.imageCount,
|
||||
ImageSize: tt.imageSize,
|
||||
Duration: time.Second,
|
||||
},
|
||||
APIKey: &APIKey{
|
||||
ID: 501,
|
||||
Quota: tt.apiKeyQuota,
|
||||
RateLimit5h: tt.rateLimit5h,
|
||||
GroupID: i64p(tt.group.ID),
|
||||
Group: tt.group,
|
||||
},
|
||||
User: &User{ID: 601},
|
||||
Account: tt.account,
|
||||
Subscription: tt.sub,
|
||||
APIKeyService: quotaSvc,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd := billingRepo.lastCmd
|
||||
require.NotNil(t, cmd, "应走统一计费命令路径")
|
||||
require.InDelta(t, tt.want.balanceCost, cmd.BalanceCost, 1e-10, "BalanceCost")
|
||||
require.InDelta(t, tt.want.subscriptionCost, cmd.SubscriptionCost, 1e-10, "SubscriptionCost")
|
||||
require.InDelta(t, tt.want.apiKeyQuotaCost, cmd.APIKeyQuotaCost, 1e-10, "APIKeyQuotaCost")
|
||||
require.InDelta(t, tt.want.apiKeyRateLimitCost, cmd.APIKeyRateLimitCost, 1e-10, "APIKeyRateLimitCost")
|
||||
require.InDelta(t, tt.want.accountQuotaCost, cmd.AccountQuotaCost, 1e-10, "AccountQuotaCost")
|
||||
if tt.want.subscriptionID != nil {
|
||||
require.NotNil(t, cmd.SubscriptionID)
|
||||
require.Equal(t, *tt.want.subscriptionID, *cmd.SubscriptionID)
|
||||
} else {
|
||||
require.Nil(t, cmd.SubscriptionID)
|
||||
}
|
||||
|
||||
log := usageRepo.lastLog
|
||||
require.NotNil(t, log)
|
||||
require.InDelta(t, tt.want.logTotal, log.TotalCost, 1e-10, "UsageLog.TotalCost")
|
||||
require.InDelta(t, tt.want.logActual, log.ActualCost, 1e-10, "UsageLog.ActualCost")
|
||||
require.Equal(t, tt.want.billingType, log.BillingType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingInvariant_CacheTier5mVs1hEndToEnd 锁定 I-2.2 端到端:上游返回的
|
||||
// 5m/1h ephemeral 明细流经 RecordUsage 后按两档单价分别计费(PR3061 漂移点②)。
|
||||
func TestBillingInvariant_CacheTier5mVs1hEndToEnd(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
billingSvc := NewBillingService(cfg, &PricingService{
|
||||
pricingData: map[string]*LiteLLMModelPricing{
|
||||
"claude-sonnet-4": {
|
||||
InputCostPerToken: 3e-6,
|
||||
OutputCostPerToken: 15e-6,
|
||||
CacheCreationInputTokenCost: 3.75e-6, // 5m 档
|
||||
CacheCreationInputTokenCostAbove1hr: 6e-6, // 1h 档(> 5m → 启用两档)
|
||||
CacheReadInputTokenCost: 0.3e-6,
|
||||
},
|
||||
},
|
||||
})
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
billingRepo := &openAIRecordUsageBillingRepoStub{result: &UsageBillingApplyResult{Applied: true}}
|
||||
svc := billInvNewGatewayService(billingSvc, nil, usageRepo, billingRepo,
|
||||
&openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{})
|
||||
|
||||
groupID := int64(11)
|
||||
err := svc.RecordUsage(context.Background(), &RecordUsageInput{
|
||||
Result: &ForwardResult{
|
||||
RequestID: "bill-inv-cache-tier-e2e",
|
||||
Usage: ClaudeUsage{
|
||||
InputTokens: 1000,
|
||||
OutputTokens: 500,
|
||||
CacheCreationInputTokens: 12000, // 总量 = 5m + 1h
|
||||
CacheCreation5mTokens: 8000,
|
||||
CacheCreation1hTokens: 4000,
|
||||
CacheReadInputTokens: 2000,
|
||||
},
|
||||
Model: "claude-sonnet-4",
|
||||
Duration: time.Second,
|
||||
},
|
||||
APIKey: &APIKey{ID: 501, GroupID: i64p(groupID), Group: &Group{ID: groupID, Platform: PlatformAnthropic, RateMultiplier: 1.0}},
|
||||
User: &User{ID: 601},
|
||||
Account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// cache_creation = 8000×3.75e-6 + 4000×6e-6 = 0.03 + 0.024 = 0.054
|
||||
// Total = 1000×3e-6 + 500×15e-6 + 0.054 + 2000×0.3e-6
|
||||
// = 0.003 + 0.0075 + 0.054 + 0.0006 = 0.0651
|
||||
log := usageRepo.lastLog
|
||||
require.NotNil(t, log)
|
||||
require.InDelta(t, 0.054, log.CacheCreationCost, 1e-10)
|
||||
require.InDelta(t, 0.0651, log.TotalCost, 1e-10)
|
||||
require.InDelta(t, 0.0651, log.ActualCost, 1e-10)
|
||||
|
||||
require.NotNil(t, billingRepo.lastCmd)
|
||||
require.InDelta(t, 0.0651, billingRepo.lastCmd.BalanceCost, 1e-10)
|
||||
}
|
||||
|
||||
// TestBillingInvariant_LegacyPathIncrements 锁定 legacy 兜底路径(统一计费仓储
|
||||
// 不可用时)的各级增量金额与生产命令路径一致:
|
||||
// - 余额扣减 = ActualCost(I-2.6)
|
||||
// - API Key quota_used 增量 = ActualCost(I-3.3)
|
||||
// - 账号配额增量 = TotalCost × AccountRateMultiplier(I-3.4)
|
||||
// - 订阅模式:IncrementUsage(ActualCost),不扣余额(I-2.6 二选一)
|
||||
func TestBillingInvariant_LegacyPathIncrements(t *testing.T) {
|
||||
groupID := int64(11)
|
||||
fourDimUsage := ClaudeUsage{
|
||||
InputTokens: 1000,
|
||||
OutputTokens: 500,
|
||||
CacheCreationInputTokens: 2000,
|
||||
CacheReadInputTokens: 3000,
|
||||
}
|
||||
|
||||
t.Run("余额模式各级增量", func(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
subRepo := &billInvSubRepoStub{}
|
||||
accountRepo := &billInvAccountRepoStub{}
|
||||
quotaSvc := &openAIRecordUsageAPIKeyQuotaStub{}
|
||||
// billingRepo == nil → applyUsageBilling 回退 postUsageBilling 直写
|
||||
svc := billInvNewGatewayService(nil, accountRepo, usageRepo, nil, userRepo, subRepo)
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &RecordUsageInput{
|
||||
Result: &ForwardResult{
|
||||
RequestID: "bill-inv-legacy-balance",
|
||||
Usage: fourDimUsage,
|
||||
Model: "claude-sonnet-4",
|
||||
Duration: time.Second,
|
||||
},
|
||||
APIKey: &APIKey{
|
||||
ID: 501, Quota: 100,
|
||||
GroupID: i64p(groupID),
|
||||
Group: &Group{ID: groupID, Platform: PlatformAnthropic, RateMultiplier: 2.0},
|
||||
},
|
||||
User: &User{ID: 601},
|
||||
Account: &Account{
|
||||
ID: 702, Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{"quota_limit": 100.0},
|
||||
RateMultiplier: billInvF64Ptr(0.5),
|
||||
},
|
||||
APIKeyService: quotaSvc,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// ActualCost = 0.0189 × 2.0 = 0.0378
|
||||
require.Equal(t, 1, userRepo.deductCalls)
|
||||
require.InDelta(t, 0.0378, userRepo.lastAmount, 1e-10, "余额扣减 = ActualCost")
|
||||
require.Equal(t, 1, quotaSvc.quotaCalls)
|
||||
require.InDelta(t, 0.0378, quotaSvc.lastAmount, 1e-10, "API Key quota 增量 = ActualCost")
|
||||
// 账号配额 = TotalCost(0.0189) × 账号倍率(0.5) = 0.00945
|
||||
require.Equal(t, 1, accountRepo.quotaIncrCalls)
|
||||
require.InDelta(t, 0.00945, accountRepo.lastQuotaAmount, 1e-10, "账号配额增量 = Total × 账号倍率")
|
||||
// 余额模式不应触发订阅增量
|
||||
require.Equal(t, 0, subRepo.incrementCalls)
|
||||
})
|
||||
|
||||
t.Run("订阅模式增量替代余额扣减", func(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
subRepo := &billInvSubRepoStub{}
|
||||
svc := billInvNewGatewayService(nil, &billInvAccountRepoStub{}, usageRepo, nil, userRepo, subRepo)
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &RecordUsageInput{
|
||||
Result: &ForwardResult{
|
||||
RequestID: "bill-inv-legacy-subscription",
|
||||
Usage: fourDimUsage,
|
||||
Model: "claude-sonnet-4",
|
||||
Duration: time.Second,
|
||||
},
|
||||
APIKey: &APIKey{
|
||||
ID: 501,
|
||||
GroupID: i64p(groupID),
|
||||
Group: &Group{
|
||||
ID: groupID, Platform: PlatformAnthropic,
|
||||
RateMultiplier: 1.5, SubscriptionType: SubscriptionTypeSubscription,
|
||||
},
|
||||
},
|
||||
User: &User{ID: 601},
|
||||
Account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
Subscription: &UserSubscription{ID: 42},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 订阅增量 = ActualCost = 0.0189 × 1.5 = 0.02835;余额不扣
|
||||
require.Equal(t, 1, subRepo.incrementCalls)
|
||||
require.InDelta(t, 0.02835, subRepo.lastCost, 1e-10, "订阅用量增量 = ActualCost")
|
||||
require.Equal(t, 0, userRepo.deductCalls, "订阅模式不应扣余额(二选一)")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//go:build unit
|
||||
|
||||
// TASK-003 overages 不变量测试(INVARIANTS.md I-2.5,PR3061 漂移点①)。
|
||||
//
|
||||
// overages(AI Credits 超量请求)由 accounts.extra["allow_overages"] 控制,仅
|
||||
// antigravity 平台生效。本文件锁定:
|
||||
// - 开关解析的允许/拒绝语义(平台门控 + 字段缺失/类型错误按拒绝处理)
|
||||
// - 拒绝路径:上游 429 quota_exhausted 时不注入 enabledCreditTypes、不发起
|
||||
// credits 重试(开关关闭 / 积分已耗尽两种拒绝原因)
|
||||
//
|
||||
// 允许路径(注入 credits 并继续请求)已由 antigravity_credits_overages_test.go
|
||||
// 覆盖。超额部分的"计价"没有独立分支:credits 重试成功后的响应走与普通请求
|
||||
// 完全相同的 RecordUsage 计费链路(由本任务 I-2.6 端到端测试锁定金额),因此
|
||||
// 这里只需锁定允许/拒绝语义。
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestBillingInvariant_OveragesFlagSemantics 锁定 allow_overages 开关解析语义。
|
||||
func TestBillingInvariant_OveragesFlagSemantics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
account Account
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "antigravity平台显式true允许",
|
||||
account: Account{Platform: PlatformAntigravity, Extra: map[string]any{"allow_overages": true}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "antigravity平台显式false拒绝",
|
||||
account: Account{Platform: PlatformAntigravity, Extra: map[string]any{"allow_overages": false}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "字段缺失默认拒绝",
|
||||
account: Account{Platform: PlatformAntigravity, Extra: map[string]any{}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Extra为nil默认拒绝",
|
||||
account: Account{Platform: PlatformAntigravity},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "非bool类型按拒绝处理",
|
||||
account: Account{Platform: PlatformAntigravity, Extra: map[string]any{"allow_overages": "true"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "非antigravity平台即使为true也拒绝",
|
||||
account: Account{Platform: PlatformAnthropic, Extra: map[string]any{"allow_overages": true}},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, tt.account.IsOveragesEnabled())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingInvariant_OveragesDeniedNoCreditsInjection 锁定拒绝路径:
|
||||
// 上游返回 429 quota_exhausted 时,若 overages 关闭或积分已耗尽,
|
||||
// handleSmartRetry 不得注入 enabledCreditTypes 发起 credits 重试,
|
||||
// 而是落入默认重试逻辑(smartRetryActionContinue)。
|
||||
func TestBillingInvariant_OveragesDeniedNoCreditsInjection(t *testing.T) {
|
||||
quotaExhaustedBody := []byte(`{"error":{"status":"RESOURCE_EXHAUSTED","message":"QUOTA_EXHAUSTED"}}`)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
extra map[string]any
|
||||
}{
|
||||
{
|
||||
name: "开关关闭时不注入credits",
|
||||
extra: map[string]any{}, // allow_overages 缺失 → 拒绝
|
||||
},
|
||||
{
|
||||
name: "积分已耗尽时不注入credits",
|
||||
extra: map[string]any{
|
||||
"allow_overages": true,
|
||||
modelRateLimitsKey: map[string]any{
|
||||
creditsExhaustedKey: map[string]any{
|
||||
"rate_limited_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"rate_limit_reset_at": time.Now().Add(5 * time.Hour).UTC().Format(time.RFC3339),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
upstream := &mockSmartRetryUpstream{}
|
||||
account := &Account{
|
||||
ID: 201,
|
||||
Name: "acc-201",
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformAntigravity,
|
||||
Extra: tt.extra,
|
||||
}
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(bytes.NewReader(quotaExhaustedBody)),
|
||||
}
|
||||
params := antigravityRetryLoopParams{
|
||||
ctx: context.Background(),
|
||||
prefix: "[bill-inv]",
|
||||
account: account,
|
||||
accessToken: "token",
|
||||
action: "generateContent",
|
||||
body: []byte(`{"model":"claude-sonnet-4-5","request":{}}`),
|
||||
httpUpstream: upstream,
|
||||
accountRepo: &stubAntigravityAccountRepo{},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
svc := &AntigravityGatewayService{}
|
||||
result := svc.handleSmartRetry(params, resp, quotaExhaustedBody, "https://ag-1.test", 0, []string{"https://ag-1.test"})
|
||||
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, smartRetryActionContinue, result.action, "拒绝 overages 后应落入默认重试逻辑")
|
||||
require.Empty(t, upstream.requestBodies, "不得发起 credits 注入重试请求")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//go:build unit
|
||||
|
||||
// TASK-003 preflight 配额/余额拒绝不变量测试(INVARIANTS.md I-3.6)。
|
||||
//
|
||||
// CheckBillingEligibility 是网关入口的统一计费资格检查;handler 在
|
||||
// AcquireUserSlotWithWait 等待结束后会用同一函数做"二次检查"
|
||||
// (internal/handler/gateway_handler.go:255),因此这里锁定其无状态语义:
|
||||
// - 余额模式:余额 <= 0 → ErrInsufficientBalance
|
||||
// - 订阅模式:日/周/月用量达到分组限额 → ErrDaily/Weekly/MonthlyLimitExceeded;
|
||||
// 订阅过期或非 active → ErrSubscriptionInvalid
|
||||
// - user×platform 配额:日限额耗尽 → ErrUserPlatformDailyQuotaExhausted(429);
|
||||
// 订阅模式豁免该检查
|
||||
// - 并发等待后二次检查:第一次放行后用量/余额变化,再次调用即拒绝
|
||||
// - simple 运行模式跳过所有计费检查
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// billInvBillingCacheStub 只实现 CheckBillingEligibility 路径会触达的读方法,
|
||||
// 其余方法走嵌入接口(不会被调用)。
|
||||
type billInvBillingCacheStub struct {
|
||||
BillingCache
|
||||
|
||||
balance float64
|
||||
sub *SubscriptionCacheData
|
||||
quotaEntry *UserPlatformQuotaCacheEntry
|
||||
}
|
||||
|
||||
func (s *billInvBillingCacheStub) GetUserBalance(ctx context.Context, userID int64) (float64, error) {
|
||||
return s.balance, nil
|
||||
}
|
||||
|
||||
func (s *billInvBillingCacheStub) GetSubscriptionCache(ctx context.Context, userID, groupID int64) (*SubscriptionCacheData, error) {
|
||||
return s.sub, nil
|
||||
}
|
||||
|
||||
func (s *billInvBillingCacheStub) GetUserPlatformQuotaCache(ctx context.Context, userID int64, platform string) (*UserPlatformQuotaCacheEntry, bool, error) {
|
||||
if s.quotaEntry == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
return s.quotaEntry, true, nil
|
||||
}
|
||||
|
||||
// billInvQuotaRepoStub 仅用于让 userPlatformQuotaRepo 非 nil(cache HIT 路径不
|
||||
// 会触达 DB),嵌入接口的其余方法不会被调用。
|
||||
type billInvQuotaRepoStub struct {
|
||||
UserPlatformQuotaRepository
|
||||
}
|
||||
|
||||
func (s *billInvQuotaRepoStub) GetByUserPlatform(ctx context.Context, userID int64, platform string) (*UserPlatformQuotaRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func billInvNewBillingCacheService(t *testing.T, cache BillingCache, cfg *config.Config) *BillingCacheService {
|
||||
t.Helper()
|
||||
if cfg == nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
svc := NewBillingCacheService(cache, nil, nil, nil, nil, nil, cfg, &billInvQuotaRepoStub{})
|
||||
t.Cleanup(svc.Stop)
|
||||
return svc
|
||||
}
|
||||
|
||||
// billInvQuotaEntryV1 构造当前窗口内的 SchemaV1 user×platform 配额缓存条目。
|
||||
func billInvQuotaEntryV1(dailyLimit, dailyUsage float64) *UserPlatformQuotaCacheEntry {
|
||||
now := time.Now()
|
||||
return &UserPlatformQuotaCacheEntry{
|
||||
SchemaVersion: UserPlatformQuotaCacheSchemaV1,
|
||||
DailyLimitUSD: &dailyLimit,
|
||||
DailyUsageUSD: dailyUsage,
|
||||
DailyWindowStart: &now,
|
||||
WeeklyWindowStart: &now,
|
||||
MonthlyWindowStart: &now,
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingInvariant_PreflightBalanceEligibility 锁定余额模式 preflight 语义。
|
||||
func TestBillingInvariant_PreflightBalanceEligibility(t *testing.T) {
|
||||
user := &User{ID: 601}
|
||||
|
||||
t.Run("余额耗尽拒绝", func(t *testing.T) {
|
||||
svc := billInvNewBillingCacheService(t, &billInvBillingCacheStub{balance: 0}, nil)
|
||||
err := svc.CheckBillingEligibility(context.Background(), user, nil, nil, nil, "")
|
||||
require.ErrorIs(t, err, ErrInsufficientBalance)
|
||||
})
|
||||
|
||||
t.Run("余额为正放行", func(t *testing.T) {
|
||||
svc := billInvNewBillingCacheService(t, &billInvBillingCacheStub{balance: 5.0}, nil)
|
||||
err := svc.CheckBillingEligibility(context.Background(), user, nil, nil, nil, "")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("并发等待后二次检查反映最新余额", func(t *testing.T) {
|
||||
cache := &billInvBillingCacheStub{balance: 0.01}
|
||||
svc := billInvNewBillingCacheService(t, cache, nil)
|
||||
|
||||
// 第一次检查(获取并发槽前):余额尚存 → 放行
|
||||
require.NoError(t, svc.CheckBillingEligibility(context.Background(), user, nil, nil, nil, ""))
|
||||
|
||||
// 等待期间其他请求把余额扣到 0 → 等待结束后的二次检查必须拒绝
|
||||
cache.balance = 0
|
||||
err := svc.CheckBillingEligibility(context.Background(), user, nil, nil, nil, "")
|
||||
require.ErrorIs(t, err, ErrInsufficientBalance)
|
||||
})
|
||||
}
|
||||
|
||||
// TestBillingInvariant_PreflightSubscriptionLimits 锁定订阅模式 preflight 语义:
|
||||
// 日/周/月任一窗口用量达到分组限额即拒绝;订阅非 active 或已过期拒绝。
|
||||
func TestBillingInvariant_PreflightSubscriptionLimits(t *testing.T) {
|
||||
user := &User{ID: 601}
|
||||
subscription := &UserSubscription{ID: 42}
|
||||
group := &Group{
|
||||
ID: 7,
|
||||
SubscriptionType: SubscriptionTypeSubscription,
|
||||
DailyLimitUSD: billInvF64Ptr(10),
|
||||
WeeklyLimitUSD: billInvF64Ptr(50),
|
||||
MonthlyLimitUSD: billInvF64Ptr(100),
|
||||
}
|
||||
activeFuture := time.Now().Add(24 * time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sub *SubscriptionCacheData
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "限额内放行",
|
||||
sub: &SubscriptionCacheData{Status: SubscriptionStatusActive, ExpiresAt: activeFuture, DailyUsage: 9.99, WeeklyUsage: 49.99, MonthlyUsage: 99.99},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "日限额达到拒绝",
|
||||
sub: &SubscriptionCacheData{Status: SubscriptionStatusActive, ExpiresAt: activeFuture, DailyUsage: 10},
|
||||
wantErr: ErrDailyLimitExceeded,
|
||||
},
|
||||
{
|
||||
name: "周限额达到拒绝",
|
||||
sub: &SubscriptionCacheData{Status: SubscriptionStatusActive, ExpiresAt: activeFuture, WeeklyUsage: 50},
|
||||
wantErr: ErrWeeklyLimitExceeded,
|
||||
},
|
||||
{
|
||||
name: "月限额达到拒绝",
|
||||
sub: &SubscriptionCacheData{Status: SubscriptionStatusActive, ExpiresAt: activeFuture, MonthlyUsage: 100},
|
||||
wantErr: ErrMonthlyLimitExceeded,
|
||||
},
|
||||
{
|
||||
name: "订阅过期拒绝",
|
||||
sub: &SubscriptionCacheData{Status: SubscriptionStatusActive, ExpiresAt: time.Now().Add(-time.Minute)},
|
||||
wantErr: ErrSubscriptionInvalid,
|
||||
},
|
||||
{
|
||||
name: "订阅非active拒绝",
|
||||
sub: &SubscriptionCacheData{Status: "cancelled", ExpiresAt: activeFuture},
|
||||
wantErr: ErrSubscriptionInvalid,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc := billInvNewBillingCacheService(t, &billInvBillingCacheStub{sub: tt.sub}, nil)
|
||||
err := svc.CheckBillingEligibility(context.Background(), user, nil, group, subscription, "")
|
||||
if tt.wantErr == nil {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingInvariant_PreflightUserPlatformQuota 锁定 user×platform 配额的
|
||||
// preflight 语义:余额模式下日限额耗尽 → 429 拒绝;订阅模式豁免该检查。
|
||||
func TestBillingInvariant_PreflightUserPlatformQuota(t *testing.T) {
|
||||
user := &User{ID: 601}
|
||||
|
||||
t.Run("日配额耗尽拒绝", func(t *testing.T) {
|
||||
cache := &billInvBillingCacheStub{
|
||||
balance: 5.0, // 余额充足,确保拒绝来自 platform quota
|
||||
quotaEntry: billInvQuotaEntryV1(5.0, 5.0),
|
||||
}
|
||||
svc := billInvNewBillingCacheService(t, cache, nil)
|
||||
err := svc.CheckBillingEligibility(context.Background(), user, nil, nil, nil, PlatformAnthropic)
|
||||
require.ErrorIs(t, err, ErrUserPlatformDailyQuotaExhausted)
|
||||
})
|
||||
|
||||
t.Run("日配额未满放行", func(t *testing.T) {
|
||||
cache := &billInvBillingCacheStub{
|
||||
balance: 5.0,
|
||||
quotaEntry: billInvQuotaEntryV1(5.0, 4.99),
|
||||
}
|
||||
svc := billInvNewBillingCacheService(t, cache, nil)
|
||||
err := svc.CheckBillingEligibility(context.Background(), user, nil, nil, nil, PlatformAnthropic)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("订阅模式豁免platform配额检查", func(t *testing.T) {
|
||||
group := &Group{
|
||||
ID: 7,
|
||||
SubscriptionType: SubscriptionTypeSubscription,
|
||||
DailyLimitUSD: billInvF64Ptr(10),
|
||||
}
|
||||
cache := &billInvBillingCacheStub{
|
||||
sub: &SubscriptionCacheData{Status: SubscriptionStatusActive, ExpiresAt: time.Now().Add(24 * time.Hour)},
|
||||
quotaEntry: billInvQuotaEntryV1(5.0, 999), // platform 配额早已超限
|
||||
}
|
||||
svc := billInvNewBillingCacheService(t, cache, nil)
|
||||
err := svc.CheckBillingEligibility(context.Background(), user, nil, group, &UserSubscription{ID: 42}, PlatformAnthropic)
|
||||
require.NoError(t, err, "订阅模式下 user×platform 配额不应生效")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBillingInvariant_PreflightSimpleModeBypass 锁定 simple 运行模式跳过所有
|
||||
// 计费检查(余额为 0 也放行)。
|
||||
func TestBillingInvariant_PreflightSimpleModeBypass(t *testing.T) {
|
||||
cfg := &config.Config{RunMode: config.RunModeSimple}
|
||||
svc := billInvNewBillingCacheService(t, &billInvBillingCacheStub{balance: 0}, cfg)
|
||||
err := svc.CheckBillingEligibility(context.Background(), &User{ID: 601}, nil, nil, nil, PlatformAnthropic)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//go:build unit
|
||||
|
||||
// Phase-3 TASK-001 T5:antigravity Forward(Claude→Gemini 转换路径)热路径基准,
|
||||
// 纳入 scripts/bench-baseline.sh 的回归 gate(基准名匹配 BenchmarkGatewayForward 模式)。
|
||||
// 覆盖 :794 分发点背后的真实转发成本:请求解析 + Claude→Gemini 转换 +
|
||||
// v1internal 包裹 + 上游 SSE 解包 +(流式透传转换 / 非流式收集合并)。
|
||||
// 与 gateway_forward_benchmark_test.go 同策略:mock 上游、静音 stdlib log、
|
||||
// allocs/op 严格、ns/op 容忍 15% 抖动。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func benchAntigravityFwdRun(b *testing.B, reqBody string) {
|
||||
b.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
// antigravity 路径每请求有数条 LegacyPrintf(基准模式下全局 logger 未初始化,
|
||||
// 回退 stdlib log),静音以降低基准 I/O 噪声(baseline 与 compare 两侧同等消除)。
|
||||
prevLogOut := log.Writer()
|
||||
log.SetOutput(io.Discard)
|
||||
b.Cleanup(func() { log.SetOutput(prevLogOut) })
|
||||
|
||||
upstreamSSE := "data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hel\"}]}}]}}\n\n" +
|
||||
"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"lo\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":12,\"candidatesTokenCount\":5,\"cachedContentTokenCount\":3}}}\n\n"
|
||||
|
||||
account := passCharAntigravityAccount(9800, map[string]any{"claude-sonnet-4-5": "gemini-3-pro-high"})
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
upstream := &httpUpstreamStub{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
"X-Request-Id": []string{"rid-bench-ag"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
|
||||
}}
|
||||
svc := passCharAntigravityService(upstream)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(reqBody))
|
||||
if _, err := svc.Forward(context.Background(), c, account, []byte(reqBody), false); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGatewayForward_AntigravityClaudeNonStream(b *testing.B) {
|
||||
benchAntigravityFwdRun(b,
|
||||
`{"model":"claude-sonnet-4-5","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`,
|
||||
)
|
||||
}
|
||||
|
||||
func BenchmarkGatewayForward_AntigravityClaudeStream(b *testing.B) {
|
||||
benchAntigravityFwdRun(b,
|
||||
`{"model":"claude-sonnet-4-5","max_tokens":64,"stream":true,"messages":[{"role":"user","content":"hi"}]}`,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//go:build unit
|
||||
|
||||
// Phase-0 TASK-005 热路径基准:覆盖 anthropic API Key 透传账号的完整
|
||||
// Forward 路径(非流式 + 流式 SSE)。与 scripts/bench-baseline.sh 配套使用,
|
||||
// 基线存于 testdata/bench/baseline.txt;对比策略:allocs/op 严格(不允许增加)、
|
||||
// ns/op 宽松(容忍 15% 抖动)。复用 TASK-002 特征化测试的夹具(passChar* 前缀)。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func benchFwdRun(b *testing.B, header http.Header, upstreamBody string, reqBody string, stream bool) {
|
||||
b.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
// 透传分支每请求有一条 stdlib log,静音以降低基准 I/O 噪声
|
||||
// (日志成本在 baseline 与 compare 两侧同等消除,不影响对比有效性)。
|
||||
prevLogOut := log.Writer()
|
||||
log.SetOutput(io.Discard)
|
||||
b.Cleanup(func() { log.SetOutput(prevLogOut) })
|
||||
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
|
||||
account := passCharAnthropicAccount(9100)
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
upstream := &anthropicHTTPUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: header.Clone(),
|
||||
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
||||
},
|
||||
}
|
||||
svc := passCharGatewayService(cfg, upstream)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
parsed := &ParsedRequest{
|
||||
Body: NewRequestBodyRef([]byte(reqBody)),
|
||||
Model: "claude-sonnet-4-20250514",
|
||||
Stream: stream,
|
||||
}
|
||||
if _, err := svc.Forward(context.Background(), c, account, parsed); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGatewayForward_AnthropicNonStreamPassthrough(b *testing.B) {
|
||||
upstreamJSON := `{"id":"msg_bench_01","type":"message","content":[{"type":"text","text":"hello"}],` +
|
||||
`"usage":{"input_tokens":12,"output_tokens":7,"cache_read_input_tokens":3}}`
|
||||
benchFwdRun(b,
|
||||
http.Header{"Content-Type": []string{"application/json"}},
|
||||
upstreamJSON,
|
||||
`{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":"hi"}]}`,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
func BenchmarkGatewayForward_AnthropicStreamPassthrough(b *testing.B) {
|
||||
events := []string{
|
||||
"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_bench_02\",\"usage\":{\"input_tokens\":9,\"cached_tokens\":3}}}",
|
||||
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hel\"}}",
|
||||
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"lo\"}}",
|
||||
"event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}",
|
||||
"event: message_stop\ndata: {\"type\":\"message_stop\"}",
|
||||
}
|
||||
benchFwdRun(b,
|
||||
http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
strings.Join(events, "\n\n")+"\n\n",
|
||||
`{"model":"claude-sonnet-4-20250514","stream":true,"messages":[{"role":"user","content":"hi"}]}`,
|
||||
true,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
//go:build unit
|
||||
|
||||
// 本文件是 Phase-0 TASK-002 的特征化测试(characterization tests)。
|
||||
// 它把"客户端最终收到的内容"固化为基线:固定 mock 上游,逐字节/逐事件断言
|
||||
// 状态码、响应体与响应头。任何后续重构若改变这些外部可观测行为,测试会立即失败。
|
||||
//
|
||||
// 覆盖的不变量(见 .claude/plugin-refactor/INVARIANTS.md):
|
||||
// - I-1.1 anthropic 非流式 2xx body/状态码逐字节透传(升级自 JSONEq 级断言)
|
||||
// - I-1.4 响应 header 白名单过滤(默认白名单 / additional_allowed / force_remove)
|
||||
// - I-1.7 上游 4xx/5xx 错误按 ErrorPassthroughService 规则透传或转换
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// passCharSplitSSEEvents 按 SSE 事件边界(空行 "\n\n")分帧。
|
||||
// 特征化测试只按事件比对,不按网络 chunk 比对;帧首尾多余的换行
|
||||
// (合法的 SSE 分帧差异,如额外空行)会被剥离,但帧内内容保持原文。
|
||||
func passCharSplitSSEEvents(body string) []string {
|
||||
frames := strings.Split(body, "\n\n")
|
||||
out := make([]string, 0, len(frames))
|
||||
for _, f := range frames {
|
||||
f = strings.Trim(f, "\r\n")
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// passCharAnthropicAccount 返回 anthropic API Key 透传账号夹具。
|
||||
func passCharAnthropicAccount(id int64) *Account {
|
||||
return &Account{
|
||||
ID: id,
|
||||
Name: "pass-char-anthropic",
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "upstream-anthropic-key",
|
||||
"base_url": "https://api.anthropic.com",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"anthropic_passthrough": true,
|
||||
},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// passCharGinContext 构造带 /v1/messages POST 请求的 gin 测试上下文。
|
||||
func passCharGinContext(t *testing.T, path string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, path, nil)
|
||||
return c, rec
|
||||
}
|
||||
|
||||
func passCharGatewayService(cfg *config.Config, upstream HTTPUpstream) *GatewayService {
|
||||
return &GatewayService{
|
||||
cfg: cfg,
|
||||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||||
httpUpstream: upstream,
|
||||
rateLimitService: &RateLimitService{},
|
||||
deferredService: &DeferredService{},
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_AnthropicNonStreamPassthroughByteExact 固化 I-1.1:
|
||||
// anthropic 透传账号非流式转发时,上游 2xx 响应体逐字节透传、状态码透传。
|
||||
// 上游 body 故意包含字段顺序、多余空白、Unicode、HTML 字符与未知字段,
|
||||
// 任何 JSON 重新序列化都会破坏逐字节相等。
|
||||
func TestGatewayCharacterization_AnthropicNonStreamPassthroughByteExact(t *testing.T) {
|
||||
upstreamJSON := "{\"id\":\"msg_char_01\", \"type\":\"message\",\n" +
|
||||
"\"unknown_field\":{\"nested\":[1,2.50,\"<&>\"]},\n" +
|
||||
"\"content\":[{\"type\":\"text\",\"text\":\"héllo ✓ <b>&</b>\"}],\n" +
|
||||
"\"usage\":{\"input_tokens\":12,\"output_tokens\":7}}"
|
||||
|
||||
for _, status := range []int{http.StatusOK, http.StatusAccepted} {
|
||||
upstream := &anthropicHTTPUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json; charset=utf-8"},
|
||||
"x-request-id": []string{"rid-char-nonstream"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamJSON)),
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
|
||||
svc := passCharGatewayService(cfg, upstream)
|
||||
|
||||
c, rec := passCharGinContext(t, "/v1/messages")
|
||||
parsed := &ParsedRequest{
|
||||
Body: NewRequestBodyRef([]byte(`{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":"hi"}]}`)),
|
||||
Model: "claude-sonnet-4-20250514",
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, passCharAnthropicAccount(9001), parsed)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
require.Equal(t, status, rec.Code, "上游 2xx 状态码必须透传")
|
||||
require.Equal(t, upstreamJSON, rec.Body.String(), "上游 2xx body 必须逐字节透传")
|
||||
require.Equal(t, "application/json; charset=utf-8", rec.Header().Get("Content-Type"), "Content-Type 必须透传")
|
||||
require.Equal(t, "rid-char-nonstream", rec.Header().Get("x-request-id"), "x-request-id 必须透传")
|
||||
require.Equal(t, 12, result.Usage.InputTokens)
|
||||
require.Equal(t, 7, result.Usage.OutputTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_AnthropicStreamPassthroughEventSequence 固化 I-1.1/I-1.2 流式侧:
|
||||
// anthropic 透传账号流式转发时,客户端收到的 SSE 事件序列(按 \n\n 分帧)与上游完全一致,
|
||||
// 含 event: 行、usage 事件与 cached_tokens 兼容字段,且网关不改写事件内容。
|
||||
func TestGatewayCharacterization_AnthropicStreamPassthroughEventSequence(t *testing.T) {
|
||||
upstreamEvents := []string{
|
||||
"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_char_02\",\"usage\":{\"input_tokens\":9,\"cached_tokens\":3}}}",
|
||||
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hel\"}}",
|
||||
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"lo ✓\"}}",
|
||||
"event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}",
|
||||
"event: message_stop\ndata: {\"type\":\"message_stop\"}",
|
||||
}
|
||||
upstreamSSE := strings.Join(upstreamEvents, "\n\n") + "\n\n"
|
||||
|
||||
upstream := &anthropicHTTPUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
"x-request-id": []string{"rid-char-stream"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
|
||||
svc := passCharGatewayService(cfg, upstream)
|
||||
|
||||
c, rec := passCharGinContext(t, "/v1/messages")
|
||||
parsed := &ParsedRequest{
|
||||
Body: NewRequestBodyRef([]byte(`{"model":"claude-sonnet-4-20250514","stream":true,"messages":[{"role":"user","content":"hi"}]}`)),
|
||||
Model: "claude-sonnet-4-20250514",
|
||||
Stream: true,
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, passCharAnthropicAccount(9002), parsed)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, result.Stream)
|
||||
|
||||
gotEvents := passCharSplitSSEEvents(rec.Body.String())
|
||||
require.Equal(t, upstreamEvents, gotEvents, "客户端收到的 SSE 事件序列必须与上游逐事件一致")
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "text/event-stream", rec.Header().Get("Content-Type"))
|
||||
require.Equal(t, "rid-char-stream", rec.Header().Get("x-request-id"))
|
||||
require.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
|
||||
require.Equal(t, "no", rec.Header().Get("X-Accel-Buffering"))
|
||||
|
||||
require.Equal(t, 9, result.Usage.InputTokens)
|
||||
require.Equal(t, 5, result.Usage.OutputTokens)
|
||||
require.Equal(t, 3, result.Usage.CacheReadInputTokens, "cached_tokens 应被解析进计费 usage")
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_ResponseHeaderFilter 固化 I-1.4:
|
||||
// 响应 header 白名单过滤语义。
|
||||
// - 关闭(security.response_headers.enabled=false,默认):只用内置白名单,
|
||||
// additional_allowed / force_remove 均不生效;
|
||||
// - 开启:additional_allowed 追加放行,force_remove 即使是默认白名单键也强制移除。
|
||||
func TestGatewayCharacterization_ResponseHeaderFilter(t *testing.T) {
|
||||
upstreamHeaders := func() http.Header {
|
||||
return http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"Cache-Control": []string{"no-store"},
|
||||
"x-request-id": []string{"rid-char-headers"},
|
||||
"X-Ratelimit-Remaining-Requests": []string{"99"},
|
||||
"Retry-After": []string{"17"},
|
||||
"Www-Authenticate": []string{"Bearer realm=api"},
|
||||
"Set-Cookie": []string{"secret=upstream"},
|
||||
"X-Custom-Upstream": []string{"custom-value"},
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
headersCfg config.ResponseHeaderConfig
|
||||
wantPresent map[string]string
|
||||
wantAbsentKeys []string
|
||||
}{
|
||||
{
|
||||
name: "默认关闭_只用内置白名单且additional与force_remove不生效",
|
||||
headersCfg: config.ResponseHeaderConfig{
|
||||
Enabled: false,
|
||||
AdditionalAllowed: []string{"x-custom-upstream"}, // 关闭时不生效
|
||||
ForceRemove: []string{"x-request-id"}, // 关闭时不生效
|
||||
},
|
||||
wantPresent: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
"x-request-id": "rid-char-headers",
|
||||
"X-Ratelimit-Remaining-Requests": "99",
|
||||
"Retry-After": "17",
|
||||
"Www-Authenticate": "Bearer realm=api",
|
||||
},
|
||||
wantAbsentKeys: []string{"Set-Cookie", "X-Custom-Upstream"},
|
||||
},
|
||||
{
|
||||
name: "开启_additional追加放行且force_remove覆盖默认白名单",
|
||||
headersCfg: config.ResponseHeaderConfig{
|
||||
Enabled: true,
|
||||
AdditionalAllowed: []string{"x-custom-upstream"},
|
||||
ForceRemove: []string{"x-request-id"},
|
||||
},
|
||||
wantPresent: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"X-Custom-Upstream": "custom-value",
|
||||
"Retry-After": "17",
|
||||
},
|
||||
wantAbsentKeys: []string{"Set-Cookie", "x-request-id"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
upstream := &anthropicHTTPUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: upstreamHeaders(),
|
||||
Body: io.NopCloser(strings.NewReader(`{"id":"msg_h","usage":{"input_tokens":1,"output_tokens":1}}`)),
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
|
||||
cfg.Security.ResponseHeaders = tt.headersCfg
|
||||
svc := passCharGatewayService(cfg, upstream)
|
||||
|
||||
c, rec := passCharGinContext(t, "/v1/messages")
|
||||
parsed := &ParsedRequest{
|
||||
Body: NewRequestBodyRef([]byte(`{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":"hi"}]}`)),
|
||||
Model: "claude-sonnet-4-20250514",
|
||||
}
|
||||
|
||||
_, err := svc.Forward(context.Background(), c, passCharAnthropicAccount(9003), parsed)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
for key, want := range tt.wantPresent {
|
||||
require.Equal(t, want, rec.Header().Get(key), "响应头 %s 应保留", key)
|
||||
}
|
||||
for _, key := range tt.wantAbsentKeys {
|
||||
require.Empty(t, rec.Header().Get(key), "响应头 %s 应被过滤", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func passCharIntPtr(v int) *int { return &v }
|
||||
func passCharStrPtr(v string) *string { return &v }
|
||||
|
||||
// passCharErrorPassthroughService 构造带固定规则的错误透传服务(不连缓存)。
|
||||
func passCharErrorPassthroughService(rules []*model.ErrorPassthroughRule) *ErrorPassthroughService {
|
||||
return NewErrorPassthroughService(&mockErrorPassthroughRepo{rules: rules}, nil)
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_UpstreamErrorPassthroughRules 固化 I-1.7:
|
||||
// 上游非 failover 错误(如 404)经 ErrorPassthroughService.MatchRule 决定透传或转换:
|
||||
// - 规则命中 + passthrough_code/passthrough_body:透传上游状态码 + 提取的上游 message;
|
||||
// - 规则命中 + 自定义 response_code/custom_message:使用自定义值;
|
||||
// - 规则未命中:默认 502 + "Upstream request failed"。
|
||||
//
|
||||
// 三种情况下错误体均为 {"type":"error","error":{"type":"upstream_error","message":...}}。
|
||||
func TestGatewayCharacterization_UpstreamErrorPassthroughRules(t *testing.T) {
|
||||
passthroughRule := &model.ErrorPassthroughRule{
|
||||
ID: 1,
|
||||
Name: "404-keyword-passthrough",
|
||||
Enabled: true,
|
||||
Priority: 1,
|
||||
ErrorCodes: []int{404},
|
||||
Keywords: []string{"quota_exceeded_marker"},
|
||||
MatchMode: model.MatchModeAll,
|
||||
Platforms: []string{model.PlatformAnthropic},
|
||||
PassthroughCode: true,
|
||||
PassthroughBody: true,
|
||||
}
|
||||
overrideRule := &model.ErrorPassthroughRule{
|
||||
ID: 2,
|
||||
Name: "404-keyword-override",
|
||||
Enabled: true,
|
||||
Priority: 1,
|
||||
ErrorCodes: []int{404},
|
||||
Keywords: []string{"quota_exceeded_marker"},
|
||||
MatchMode: model.MatchModeAll,
|
||||
Platforms: []string{model.PlatformAnthropic},
|
||||
PassthroughCode: false,
|
||||
ResponseCode: passCharIntPtr(http.StatusTooManyRequests),
|
||||
PassthroughBody: false,
|
||||
CustomMessage: passCharStrPtr("custom upstream busy"),
|
||||
}
|
||||
|
||||
upstreamHitBody := `{"type":"error","error":{"type":"not_found_error","message":"quota_exceeded_marker: model not found"}}`
|
||||
upstreamMissBody := `{"type":"error","error":{"type":"not_found_error","message":"plain not found"}}`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rules []*model.ErrorPassthroughRule
|
||||
upstreamBody string
|
||||
wantStatus int
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "规则命中_透传上游状态码与消息",
|
||||
rules: []*model.ErrorPassthroughRule{passthroughRule},
|
||||
upstreamBody: upstreamHitBody,
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantMessage: "quota_exceeded_marker: model not found",
|
||||
},
|
||||
{
|
||||
name: "规则命中_自定义状态码与消息",
|
||||
rules: []*model.ErrorPassthroughRule{overrideRule},
|
||||
upstreamBody: upstreamHitBody,
|
||||
wantStatus: http.StatusTooManyRequests,
|
||||
wantMessage: "custom upstream busy",
|
||||
},
|
||||
{
|
||||
name: "规则未命中_默认502转换",
|
||||
rules: []*model.ErrorPassthroughRule{passthroughRule},
|
||||
upstreamBody: upstreamMissBody,
|
||||
wantStatus: http.StatusBadGateway,
|
||||
wantMessage: "Upstream request failed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
upstream := &anthropicHTTPUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(tt.upstreamBody)),
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
|
||||
svc := &GatewayService{
|
||||
cfg: cfg,
|
||||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
|
||||
c, rec := passCharGinContext(t, "/v1/messages")
|
||||
BindErrorPassthroughService(c, passCharErrorPassthroughService(tt.rules))
|
||||
|
||||
parsed := &ParsedRequest{
|
||||
Body: NewRequestBodyRef([]byte(`{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":"hi"}]}`)),
|
||||
Model: "claude-sonnet-4-20250514",
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, passCharAnthropicAccount(9004), parsed)
|
||||
require.Error(t, err, "上游错误时 Forward 必须返回 error(响应已写给客户端)")
|
||||
require.Nil(t, result)
|
||||
|
||||
require.Equal(t, tt.wantStatus, rec.Code)
|
||||
require.JSONEq(t,
|
||||
`{"type":"error","error":{"type":"upstream_error","message":"`+tt.wantMessage+`"}}`,
|
||||
rec.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_Upstream400BodyPassthrough 固化 handleErrorResponse 对 400 的
|
||||
// 特殊语义:未命中透传规则时,上游 400 响应体原样透传给客户端(状态码 400 + 原 body)。
|
||||
func TestGatewayCharacterization_Upstream400BodyPassthrough(t *testing.T) {
|
||||
upstreamBody := `{"type":"error","error":{"type":"invalid_request_error","message":"max_tokens: required"}}`
|
||||
upstream := &anthropicHTTPUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
|
||||
svc := &GatewayService{
|
||||
cfg: cfg,
|
||||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
|
||||
c, rec := passCharGinContext(t, "/v1/messages")
|
||||
parsed := &ParsedRequest{
|
||||
Body: NewRequestBodyRef([]byte(`{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":"hi"}]}`)),
|
||||
Model: "claude-sonnet-4-20250514",
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, passCharAnthropicAccount(9005), parsed)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.Equal(t, upstreamBody, rec.Body.String(), "上游 400 body 应原样透传")
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//go:build unit
|
||||
|
||||
// Phase-0 TASK-002 特征化测试:OpenAI 路径流式/非流式透传(INVARIANTS I-1.5)。
|
||||
// 固定 mock 上游,断言客户端最终收到的字节/SSE 事件序列/状态码/响应头。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// passCharOpenAIAccount 返回 OpenAI API Key 自动透传账号夹具。
|
||||
func passCharOpenAIAccount(id int64) *Account {
|
||||
return &Account{
|
||||
ID: id,
|
||||
Name: "pass-char-openai",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-upstream-openai",
|
||||
"base_url": "https://upstream.example.com",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"use_responses_api": true,
|
||||
"openai_passthrough": true,
|
||||
},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
}
|
||||
|
||||
func passCharOpenAIService(cfg *config.Config, upstream HTTPUpstream) *OpenAIGatewayService {
|
||||
return &OpenAIGatewayService{
|
||||
cfg: cfg,
|
||||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
}
|
||||
|
||||
func passCharOpenAIGinContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
|
||||
SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
|
||||
return c, rec
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_OpenAIStreamPassthrough 固化 I-1.5 流式侧:
|
||||
// OpenAI 透传账号流式转发时,客户端收到的 SSE 内容与上游逐事件一致(按 \n\n 分帧),
|
||||
// 包含 event: 行、preamble 事件(response.created)与终止事件(response.completed/[DONE])。
|
||||
func TestGatewayCharacterization_OpenAIStreamPassthrough(t *testing.T) {
|
||||
upstreamEvents := []string{
|
||||
"event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_char_1\"}}",
|
||||
"event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"Hel\"}",
|
||||
"event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"lo ✓\"}",
|
||||
"event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_char_1\",\"usage\":{\"input_tokens\":11,\"output_tokens\":4,\"input_tokens_details\":{\"cached_tokens\":2}}}}",
|
||||
"data: [DONE]",
|
||||
}
|
||||
upstreamSSE := strings.Join(upstreamEvents, "\n\n") + "\n\n"
|
||||
|
||||
upstream := &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
"x-request-id": []string{"rid-char-openai-stream"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
|
||||
svc := passCharOpenAIService(cfg, upstream)
|
||||
|
||||
c, rec := passCharOpenAIGinContext(t)
|
||||
body := []byte(`{"model":"gpt-5","stream":true,"instructions":"be brief","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}]}`)
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, passCharOpenAIAccount(9101), body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, result.Stream)
|
||||
|
||||
gotEvents := passCharSplitSSEEvents(rec.Body.String())
|
||||
require.Equal(t, upstreamEvents, gotEvents, "客户端收到的 SSE 事件序列必须与上游逐事件一致")
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "text/event-stream", rec.Header().Get("Content-Type"))
|
||||
require.Equal(t, "rid-char-openai-stream", rec.Header().Get("x-request-id"))
|
||||
require.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
|
||||
|
||||
// 透传模式下请求侧只替换认证:上游应收到原始 body + Bearer 上游 key,不残留入站鉴权。
|
||||
require.NotNil(t, upstream.lastReq)
|
||||
require.Equal(t, string(body), string(upstream.lastBody), "透传模式上游请求体应与客户端原始 body 一致")
|
||||
require.Equal(t, "Bearer sk-upstream-openai", upstream.lastReq.Header.Get("Authorization"))
|
||||
|
||||
require.Equal(t, 11, result.Usage.InputTokens)
|
||||
require.Equal(t, 4, result.Usage.OutputTokens)
|
||||
require.Equal(t, 2, result.Usage.CacheReadInputTokens)
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_OpenAINonStreamPassthroughByteExact 固化 I-1.5 非流式侧:
|
||||
// 上游 2xx JSON 响应体逐字节透传、状态码/Content-Type 透传,x-codex-* 配额头强制放行。
|
||||
func TestGatewayCharacterization_OpenAINonStreamPassthroughByteExact(t *testing.T) {
|
||||
upstreamJSON := "{\"id\":\"resp_char_2\", \"object\":\"response\",\n" +
|
||||
"\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"héllo <&> ✓\"}]}],\n" +
|
||||
"\"usage\":{\"input_tokens\":21,\"output_tokens\":8,\"input_tokens_details\":{\"cached_tokens\":5}}}"
|
||||
|
||||
upstream := &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"x-request-id": []string{"rid-char-openai-nonstream"},
|
||||
"Set-Cookie": []string{"secret=upstream"},
|
||||
"X-Codex-Primary-Used-Percent": []string{"42.5"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamJSON)),
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
|
||||
svc := passCharOpenAIService(cfg, upstream)
|
||||
|
||||
c, rec := passCharOpenAIGinContext(t)
|
||||
body := []byte(`{"model":"gpt-5","stream":false,"instructions":"be brief","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}]}`)
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, passCharOpenAIAccount(9102), body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.False(t, result.Stream)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, upstreamJSON, rec.Body.String(), "上游 2xx body 必须逐字节透传")
|
||||
require.Equal(t, "application/json", rec.Header().Get("Content-Type"))
|
||||
require.Equal(t, "rid-char-openai-nonstream", rec.Header().Get("x-request-id"))
|
||||
require.Equal(t, "42.5", rec.Header().Get("X-Codex-Primary-Used-Percent"), "x-codex-* 配额头透传模式强制放行")
|
||||
require.Empty(t, rec.Header().Get("Set-Cookie"), "Set-Cookie 应被响应头过滤移除")
|
||||
|
||||
require.Equal(t, 21, result.Usage.InputTokens)
|
||||
require.Equal(t, 8, result.Usage.OutputTokens)
|
||||
require.Equal(t, 5, result.Usage.CacheReadInputTokens)
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_OpenAIPassthroughUpstreamErrorBodyVerbatim 固化 OpenAI 透传
|
||||
// 模式的错误语义:非容量类 4xx(如 400)保持原样代理——上游状态码 + 原始错误体透传,
|
||||
// Forward 返回 error(供 handler 记日志,但响应已写完)。
|
||||
func TestGatewayCharacterization_OpenAIPassthroughUpstreamErrorBodyVerbatim(t *testing.T) {
|
||||
upstreamErrJSON := `{"error":{"type":"invalid_request_error","message":"Unsupported parameter: 'foo'","param":"foo"}}`
|
||||
upstream := &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"x-request-id": []string{"rid-char-openai-err"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamErrJSON)),
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
|
||||
svc := passCharOpenAIService(cfg, upstream)
|
||||
|
||||
c, rec := passCharOpenAIGinContext(t)
|
||||
body := []byte(`{"model":"gpt-5","stream":false,"instructions":"be brief","input":"hi","foo":1}`)
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, passCharOpenAIAccount(9103), body)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, "透传模式上游错误状态码原样代理")
|
||||
require.Equal(t, upstreamErrJSON, rec.Body.String(), "透传模式上游错误体逐字节透传")
|
||||
require.Equal(t, "application/json", rec.Header().Get("Content-Type"))
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
)
|
||||
|
||||
// DefaultModelIDsForPlatform 返回平台默认模型 ID 列表(未知平台回退 Claude 默认集)。
|
||||
//
|
||||
// 由 handler 的 /v1/models 自定义模型过滤与 admin 的默认模型候选两处共同消费——
|
||||
// 此前两处各持一份同构 switch,必须人工保持同步(Phase-3 TASK-003 收敛为单一来源)。
|
||||
// 仅共享"ID 列表"这一形状;需要完整模型结构体的调用方仍直接使用各平台包的导出值。
|
||||
func DefaultModelIDsForPlatform(platform string) []string {
|
||||
switch platform {
|
||||
case PlatformOpenAI:
|
||||
return openai.DefaultModelIDs()
|
||||
case PlatformGemini:
|
||||
ids := make([]string, 0, len(geminicli.DefaultModels))
|
||||
for _, model := range geminicli.DefaultModels {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return ids
|
||||
case PlatformAntigravity:
|
||||
models := antigravity.DefaultModels()
|
||||
ids := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return ids
|
||||
default:
|
||||
ids := make([]string, 0, len(claude.DefaultModels))
|
||||
for _, model := range claude.DefaultModels {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
)
|
||||
|
||||
// TestDefaultModelIDsForPlatform 锁定四平台 + 未知平台兜底的 ID 来源
|
||||
//(Phase-3 TASK-003:两处同构 switch 收敛后的单一来源回归测试)。
|
||||
func TestDefaultModelIDsForPlatform(t *testing.T) {
|
||||
require.Equal(t, openai.DefaultModelIDs(), DefaultModelIDsForPlatform(PlatformOpenAI))
|
||||
|
||||
geminiIDs := DefaultModelIDsForPlatform(PlatformGemini)
|
||||
require.Len(t, geminiIDs, len(geminicli.DefaultModels))
|
||||
for i, m := range geminicli.DefaultModels {
|
||||
require.Equal(t, m.ID, geminiIDs[i])
|
||||
}
|
||||
|
||||
agModels := antigravity.DefaultModels()
|
||||
agIDs := DefaultModelIDsForPlatform(PlatformAntigravity)
|
||||
require.Len(t, agIDs, len(agModels))
|
||||
for i, m := range agModels {
|
||||
require.Equal(t, m.ID, agIDs[i])
|
||||
}
|
||||
|
||||
for _, unknown := range []string{PlatformAnthropic, "", "no-such-platform"} {
|
||||
ids := DefaultModelIDsForPlatform(unknown)
|
||||
require.Len(t, ids, len(claude.DefaultModels), "platform=%q 应回退 Claude 默认集", unknown)
|
||||
for i, m := range claude.DefaultModels {
|
||||
require.Equal(t, m.ID, ids[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
//go:build unit
|
||||
|
||||
// Phase-0 TASK-004 调度不变量测试(INVARIANTS I-4.1 / I-4.2 / I-4.3 / I-4.5)。
|
||||
//
|
||||
// 本文件固化粘性会话与选号过滤的外部可观测行为:
|
||||
// - I-4.1 绑定后二次请求命中同账号;session hash 输入优先级
|
||||
// (metadata.user_id → cacheable content → IP+UA+APIKeyID+system+messages);
|
||||
// - I-4.2 粘性绑定 TTL = 1 小时(常量引用 + 硬编码双断言 + miniredis 过期语义);
|
||||
// - I-4.3 sticky_escape:粘性账号不可用时能逃逸并重选成功(只断言硬语义,
|
||||
// 不断言触发阈值细节);
|
||||
// - I-4.5 账号级模型映射白名单(model_mapping)与渠道模型映射/定价限制对选号的影响。
|
||||
//
|
||||
// 断言纪律:不断言选号的具体排序结果(负载感知排序属可演化策略),
|
||||
// 只断言"命中/逃逸/排除/拒绝"等硬语义。
|
||||
//
|
||||
// 复用同包既有夹具:mockAccountRepoForPlatform / mockGroupRepoForGateway /
|
||||
// mockConcurrencyCache(gateway_multiplatform_test.go)、newTestChannelService /
|
||||
// makeStandardRepo(channel_service_test.go)、mustParseSessionHashRequest /
|
||||
// anthropicSessionBody / msg(generate_session_hash_test.go)。
|
||||
// 本文件新增的包级辅助类型/函数一律带 schedInv 前缀。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// schedInv 夹具
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// schedInvSetCall 记录一次 SetSessionAccountID / RefreshSessionTTL 调用。
|
||||
type schedInvSetCall struct {
|
||||
groupID int64
|
||||
hash string
|
||||
accountID int64
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// schedInvGatewayCache 是 GatewayCache 的内存实现,记录 TTL 与删除调用。
|
||||
type schedInvGatewayCache struct {
|
||||
mu sync.Mutex
|
||||
bindings map[string]int64
|
||||
setCalls []schedInvSetCall
|
||||
refreshCalls []schedInvSetCall
|
||||
deleteCalls []string
|
||||
}
|
||||
|
||||
var _ GatewayCache = (*schedInvGatewayCache)(nil)
|
||||
|
||||
func schedInvNewGatewayCache() *schedInvGatewayCache {
|
||||
return &schedInvGatewayCache{bindings: make(map[string]int64)}
|
||||
}
|
||||
|
||||
func schedInvCacheKey(groupID int64, hash string) string {
|
||||
return fmt.Sprintf("%d:%s", groupID, hash)
|
||||
}
|
||||
|
||||
func (c *schedInvGatewayCache) GetSessionAccountID(_ context.Context, groupID int64, sessionHash string) (int64, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if id, ok := c.bindings[schedInvCacheKey(groupID, sessionHash)]; ok {
|
||||
return id, nil
|
||||
}
|
||||
return 0, errors.New("schedInv: session binding not found")
|
||||
}
|
||||
|
||||
func (c *schedInvGatewayCache) SetSessionAccountID(_ context.Context, groupID int64, sessionHash string, accountID int64, ttl time.Duration) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.bindings[schedInvCacheKey(groupID, sessionHash)] = accountID
|
||||
c.setCalls = append(c.setCalls, schedInvSetCall{groupID: groupID, hash: sessionHash, accountID: accountID, ttl: ttl})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *schedInvGatewayCache) RefreshSessionTTL(_ context.Context, groupID int64, sessionHash string, ttl time.Duration) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.refreshCalls = append(c.refreshCalls, schedInvSetCall{groupID: groupID, hash: sessionHash, ttl: ttl})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *schedInvGatewayCache) DeleteSessionAccountID(_ context.Context, groupID int64, sessionHash string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.bindings, schedInvCacheKey(groupID, sessionHash))
|
||||
c.deleteCalls = append(c.deleteCalls, schedInvCacheKey(groupID, sessionHash))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *schedInvGatewayCache) binding(groupID int64, hash string) int64 {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.bindings[schedInvCacheKey(groupID, hash)]
|
||||
}
|
||||
|
||||
// schedInvRedisGatewayCache 是 miniredis 后端的 GatewayCache,
|
||||
// 与 repository/gateway_cache.go 的键语义一致,用于验证 TTL 过期行为。
|
||||
type schedInvRedisGatewayCache struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
var _ GatewayCache = (*schedInvRedisGatewayCache)(nil)
|
||||
|
||||
func (c *schedInvRedisGatewayCache) key(groupID int64, hash string) string {
|
||||
return fmt.Sprintf("sticky_session:%d:%s", groupID, hash)
|
||||
}
|
||||
|
||||
func (c *schedInvRedisGatewayCache) GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error) {
|
||||
return c.rdb.Get(ctx, c.key(groupID, sessionHash)).Int64()
|
||||
}
|
||||
|
||||
func (c *schedInvRedisGatewayCache) SetSessionAccountID(ctx context.Context, groupID int64, sessionHash string, accountID int64, ttl time.Duration) error {
|
||||
return c.rdb.Set(ctx, c.key(groupID, sessionHash), accountID, ttl).Err()
|
||||
}
|
||||
|
||||
func (c *schedInvRedisGatewayCache) RefreshSessionTTL(ctx context.Context, groupID int64, sessionHash string, ttl time.Duration) error {
|
||||
return c.rdb.Expire(ctx, c.key(groupID, sessionHash), ttl).Err()
|
||||
}
|
||||
|
||||
func (c *schedInvRedisGatewayCache) DeleteSessionAccountID(ctx context.Context, groupID int64, sessionHash string) error {
|
||||
return c.rdb.Del(ctx, c.key(groupID, sessionHash)).Err()
|
||||
}
|
||||
|
||||
// schedInvAccountRepo 构造账号 repo mock(复用 mockAccountRepoForPlatform)。
|
||||
func schedInvAccountRepo(accounts ...Account) *mockAccountRepoForPlatform {
|
||||
repo := &mockAccountRepoForPlatform{
|
||||
accounts: accounts,
|
||||
accountsByID: map[int64]*Account{},
|
||||
}
|
||||
for i := range repo.accounts {
|
||||
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
// schedInvLoadAwareService 构造启用负载感知调度(Layer1.5/Layer2)的 GatewayService。
|
||||
func schedInvLoadAwareService(repo *mockAccountRepoForPlatform, cache GatewayCache) *GatewayService {
|
||||
cfg := testConfig()
|
||||
cfg.Gateway.Scheduling.LoadBatchEnabled = true
|
||||
return &GatewayService{
|
||||
accountRepo: repo,
|
||||
cache: cache,
|
||||
cfg: cfg,
|
||||
concurrencyService: NewConcurrencyService(&mockConcurrencyCache{}),
|
||||
}
|
||||
}
|
||||
|
||||
func schedInvAnthropicAccount(id int64, priority int) Account {
|
||||
return Account{
|
||||
ID: id,
|
||||
Name: fmt.Sprintf("sched-inv-%d", id),
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Priority: priority,
|
||||
Concurrency: 5,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-4.1 session hash 输入优先级
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestSchedulingInvariant_SessionHashSourcePriority 固化 session hash 三级输入优先级:
|
||||
// metadata.user_id 的 session_xxx > cache_control(ephemeral) 内容 >
|
||||
// IP+UA+APIKeyID+system+messages 完整摘要兜底。
|
||||
func TestSchedulingInvariant_SessionHashSourcePriority(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
sessionCtx := &SessionContext{ClientIP: "10.1.2.3", UserAgent: "claude-cli/2.0.0", APIKeyID: 77}
|
||||
metadata := "user_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2_account__session_123e4567-e89b-12d3-a456-426614174000"
|
||||
cacheableSystem := []any{map[string]any{
|
||||
"type": "text",
|
||||
"text": "long cacheable system prompt",
|
||||
"cache_control": map[string]any{"type": "ephemeral"},
|
||||
}}
|
||||
messages := []any{msg("user", "hello invariants")}
|
||||
|
||||
// 1. 三个来源齐备 → metadata session_id 胜出
|
||||
full := mustParseSessionHashRequest(t, anthropicSessionBody(cacheableSystem, messages, metadata), sessionCtx)
|
||||
require.Equal(t, "123e4567-e89b-12d3-a456-426614174000", svc.GenerateSessionHash(full),
|
||||
"metadata.user_id 的 session_id 必须有最高优先级")
|
||||
|
||||
// 2. 去掉 metadata → cacheable content hash 胜出(与 SessionContext 无关)
|
||||
cacheableOnly := mustParseSessionHashRequest(t, anthropicSessionBody(cacheableSystem, messages, ""), sessionCtx)
|
||||
cacheableNoCtx := mustParseSessionHashRequest(t, anthropicSessionBody(cacheableSystem, messages, ""), nil)
|
||||
h2 := svc.GenerateSessionHash(cacheableOnly)
|
||||
require.NotEmpty(t, h2)
|
||||
require.Equal(t, svc.GenerateSessionHash(cacheableNoCtx), h2,
|
||||
"存在 cacheable content 时 SessionContext 不参与 hash(第 2 级优先于第 3 级)")
|
||||
|
||||
// 3. 无 metadata 且无 cacheable content → 兜底摘要,SessionContext 参与区分
|
||||
fallbackA := mustParseSessionHashRequest(t, anthropicSessionBody("plain system", messages, ""), sessionCtx)
|
||||
fallbackB := mustParseSessionHashRequest(t, anthropicSessionBody("plain system", messages, ""),
|
||||
&SessionContext{ClientIP: "10.9.9.9", UserAgent: "other-agent/1.0", APIKeyID: 88})
|
||||
h3a := svc.GenerateSessionHash(fallbackA)
|
||||
h3b := svc.GenerateSessionHash(fallbackB)
|
||||
require.NotEmpty(t, h3a)
|
||||
require.NotEqual(t, h3a, h3b, "兜底级 hash 必须混入 IP+UA+APIKeyID 区分因子")
|
||||
require.NotEqual(t, h2, h3a, "第 2 级与第 3 级来源应产生不同 hash")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-4.1 绑定后二次请求命中同账号
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSchedulingInvariant_StickySession_SecondSelectionHitsSameAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := schedInvAccountRepo(
|
||||
schedInvAnthropicAccount(1, 1),
|
||||
schedInvAnthropicAccount(2, 1),
|
||||
schedInvAnthropicAccount(3, 1),
|
||||
)
|
||||
cache := schedInvNewGatewayCache()
|
||||
svc := schedInvLoadAwareService(repo, cache)
|
||||
|
||||
const sessionHash = "sched-inv-sticky-hit"
|
||||
|
||||
// 第一次选号:无绑定 → 任选一个账号并写入粘性绑定
|
||||
first, err := svc.SelectAccountWithLoadAwareness(ctx, nil, sessionHash, "claude-sonnet-4-5", nil, "", 0)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, first)
|
||||
require.NotNil(t, first.Account)
|
||||
require.True(t, first.Acquired)
|
||||
boundID := cache.binding(0, sessionHash)
|
||||
require.Equal(t, first.Account.ID, boundID, "首次选号成功后必须建立粘性绑定")
|
||||
|
||||
// 第二次选号:同 sessionHash 必须命中同一账号(无论负载排序如何演化)
|
||||
second, err := svc.SelectAccountWithLoadAwareness(ctx, nil, sessionHash, "claude-sonnet-4-5", nil, "", 0)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, second.Account)
|
||||
require.Equal(t, first.Account.ID, second.Account.ID, "二次请求必须命中粘性绑定的同一账号")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-4.2 粘性绑定 TTL = 1 小时
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSchedulingInvariant_StickySession_TTLIsOneHour(t *testing.T) {
|
||||
// 双断言①:常量引用(常量被改动时此处失败)
|
||||
require.Equal(t, time.Hour, stickySessionTTL, "粘性会话 TTL 常量必须保持 1 小时")
|
||||
|
||||
t.Run("BindStickySession传递TTL", func(t *testing.T) {
|
||||
cache := schedInvNewGatewayCache()
|
||||
svc := &GatewayService{cache: cache}
|
||||
require.NoError(t, svc.BindStickySession(context.Background(), nil, "sess-ttl", 42))
|
||||
require.Len(t, cache.setCalls, 1)
|
||||
// 双断言②:硬编码当前值 + 常量引用
|
||||
require.Equal(t, time.Hour, cache.setCalls[0].ttl)
|
||||
require.Equal(t, stickySessionTTL, cache.setCalls[0].ttl)
|
||||
})
|
||||
|
||||
t.Run("粘性命中刷新TTL为1小时", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := schedInvAccountRepo(schedInvAnthropicAccount(1, 1), schedInvAnthropicAccount(2, 1))
|
||||
cache := schedInvNewGatewayCache()
|
||||
require.NoError(t, cache.SetSessionAccountID(ctx, 0, "sess-refresh", 1, stickySessionTTL))
|
||||
svc := schedInvLoadAwareService(repo, cache)
|
||||
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sess-refresh", "claude-sonnet-4-5", nil, "", 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), result.Account.ID, "应命中粘性账号")
|
||||
require.NotEmpty(t, cache.refreshCalls, "粘性命中后必须刷新 TTL")
|
||||
require.Equal(t, time.Hour, cache.refreshCalls[0].ttl)
|
||||
})
|
||||
|
||||
t.Run("miniredis过期语义", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = rdb.Close() })
|
||||
svc := &GatewayService{cache: &schedInvRedisGatewayCache{rdb: rdb}}
|
||||
|
||||
require.NoError(t, svc.BindStickySession(ctx, nil, "sess-expiry", 7))
|
||||
|
||||
got, err := svc.GetCachedSessionAccountID(ctx, nil, "sess-expiry")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(7), got)
|
||||
|
||||
// TTL 内(59 分钟后)仍然命中
|
||||
mr.FastForward(stickySessionTTL - time.Minute)
|
||||
got, err = svc.GetCachedSessionAccountID(ctx, nil, "sess-expiry")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(7), got)
|
||||
|
||||
// 超过 TTL 后绑定消失(当前实现返回 0 + 底层 miss error,handler 忽略 error)
|
||||
mr.FastForward(2 * time.Minute)
|
||||
got, _ = svc.GetCachedSessionAccountID(ctx, nil, "sess-expiry")
|
||||
require.Equal(t, int64(0), got, "TTL 过期后粘性绑定必须失效")
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-4.3 sticky_escape:粘性账号不可用时逃逸重选
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSchedulingInvariant_StickyEscape_ReselectsWhenBoundAccountUnavailable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const model = "claude-sonnet-4-5"
|
||||
|
||||
t.Run("绑定账号不可调度_逃逸并重绑", func(t *testing.T) {
|
||||
// 账号 1 状态 error → 不在可调度列表;粘性绑定仍指向它
|
||||
broken := schedInvAnthropicAccount(1, 1)
|
||||
broken.Status = StatusError
|
||||
healthy := schedInvAnthropicAccount(2, 1)
|
||||
repo := schedInvAccountRepo(broken, healthy)
|
||||
cache := schedInvNewGatewayCache()
|
||||
require.NoError(t, cache.SetSessionAccountID(ctx, 0, "sess-escape-a", 1, time.Hour))
|
||||
svc := schedInvLoadAwareService(repo, cache)
|
||||
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sess-escape-a", model, nil, "", 0)
|
||||
require.NoError(t, err, "粘性账号不可用时必须能逃逸重选,而不是失败")
|
||||
require.NotNil(t, result.Account)
|
||||
require.NotEqual(t, int64(1), result.Account.ID, "不得再选中不可用的粘性账号")
|
||||
require.Equal(t, result.Account.ID, cache.binding(0, "sess-escape-a"),
|
||||
"逃逸成功后粘性绑定更新为新账号(当前实现行为)")
|
||||
})
|
||||
|
||||
t.Run("绑定账号模型限流_清除绑定并逃逸", func(t *testing.T) {
|
||||
limited := schedInvAnthropicAccount(1, 1)
|
||||
limited.Extra = map[string]any{
|
||||
"model_rate_limits": map[string]any{
|
||||
model: map[string]any{
|
||||
"rate_limit_reset_at": time.Now().Add(30 * time.Minute).Format(time.RFC3339),
|
||||
},
|
||||
},
|
||||
}
|
||||
healthy := schedInvAnthropicAccount(2, 1)
|
||||
repo := schedInvAccountRepo(limited, healthy)
|
||||
cache := schedInvNewGatewayCache()
|
||||
require.NoError(t, cache.SetSessionAccountID(ctx, 0, "sess-escape-b", 1, time.Hour))
|
||||
svc := schedInvLoadAwareService(repo, cache)
|
||||
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sess-escape-b", model, nil, "", 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), result.Account.ID, "逃逸后应重选到可用账号")
|
||||
require.NotEmpty(t, cache.deleteCalls, "模型限流的粘性绑定应被清除(当前实现行为)")
|
||||
})
|
||||
|
||||
t.Run("绑定账号在failover排除集合_跳过粘性", func(t *testing.T) {
|
||||
repo := schedInvAccountRepo(schedInvAnthropicAccount(1, 1), schedInvAnthropicAccount(2, 1))
|
||||
cache := schedInvNewGatewayCache()
|
||||
require.NoError(t, cache.SetSessionAccountID(ctx, 0, "sess-escape-c", 1, time.Hour))
|
||||
svc := schedInvLoadAwareService(repo, cache)
|
||||
|
||||
excluded := map[int64]struct{}{1: {}}
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sess-escape-c", model, excluded, "", 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), result.Account.ID, "排除集合中的粘性账号必须被跳过")
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-4.5 账号级模型映射白名单对选号的影响
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSchedulingInvariant_AccountModelMappingFiltersSelection(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("不支持请求模型的账号被排除", func(t *testing.T) {
|
||||
// 账号 1 配了 model_mapping 白名单(只支持 opus),优先级更高;
|
||||
// 账号 2 未配映射(支持所有模型)。请求 sonnet 必须落到账号 2。
|
||||
restricted := schedInvAnthropicAccount(1, 0)
|
||||
restricted.Credentials = map[string]any{
|
||||
"model_mapping": map[string]any{"claude-opus-4-6": "claude-opus-4-6"},
|
||||
}
|
||||
open := schedInvAnthropicAccount(2, 9)
|
||||
repo := schedInvAccountRepo(restricted, open)
|
||||
svc := schedInvLoadAwareService(repo, schedInvNewGatewayCache())
|
||||
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-sonnet-4-5", nil, "", 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), result.Account.ID,
|
||||
"配置了 model_mapping 白名单且不含请求模型的账号必须被排除(即使优先级更高)")
|
||||
})
|
||||
|
||||
t.Run("支持模型的账号正常命中映射", func(t *testing.T) {
|
||||
restricted := schedInvAnthropicAccount(1, 0)
|
||||
restricted.Credentials = map[string]any{
|
||||
"model_mapping": map[string]any{"claude-opus-4-6": "claude-opus-4-6"},
|
||||
}
|
||||
repo := schedInvAccountRepo(restricted)
|
||||
svc := schedInvLoadAwareService(repo, schedInvNewGatewayCache())
|
||||
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-opus-4-6", nil, "", 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), result.Account.ID)
|
||||
})
|
||||
|
||||
t.Run("所有账号都不支持模型_返回无可用账号", func(t *testing.T) {
|
||||
a := schedInvAnthropicAccount(1, 1)
|
||||
a.Credentials = map[string]any{"model_mapping": map[string]any{"claude-opus-4-6": "claude-opus-4-6"}}
|
||||
b := schedInvAnthropicAccount(2, 1)
|
||||
b.Credentials = map[string]any{"model_mapping": map[string]any{"claude-haiku-4-5": "claude-haiku-4-5"}}
|
||||
repo := schedInvAccountRepo(a, b)
|
||||
svc := schedInvLoadAwareService(repo, schedInvNewGatewayCache())
|
||||
|
||||
_, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-sonnet-4-5", nil, "", 0)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrNoAvailableAccounts)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-4.5 渠道模型映射 + 定价限制对选号的影响
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSchedulingInvariant_ChannelMappingPricingRestrictionAffectsSelection(t *testing.T) {
|
||||
const groupID = int64(10)
|
||||
newSvc := func(pricingModels []string) (*GatewayService, *mockGroupRepoForGateway) {
|
||||
ch := Channel{
|
||||
ID: 1,
|
||||
Status: StatusActive,
|
||||
GroupIDs: []int64{groupID},
|
||||
RestrictModels: true,
|
||||
BillingModelSource: BillingModelSourceChannelMapped,
|
||||
ModelPricing: []ChannelModelPricing{
|
||||
{Platform: PlatformAnthropic, Models: pricingModels},
|
||||
},
|
||||
ModelMapping: map[string]map[string]string{
|
||||
PlatformAnthropic: {"claude-sonnet-4-5": "claude-sonnet-4-6"},
|
||||
},
|
||||
}
|
||||
channelSvc := newTestChannelService(makeStandardRepo(ch, map[int64]string{groupID: PlatformAnthropic}))
|
||||
repo := schedInvAccountRepo(func() Account {
|
||||
a := schedInvAnthropicAccount(1, 1)
|
||||
a.AccountGroups = []AccountGroup{{AccountID: 1, GroupID: groupID}}
|
||||
return a
|
||||
}())
|
||||
groupRepo := &mockGroupRepoForGateway{
|
||||
groups: map[int64]*Group{
|
||||
groupID: {ID: groupID, Platform: PlatformAnthropic, Status: StatusActive, Hydrated: true},
|
||||
},
|
||||
}
|
||||
svc := &GatewayService{
|
||||
accountRepo: repo,
|
||||
groupRepo: groupRepo,
|
||||
channelService: channelSvc,
|
||||
cache: schedInvNewGatewayCache(),
|
||||
cfg: testConfig(),
|
||||
}
|
||||
return svc, groupRepo
|
||||
}
|
||||
ctxWithGroup := func(groupRepo *mockGroupRepoForGateway) context.Context {
|
||||
return context.WithValue(context.Background(), ctxkey.Group, groupRepo.groups[groupID])
|
||||
}
|
||||
|
||||
t.Run("映射目标不在渠道定价_选号被拒绝", func(t *testing.T) {
|
||||
// 渠道映射 sonnet-4-5 → sonnet-4-6,但定价列表只有 opus:
|
||||
// 限制检查对象是"映射后模型",因此请求 sonnet-4-5 必须被拒绝。
|
||||
gid := groupID
|
||||
svc, groupRepo := newSvc([]string{"claude-opus-4-6"})
|
||||
_, err := svc.SelectAccountWithLoadAwareness(ctxWithGroup(groupRepo), &gid, "", "claude-sonnet-4-5", nil, "", 0)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrNoAvailableAccounts)
|
||||
require.Contains(t, err.Error(), "channel pricing restriction",
|
||||
"渠道定价限制必须以专属错误语义拒绝选号")
|
||||
})
|
||||
|
||||
t.Run("映射目标在渠道定价_正常选号", func(t *testing.T) {
|
||||
// 定价列表含映射后的 sonnet-4-6(即使不含原始请求模型 sonnet-4-5)→ 放行。
|
||||
// 这证明渠道映射结果(而非原始模型名)决定选号阶段的限制判定。
|
||||
gid := groupID
|
||||
svc, groupRepo := newSvc([]string{"claude-sonnet-4-6"})
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctxWithGroup(groupRepo), &gid, "", "claude-sonnet-4-5", nil, "", 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), result.Account.ID)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase-0 TASK-005 热路径基准采集与对比(插件化改造回归安全网)。
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/bench-baseline.sh collect # 采集基线 -> testdata/bench/baseline.txt
|
||||
# ./scripts/bench-baseline.sh compare # 当前代码 vs 基线,违反阈值时退出码 1
|
||||
#
|
||||
# 对比策略(每个基准按多次采样的均值比较):
|
||||
# - allocs/op 严格:增加 > ALLOC_TOLERANCE(默认 0)即失败
|
||||
# - ns/op 宽松:劣化 > TIME_TOLERANCE_PCT%(默认 15,吸收 runner 抖动)
|
||||
# 环境变量:BENCH_COUNT(采样次数,默认 6)、TIME_TOLERANCE_PCT、ALLOC_TOLERANCE
|
||||
#
|
||||
# 注意:基线与对比应在同类机器上进行;换机器/换 Go 版本后请重新 collect
|
||||
# (环境信息记录在 testdata/bench/baseline.env.txt)。
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
PATTERN='BenchmarkGatewayForward|BenchmarkGatewayService_ParseSSEUsage|BenchmarkParseClaudeUsageFromResponseBody'
|
||||
PKGS='./internal/service/'
|
||||
BASELINE=testdata/bench/baseline.txt
|
||||
COUNT="${BENCH_COUNT:-6}"
|
||||
TIME_TOLERANCE_PCT="${TIME_TOLERANCE_PCT:-15}"
|
||||
ALLOC_TOLERANCE="${ALLOC_TOLERANCE:-0}"
|
||||
|
||||
run_bench() {
|
||||
go test -tags=unit -run '^$' -bench "$PATTERN" -benchmem -count="$COUNT" "$PKGS"
|
||||
}
|
||||
|
||||
# 从基准输出中提取每个基准的均值:name mean_ns mean_allocs
|
||||
# ns 与 allocs 独立计数;缺任一指标(如未带 -benchmem)的基准跳过并警告,
|
||||
# 避免除零产生 inf/nan 静默污染对比结果。
|
||||
summarize() {
|
||||
awk '/^Benchmark/ {
|
||||
name=$1; sub(/-[0-9]+$/, "", name)
|
||||
for (i=2; i<=NF; i++) {
|
||||
if ($(i)=="ns/op") { ns[name]+=$(i-1); nns[name]++ }
|
||||
if ($(i)=="allocs/op") { al[name]+=$(i-1); nal[name]++ }
|
||||
}
|
||||
}
|
||||
END {
|
||||
for (k in nns) {
|
||||
if (nns[k]>0 && nal[k]>0) printf "%s %.2f %.2f\n", k, ns[k]/nns[k], al[k]/nal[k]
|
||||
else print "warn: skip " k " (missing ns/op or allocs/op samples, run with -benchmem)" > "/dev/stderr"
|
||||
}
|
||||
}' "$1" | sort
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
collect)
|
||||
mkdir -p "$(dirname "$BASELINE")"
|
||||
{
|
||||
echo "go: $(go version)"
|
||||
echo "cpu: $(grep -m1 'model name' /proc/cpuinfo 2>/dev/null | cut -d: -f2- | xargs || echo unknown)"
|
||||
echo "date: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "count: $COUNT"
|
||||
} > "$(dirname "$BASELINE")/baseline.env.txt"
|
||||
run_bench | tee "$BASELINE"
|
||||
echo "基线已写入 $BASELINE"
|
||||
;;
|
||||
compare)
|
||||
[ -f "$BASELINE" ] || { echo "缺少基线 $BASELINE,先运行: $0 collect" >&2; exit 2; }
|
||||
new="$(mktemp)"
|
||||
run_bench | tee "$new"
|
||||
echo
|
||||
if command -v benchstat >/dev/null 2>&1; then
|
||||
benchstat "$BASELINE" "$new" || true
|
||||
echo
|
||||
fi
|
||||
base_sum="$(mktemp)"; new_sum="$(mktemp)"
|
||||
summarize "$BASELINE" > "$base_sum"
|
||||
summarize "$new" > "$new_sum"
|
||||
# 基准名集合对比(审计 F-1):join 只输出两侧共有的基准,增删/改名若不检查
|
||||
# 会被静默丢弃,造成"基准消失但 compare 仍绿"的假阴性。
|
||||
added="$(join -v2 "$base_sum" "$new_sum" | awk '{print $1}')"
|
||||
missing="$(join -v1 "$base_sum" "$new_sum" | awk '{print $1}')"
|
||||
table_status=0
|
||||
join "$base_sum" "$new_sum" | awk -v tp="$TIME_TOLERANCE_PCT" -v at="$ALLOC_TOLERANCE" '
|
||||
{
|
||||
name=$1; bns=$2; bal=$3; nns=$4; nal=$5
|
||||
dns = (bns>0) ? (nns-bns)/bns*100 : 0
|
||||
dal = nal-bal
|
||||
status="ok"
|
||||
if (dal > at) { status="FAIL(allocs/op +" dal ")"; fail=1 }
|
||||
else if (dns > tp) { status="FAIL(ns/op +" sprintf("%.1f", dns) "%)"; fail=1 }
|
||||
printf "%-60s ns/op %10.0f -> %10.0f (%+6.1f%%) allocs/op %7.1f -> %7.1f %s\n", name, bns, nns, dns, bal, nal, status
|
||||
}
|
||||
END { exit fail ? 1 : 0 }' || table_status=$?
|
||||
if [ -n "$added" ]; then
|
||||
printf 'warn: 以下基准在基线中不存在,未参与对比(新增基准请重新 collect 基线):\n%s\n' "$added" >&2
|
||||
fi
|
||||
if [ -n "$missing" ]; then
|
||||
printf 'FAIL: 基线中的以下基准在本次运行缺失(被删除/改名?回归网已失效,须修复或重新 collect):\n%s\n' "$missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
exit "$table_status"
|
||||
;;
|
||||
*)
|
||||
echo "用法: $0 {collect|compare}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
go: go version go1.26.4 linux/amd64
|
||||
cpu: 13th Gen Intel(R) Core(TM) i5-13600KF
|
||||
date: 2026-06-11T15:55:37Z
|
||||
count: 6
|
||||
Vendored
+60
@@ -0,0 +1,60 @@
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
pkg: github.com/Wei-Shaw/sub2api/internal/service
|
||||
cpu: 13th Gen Intel(R) Core(TM) i5-13600KF
|
||||
BenchmarkGatewayService_ParseSSEUsage_MessageStart-20 467865 2403 ns/op 2224 B/op 40 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsage_MessageStart-20 463100 2455 ns/op 2224 B/op 40 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsage_MessageStart-20 472974 2421 ns/op 2224 B/op 40 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsage_MessageStart-20 468956 2379 ns/op 2224 B/op 40 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsage_MessageStart-20 508983 2372 ns/op 2224 B/op 40 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsage_MessageStart-20 494251 2378 ns/op 2224 B/op 40 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart-20 1846669 643.4 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart-20 1850743 655.2 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart-20 1842531 650.7 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart-20 1842110 649.5 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart-20 1824736 647.4 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart-20 1847221 649.1 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsage_MessageDelta-20 515887 2186 ns/op 1872 B/op 37 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsage_MessageDelta-20 519673 2255 ns/op 1872 B/op 37 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsage_MessageDelta-20 555856 2197 ns/op 1872 B/op 37 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsage_MessageDelta-20 547356 2194 ns/op 1872 B/op 37 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsage_MessageDelta-20 520796 2224 ns/op 1872 B/op 37 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsage_MessageDelta-20 531657 2177 ns/op 1872 B/op 37 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta-20 1596841 755.8 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta-20 1580008 751.3 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta-20 1594468 756.5 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta-20 1579392 750.5 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta-20 1595959 751.4 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta-20 1604078 753.9 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkParseClaudeUsageFromResponseBody-20 1474922 817.2 ns/op 320 B/op 2 allocs/op
|
||||
BenchmarkParseClaudeUsageFromResponseBody-20 1468426 820.2 ns/op 320 B/op 2 allocs/op
|
||||
BenchmarkParseClaudeUsageFromResponseBody-20 1469355 815.5 ns/op 320 B/op 2 allocs/op
|
||||
BenchmarkParseClaudeUsageFromResponseBody-20 1473657 819.9 ns/op 320 B/op 2 allocs/op
|
||||
BenchmarkParseClaudeUsageFromResponseBody-20 1353183 863.3 ns/op 320 B/op 2 allocs/op
|
||||
BenchmarkParseClaudeUsageFromResponseBody-20 1439142 825.9 ns/op 320 B/op 2 allocs/op
|
||||
BenchmarkGatewayForward_AntigravityClaudeNonStream-20 28329 39620 ns/op 30780 B/op 266 allocs/op
|
||||
BenchmarkGatewayForward_AntigravityClaudeNonStream-20 30484 39647 ns/op 30791 B/op 266 allocs/op
|
||||
BenchmarkGatewayForward_AntigravityClaudeNonStream-20 29310 39630 ns/op 30832 B/op 266 allocs/op
|
||||
BenchmarkGatewayForward_AntigravityClaudeNonStream-20 31288 38798 ns/op 30769 B/op 266 allocs/op
|
||||
BenchmarkGatewayForward_AntigravityClaudeNonStream-20 31226 40313 ns/op 30848 B/op 266 allocs/op
|
||||
BenchmarkGatewayForward_AntigravityClaudeNonStream-20 30166 39549 ns/op 30793 B/op 266 allocs/op
|
||||
BenchmarkGatewayForward_AntigravityClaudeStream-20 27830 42915 ns/op 39901 B/op 329 allocs/op
|
||||
BenchmarkGatewayForward_AntigravityClaudeStream-20 28850 43027 ns/op 39900 B/op 329 allocs/op
|
||||
BenchmarkGatewayForward_AntigravityClaudeStream-20 26864 41222 ns/op 39906 B/op 329 allocs/op
|
||||
BenchmarkGatewayForward_AntigravityClaudeStream-20 28328 43579 ns/op 39959 B/op 329 allocs/op
|
||||
BenchmarkGatewayForward_AntigravityClaudeStream-20 28596 43420 ns/op 39852 B/op 329 allocs/op
|
||||
BenchmarkGatewayForward_AntigravityClaudeStream-20 28867 42027 ns/op 39861 B/op 329 allocs/op
|
||||
BenchmarkGatewayForward_AnthropicNonStreamPassthrough-20 132291 9375 ns/op 14666 B/op 93 allocs/op
|
||||
BenchmarkGatewayForward_AnthropicNonStreamPassthrough-20 129505 9491 ns/op 14666 B/op 93 allocs/op
|
||||
BenchmarkGatewayForward_AnthropicNonStreamPassthrough-20 122263 9278 ns/op 14666 B/op 93 allocs/op
|
||||
BenchmarkGatewayForward_AnthropicNonStreamPassthrough-20 132528 9455 ns/op 14666 B/op 93 allocs/op
|
||||
BenchmarkGatewayForward_AnthropicNonStreamPassthrough-20 130437 9289 ns/op 14666 B/op 93 allocs/op
|
||||
BenchmarkGatewayForward_AnthropicNonStreamPassthrough-20 131500 9505 ns/op 14666 B/op 93 allocs/op
|
||||
BenchmarkGatewayForward_AnthropicStreamPassthrough-20 42405 27982 ns/op 19168 B/op 134 allocs/op
|
||||
BenchmarkGatewayForward_AnthropicStreamPassthrough-20 43042 28211 ns/op 19174 B/op 134 allocs/op
|
||||
BenchmarkGatewayForward_AnthropicStreamPassthrough-20 41194 28241 ns/op 19221 B/op 134 allocs/op
|
||||
BenchmarkGatewayForward_AnthropicStreamPassthrough-20 42230 28071 ns/op 19172 B/op 134 allocs/op
|
||||
BenchmarkGatewayForward_AnthropicStreamPassthrough-20 43098 27915 ns/op 19186 B/op 134 allocs/op
|
||||
BenchmarkGatewayForward_AnthropicStreamPassthrough-20 42762 27136 ns/op 19161 B/op 134 allocs/op
|
||||
PASS
|
||||
ok github.com/Wei-Shaw/sub2api/internal/service 85.616s
|
||||
@@ -0,0 +1,184 @@
|
||||
// Command newmodule 生成插件模块骨架(纯开发工具,不进生产二进制)。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// go run ./tools/newmodule -id job.foo
|
||||
// make new-module ID=job.foo
|
||||
//
|
||||
// 在 internal/modules/<模块名>/ 下生成模块文件与基于 plugintest 的测试文件
|
||||
// (模板从 internal/modules/hello 提炼:四个可选生命周期接口骨架、编译期
|
||||
// 断言、EnabledByDefault=false、示例私有配置与校验),并打印后续插装步骤。
|
||||
//
|
||||
// 生成器不会自动修改 internal/modules/standard/imports.go——
|
||||
// 显式插装可 review 是设计原则,import 行由作者手工加入。
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"go/token"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"text/template"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/plugin"
|
||||
)
|
||||
|
||||
// modulePath 是本仓库的 Go module 路径,用于生成插装 import 行。
|
||||
const modulePath = "github.com/Wei-Shaw/sub2api"
|
||||
|
||||
// authorGuidePath 是模块作者指南的仓库相对路径(next steps 提示用)。
|
||||
const authorGuidePath = "docs/plugin-architecture/MODULE-AUTHOR-GUIDE.md"
|
||||
|
||||
//go:embed templates/module.go.tmpl templates/module_test.go.tmpl
|
||||
var templatesFS embed.FS
|
||||
|
||||
// templateData 是模板渲染数据。
|
||||
type templateData struct {
|
||||
// ID 是完整模块 ID,如 "job.foo"。
|
||||
ID string
|
||||
// Package 是模块包名(ID 最后一段),如 "foo"。
|
||||
Package string
|
||||
}
|
||||
|
||||
func main() {
|
||||
id := flag.String("id", "", "模块 ID(点分层级命名,如 job.foo)")
|
||||
dir := flag.String("dir", ".", "backend 根目录(须包含 internal/modules)")
|
||||
flag.Parse()
|
||||
|
||||
if err := run(*id, *dir, os.Stdout); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "newmodule: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// run 执行完整的生成流程:校验 ID 与包名 → 渲染模板 → 写盘 → 打印后续步骤。
|
||||
// root 是 backend 根目录(包含 internal/modules),out 接收 next steps 输出。
|
||||
func run(id, root string, out io.Writer) error {
|
||||
if id == "" {
|
||||
return errors.New(`missing module ID: usage "go run ./tools/newmodule -id job.foo" or "make new-module ID=job.foo"`)
|
||||
}
|
||||
if err := validateModuleID(id); err != nil {
|
||||
return err
|
||||
}
|
||||
pkg := plugin.ModuleID(id).Name()
|
||||
if err := validatePackageName(pkg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
modulesDir := filepath.Join(root, "internal", "modules")
|
||||
if info, err := os.Stat(modulesDir); err != nil || !info.IsDir() {
|
||||
return fmt.Errorf("%s not found: run from the backend root (or pass -dir)", modulesDir)
|
||||
}
|
||||
targetDir := filepath.Join(modulesDir, pkg)
|
||||
if _, err := os.Stat(targetDir); err == nil {
|
||||
return fmt.Errorf("target directory %s already exists; refusing to overwrite", targetDir)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("stat %s: %w", targetDir, err)
|
||||
}
|
||||
|
||||
data := templateData{ID: id, Package: pkg}
|
||||
moduleSrc, err := render("module.go.tmpl", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
testSrc, err := render("module_test.go.tmpl", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Mkdir(targetDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create %s: %w", targetDir, err)
|
||||
}
|
||||
files := []struct {
|
||||
name string
|
||||
content []byte
|
||||
}{
|
||||
{pkg + ".go", moduleSrc},
|
||||
{pkg + "_test.go", testSrc},
|
||||
}
|
||||
for _, f := range files {
|
||||
path := filepath.Join(targetDir, f.name)
|
||||
if err := os.WriteFile(path, f.content, 0o644); err != nil {
|
||||
// 写盘失败时回滚刚创建的目标目录,避免留下半成品。
|
||||
if rmErr := os.RemoveAll(targetDir); rmErr != nil {
|
||||
return errors.Join(fmt.Errorf("write %s: %w", path, err), rmErr)
|
||||
}
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
printNextSteps(out, id, pkg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateModuleID 通过内核导出口径 plugin.ParseConfig 复用 ModuleID.validate()
|
||||
// 的格式规则(见 internal/plugin/module.go):非空、点分、各段为非空的
|
||||
// [a-z0-9_-]+。不在生成器中复制规则,内核规则演进时自动跟随。
|
||||
func validateModuleID(id string) error {
|
||||
if _, err := plugin.ParseConfig(map[string]map[string]any{id: {}}); err != nil {
|
||||
return fmt.Errorf("invalid module ID %q (kernel rule, see internal/plugin/module.go ModuleID.validate): %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePackageName 校验模块名(ID 最后一段)可用作 Go 包名。
|
||||
// 内核 ID 规则允许 [a-z0-9_-],但包含 '-'、数字开头、Go 关键字与 "_"
|
||||
// 都不是合法包名,必须在生成前拒绝。
|
||||
func validatePackageName(name string) error {
|
||||
if name == "_" || !token.IsIdentifier(name) {
|
||||
return fmt.Errorf("module name %q (the last ID segment, used as the Go package name) is not a valid package name: it must be a non-keyword Go identifier (no '-', no leading digit, not a Go keyword, not %q)", name, "_")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// render 渲染指定模板并经 go/format 规范化,保证产物 gofmt 干净;
|
||||
// 渲染结果无法通过 gofmt(模板自身腐化)时立即报错,不写盘。
|
||||
func render(name string, data templateData) ([]byte, error) {
|
||||
tmpl, err := template.ParseFS(templatesFS, "templates/"+name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse template %s: %w", name, err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return nil, fmt.Errorf("execute template %s: %w", name, err)
|
||||
}
|
||||
src, err := format.Source(buf.Bytes())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gofmt rendered %s (template is corrupted, fix tools/newmodule/templates): %w", name, err)
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
// printNextSteps 打印生成结果与后续插装步骤(生成器不自动改 imports.go)。
|
||||
func printNextSteps(out io.Writer, id, pkg string) {
|
||||
fmt.Fprintf(out, `module %[1]s generated:
|
||||
internal/modules/%[2]s/%[2]s.go
|
||||
internal/modules/%[2]s/%[2]s_test.go
|
||||
|
||||
Next steps (the generator never edits imports.go -- explicit instrumentation keeps it reviewable):
|
||||
|
||||
1. 编译期插装:在 internal/modules/standard/imports.go 的 import 块中加入:
|
||||
|
||||
// %[1]s:TODO 一句话描述模块用途(默认 disabled)。
|
||||
_ "%[3]s/internal/modules/%[2]s"
|
||||
|
||||
2. 配置启用(config.yaml;新模块默认 disabled,未显式 enabled 时零行为变更):
|
||||
|
||||
modules:
|
||||
%[1]s:
|
||||
enabled: true
|
||||
interval: 30s
|
||||
|
||||
3. 填充代码中的 TODO 后运行测试:
|
||||
|
||||
go test -count=1 ./internal/modules/%[2]s/
|
||||
|
||||
生命周期契约与完整示例见 %[4]s(蓝本实现:internal/modules/hello/)。
|
||||
`, id, pkg, modulePath, authorGuidePath)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"go/format"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newRoot 构造一个带 internal/modules 结构的临时 backend 根目录。
|
||||
func newRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "internal", "modules"), 0o755))
|
||||
return root
|
||||
}
|
||||
|
||||
// TestRunGeneratesGofmtCleanModule 验证生成产物:文件路径正确、gofmt 干净
|
||||
// (format.Source 幂等)、关键骨架内容齐全,且 next steps 含确切的插装 import 行。
|
||||
func TestRunGeneratesGofmtCleanModule(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
var out bytes.Buffer
|
||||
require.NoError(t, run("job.foo", root, &out))
|
||||
|
||||
moduleFile := filepath.Join(root, "internal", "modules", "foo", "foo.go")
|
||||
testFile := filepath.Join(root, "internal", "modules", "foo", "foo_test.go")
|
||||
|
||||
for _, path := range []string{moduleFile, testFile} {
|
||||
content, err := os.ReadFile(path)
|
||||
require.NoError(t, err, "generated file should exist: %s", path)
|
||||
formatted, err := format.Source(content)
|
||||
require.NoError(t, err, "generated file must be valid Go: %s", path)
|
||||
require.Equal(t, string(formatted), string(content), "generated file must be gofmt-clean: %s", path)
|
||||
require.Contains(t, string(content), "package foo")
|
||||
}
|
||||
|
||||
moduleSrc, err := os.ReadFile(moduleFile)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(moduleSrc), `const ID plugin.ModuleID = "job.foo"`)
|
||||
require.Contains(t, string(moduleSrc), "EnabledByDefault: false")
|
||||
require.Contains(t, string(moduleSrc), "plugin.RegisterModule(&Module{})")
|
||||
// 四个可选生命周期接口的编译期断言齐全。
|
||||
for _, iface := range []string{"plugin.Provisioner", "plugin.Validator", "plugin.Starter", "plugin.Stopper"} {
|
||||
require.Contains(t, string(moduleSrc), iface)
|
||||
}
|
||||
require.Contains(t, string(moduleSrc), "TODO", "module template should mark author fill-in points")
|
||||
|
||||
testSrc, err := os.ReadFile(testFile)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(testSrc), "internal/plugin/plugintest", "test template must use plugintest fixtures")
|
||||
require.Contains(t, string(testSrc), "plugintest.RunLifecycle")
|
||||
|
||||
// next steps:确切 import 行、不自动插装的提示、配置示例与作者指南链接。
|
||||
nextSteps := out.String()
|
||||
require.Contains(t, nextSteps, `_ "github.com/Wei-Shaw/sub2api/internal/modules/foo"`)
|
||||
require.Contains(t, nextSteps, "internal/modules/standard/imports.go")
|
||||
require.Contains(t, nextSteps, "enabled: true")
|
||||
require.Contains(t, nextSteps, "docs/plugin-architecture/MODULE-AUTHOR-GUIDE.md")
|
||||
}
|
||||
|
||||
// TestRunRejectsInvalidModuleID 验证不符合内核规则的 ID 被拒绝,
|
||||
// 错误信息引用内核规则来源,且不留下任何产物目录。
|
||||
func TestRunRejectsInvalidModuleID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"uppercase", "Job.Foo"},
|
||||
{"empty segment", "job..foo"},
|
||||
{"illegal char", "job.foo!"},
|
||||
{"trailing dot", "job.foo."},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
err := run(tt.id, root, &bytes.Buffer{})
|
||||
require.Error(t, err)
|
||||
if tt.id != "" {
|
||||
require.Contains(t, err.Error(), "internal/plugin/module.go",
|
||||
"error should reference the kernel rule source")
|
||||
}
|
||||
entries, readErr := os.ReadDir(filepath.Join(root, "internal", "modules"))
|
||||
require.NoError(t, readErr)
|
||||
require.Empty(t, entries, "rejected ID must not leave artifacts")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunRejectsInvalidPackageName 验证 ID 符合内核规则但最后一段
|
||||
// 不是合法 Go 包名(关键字、含 '-'、数字开头、下划线)时被拒绝。
|
||||
func TestRunRejectsInvalidPackageName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
}{
|
||||
{"go keyword", "job.type"},
|
||||
{"contains dash", "job.foo-bar"},
|
||||
{"leading digit", "job.1foo"},
|
||||
{"blank identifier", "job._"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
err := run(tt.id, root, &bytes.Buffer{})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "package name")
|
||||
entries, readErr := os.ReadDir(filepath.Join(root, "internal", "modules"))
|
||||
require.NoError(t, readErr)
|
||||
require.Empty(t, entries, "rejected package name must not leave artifacts")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunRejectsExistingTargetDir 验证目标目录已存在时拒绝覆盖,且原内容不被触碰。
|
||||
func TestRunRejectsExistingTargetDir(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
existing := filepath.Join(root, "internal", "modules", "foo")
|
||||
require.NoError(t, os.MkdirAll(existing, 0o755))
|
||||
sentinel := filepath.Join(existing, "keep.go")
|
||||
require.NoError(t, os.WriteFile(sentinel, []byte("package foo\n"), 0o644))
|
||||
|
||||
err := run("job.foo", root, &bytes.Buffer{})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "already exists")
|
||||
|
||||
content, readErr := os.ReadFile(sentinel)
|
||||
require.NoError(t, readErr)
|
||||
require.Equal(t, "package foo\n", string(content), "existing files must be untouched")
|
||||
}
|
||||
|
||||
// TestRunRequiresModulesDir 验证 root 下没有 internal/modules 时给出明确提示。
|
||||
func TestRunRequiresModulesDir(t *testing.T) {
|
||||
err := run("job.foo", t.TempDir(), &bytes.Buffer{})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "internal")
|
||||
require.Contains(t, err.Error(), "backend root")
|
||||
}
|
||||
|
||||
// TestRunRequiresID 验证缺少 -id 参数时报用法错误。
|
||||
func TestRunRequiresID(t *testing.T) {
|
||||
err := run("", newRoot(t), &bytes.Buffer{})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "make new-module ID=job.foo")
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Package {{.Package}} 实现插件模块 {{.ID}}。
|
||||
//
|
||||
// TODO: 用 1-3 句话描述模块职责。
|
||||
//
|
||||
// 本骨架由 tools/newmodule 生成(蓝本:internal/modules/hello),实现了
|
||||
// 全部四个可选生命周期接口(Provision / Validate / Start / Stop)。
|
||||
// 模块默认 disabled——仅在 `modules:` 配置中显式 enabled 后才会运行。
|
||||
package {{.Package}}
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/plugin"
|
||||
)
|
||||
|
||||
// ID 是 {{.Package}} 模块的模块 ID。
|
||||
const ID plugin.ModuleID = "{{.ID}}"
|
||||
|
||||
func init() {
|
||||
plugin.RegisterModule(&Module{})
|
||||
}
|
||||
|
||||
// Config 是 {{.Package}} 模块的私有配置
|
||||
// (`modules:` 子树中 "{{.ID}}" 项除 enabled 外的部分)。
|
||||
//
|
||||
// TODO: 替换为模块真实的配置字段(同步更新 defaultConfig 与 Validate)。
|
||||
// 注意:全局配置经 Viper 加载时所有 key 会被静默小写化,
|
||||
// mapstructure 标签必须全小写。
|
||||
type Config struct {
|
||||
// Interval 是后台工作循环的执行间隔,必须大于 0。
|
||||
// TODO: 示例字段,按需替换或删除。
|
||||
Interval time.Duration `mapstructure:"interval"`
|
||||
}
|
||||
|
||||
// defaultConfig 返回 {{.Package}} 模块的默认配置(用户未配置的字段保持默认值)。
|
||||
func defaultConfig() Config {
|
||||
return Config{
|
||||
Interval: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Module 是 {{.ID}} 模块实例。零值即可用,依赖在 Provision 中装配;
|
||||
// 每次 Runtime.Build 都会通过 ModuleInfo.New 创建全新实例,
|
||||
// 模块不应依赖包级可变状态。
|
||||
type Module struct {
|
||||
cfg Config
|
||||
logger *zap.Logger
|
||||
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// 编译期断言:Module 实现 plugin.Module 与全部四个可选生命周期接口。
|
||||
// TODO: 用不到的可选接口请连同对应方法与断言一起删除(plugin.Module 必须保留)。
|
||||
var (
|
||||
_ plugin.Module = (*Module)(nil)
|
||||
_ plugin.Provisioner = (*Module)(nil)
|
||||
_ plugin.Validator = (*Module)(nil)
|
||||
_ plugin.Starter = (*Module)(nil)
|
||||
_ plugin.Stopper = (*Module)(nil)
|
||||
)
|
||||
|
||||
// ModuleInfo 返回模块注册信息。EnabledByDefault 必须为 false:
|
||||
// 未在 `modules:` 中显式 enabled 时模块不实例化、无任何副作用,
|
||||
// 保证引入新模块在缺省配置下零行为变更。
|
||||
func (*Module) ModuleInfo() plugin.ModuleInfo {
|
||||
return plugin.ModuleInfo{
|
||||
ID: ID,
|
||||
New: func() plugin.Module { return new(Module) },
|
||||
EnabledByDefault: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Provision 从宿主获取依赖,并在默认配置之上解码模块私有配置。
|
||||
//
|
||||
// TODO: 按需从 host 获取其他能力(Logger / ConfigOf / DB / Redis)。
|
||||
// 模块依赖只能经 Host 获取,禁止依赖具体 Service。
|
||||
func (m *Module) Provision(host *plugin.Host) error {
|
||||
m.cfg = defaultConfig()
|
||||
if host != nil {
|
||||
m.logger = host.Logger
|
||||
if host.ConfigOf != nil {
|
||||
if err := host.ConfigOf(ID, &m.cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.logger == nil {
|
||||
m.logger = zap.NewNop()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate 校验模块配置与状态,失败将中止启动(fail-fast)。
|
||||
//
|
||||
// TODO: 补充模块真实配置的校验规则。
|
||||
func (m *Module) Validate() error {
|
||||
if m.cfg.Interval <= 0 {
|
||||
return fmt.Errorf("{{.Package}}: interval must be positive, got %s", m.cfg.Interval)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start 启动模块的后台工作。
|
||||
//
|
||||
// 契约:Start 必须快速返回——长任务在独立 goroutine 中运行(Runtime 在
|
||||
// 持锁状态下调用 Start/Stop,阻塞的实现会同时阻塞 Snapshot 与其他生命周期
|
||||
// 操作)。模块持有独立于启动 ctx 的生命周期上下文,由 Stop 负责取消,
|
||||
// 因此模块的运行寿命不受启动流程上下文的影响。
|
||||
func (m *Module) Start(_ context.Context) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m.cancel = cancel
|
||||
m.done = make(chan struct{})
|
||||
go m.run(ctx)
|
||||
m.logger.Info("{{.Package}} module started",
|
||||
zap.String("module", string(ID)),
|
||||
zap.Duration("interval", m.cfg.Interval))
|
||||
return nil
|
||||
}
|
||||
|
||||
// run 是模块的后台工作循环,直到 ctx 被取消。
|
||||
//
|
||||
// TODO: 替换为模块真实的后台逻辑;没有后台工作的模块可整体删除
|
||||
// Start / Stop / run(连同对应的编译期断言与字段)。
|
||||
func (m *Module) run(ctx context.Context) {
|
||||
defer close(m.done)
|
||||
ticker := time.NewTicker(m.cfg.Interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
// TODO: 在这里执行周期工作。
|
||||
m.logger.Debug("{{.Package}} tick", zap.String("module", string(ID)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 取消后台 goroutine 并等待其退出,尊重 ctx 的 deadline
|
||||
// (与进程优雅关闭超时协同)。未 Start 过(或 Start 之前失败)时调用为 no-op。
|
||||
func (m *Module) Stop(ctx context.Context) error {
|
||||
if m.cancel == nil {
|
||||
return nil
|
||||
}
|
||||
m.cancel()
|
||||
// 先非阻塞探测 worker 是否已退出:当传入的 ctx 已取消/超时而 worker 其实
|
||||
// 已干净退出时,select 双臂就绪的随机选取可能把成功停止误报为失败。
|
||||
select {
|
||||
case <-m.done:
|
||||
m.logger.Info("{{.Package}} module stopped", zap.String("module", string(ID)))
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-m.done:
|
||||
m.logger.Info("{{.Package}} module stopped", zap.String("module", string(ID)))
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("{{.Package}}: stop interrupted while waiting for worker exit: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package {{.Package}}
|
||||
|
||||
// 本测试文件由 tools/newmodule 生成,基于 plugintest 夹具覆盖注册校验、
|
||||
// 默认配置与 Runtime 生命周期冒烟。
|
||||
// TODO: 模块实现真实逻辑后请补充对应的行为测试
|
||||
// (更多夹具用法参照 internal/modules/hello/hello_test.go)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/plugin"
|
||||
"github.com/Wei-Shaw/sub2api/internal/plugin/plugintest"
|
||||
)
|
||||
|
||||
// TestModuleRegisteredInDefaultRegistry 验证 init() 已向包级默认注册表
|
||||
// 注册 {{.ID}},且默认 disabled(EnabledByDefault=false)。
|
||||
func TestModuleRegisteredInDefaultRegistry(t *testing.T) {
|
||||
info, ok := plugin.GetModule(ID)
|
||||
require.True(t, ok, "{{.ID}} should self-register via init()")
|
||||
require.Equal(t, ID, info.ID)
|
||||
require.False(t, info.EnabledByDefault, "new modules must be disabled by default")
|
||||
require.NotNil(t, info.New)
|
||||
require.IsType(t, &Module{}, info.New())
|
||||
}
|
||||
|
||||
// TestProvisionDefaults 验证未提供私有配置时 Provision 使用默认配置且可通过校验。
|
||||
func TestProvisionDefaults(t *testing.T) {
|
||||
m := new(Module)
|
||||
require.NoError(t, m.Provision(plugintest.NewHost(t)))
|
||||
require.Equal(t, defaultConfig(), m.cfg)
|
||||
require.NoError(t, m.Validate())
|
||||
|
||||
// nil host 防御路径:不 panic、使用默认配置。
|
||||
m2 := new(Module)
|
||||
require.NoError(t, m2.Provision(nil))
|
||||
require.Equal(t, defaultConfig(), m2.cfg)
|
||||
require.NoError(t, m2.Validate())
|
||||
}
|
||||
|
||||
// TestValidateRejectsInvalidConfig 验证非法配置被 Validate 拒绝。
|
||||
// TODO: 配置字段变化后同步更新本用例。
|
||||
func TestValidateRejectsInvalidConfig(t *testing.T) {
|
||||
m := &Module{cfg: Config{Interval: 0}}
|
||||
err := m.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "interval must be positive")
|
||||
}
|
||||
|
||||
// TestRuntimeDefaultConfigIsNoop 验证缺省配置(modules: 子树为空)下,
|
||||
// 模块保持 disabled:Build/Start/Stop 全程 no-op,无任何副作用。
|
||||
func TestRuntimeDefaultConfigIsNoop(t *testing.T) {
|
||||
logOpt, logs := plugintest.WithObservedLogger()
|
||||
rt := plugintest.RunLifecycle(t, &Module{}, plugintest.NewHost(t, logOpt), nil)
|
||||
|
||||
require.NoError(t, rt.Stop(context.Background()))
|
||||
|
||||
snap := rt.Snapshot()
|
||||
require.Len(t, snap, 1)
|
||||
require.Equal(t, ID, snap[0].ID)
|
||||
require.False(t, snap[0].Enabled)
|
||||
require.Equal(t, plugin.StateRegistered, snap[0].State)
|
||||
require.Zero(t, logs.Len(), "disabled module must not produce any module logs")
|
||||
}
|
||||
|
||||
// TestRuntimeEnabledLifecycle 验证启用 {{.ID}} 后的完整生命周期:
|
||||
// Build(Provision/Validate)→ Start(后台工作运行)→ Stop(优雅退出)。
|
||||
func TestRuntimeEnabledLifecycle(t *testing.T) {
|
||||
logOpt, logs := plugintest.WithObservedLogger()
|
||||
rt := plugintest.RunLifecycle(t, &Module{}, plugintest.NewHost(t, logOpt), map[string]map[string]any{
|
||||
"{{.ID}}": {"enabled": true, "interval": "5ms"},
|
||||
})
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return logs.FilterMessage("{{.Package}} tick").Len() >= 1
|
||||
}, 2*time.Second, 5*time.Millisecond, "background work should run after Start")
|
||||
|
||||
require.NoError(t, rt.Stop(context.Background()))
|
||||
snap := rt.Snapshot()
|
||||
require.Len(t, snap, 1)
|
||||
require.True(t, snap[0].Enabled)
|
||||
require.Equal(t, plugin.StateStopped, snap[0].State)
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
# Sub2API 模块作者指南(Module Author Guide)
|
||||
|
||||
> 本指南面向需要编写或迁移插件模块的开发者,内容以 `backend/internal/plugin/` 已落地代码为准。
|
||||
> 配套文档:[插件化改造 ROADMAP](../../.claude/plugin-refactor/ROADMAP.md)(命名空间规划与全程铁律)、
|
||||
> [回归不变量清单 INVARIANTS](../../.claude/plugin-refactor/INVARIANTS.md)(每 PR 必须保持的外部行为)。
|
||||
|
||||
## 1. 概述与设计理念
|
||||
|
||||
Sub2API 的插件系统是 **Caddy 式进程内模块系统**:模块与宿主编译进同一个二进制,没有动态加载。内核由四部分组成——**命名空间化模块注册表**(模块包在 `init()` 期自注册,编译期插装清单决定哪些模块被编译进来)、**`modules:` 配置子树**(每模块的 enabled 开关 + 私有配置)、**宿主能力面 Host**(模块可使用的全部宿主能力,只暴露 ports 级接口)、**生命周期驱动器 Runtime**(按稳定序驱动 Provision → Validate → Start → Stop)。注册表只是"目录",模块的实例化与生命周期统一由 Runtime 驱动。引入新模块在缺省配置下必须是零行为变更。
|
||||
|
||||
内核代码全部位于 `backend/internal/plugin/`:
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `module.go` | `ModuleID` / `ModuleInfo` / `Module` 与 4 个可选生命周期接口 |
|
||||
| `registry.go` | 注册表:`RegisterModule` / `GetModule` / `GetModulesInNamespace`,可实例化的 `Registry` |
|
||||
| `host.go` | 宿主能力面 `Host`(Logger / ConfigOf / DB / Redis) |
|
||||
| `config.go` | `modules:` 配置子树解析(`ParseConfig` / `Config.Of`) |
|
||||
| `runtime.go` | 生命周期驱动器 `Runtime`(Build / Start / Stop / Snapshot) |
|
||||
| `wire.go` | Wire 装配(ProvideModuleConfig / ProvideHost / ProvideModuleRuntime) |
|
||||
|
||||
## 2. 模块结构与命名空间
|
||||
|
||||
### 2.1 ModuleID 规则
|
||||
|
||||
`ModuleID` 是模块的全局唯一标识,采用点分层级命名,形如 `job.hello`、`gateway.platform.anthropic`:
|
||||
|
||||
- 最后一段为模块名(`Name()`),之前的部分为命名空间(`Namespace()`);
|
||||
- 每一段只允许 `[a-z0-9_-]` 字符且不能为空;
|
||||
- 单段 ID(如 `hello`)的命名空间为空字符串;
|
||||
- `GetModulesInNamespace(ns)` 为**精确匹配**(非前缀匹配):`ns` 为 `gateway.platform` 时返回 `gateway.platform.anthropic` 等直接子模块,不包含 `gateway.platform.x.y`。
|
||||
|
||||
ID 格式非法(空段、非法字符)在注册时直接 panic,在配置解析时返回错误,均为 fail-fast。
|
||||
|
||||
### 2.2 命名空间规划
|
||||
|
||||
命名空间在 [ROADMAP](../../.claude/plugin-refactor/ROADMAP.md) 中一次定义、分阶段填充。新模块必须落入已规划的命名空间;需要新命名空间时先在 ROADMAP 登记:
|
||||
|
||||
| 命名空间 | 宿主接口 | 迁移阶段 | 状态 |
|
||||
|---|---|---|---|
|
||||
| `job.*` | Starter/Stopper | Phase 1 示例 / Phase 2 可选 | **已实现**(示例模块 `job.hello`) |
|
||||
| `payment.provider.*` | 现有 `payment.Provider` | Phase 2 | 规划中 |
|
||||
| `gateway.hook.*` | pre/post-flight 管道钩子 | Phase 2 | 规划中 |
|
||||
| `gateway.platform.*` | PlatformProvider | Phase 3 | 规划中 |
|
||||
| `channel.*` / `notify.*` | 数据契约 / Notifier | Phase 4 | 规划中 |
|
||||
| `plugin.bridge.*` | gRPC 进程外桥 | Phase 5(可选) | 规划中 |
|
||||
|
||||
### 2.3 模块的最低要求
|
||||
|
||||
模块是一个实现 `plugin.Module` 接口的 struct,放在 `backend/internal/modules/<模块名>/` 包内:
|
||||
|
||||
```go
|
||||
// Module 是所有插件模块必须实现的最小接口。
|
||||
type Module interface {
|
||||
// ModuleInfo 返回模块的注册信息。该方法必须无副作用且可在零值实例上调用。
|
||||
ModuleInfo() ModuleInfo
|
||||
}
|
||||
```
|
||||
|
||||
`ModuleInfo` 是注册表中的一条记录:
|
||||
|
||||
```go
|
||||
type ModuleInfo struct {
|
||||
// ID 是模块的全局唯一标识。
|
||||
ID ModuleID
|
||||
|
||||
// New 返回该模块的一个新实例。
|
||||
// 每次 Runtime.Build 都会通过 New 创建全新实例,模块不应依赖包级可变状态。
|
||||
New func() Module
|
||||
|
||||
// EnabledByDefault 声明该模块在 `modules:` 配置未显式设置 enabled 时的默认启停状态。
|
||||
// 零值 false 意味着新模块默认不启用,保证引入新模块不会在未配置时改变现有行为。
|
||||
EnabledByDefault bool
|
||||
}
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **零值即可用**:`ModuleInfo()` 必须可在零值实例上调用且无副作用;依赖在 `Provision` 中装配;
|
||||
- **默认 disabled**:除非有明确决策,新模块的 `EnabledByDefault` 应为 `false`(零值),未配置时模块不实例化、无任何副作用;
|
||||
- **注册在 `init()` 期完成**,重复 ID、nil New、非法 ID 一律 panic(注册错误属于编译期插装错误,必须在进程启动最早期暴露):
|
||||
|
||||
```go
|
||||
const ID plugin.ModuleID = "job.hello"
|
||||
|
||||
func init() {
|
||||
plugin.RegisterModule(&Module{})
|
||||
}
|
||||
```
|
||||
|
||||
建议在模块包中加编译期断言,确保实现的生命周期接口与预期一致:
|
||||
|
||||
```go
|
||||
var (
|
||||
_ plugin.Module = (*Module)(nil)
|
||||
_ plugin.Provisioner = (*Module)(nil)
|
||||
_ plugin.Validator = (*Module)(nil)
|
||||
_ plugin.Starter = (*Module)(nil)
|
||||
_ plugin.Stopper = (*Module)(nil)
|
||||
)
|
||||
```
|
||||
|
||||
## 3. 生命周期契约
|
||||
|
||||
除 `Module` 外,模块可按需实现四个**可选**生命周期接口:
|
||||
|
||||
```go
|
||||
type Provisioner interface{ Provision(host *Host) error }
|
||||
type Validator interface{ Validate() error }
|
||||
type Starter interface{ Start(ctx context.Context) error }
|
||||
type Stopper interface{ Stop(ctx context.Context) error }
|
||||
```
|
||||
|
||||
Runtime 按**注册表稳定序(ID 字典序)**驱动生命周期,宿主侧用法(`cmd/server/main.go` 已接好,模块作者无需关心):
|
||||
|
||||
```go
|
||||
rt := plugin.NewRuntime(host, cfg)
|
||||
if err := rt.Build(); err != nil { ... } // 启动中止
|
||||
if err := rt.Start(ctx); err != nil { ... } // 已自动逆序回滚
|
||||
defer rt.Stop(shutdownCtx) // 接入既有 cleanup 序列
|
||||
```
|
||||
|
||||
### 3.1 各阶段语义
|
||||
|
||||
| 阶段 | 调用时机 | 失败后果 |
|
||||
|---|---|---|
|
||||
| `New()` | `Runtime.Build`,仅实例化 enabled 模块 | 返回 nil 实例 → Build 报错,启动中止 |
|
||||
| `Provision(host)` | Build 阶段,实例化后。模块在此从 Host 获取所需能力(Logger / ConfigOf / DB / Redis)并完成自身初始化 | Build 报错,**启动中止**(fail-fast) |
|
||||
| `Validate()` | Build 阶段,Provision 之后。校验模块配置与状态 | Build 报错,**启动中止** |
|
||||
| `Start(ctx)` | `Runtime.Start`,HTTP server 启动前,按稳定序。启动后台工作(goroutine、监听等);未实现 Starter 的 enabled 模块直接视为 running | 见 3.2 逆序回滚 |
|
||||
| `Stop(ctx)` | `Runtime.Stop`,进程优雅关闭时,按**稳定序的逆序** | 见 3.3 |
|
||||
|
||||
`Build` 任一模块失败立即返回包含模块 ID 的错误,不会继续处理后续模块;已成功 Provision 的模块保持 provisioned 状态,由进程启动失败路径整体退出(`main` 中 `log.Fatalf`)。Runtime 为单次使用:Build/Start 各只能调用一次,不支持 Stop 后重新 Start。
|
||||
|
||||
### 3.2 Start 半途失败:逆序回滚
|
||||
|
||||
任一模块 `Start` 失败时,Runtime 对**已进入 running 的模块按逆序执行 Stop 回滚**(回滚沿用传入的 ctx;回滚失败仅记日志),然后返回包含失败模块 ID 的错误,进程启动中止。因此:
|
||||
|
||||
- `Start` 失败的模块应自行清理 `Start` 内部已分配的资源(Runtime 不会对失败模块本身调用 Stop);
|
||||
- `Stop` 必须容忍"Start 从未成功过"的状态(见 9.3 hello 的 no-op 处理)。
|
||||
|
||||
### 3.3 Stop 逆序与 ctx deadline
|
||||
|
||||
- `Stop` 按稳定序的**逆序**执行:后启动的模块先停止;
|
||||
- 单个模块 Stop 失败**只记日志并继续**关闭其余模块,最终返回 `errors.Join` 聚合的失败集合(与既有 cleanup 序列的容错纪律一致);
|
||||
- ctx 原样传递给各模块 Stop,**模块必须自行尊重其 deadline**——宿主的 cleanup 整体超时为 10 秒,模块 Stop 与其他业务服务关闭并行执行,且先于 Redis/Ent 等基础设施关闭(模块停止时仍可写 DB/Redis)。
|
||||
|
||||
### 3.4 状态机
|
||||
|
||||
```
|
||||
registered ──Build──▶ provisioned ──Start──▶ running ──Stop──▶ stopped
|
||||
│(disabled 模块停留在 registered) 任一阶段失败 ──▶ errored
|
||||
```
|
||||
|
||||
- `registered`:已注册未实例化(disabled 模块始终处于该状态);
|
||||
- `provisioned`:已实例化并通过 Provision/Validate,等待启动;
|
||||
- `running`:已启动(未实现 Starter 的 enabled 模块在 Start 阶段也进入该状态);
|
||||
- `stopped`:已正常停止;
|
||||
- `errored`:在某个生命周期阶段失败,错误见 `ModuleStatus.Err`。
|
||||
|
||||
`Runtime.Snapshot()` 返回全部已注册模块(含 disabled)的状态快照,按 ID 字典序稳定排序,供可观测使用。
|
||||
|
||||
## 4. Host 能力面
|
||||
|
||||
`Provision` 注入的 `*plugin.Host` 是模块可使用的**全部**宿主能力,当前仅四项:
|
||||
|
||||
```go
|
||||
type Host struct {
|
||||
// Logger 是宿主提供的结构化日志器。模块应通过它输出日志,而不是自建全局 logger。
|
||||
Logger *zap.Logger
|
||||
|
||||
// ConfigOf 将 `modules:` 配置子树中 id 对应的 raw 私有配置解码到 out
|
||||
// (out 必须是指向模块自定义配置 struct 的指针)。
|
||||
ConfigOf func(id ModuleID, out any) error
|
||||
|
||||
// DB 是 Ent ORM 客户端(数据库访问能力)。
|
||||
DB *ent.Client
|
||||
|
||||
// Redis 是 go-redis 客户端(缓存/队列能力)。
|
||||
Redis redis.UniversalClient
|
||||
}
|
||||
```
|
||||
|
||||
**铁律(host.go 边界纪律):**
|
||||
|
||||
1. Host 只暴露 ports 级基础能力,**禁止暴露任何具体 Service**——模块依赖具体 Service 等于绕过插件边界,回到单体耦合;
|
||||
2. **后续每新增一项 Host 能力,必须在[插件化 ROADMAP](../../.claude/plugin-refactor/ROADMAP.md) 决策记录中登记**,防止 Host 膨胀为上帝对象;
|
||||
3. 模块内部依赖一律经 Host 获取,**不进入 Wire graph**(Wire 只负责装配 Host 与 Runtime,模块目录由注册表负责)。
|
||||
|
||||
防御性约定:`Provision` 应容忍 `host`、`host.Logger`、`host.ConfigOf` 为 nil(测试中常以部分填充的 Host 驱动模块),参考 hello 模块的处理(见第 9 节)。
|
||||
|
||||
## 5. 配置子树(`modules:`)
|
||||
|
||||
### 5.1 用法
|
||||
|
||||
模块配置位于全局配置(`config.yaml`)的 `modules:` 子树,**顶层 key 是完整的带点模块 ID 字面量**(一个 key,不是嵌套层级):
|
||||
|
||||
```yaml
|
||||
modules:
|
||||
job.hello:
|
||||
enabled: true # 内核保留键:启停开关
|
||||
interval: 10s # 以下为模块私有配置,内核不解释
|
||||
greeting: "hello from sub2api"
|
||||
```
|
||||
|
||||
每个模块项中只有 `enabled` 键由内核解释(接受 bool 或可解析为 bool 的字符串),其余键原样保留为模块私有配置,由 `Config.Of` 解码到模块自定义的配置 struct。
|
||||
|
||||
### 5.2 enabled 三态语义
|
||||
|
||||
| 配置情况 | 最终启停 |
|
||||
|---|---|
|
||||
| `enabled: true` | 启用 |
|
||||
| `enabled: false` | 禁用 |
|
||||
| 未显式配置(含整个模块项缺失、`modules:` 子树不存在、或写了空的 `enabled:` 即 YAML null) | 由注册时声明的 `ModuleInfo.EnabledByDefault` 决定 |
|
||||
|
||||
缺省(`modules:` 子树不存在)时为空配置,所有模块按各自默认 enabled 值运行——对默认 disabled 的模块即"完全不存在",行为与未引入插件内核前一致。
|
||||
|
||||
### 5.3 私有配置解码
|
||||
|
||||
`Config.Of`(经 `host.ConfigOf` 暴露给模块)的解码行为与项目全局配置(Viper 默认解码器)保持一致:
|
||||
|
||||
- `mapstructure` 标签、弱类型转换、字符串到 `time.Duration` / 逗号分隔切片的钩子;
|
||||
- **模块未配置或私有配置为空时不修改 out**——模块应先填默认值再调用 `ConfigOf`(参考 hello 的 `defaultConfig()`);
|
||||
- 类型不匹配等解码失败时返回包含模块 ID 的错误,模块应将其从 `Provision` 原样返回(启动中止)。
|
||||
|
||||
### 5.4 未知 ID 忽略 / 非法 ID fail-fast
|
||||
|
||||
`ParseConfig` 的语义(在应用启动装配阶段执行,见 `plugin/wire.go` 的 `ProvideModuleConfig`):
|
||||
|
||||
- **ID 格式非法**(如 `modules:` 下出现 `Job.Hello`、空段)或 **enabled 类型错误** → 返回错误,**应用启动 fail-fast**,配置笔误在启动时即暴露;
|
||||
- **格式合法但未注册**的模块 ID(例如不同编译变体的配置共用)→ 允许存在,**被 Runtime 忽略**,不影响其他模块。
|
||||
|
||||
### 5.5 viper 含点 key 的注意事项
|
||||
|
||||
`modules:` 子树**不走 `viper.Unmarshal`**,而是在配置加载时通过 `viper.Get("modules")` 手工提取(见 `internal/config/config.go` 的 `Config.Modules` 字段与 `normalizeModulesSubtree`)——Unmarshal 基于 AllSettings 重建嵌套结构,会把含点的模块 ID key(`job.hello`)错误拆分为多级嵌套(`job: { hello: ... }`)。模块作者无需做任何事,但需注意:
|
||||
|
||||
- 在 YAML 中模块 ID 必须写成**单个 key**(`job.hello:`),不要写成嵌套层级;
|
||||
- 空模块项(如 `job.hello:` 后无内容)规整为空 map,语义是"提及但无私有配置"(enabled 仍走默认值);
|
||||
- 全局配置的环境变量覆盖(`.` → `_` 替换)不适用于 `modules:` 子树下含点的模块 ID key,模块配置请写在配置文件中。
|
||||
|
||||
## 6. 插装清单(Instrumentation List)
|
||||
|
||||
`backend/internal/modules/standard/imports.go` 是**唯一插装点**:所有需要编译进二进制的内置模块必须且只能在该文件中匿名 import。模块包在 `init()` 期向包级默认注册表自注册,`cmd/server/main.go` 匿名 import 本包即完成全部内置模块的插装。
|
||||
|
||||
```go
|
||||
// Package standard 是插件模块的唯一插装清单(instrumentation list)。
|
||||
package standard
|
||||
|
||||
import (
|
||||
// job.hello:示例模块(默认 disabled),验证插件链路连通性。
|
||||
_ "github.com/Wei-Shaw/sub2api/internal/modules/hello"
|
||||
)
|
||||
```
|
||||
|
||||
**新增一个模块 = 在此文件加一行 import**(带一行用途注释),使插装变更集中、可 review。不要在其他任何位置(包括测试外的业务代码)匿名 import 模块包。
|
||||
|
||||
## 7. 测试要求
|
||||
|
||||
### 7.1 用隔离 Registry,不污染包级默认注册表
|
||||
|
||||
涉及 Runtime 生命周期的测试**必须**在隔离注册表中驱动,避免依赖(或污染)包级默认注册表。默认入口是 `internal/plugin/plugintest` 夹具包——`RunLifecycle` / `BuildOnly` / `BuildExpectingError` 内部已用 `plugin.NewRegistry()` 构造隔离环境,一行驱动完整生命周期(API 速览与典型用法见 8.2)。
|
||||
|
||||
plugintest 未覆盖的复杂编排(Start 半途失败回滚、多模块顺序等)直接使用完全公开的内核 API:
|
||||
|
||||
```go
|
||||
reg := plugin.NewRegistry()
|
||||
reg.RegisterModule(&Module{})
|
||||
rt := plugin.NewRuntimeWithRegistry(&plugin.Host{Logger: logger}, cfg, reg)
|
||||
|
||||
require.NoError(t, rt.Build())
|
||||
require.NoError(t, rt.Start(context.Background()))
|
||||
require.NoError(t, rt.Stop(context.Background()))
|
||||
```
|
||||
|
||||
测内核语义本身(注册 panic、命名空间查询、生命周期顺序等)时,用 fake 模块(实现部分生命周期接口的最小 struct)注册进隔离 Registry,参考 `internal/plugin/registry_test.go`、`runtime_test.go` 中的写法。
|
||||
|
||||
### 7.2 模块必测清单
|
||||
|
||||
参照 `internal/modules/hello/hello_test.go`,新模块至少覆盖:
|
||||
|
||||
1. **已注册且默认值正确**:`plugin.GetModule(ID)` 可查到,`EnabledByDefault` 符合预期;
|
||||
2. **Provision 默认配置**:未提供私有配置时使用默认值并通过 Validate;nil host 不 panic;
|
||||
3. **私有配置解码**:经 `host.ConfigOf` 解码(含 duration 字符串等弱类型转换);解码类型错误时 Provision 返回含模块 ID 的错误;
|
||||
4. **Validate 拒绝非法配置**(表驱动);
|
||||
5. **Start/Stop 行为**:启动后副作用可观测(如用 `zaptest/observer` 断言日志)、Stop 后 goroutine 优雅退出、未 Start 时 Stop 为 no-op;
|
||||
6. **缺省配置零副作用**:`plugin.Config{}` 下模块保持 disabled,Build/Start/Stop 全程 no-op、Snapshot 状态为 `registered`、无任何模块日志。
|
||||
|
||||
### 7.3 回归 gate(每 PR 硬性要求)
|
||||
|
||||
插件化改造全程零回归,模块相关 PR 必须通过 Phase-0 回归安全网(在 `backend/` 目录执行):
|
||||
|
||||
```bash
|
||||
make test-invariants # 不变量/特征化测试(详见 .claude/plugin-refactor/INVARIANTS.md)
|
||||
./scripts/bench-baseline.sh compare # 热路径基准 vs 基线(allocs/op 严格、ns/op 容差 15%)
|
||||
```
|
||||
|
||||
不变量清单见 [INVARIANTS.md](../../.claude/plugin-refactor/INVARIANTS.md),基准脚本说明见 [bench-baseline.sh](../../backend/scripts/bench-baseline.sh) 文件头注释。**默认配置(不启用任何模块)下,全部不变量必须绿、基准无回归。** 两条命令的预期输出、阈值语义与 Wire 重生成注意事项见 8.5。
|
||||
|
||||
## 8. 开发与调试工作流
|
||||
|
||||
本章把"从零开发并调试一个模块"串成可照做的闭环,每一步都给出实测过的命令与预期输出(实测环境:2026-06 主线)。除特别说明外,命令均在 `backend/` 目录下执行。
|
||||
|
||||
```
|
||||
make new-module(8.1 脚手架)→ plugintest 单测驱动开发(8.2)
|
||||
→ imports.go 插装 + config.yaml 启用 + go run 本地运行(8.3)
|
||||
→ 日志 / admin API / 前端页面观测(8.4)
|
||||
→ make test-invariants + bench compare 提交前自检(8.5)
|
||||
```
|
||||
|
||||
### 8.1 脚手架:生成模块骨架
|
||||
|
||||
```bash
|
||||
make new-module ID=job.foo # 等价:go run ./tools/newmodule -id job.foo
|
||||
```
|
||||
|
||||
预期输出(实测,全文):
|
||||
|
||||
```
|
||||
module job.foo generated:
|
||||
internal/modules/foo/foo.go
|
||||
internal/modules/foo/foo_test.go
|
||||
|
||||
Next steps (the generator never edits imports.go -- explicit instrumentation keeps it reviewable):
|
||||
|
||||
1. 编译期插装:在 internal/modules/standard/imports.go 的 import 块中加入:
|
||||
|
||||
// job.foo:TODO 一句话描述模块用途(默认 disabled)。
|
||||
_ "github.com/Wei-Shaw/sub2api/internal/modules/foo"
|
||||
|
||||
2. 配置启用(config.yaml;新模块默认 disabled,未显式 enabled 时零行为变更):
|
||||
|
||||
modules:
|
||||
job.foo:
|
||||
enabled: true
|
||||
interval: 30s
|
||||
|
||||
3. 填充代码中的 TODO 后运行测试:
|
||||
|
||||
go test -count=1 ./internal/modules/foo/
|
||||
|
||||
生命周期契约与完整示例见 docs/plugin-architecture/MODULE-AUTHOR-GUIDE.md(蓝本实现:internal/modules/hello/)。
|
||||
```
|
||||
|
||||
产物说明(模板从 hello 蓝本提炼,渲染时经 gofmt 校验,ID/包名非法直接报错、目标目录已存在拒绝覆盖):
|
||||
|
||||
- `foo.go`:全部四个可选生命周期接口的骨架 + 编译期断言 + `EnabledByDefault: false` + 示例私有配置(`interval`)与校验,`TODO` 注释标出需要填充/删改的位置;
|
||||
- `foo_test.go`:基于 plugintest 的 5 个用例(注册校验、默认配置、非法配置、缺省 no-op、启用生命周期),**开箱即过**:
|
||||
|
||||
```bash
|
||||
$ go test -count=1 ./internal/modules/foo/
|
||||
ok github.com/Wei-Shaw/sub2api/internal/modules/foo 0.017s
|
||||
```
|
||||
|
||||
生成器**不会**自动修改 `standard/imports.go`——插装行按 next steps 提示手工加入(设计原则与唯一插装点纪律见第 6 节)。
|
||||
|
||||
### 8.2 单测驱动开发:plugintest 夹具
|
||||
|
||||
`internal/plugin/plugintest` 是模块测试的默认入口(**仅供 `_test.go` import**,进入生产依赖面在评审中一票否决)。API 速览:
|
||||
|
||||
| 入口 | 用途 |
|
||||
|---|---|
|
||||
| `NewHost(t, ...Option)` | 一行构造 Host,默认零依赖可用:nop logger + 空 ConfigOf + nil DB/Redis |
|
||||
| `WithConfig(raw)` | 把与 `modules:` 子树同形状的 raw 绑定为 ConfigOf,供直接调 `Provision` 的测试 |
|
||||
| `WithObservedLogger()` | 返回 `(Option, *observer.ObservedLogs)`,debug 级别,用于断言日志 |
|
||||
| `WithRedis(t)` | 附带 miniredis 支撑的 go-redis 客户端,生命周期挂 `t.Cleanup` 自动清理 |
|
||||
| `RunLifecycle(t, m, host, raw)` | 隔离注册 → 解析 raw → Build → Start;`t.Cleanup` 自动 Stop(10s 上限)并断言无错 |
|
||||
| `BuildOnly(...)` | 同 RunLifecycle 但停在 Build 之后,只验证 Provision/Validate |
|
||||
| `BuildExpectingError(...)` | 期望 Build 失败,返回错误供断言内容(错误信息含模块 ID) |
|
||||
|
||||
典型用法(均摘自 `internal/modules/hello/hello_test.go`)。
|
||||
|
||||
直接调 Provision 验证私有配置解码——`NewHost` + `WithConfig`:
|
||||
|
||||
```go
|
||||
host := plugintest.NewHost(t, plugintest.WithConfig(map[string]map[string]any{
|
||||
"job.hello": {"enabled": true, "interval": "15ms", "greeting": "hi"},
|
||||
}))
|
||||
|
||||
m := new(Module)
|
||||
require.NoError(t, m.Provision(host))
|
||||
require.Equal(t, 15*time.Millisecond, m.cfg.Interval)
|
||||
require.Equal(t, "hi", m.cfg.Greeting)
|
||||
```
|
||||
|
||||
完整生命周期 + 日志断言——`RunLifecycle` + `WithObservedLogger`:
|
||||
|
||||
```go
|
||||
logOpt, logs := plugintest.WithObservedLogger()
|
||||
rt := plugintest.RunLifecycle(t, &Module{}, plugintest.NewHost(t, logOpt), map[string]map[string]any{
|
||||
"job.hello": {"enabled": true, "interval": "5ms"},
|
||||
})
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return logs.FilterMessage(defaultConfig().Greeting).Len() >= 1
|
||||
}, 2*time.Second, 5*time.Millisecond, "periodic debug log should appear after Start")
|
||||
|
||||
require.NoError(t, rt.Stop(context.Background()))
|
||||
snap := rt.Snapshot()
|
||||
require.True(t, snap[0].Enabled)
|
||||
require.Equal(t, plugin.StateStopped, snap[0].State)
|
||||
```
|
||||
|
||||
非法配置应使 Build 失败(fail-fast)——`BuildExpectingError`:
|
||||
|
||||
```go
|
||||
err := plugintest.BuildExpectingError(t, &Module{}, plugintest.NewHost(t), map[string]map[string]any{
|
||||
"job.hello": {"enabled": true, "interval": "-1s"},
|
||||
})
|
||||
require.Contains(t, err.Error(), `validate module "job.hello"`)
|
||||
require.Contains(t, err.Error(), "interval must be positive")
|
||||
```
|
||||
|
||||
语义提醒:
|
||||
|
||||
- `RunLifecycle` / `BuildOnly` 的 `raw` 是模块配置的**唯一来源**(会覆盖 host 上 `WithConfig` 设置的 ConfigOf),两者不要混用;
|
||||
- raw 未写 `enabled: true` 且模块默认 disabled 时,Build/Start 全程 no-op,夹具会 `t.Logf` 提示(防"忘写 enabled"空跑:`module "job.foo" resolved to disabled under the given config ...`);
|
||||
- Host 是纯数据结构,夹具未覆盖的少见场景(如注入 enttest 客户端)直接对字段赋值即可。
|
||||
|
||||
模块必测清单见 7.2;plugintest 未覆盖的复杂编排用内核 API(见 7.1)。
|
||||
|
||||
### 8.3 本地运行:在真实进程中启用模块
|
||||
|
||||
**依赖准备(PostgreSQL 15+ / Redis 7+)。** 调试模块推荐在宿主机直跑后端:
|
||||
|
||||
- 本机已有 PG/Redis:`config.yaml` 的 `database:` / `redis:` 指向 `127.0.0.1` 即可(本地环境约定参照仓库根 `DEV_GUIDE.md`);
|
||||
- 用 Docker 起依赖:`deploy/docker-compose.dev.yml` 内有 `postgres` / `redis` 服务,但**默认不映射宿主端口**(仅供同 compose 网络内的 sub2api 容器使用)。整栈在容器内运行(改模块代码需重新 build):
|
||||
|
||||
```bash
|
||||
cd deploy && POSTGRES_PASSWORD=devpass docker compose -f docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
若坚持宿主跑后端 + Docker 跑依赖,需自行给 `postgres` / `redis` 服务加 `ports:` 映射(`5432:5432` / `6379:6379`)后再 `docker compose -f docker-compose.dev.yml up -d postgres redis`。
|
||||
|
||||
**配置。** 后端从工作目录查找 `config.yaml`(还会查 `./config`、`/etc/sub2api` 等;完整模板见 `deploy/config.example.yaml`),在 `backend/` 下运行即读取 `backend/config.yaml`。在其中追加 `modules:` 子树(顶层任意位置,语义见第 5 节):
|
||||
|
||||
```yaml
|
||||
modules:
|
||||
job.hello:
|
||||
enabled: true
|
||||
interval: 10s
|
||||
```
|
||||
|
||||
**启动与日志。**
|
||||
|
||||
```bash
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
启动序列中、HTTP server 监听之前,可看到模块生命周期日志(实测摘录;`interval` 字段为毫秒数):
|
||||
|
||||
```
|
||||
2026-06-11T20:01:31.765+0800 INFO hello/hello.go:111 hello module started {"service": "sub2api", "env": "production", "module": "job.hello", "interval": 10000}
|
||||
2026-06-11T20:01:31.765+0800 INFO plugin/runtime.go:209 plugin module started {"service": "sub2api", "env": "production", "module": "job.hello"}
|
||||
```
|
||||
|
||||
随后出现 `Server started on <host>:<port>`。注意 `plugin module provisioned` 与 hello 的周期 greeting 是 **Debug** 级别,默认 `log.level: info` 下不可见——要观察周期行为,在 `config.yaml` 中设 `log: { level: debug }`。模块配置非法时启动 fail-fast:进程以 `Failed to build plugin modules: ...`(错误含模块 ID)退出,符合 5.4 的语义。
|
||||
|
||||
**优雅关闭。** Ctrl+C(SIGINT/SIGTERM)后模块按逆序停止,与既有 cleanup 步骤交织输出(实测摘录):
|
||||
|
||||
```
|
||||
2026-06-11T20:03:50.449+0800 INFO hello/hello.go:149 hello module stopped {"service": "sub2api", "env": "production", "module": "job.hello"}
|
||||
2026-06-11T20:03:50.449+0800 INFO plugin/runtime.go:252 plugin module stopped {"service": "sub2api", "env": "production", "module": "job.hello"}
|
||||
2026-06-11T20:03:50.449+0800 INFO stdlog runtime/asm_amd64.s:1771 [Cleanup] PluginModuleRuntime succeeded {"service": "sub2api", "env": "production", "legacy_stdlog": true}
|
||||
```
|
||||
|
||||
### 8.4 三种观测方式
|
||||
|
||||
**1) 结构化日志。** 模块经 `Host.Logger` 输出的日志自带全局字段(`service` / `env`),按模块自身附加的 `module` 字段过滤即可。Runtime 侧固定输出:`plugin module started` / `plugin module stopped`(Info)、`plugin module provisioned`(Debug)、`plugin module stop failed`(Error),均含 `"module": "<ID>"` 字段。
|
||||
|
||||
**2) admin API(只读)。** 支持管理员 JWT 或系统设置中配置的 Admin API Key 两种鉴权(端口以 `server.port` 为准):
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8080/api/v1/admin/modules -H "x-api-key: <admin-api-key>"
|
||||
# 或:curl -s http://127.0.0.1:8080/api/v1/admin/modules -H "Authorization: Bearer <admin-jwt>"
|
||||
```
|
||||
|
||||
实测响应(job.hello 启用且运行中):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"modules": [
|
||||
{
|
||||
"id": "job.hello",
|
||||
"namespace": "job",
|
||||
"name": "hello",
|
||||
"enabled": true,
|
||||
"state": "running",
|
||||
"error": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
要点:返回**全部已注册模块**(disabled 模块也在列,`state` 为 `registered`),按 ID 字典序;`error` 文本已统一脱敏;全新部署需先完成管理员合规确认,否则该接口返回 423 `ADMIN_COMPLIANCE_ACK_REQUIRED`(用前端登录一次按引导确认即可)。
|
||||
|
||||
**3) 前端「插件模块」页。**
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm install && pnpm run dev # http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
dev server 默认把 `/api` 代理到 `http://localhost:8080`;后端端口不是 8080 时在 `frontend/.env` 设 `VITE_DEV_PROXY_TARGET=http://127.0.0.1:<port>`。管理员登录后,经侧边栏「插件模块」进入 `/admin/modules`:只读列表展示模块 ID / 命名空间 / 名称 / 启用 / 状态 / 错误信息,状态以语义徽章着色(running=绿、errored=红、registered=灰、provisioned/stopped=主色),错误长文本截断 + 悬浮查看完整内容,右上角手动刷新。页面消费的就是上面的 admin API(实现:`frontend/src/views/admin/ModulesView.vue`)。
|
||||
|
||||
### 8.5 提交前自检
|
||||
|
||||
```bash
|
||||
make test-invariants # 回归不变量/特征化测试(实测约 16s)
|
||||
./scripts/bench-baseline.sh compare # 热路径基准 vs 基线(BENCH_COUNT=6 实测约 4 分钟)
|
||||
```
|
||||
|
||||
`test-invariants` 只筛选 `Characterization|Invariant` 用例,多数包显示 `[no tests to run]` 属正常,关注每行是否全 `ok`、退出码为 0。
|
||||
|
||||
`compare` 先跑当前基准(有 `benchstat` 时附其报告),再对每个基准输出对比行并按阈值判定(实测样例):
|
||||
|
||||
```
|
||||
BenchmarkGatewayForward_AnthropicNonStreamPassthrough ns/op 10164 -> 9882 (-2.8%) allocs/op 93.0 -> 93.0 ok
|
||||
```
|
||||
|
||||
阈值语义(详见脚本头注释):
|
||||
|
||||
- **allocs/op 严格**:均值增加超过 `ALLOC_TOLERANCE`(默认 0)即 FAIL;
|
||||
- **ns/op 宽松**:劣化超过 `TIME_TOLERANCE_PCT`%(默认 15,吸收 runner 抖动)即 FAIL;
|
||||
- **基线中的基准在本次运行缺失**(被删除/改名)→ FAIL(回归网已失效,须修复或重新 collect);新增基准只 warn、不参与对比;
|
||||
- 基线文件不存在 → 退出码 2,提示先 `./scripts/bench-baseline.sh collect`(换机器/换 Go 版本后也要重新采集)。
|
||||
|
||||
**Wire 重生成注意。** 普通模块不进 Wire graph,无需此步;只有改了 Wire 装配(如 `internal/plugin/wire.go`、`cmd/server/wire.go`)才需要重生成。当前 `make generate` / `go generate ./cmd/server` 中无版本号的 wire 调用因 go.sum 缺 `github.com/google/subcommands` 条目而失败(预存在问题,实测报错 `missing go.sum entry for module providing package github.com/google/subcommands`),实测可用的调用方式:
|
||||
|
||||
```bash
|
||||
go run github.com/google/wire/cmd/wire@v0.7.0 ./cmd/server/
|
||||
```
|
||||
|
||||
**前端有改动时**,在仓库根执行 `make test-frontend`(lint + typecheck + 关键 vitest 集)。
|
||||
|
||||
### 8.6 常见坑
|
||||
|
||||
| 坑 | 现象 | 处置 |
|
||||
|---|---|---|
|
||||
| 忘加 imports.go | 单包 `go test` 全绿(同包测试必然触发 `init()` 注册),但真实进程里模块"不存在":无生命周期日志,Snapshot / admin API / 前端页面的列表里**根本没有这个模块**(配置中格式合法但未注册的 ID 被 Runtime 静默忽略,见 5.4) | 在 `standard/imports.go` 加匿名 import(见第 6 节) |
|
||||
| `enabled:` 留空 | YAML null = 未显式配置 = 走 `EnabledByDefault`(默认 false),模块不会运行 | 启用必须写 `enabled: true`(三态语义见 5.2) |
|
||||
| 含点 ID 的 viper 行为 | 模块 ID 在 YAML 里写成嵌套层级(`job:` 下挂 `hello:`)不被识别;环境变量覆盖对 `modules:` 子树不生效 | ID 写成单个 key、模块配置写在配置文件中(见 5.5)。改动 modules 配置解析本身时,注意该子树不走 `viper.Unmarshal`,而是 `internal/config` 的 `normalizeModulesSubtree` 手工提取 |
|
||||
| `EnabledByDefault: true` | 模块未配置即运行——"引入新模块在缺省配置下零行为变更"的铁律被打破 | 保持 false;确需默认启用须先在 ROADMAP 登记决策(见 2.3) |
|
||||
| Stop 不尊重 ctx deadline | 宿主 cleanup 整体超时 10 秒、模块 Stop 与其他服务关闭并行,悬挂的 Stop 拖死优雅关闭;plugintest 兜底 Stop 同样 10s 上限,悬挂表现为测试失败 | 等待 worker 退出必须同时 `select` 上 `ctx.Done()`(照抄 9.3 的 Stop 写法) |
|
||||
| Start 里跑长任务 | Runtime 在持锁状态下调用 Start/Stop,阻塞的 Start 会卡住 Snapshot(admin API 与前端页面跟着挂起)和后续模块的启动 | Start 必须快速返回,长任务自起 goroutine;goroutine 不要挂在 Start 的 ctx 上,寿命由 Stop 终结(见 9.3) |
|
||||
| mapstructure 标签含大写 | 全局配置经 Viper 加载时 key 被静默小写化,含大写的标签永远解码不到值 | `mapstructure` 标签全小写(脚手架模板注释已注明) |
|
||||
| 前端单文件测试跑成全量 | `pnpm run test:run -- <file>` 的 `--` 透传会使 vitest 文件过滤失效 | 用 `pnpm exec vitest run <file>`(如 `pnpm exec vitest run src/views/admin/__tests__/ModulesView.spec.ts`) |
|
||||
|
||||
## 9. 完整示例:job.hello
|
||||
|
||||
`backend/internal/modules/hello/hello.go` 是最小可运行的参考实现:实现全部四个可选生命周期接口,启动后按配置间隔周期性输出一条 debug 日志,默认 disabled。以下代码均摘自真实文件(有裁剪)。
|
||||
|
||||
### 9.1 注册
|
||||
|
||||
```go
|
||||
// ID 是 hello 模块的模块 ID。
|
||||
const ID plugin.ModuleID = "job.hello"
|
||||
|
||||
func init() {
|
||||
plugin.RegisterModule(&Module{})
|
||||
}
|
||||
|
||||
// Module 是 job.hello 模块实例。零值即可用,依赖在 Provision 中装配;
|
||||
// 每次 Runtime.Build 都会通过 ModuleInfo.New 创建全新实例。
|
||||
type Module struct {
|
||||
cfg Config
|
||||
logger *zap.Logger
|
||||
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// ModuleInfo 返回模块注册信息。EnabledByDefault 为 false:
|
||||
// 未在 `modules:` 中显式 enabled 时模块不实例化、无任何副作用。
|
||||
func (*Module) ModuleInfo() plugin.ModuleInfo {
|
||||
return plugin.ModuleInfo{
|
||||
ID: ID,
|
||||
New: func() plugin.Module { return new(Module) },
|
||||
EnabledByDefault: false,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
插装:`internal/modules/standard/imports.go` 中已有 `_ "github.com/Wei-Shaw/sub2api/internal/modules/hello"` 一行。
|
||||
|
||||
### 9.2 配置(默认值 + 解码 + 校验)
|
||||
|
||||
```go
|
||||
// Config 是 hello 模块的私有配置
|
||||
// (`modules:` 子树中 "job.hello" 项除 enabled 外的部分)。
|
||||
type Config struct {
|
||||
// Interval 是周期日志的输出间隔,必须大于 0。
|
||||
Interval time.Duration `mapstructure:"interval"`
|
||||
|
||||
// Greeting 是周期日志输出的内容,不能为空。
|
||||
Greeting string `mapstructure:"greeting"`
|
||||
}
|
||||
|
||||
// defaultConfig 返回 hello 模块的默认配置(用户未配置的字段保持默认值)。
|
||||
func defaultConfig() Config {
|
||||
return Config{
|
||||
Interval: 30 * time.Second,
|
||||
Greeting: "hello from job.hello",
|
||||
}
|
||||
}
|
||||
|
||||
// Provision 从宿主获取 logger,并在默认配置之上解码模块私有配置。
|
||||
func (m *Module) Provision(host *plugin.Host) error {
|
||||
m.cfg = defaultConfig()
|
||||
if host != nil {
|
||||
m.logger = host.Logger
|
||||
if host.ConfigOf != nil {
|
||||
if err := host.ConfigOf(ID, &m.cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.logger == nil {
|
||||
m.logger = zap.NewNop()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate 校验模块配置:interval 必须大于 0,greeting 不能为空。
|
||||
func (m *Module) Validate() error {
|
||||
if m.cfg.Interval <= 0 {
|
||||
return fmt.Errorf("hello: interval must be positive, got %s", m.cfg.Interval)
|
||||
}
|
||||
if m.cfg.Greeting == "" {
|
||||
return errors.New("hello: greeting must not be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
注意先填默认值再解码:`ConfigOf` 对未配置的模块不修改 out,默认值由模块自带。
|
||||
|
||||
### 9.3 启动与停止
|
||||
|
||||
```go
|
||||
// Start 启动周期日志 goroutine。
|
||||
//
|
||||
// 模块持有独立于启动 ctx 的生命周期上下文,由 Stop 负责取消,
|
||||
// 因此模块的运行寿命不受启动流程上下文的影响。
|
||||
func (m *Module) Start(_ context.Context) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m.cancel = cancel
|
||||
m.done = make(chan struct{})
|
||||
go m.run(ctx)
|
||||
m.logger.Info("hello module started",
|
||||
zap.String("module", string(ID)),
|
||||
zap.Duration("interval", m.cfg.Interval))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop 取消周期 goroutine 并等待其退出,尊重 ctx 的 deadline。
|
||||
// 未 Start 过(或 Start 之前失败)时调用为 no-op。
|
||||
func (m *Module) Stop(ctx context.Context) error {
|
||||
if m.cancel == nil {
|
||||
return nil
|
||||
}
|
||||
m.cancel()
|
||||
select {
|
||||
case <-m.done:
|
||||
m.logger.Info("hello module stopped", zap.String("module", string(ID)))
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("hello: stop interrupted while waiting for worker exit: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
两个值得照抄的细节:
|
||||
|
||||
- **后台 goroutine 不挂在 Start 的 ctx 上**——Start 的 ctx 只是启动流程上下文,模块寿命由 Stop 显式终结;
|
||||
- **Stop 等待 worker 真正退出,且尊重 ctx deadline**——超时返回包装了 `ctx.Err()` 的错误,Runtime 会记日志并继续关闭其余模块。
|
||||
|
||||
### 9.4 启用与观测
|
||||
|
||||
启用(`config.yaml`):
|
||||
|
||||
```yaml
|
||||
modules:
|
||||
job.hello:
|
||||
enabled: true
|
||||
interval: 10s
|
||||
```
|
||||
|
||||
启动/关闭日志样例、`GET /api/v1/admin/modules`(实现见 `internal/handler/admin/module_handler.go`)与前端「插件模块」页等观测方式,统一见第 8 章(8.3 本地运行、8.4 三种观测方式);程序内观测用 `Runtime.Snapshot()`(语义见 3.4)。
|
||||
|
||||
### 9.5 新模块检查清单
|
||||
|
||||
- [ ] 模块包位于 `internal/modules/<模块名>/`,ID 落入 ROADMAP 已规划命名空间;
|
||||
- [ ] `init()` 中 `plugin.RegisterModule`,零值可用、`ModuleInfo()` 无副作用;
|
||||
- [ ] `EnabledByDefault: false`(除非有登记过的决策);
|
||||
- [ ] 依赖只经 Host 获取,不依赖任何具体 Service、不进 Wire graph;
|
||||
- [ ] 私有配置:默认值自带 + `ConfigOf` 解码 + `Validate` 校验;
|
||||
- [ ] Stop 容忍未 Start、尊重 ctx deadline;
|
||||
- [ ] `standard/imports.go` 加一行匿名 import;
|
||||
- [ ] 测试覆盖第 7.2 节清单,使用隔离 Registry(默认经 plugintest,见 7.1 / 8.2);
|
||||
- [ ] `make test-invariants` 与 `./scripts/bench-baseline.sh compare` 全绿(运行细节见 8.5)。
|
||||
|
||||
---
|
||||
|
||||
后续阶段的迁移计划与远期能力(试点模块、平台 Provider 模块化、模块自带前端页面、进程外桥等)以 [插件化 ROADMAP](../../.claude/plugin-refactor/ROADMAP.md) 为准,本指南只描述已落地的内核行为。
|
||||
@@ -32,6 +32,7 @@ import adminPaymentAPI from './payment'
|
||||
import affiliatesAPI from './affiliates'
|
||||
import riskControlAPI from './riskControl'
|
||||
import adminComplianceAPI from './compliance'
|
||||
import modulesAPI from './modules'
|
||||
|
||||
/**
|
||||
* Unified admin API object for convenient access
|
||||
@@ -65,7 +66,8 @@ export const adminAPI = {
|
||||
payment: adminPaymentAPI,
|
||||
affiliates: affiliatesAPI,
|
||||
riskControl: riskControlAPI,
|
||||
compliance: adminComplianceAPI
|
||||
compliance: adminComplianceAPI,
|
||||
modules: modulesAPI
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -97,7 +99,8 @@ export {
|
||||
adminPaymentAPI,
|
||||
affiliatesAPI,
|
||||
riskControlAPI,
|
||||
adminComplianceAPI
|
||||
adminComplianceAPI,
|
||||
modulesAPI
|
||||
}
|
||||
|
||||
export default adminAPI
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Admin Modules API endpoints (plugin module observability, read-only)
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type { Module } from '@/types'
|
||||
|
||||
export async function list(): Promise<{ modules: Module[] }> {
|
||||
const { data } = await apiClient.get<{ modules: Module[] }>('/admin/modules')
|
||||
return data
|
||||
}
|
||||
|
||||
const modulesAPI = {
|
||||
list
|
||||
}
|
||||
|
||||
export default modulesAPI
|
||||
@@ -643,6 +643,21 @@ const ChevronDownIcon = {
|
||||
)
|
||||
}
|
||||
|
||||
const CubeIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// Public-settings flags go through the registry in utils/featureFlags.ts,
|
||||
// which handles the opt-in vs opt-out fallback when settings haven't loaded
|
||||
// yet. Admin-only flags (not in public settings) stay inline below.
|
||||
@@ -764,7 +779,8 @@ const adminNavItems = computed((): NavItem[] => {
|
||||
{ path: '/admin/orders/plans', label: t('nav.paymentPlans'), icon: CreditCardIcon },
|
||||
],
|
||||
},
|
||||
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon }
|
||||
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon },
|
||||
{ path: '/admin/modules', label: t('nav.modules'), icon: CubeIcon, hideInSimpleMode: true }
|
||||
]
|
||||
|
||||
const visible = applyFeatureFlags(baseItems)
|
||||
|
||||
@@ -416,6 +416,7 @@ export default {
|
||||
channelMonitor: 'Channel Monitor',
|
||||
channelStatus: 'Channel Status',
|
||||
riskControl: 'Risk Control',
|
||||
modules: 'Modules',
|
||||
},
|
||||
|
||||
// Auth
|
||||
@@ -4511,6 +4512,34 @@ export default {
|
||||
deleteConfirm: 'Are you sure you want to delete this announcement? This action cannot be undone.'
|
||||
},
|
||||
|
||||
// Plugin Modules
|
||||
modules: {
|
||||
title: 'Plugin Modules',
|
||||
description: 'Observe registered plugin modules and their runtime state',
|
||||
columns: {
|
||||
id: 'Module ID',
|
||||
namespace: 'Namespace',
|
||||
name: 'Name',
|
||||
enabled: 'Enabled',
|
||||
state: 'State',
|
||||
error: 'Error'
|
||||
},
|
||||
enabledLabels: {
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled'
|
||||
},
|
||||
stateLabels: {
|
||||
registered: 'Registered',
|
||||
provisioned: 'Provisioned',
|
||||
running: 'Running',
|
||||
stopped: 'Stopped',
|
||||
errored: 'Errored'
|
||||
},
|
||||
noModules: 'No modules registered',
|
||||
noModulesDescription: 'No plugin modules are registered in this build',
|
||||
failedToLoad: 'Failed to load modules'
|
||||
},
|
||||
|
||||
// Promo Codes
|
||||
promo: {
|
||||
title: 'Promo Code Management',
|
||||
|
||||
@@ -416,6 +416,7 @@ export default {
|
||||
channelMonitor: '渠道监控',
|
||||
channelStatus: '渠道状态',
|
||||
riskControl: '风控中心',
|
||||
modules: '插件模块',
|
||||
},
|
||||
|
||||
// Auth
|
||||
@@ -4664,6 +4665,34 @@ export default {
|
||||
deleteConfirm: '确定要删除该公告吗?此操作无法撤销。'
|
||||
},
|
||||
|
||||
// Plugin Modules
|
||||
modules: {
|
||||
title: '插件模块',
|
||||
description: '查看已注册插件模块及其运行状态',
|
||||
columns: {
|
||||
id: '模块 ID',
|
||||
namespace: '命名空间',
|
||||
name: '名称',
|
||||
enabled: '启用',
|
||||
state: '状态',
|
||||
error: '错误信息'
|
||||
},
|
||||
enabledLabels: {
|
||||
enabled: '已启用',
|
||||
disabled: '未启用'
|
||||
},
|
||||
stateLabels: {
|
||||
registered: '已注册',
|
||||
provisioned: '已装配',
|
||||
running: '运行中',
|
||||
stopped: '已停止',
|
||||
errored: '异常'
|
||||
},
|
||||
noModules: '暂无插件模块',
|
||||
noModulesDescription: '当前构建中没有已注册的插件模块',
|
||||
failedToLoad: '加载插件模块失败'
|
||||
},
|
||||
|
||||
// Promo Codes
|
||||
promo: {
|
||||
title: '优惠码管理',
|
||||
|
||||
@@ -549,6 +549,18 @@ const routes: RouteRecordRaw[] = [
|
||||
descriptionKey: 'admin.settings.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/modules',
|
||||
name: 'AdminModules',
|
||||
component: () => import('@/views/admin/ModulesView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Plugin Modules',
|
||||
titleKey: 'admin.modules.title',
|
||||
descriptionKey: 'admin.modules.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/risk-control',
|
||||
name: 'AdminRiskControl',
|
||||
|
||||
@@ -11,6 +11,7 @@ export { useOnboardingStore } from './onboarding'
|
||||
export { useAnnouncementStore } from './announcements'
|
||||
export { usePaymentStore } from './payment'
|
||||
export { useAdminComplianceStore } from './adminCompliance'
|
||||
export { useModulesStore } from './modules'
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Module } from '@/types'
|
||||
|
||||
export const useModulesStore = defineStore('modules', () => {
|
||||
// State
|
||||
const modules = ref<Module[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Actions
|
||||
async function fetchModules() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await adminAPI.modules.list()
|
||||
modules.value = res.modules ?? []
|
||||
} catch (err: any) {
|
||||
error.value = err?.response?.data?.message || err?.message || 'Failed to load modules'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
modules,
|
||||
loading,
|
||||
error,
|
||||
// Actions
|
||||
fetchModules,
|
||||
}
|
||||
})
|
||||
@@ -361,6 +361,19 @@ export interface AnnouncementUserReadStatus {
|
||||
read_at?: string
|
||||
}
|
||||
|
||||
// ==================== Plugin Module Types ====================
|
||||
|
||||
export type ModuleState = 'registered' | 'provisioned' | 'running' | 'stopped' | 'errored'
|
||||
|
||||
export interface Module {
|
||||
id: string
|
||||
namespace: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
state: ModuleState
|
||||
error: string
|
||||
}
|
||||
|
||||
// ==================== Proxy Node Types ====================
|
||||
|
||||
export interface ProxyNode {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<template #filters>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<!-- Right: Action buttons -->
|
||||
<div class="flex flex-1 flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
@click="loadModules"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="modules" :loading="loading" row-key="id">
|
||||
<template #cell-id="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-namespace="{ value }">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-enabled="{ value }">
|
||||
<span :class="['badge', value ? 'badge-primary' : 'badge-gray']">
|
||||
{{
|
||||
value
|
||||
? t('admin.modules.enabledLabels.enabled')
|
||||
: t('admin.modules.enabledLabels.disabled')
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-state="{ value }">
|
||||
<span :class="['badge', stateBadgeClass(value)]">
|
||||
{{ stateLabel(value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-error="{ value }">
|
||||
<span
|
||||
v-if="value"
|
||||
class="block max-w-xs truncate text-sm text-red-600 dark:text-red-400"
|
||||
:title="value"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<EmptyState
|
||||
:title="t('admin.modules.noModules')"
|
||||
:description="t('admin.modules.noModulesDescription')"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useModulesStore } from '@/stores/modules'
|
||||
import type { ModuleState } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const modulesStore = useModulesStore()
|
||||
|
||||
const { modules, loading } = storeToRefs(modulesStore)
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'id', label: t('admin.modules.columns.id') },
|
||||
{ key: 'namespace', label: t('admin.modules.columns.namespace') },
|
||||
{ key: 'name', label: t('admin.modules.columns.name') },
|
||||
{ key: 'enabled', label: t('admin.modules.columns.enabled') },
|
||||
{ key: 'state', label: t('admin.modules.columns.state') },
|
||||
{ key: 'error', label: t('admin.modules.columns.error') }
|
||||
])
|
||||
|
||||
const stateBadgeClass = (state: ModuleState) => {
|
||||
if (state === 'running') return 'badge-success'
|
||||
if (state === 'errored') return 'badge-danger'
|
||||
if (state === 'registered') return 'badge-gray'
|
||||
// stopped / provisioned: secondary tone
|
||||
return 'badge-primary'
|
||||
}
|
||||
|
||||
const stateLabel = (state: ModuleState) => {
|
||||
if (state === 'registered') return t('admin.modules.stateLabels.registered')
|
||||
if (state === 'provisioned') return t('admin.modules.stateLabels.provisioned')
|
||||
if (state === 'running') return t('admin.modules.stateLabels.running')
|
||||
if (state === 'stopped') return t('admin.modules.stateLabels.stopped')
|
||||
if (state === 'errored') return t('admin.modules.stateLabels.errored')
|
||||
return state
|
||||
}
|
||||
|
||||
async function loadModules() {
|
||||
try {
|
||||
await modulesStore.fetchModules()
|
||||
} catch (error: any) {
|
||||
console.error('Error loading modules:', error)
|
||||
appStore.showError(error?.response?.data?.message || t('admin.modules.failedToLoad'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadModules()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,193 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import type { DOMWrapper, VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import ModulesView from '../ModulesView.vue'
|
||||
import type { Module } from '@/types'
|
||||
|
||||
const { listModules, showError, showSuccess } = vi.hoisted(() => ({
|
||||
listModules: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
modules: {
|
||||
list: listModules
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError,
|
||||
showSuccess
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const createModules = (): Module[] => [
|
||||
{
|
||||
id: 'core.gateway',
|
||||
namespace: 'core',
|
||||
name: 'gateway',
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
error: ''
|
||||
},
|
||||
{
|
||||
id: 'demo.broken',
|
||||
namespace: 'demo',
|
||||
name: 'broken',
|
||||
enabled: true,
|
||||
state: 'errored',
|
||||
error: 'provision failed: dependency missing'
|
||||
}
|
||||
]
|
||||
|
||||
const AppLayoutStub = { template: '<div><slot /></div>' }
|
||||
const TablePageLayoutStub = {
|
||||
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>'
|
||||
}
|
||||
const DataTableStub = {
|
||||
props: ['columns', 'data', 'loading'],
|
||||
template: `
|
||||
<div>
|
||||
<div data-test="columns">{{ columns.map(col => col.key).join(',') }}</div>
|
||||
<div v-if="loading" data-test="loading">loading</div>
|
||||
<div v-else-if="!data || data.length === 0" data-test="empty"><slot name="empty" /></div>
|
||||
<template v-else>
|
||||
<div v-for="row in data" :key="row.id" data-test="row">
|
||||
<slot name="cell-id" :value="row.id" :row="row" />
|
||||
<slot name="cell-namespace" :value="row.namespace" :row="row" />
|
||||
<slot name="cell-enabled" :value="row.enabled" :row="row" />
|
||||
<slot name="cell-state" :value="row.state" :row="row" />
|
||||
<slot name="cell-error" :value="row.error" :row="row" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function mountView() {
|
||||
return mount(ModulesView, {
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
stubs: {
|
||||
AppLayout: AppLayoutStub,
|
||||
TablePageLayout: TablePageLayoutStub,
|
||||
DataTable: DataTableStub,
|
||||
Icon: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function findRefreshButton(wrapper: VueWrapper): DOMWrapper<HTMLButtonElement> {
|
||||
const button = wrapper
|
||||
.findAll<HTMLButtonElement>('button')
|
||||
.find((item) => item.attributes('title') === 'common.refresh')
|
||||
if (!button) {
|
||||
throw new Error('refresh button not found')
|
||||
}
|
||||
return button
|
||||
}
|
||||
|
||||
describe('admin ModulesView', () => {
|
||||
beforeEach(() => {
|
||||
listModules.mockReset()
|
||||
showError.mockReset()
|
||||
showSuccess.mockReset()
|
||||
|
||||
listModules.mockResolvedValue({ modules: createModules() })
|
||||
})
|
||||
|
||||
it('fetches modules on mount and renders the module list', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
expect(listModules).toHaveBeenCalledTimes(1)
|
||||
|
||||
const columns = wrapper.get('[data-test="columns"]').text()
|
||||
expect(columns.split(',')).toEqual(['id', 'namespace', 'name', 'enabled', 'state', 'error'])
|
||||
|
||||
const rows = wrapper.findAll('[data-test="row"]')
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('core.gateway')
|
||||
expect(wrapper.text()).toContain('demo.broken')
|
||||
expect(showError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders semantic state badges and error text', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.badge-success').text()).toBe('admin.modules.stateLabels.running')
|
||||
expect(wrapper.find('.badge-danger').text()).toBe('admin.modules.stateLabels.errored')
|
||||
expect(wrapper.text()).toContain('provision failed: dependency missing')
|
||||
})
|
||||
|
||||
it('shows the loading state while the request is in flight', async () => {
|
||||
let resolveList!: (value: { modules: Module[] }) => void
|
||||
listModules.mockImplementation(
|
||||
() =>
|
||||
new Promise<{ modules: Module[] }>((resolve) => {
|
||||
resolveList = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-test="loading"]').exists()).toBe(true)
|
||||
|
||||
resolveList({ modules: [] })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-test="loading"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders the empty state when no modules are registered', async () => {
|
||||
listModules.mockResolvedValue({ modules: [] })
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-test="empty"]').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('admin.modules.noModules')
|
||||
expect(wrapper.text()).toContain('admin.modules.noModulesDescription')
|
||||
})
|
||||
|
||||
it('reloads modules when the refresh button is clicked', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
expect(listModules).toHaveBeenCalledTimes(1)
|
||||
|
||||
await findRefreshButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(listModules).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('shows an error toast when loading fails', async () => {
|
||||
listModules.mockRejectedValue(new Error('network down'))
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.modules.failedToLoad')
|
||||
expect(wrapper.find('[data-test="empty"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
# PR #3242 终审审计总报告(2026-06-12)
|
||||
|
||||
> 用户要求的最终深度审计。三路并行,角度均为此前 per-阶段审计的盲区。审计对象:preview-dev 完整 diff(分叉点 e34ad2b1,main 零移动)。
|
||||
|
||||
## 总结论
|
||||
|
||||
**零 P0、零 P1。** 一个测试幂等性瑕疵当场修复(commit 8aef24f3),两条信息性 P2 处置完毕。PR 可安全合并。
|
||||
|
||||
## 三路明细
|
||||
|
||||
### 第一路:整 PR 生产 diff 对抗审查(跨阶段交互盲区)— P0/P1/P2 = 0/0/0
|
||||
|
||||
七个审查面全过:①gateway_handler 复合管道(钩子拦截先于一切槽位获取、registry 调用为原 service 调用的 drop-in、attempt 变量配套、清理序列原样);②moderation helper 删减零残留、WS 路径完整;③main/wire/cleanup 链(双 Stop 疑点排除:Start 失败已逆序回滚→显式 Cleanup 对已回滚模块 no-op;wire 重生成零 diff);④config Modules 子树对其他配置零影响;⑤payment init 顺序的 Go 规范级证明(包级变量初始化先于一切 init());⑥新包首见视角通读;⑦go.mod 恰一行/CI/gitignore 无夹带。
|
||||
非阻塞观察:模块 Stop 在 cleanup 并行组与其他服务并发——当前模块集无顺序依赖,未来模块若依赖他服务停止顺序需重评。
|
||||
|
||||
### 第二路:并发安全 + 稳定性实测 — race 0、flake 0
|
||||
|
||||
- `-race -count=2` 全部新增/改动包 + handler 全包 + service 安全网子集:零竞争;
|
||||
- `-count=3` 抖动扫描(粘性会话/failover/slot 唤醒/WS turn-2 全部时序敏感测试):零 flake;
|
||||
- 八个跨请求共享单例的"构造后只读/锁保护"契约逐项核证成立;
|
||||
- **integration 测试实测通过**(Docker testcontainers 路径健康);embed 构建变体健康;
|
||||
- 发现并已修复:`TestPackageLevelFunctionsDelegateToDefaultRegistry` 对 `-count>1` 不幂等(全局注册表探针无清理)→ 已注册即复用(8aef24f3),count=3 与 -race -count=2 验证通过;
|
||||
- 预存在备注:payment RefreshProviders 的 clear-reload 瞬时窗口为改造前既有语义,非本 PR 引入。
|
||||
|
||||
### 第三路:diff 完整性盘点 + 零行为变更核证 — 全部归类、声明成立
|
||||
|
||||
- **59 个非测试生产文件全部归入 A(纯新增死路径,已验证不被调用)/ B(等价替换,7 组逐一标注守护测试且全绿)/ C(配置面,最小性核对)三类,零例外**;
|
||||
- "默认配置零行为变更"逐条核证:空配置 Runtime 真 no-op(tri-state 专测)、hello 默认 disabled、config 子树缺省等价有专测断言、i18n 仅新增零删改;
|
||||
- bypass grep:无漏网 moderation 调用点(10=8HTTP+2WS 与清单精确吻合)、无绕过 factory 的构造、无绕过 registry 的违规直调(14 处直调全部为已裁决保留例外);
|
||||
- 信息性 P2:①Makefile test-e2e 修复(指向不存在脚本的既有破损目标)超出"插件化"范畴属顺手修复——已补入 PR 描述;②zzz 审计桩文件名漂移——该文件为第一轮内核审计遗留、早已删除,断言由 modules_config_test.go 覆盖。
|
||||
|
||||
## 全程审计账本(六轮累计)
|
||||
|
||||
| 轮 | 对象 | P1 | P2 | 突变实验 |
|
||||
|---|---|---|---|---|
|
||||
| 内核两轮(Phase-1.5) | plugin 内核+接入+Phase-0 资产 | 1(已修) | 11(10 修 2 备查) | 6/6 红 |
|
||||
| Phase-2 实施后 | payment+gatewayhook+8 调用点 | 0 | 3 备查 | 4/4 红 |
|
||||
| Phase-3 实施后 | gatewayplatform+2 分发点 | 0 | 3 备查 | 3/3 红 |
|
||||
| 终审三路 | 整 PR/并发实测/完整性盘点 | 0 | 1 修+2 信息性 | —(复用既有网) |
|
||||
|
||||
突变实验累计 **13/13** 被测试网捕获;热路径 allocs 全程恒等;CI 全检查 PASS。
|
||||
@@ -0,0 +1,58 @@
|
||||
# 阶段总结报告: Phase-0 回归安全网(Safety Net)
|
||||
|
||||
- **阶段状态**: Completed
|
||||
- **完成时间**: 2026-06-11
|
||||
- **关联阶段计划**: [链接](../../../.claude/plugin-refactor/phases/phase-0_safety-net/PHASE_PLAN.md)
|
||||
|
||||
## 1. 阶段目标达成情况
|
||||
|
||||
目标"把'绝不引入回归'变成机器可验证的硬约束"已达成:
|
||||
|
||||
1. ✅ INVARIANTS.md:八类 30 条不变量,全部标注覆盖状态(含两处按实测修正的事实基线);
|
||||
2. ✅ 新增测试在当前 main 上全绿(绿色基线成立);
|
||||
3. ✅ 流式 SSE 逐事件 + 非流式逐字节透传断言就位(anthropic/openai/gemini 三路径);
|
||||
4. ✅ 计费精度不变量含 PR 3061 三个漂移点(overages/1h-cache/ImageCount)全部金额级断言;
|
||||
5. ✅ 调度不变量(粘性会话、failover 10/3/3 完整循环、槽配平、等待队列)就位;
|
||||
6. ✅ 基准基线入库 + benchstat/awk 双对比脚本(allocs 严格 / ns 宽松 15%);
|
||||
7. ✅ `go test ./internal/...`(无标签)与 `-tags=unit` 全量均 0 FAIL;vet 干净;CI invariants job 为 PR 阻断门禁。
|
||||
|
||||
## 2. 任务完成统计
|
||||
|
||||
| 任务ID | 任务描述 | 状态 | 完成报告 |
|
||||
|:--|:--|:--|:--|
|
||||
| TASK-001 | 回归不变量清单与缺口分析 | Completed | [链接](./TASK-001_invariants_inventory.md) |
|
||||
| TASK-002 | 透传/拦截特征化测试 | Completed | [链接](./TASK-002_passthrough_characterization.md) |
|
||||
| TASK-003 | 计费精度不变量测试 | Completed | [链接](./TASK-003_billing_invariants.md) |
|
||||
| TASK-004 | 调度与并发不变量测试 | Completed | [链接](./TASK-004_scheduling_invariants.md) |
|
||||
| TASK-005 | 性能基准基线与 CI gate | Completed | [链接](./TASK-005_bench_baseline_ci.md) |
|
||||
|
||||
**总计**: 5 个任务全部完成。新增测试代码约 3700 行(11 个测试文件、42+ 测试函数)+ 2 个全链路基准 + 脚本与 CI 配置;**业务代码零改动**。
|
||||
|
||||
## 3. 关键技术成果
|
||||
|
||||
- 后续所有插件化 PR 的硬性合并门禁:`make test-invariants`(CI invariants job)+ `scripts/bench-baseline.sh compare`(本地/PR 人工);
|
||||
- 全链路 Forward 基准基线:非流式 ~10.2µs/93 allocs、流式 ~29.6µs/134 allocs(allocs 跨采样零漂移,可灵敏捕获 Phase-3 adapter 引入的开销)。
|
||||
|
||||
## 4. 遇到的问题与解决方案
|
||||
|
||||
- **问题1**: TASK-002/003 实施代理两次遭遇上游 API 过载(529)在收尾阶段中断
|
||||
- **解决**: 产出文件已落盘且质量达标,主控完成验证(全量测试/vet/抽查断言粒度)并代写完成报告;后续任务改为单代理串行执行。
|
||||
- **问题2**: INVARIANTS 初稿两处与实测不符(failover 耗尽错误体路径差异、计费为 float64 非 decimal)
|
||||
- **解决**: 按"以代码为准"原则实测修正清单与任务规格,体现了"清单须经验证"流程的必要性。
|
||||
|
||||
## 5. 技术债务与待优化项(不阻塞,移交后续阶段处理)
|
||||
|
||||
- 生产热路径上有 stdlib `log.Printf`(透传分支每请求一条),基准已静音处理;Phase-3 anthropic 搬家时可顺手评估收敛到结构化日志(属行为等价优化,需单独 PR);
|
||||
- `AcquireResult.ReleaseFunc` 无 once 保护(依赖 ZREM 幂等 + wrapReleaseOnDone),已固化现状,Phase-3 建缝时保持语义不变;
|
||||
- gemini/openai 完整 Forward e2e 失败循环因内置长退避无法纳入单测(已用循环契约测试替代),真实环境冒烟清单中补充;
|
||||
- golangci-lint 在本地 WSL /mnt 盘超时,依赖 CI 把关。
|
||||
|
||||
## 6. 经验总结与建议
|
||||
|
||||
- "先盘点缺口再补齐"避免了对 627 个现有测试文件的重复建设;
|
||||
- characterization 纪律(固化现状而非应然值)在 TASK-004 发现错误体路径差异时发挥了作用——若按 INVARIANTS 初稿写"应然断言",会在 main 上直接红灯。
|
||||
|
||||
## 7. 下一阶段准备
|
||||
|
||||
- Phase-1(插件内核)就绪:5 个任务规格已细化(.claude/plugin-refactor/phases/phase-1_plugin-kernel/);
|
||||
- Phase-1 全程以本阶段安全网为门禁:每个 PR 须 `make test-invariants` 全绿 + bench compare 无劣化。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 完成报告: [TASK-001] 回归不变量清单与现有测试缺口分析
|
||||
|
||||
- **完成状态**: Success
|
||||
- **关联任务规格**: [链接](../../../.claude/plugin-refactor/phases/phase-0_safety-net/TASK-001_invariants_inventory.md)
|
||||
- **完成日期**: 2026-06-11
|
||||
|
||||
## 1. 任务完成简报
|
||||
|
||||
通过两轮并行代码调研(现有 627 个测试文件的覆盖盘点 + 不变量行为的代码事实核实),产出 `.claude/plugin-refactor/INVARIANTS.md`:八类共 30 条不变量,每条标注覆盖状态与代码事实位置,并为 TASK-002/003/004 划定了精确的补齐清单。
|
||||
|
||||
**调研中的重要发现(影响后续任务):**
|
||||
1. 计费金额实现为 **float64**(非 decimal 库),TASK-003 规格中"decimal 精确断言"已修订为"固定期望值 + ≤1e-10 容差";
|
||||
2. `make test-e2e` 引用的 `scripts/e2e-test.sh` **不存在**,可用的是 `test-e2e-local`(env 驱动);已列入 TASK-005 顺手处理;
|
||||
3. failover 上限为 handler 层常量:anthropic=10(gateway_handler.go:77)、gemini=3(:78)、openai=3(openai_gateway_handler.go:111);
|
||||
4. 内容审核为 **fail-open**(审核服务失败时放行)——这是必须锁定的安全语义;
|
||||
5. PR 3061 三个计费漂移点在当前代码中的位置均已定位(overages=accounts.extra.allow_overages、5m/1h 双档缓存价、ImageOutputTokens 独立计价),现有测试对这三点均无金额级断言。
|
||||
|
||||
## 2. 核心计划回顾
|
||||
|
||||
> 1. 通读热路径关键文件,固化"外部可观测行为"列表(八类);
|
||||
> 2. 盘点现有 gateway_*/billing_*/concurrency_* 测试,建立"不变量 → 现有测试"映射;
|
||||
> 3. 重点核对 PR 3061 三个计费漂移点的现有断言情况;
|
||||
> 4. 写 INVARIANTS.md,附覆盖标注与补齐分派。
|
||||
|
||||
全部按计划完成。
|
||||
|
||||
## 3. 文件变更详情
|
||||
|
||||
### 创建的文件
|
||||
- `.claude/plugin-refactor/INVARIANTS.md` — 八类 30 条不变量清单 + 补齐分派 + 测试基础设施备注
|
||||
|
||||
### 修改的文件
|
||||
- `.claude/plugin-refactor/phases/phase-0_safety-net/TASK-003_billing_invariants.md` — DoD 第 1 条按 float64 现实修订断言策略
|
||||
|
||||
### 业务代码
|
||||
- 零改动(符合任务约束)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user