Compare commits

...
13 Commits
Author SHA1 Message Date
shaw a6c80e8aa8 docs(audit): PR #3242 终审三路审计总报告——零 P0/P1
①整 PR 生产 diff 对抗审查(跨阶段交互)P0/P1/P2=0/0/0
②-race 全扫+count=3 抖动+integration 实测+embed 构建:零竞争零 flake
③59 文件全归类 A/B/C 零例外,'默认配置零行为变更'逐条核证成立
六轮审计累计:突变 13/13 捕获、热路径 allocs 全程恒等
2026-06-12 11:14:00 +08:00
shaw 8aef24f3a6 test(plugin): 修复默认注册表委托测试对 -count>1 的不幂等
终审第二路审计发现:该测试向进程级全局默认注册表注册探针且无法清理,
go test -count=2 同进程重跑撞重复注册 panic。改为已注册即复用探针,
使 -count>1 抖动扫描(审计常用手段)可覆盖本包。count=3 与 -race -count=2 验证通过。
2026-06-12 11:12:32 +08:00
shaw 1922c271c8 docs(phase-4): R6 勘误——AccountRuntimeBlocker'归属错误'债务经实施前核实撤回
代码事实:接口本就定义在消费者侧 ratelimit_service.go:37(评审称'定义在
openai 卫星文件'有误)、命名平台中立;实现挂 OpenAIGatewayService 正确
(守护 openai 调度器内部状态,非 openai 账号为设计内 no-op)。
执行 R6 重构=纯 churn,撤回。Phase-4 Deferred 总裁决不受影响。
本项目第三次'下游核实推翻上游结论'——多轮验证纪律双向起效。
2026-06-12 09:01:53 +08:00
shaw b3261c94ea docs(phase-4): 依赖测绘 + Phase-4 整包搬家 Deferred 裁决
双视角评审实测证伪整包搬家可行性前提(go build 证实 import cycle):
- openai 子系统实为 46 文件 65K 行(含 69 白盒测试),非摸底称的 7168 行单文件
- 双向硬耦合:正向 178 符号/64 未导出;反向 57 符号经 6 个核心文件结构级引用
- 69 个 openai 测试 100% 白盒,move 即破 gate,无黑盒等价锚点
- 循环论证(L2 触发=搬家完成、搬家收益=为 L2 铺路)+ 零拉动信号

裁决:交付 C-C 依赖测绘,Phase-4 标 Deferred + 可观测触发条件。
架构债务发现:AccountRuntimeBlocker 误命名 openai 专属、实为通用调度依赖。
含 Phase-3 PHASE_SUMMARY 归档。
2026-06-12 08:46:02 +08:00
shaw 625c691b63 refactor(platform-seam): 默认模型 ID 同构 switch 收敛为单一来源
handler 与 admin_service 各持一份逐分支同构的平台默认模型 switch(必须人工同步)。
提取 service.DefaultModelIDsForPlatform 为单一来源 + 表驱动测试;
原函数改单行委托(调用点零改动)。含 Phase-3 审计报告归档(零 P0/P1,突变 3/3 捕获)。
2026-06-12 00:58:14 +08:00
shaw f08f9111e5 refactor(platform-seam): gatewayplatform 接缝——Provider/Registry + 两处 Forward 分发替换
经双视角评审裁决的收窄版 Phase-3 接缝(零行为变更):
- internal/gatewayplatform:Provider{Platform,Forward} 两方法 v1 接口 +
  ForwardRequest{Parsed,Body,IsStickySession,SessionGroupID,SessionKey} +
  构造期注册 Registry(重复 panic,运行期并发只读)
- 3 个单语句直返 adapter(anthropic/antigravity/gemini,错误零包裹——
  BetaBlockedError/PromptTooLong 的 errors.As 链由 T4 特征化守护)
- Messages 端点两处平台分发改 registry 查找;Type!=APIKey 条件保留调用点
- OpenAI(独立 handler)、v1beta 端点、内核 internal/plugin 零触碰

门禁:T1-T5 特征化全绿、安全网 45 包、bench allocs 全部持平(266/329/93/134)。
2026-06-12 00:27:10 +08:00
shaw 57a2e7184b test(platform-seam): Phase-3 前置特征化 T1-T5——平台分发行为锁定
为 gatewayplatform 接缝(:444/:794 分发改 registry)建立等价性 gate:
- T1 :794 路由矩阵(antigravity×{非APIKey,APIKey},锁定 Type!=APIKey 真实条件)
- T2 :444 gemini→ForwardGemini 参数逐一断言 + action 契约(service 层)
- T3 粘性 session 选项端到端透传(DeleteSessionAccountID 参数 + force_cache_billing)
- T4 BetaBlockedError/PromptTooLong 错误链(adapter 包裹即红)
- T5 antigravity forward 基准 ×2 纳入基线(旧基准零漂移后重采)

含 Phase-3 设计双视角评审归档(修正摸底三处事实错误、接口砍至 2 方法、OpenAI 剔出本期)。
2026-06-12 00:07:02 +08:00
shaw 0df861f450 chore: 收窄 backend/scripts 白名单至 bench-baseline.sh(保持既有本地脚本忽略状态) 2026-06-11 23:01:57 +08:00
shaw 7d67eb083a docs(refactor): 插件化改造各阶段完成报告与审计记录归档
issues/plugin-refactor/:Phase-0~2 的任务完成报告、阶段总结、
两轮内核审计报告、Phase-2 接缝设计双视角评审与实施后对抗审计、调用点清单。
2026-06-11 23:00:19 +08:00
shaw 1cdab6f858 refactor(seams): Phase-2 接缝——payment 注册表化 + 网关 pre-flight 钩子链
设计经双视角架构评审裁决(零行为变更,特征化测试先行锁定):
- payment/provider:factory switch → 包内私有构造器注册表(init 自注册);
  unknown-key 文案与 ApplicationError 透传逐字节等价(前端 i18n 依赖)
- internal/gatewayhook:协议无关 pre-flight 钩子链(panic 隔离、error 默认
  fail-open、首 Blocked 短路、空链零分配);Decision→格式化保留在各调用点
- 8 个 HTTP moderation 调用点收敛经链(各点入参表达式/格式化函数逐一保留),
  WebSocket 两点按裁决保持原路径;moderation 为 Wire 装配的核心钩子
- 新增 7 个拦截格式特征化测试(5 种 HTTP 格式差异 + WS turn-2 + fail-open)

实施后对抗审计零 P0/P1,突变实验 4/4 被测试捕获;bench allocs 全部零增加。
2026-06-11 22:59:47 +08:00
shaw 9be516448e feat(devkit): Phase-1.5 模块脚手架 + 前端模块可观测页 + 作者指南
- tools/newmodule + make new-module ID=job.foo:开箱即编译即测试的模块骨架
  (ID 校验复用内核规则、渲染必经 gofmt、失败原子回滚、显式插装提示)
- 前端 /admin/modules 只读页:列表/状态徽章/错误悬浮/中英 i18n + vitest
- docs/plugin-architecture/MODULE-AUTHOR-GUIDE.md:模块作者指南
  (生命周期契约/配置子树/插装清单/plugintest 用法/开发调试工作流闭环/常见坑)
2026-06-11 22:59:07 +08:00
shaw 8ba2fbd786 feat(plugin): Phase-1/1.5 进程内插件内核 + 开发套件后端
Caddy 式进程内模块系统(详见 docs/plugin-architecture/MODULE-AUTHOR-GUIDE.md):
- internal/plugin:命名空间注册表(init 注册/重复 panic)、Provision→Validate→Start→Stop
  生命周期(Start 半途逆序回滚、Stop 逆序+ctx deadline)、Host 四项 ports 能力面、
  modules: 配置子树(enabled 三态;viper 含点 key 手工提取)、Runtime 状态机
- internal/plugin/plugintest:模块测试夹具(NewHost Options + RunLifecycle)
- internal/modules:standard/imports.go 唯一插装清单 + job.hello 示例模块(默认 disabled)
- Wire/main 接入:Build+Start 失败 fail-fast(显式 Cleanup 防 leader 锁泄漏)、
  runtime.Stop 在 cleanup 并行组首位(先于 Redis/Ent)
- admin 可观测:GET /api/v1/admin/modules(只读,错误文本脱敏)
- 默认配置零行为变更(Phase-0 安全网 + bench allocs 零漂移验证)

含两轮对抗式审计修复(1×P1 启动失败泄漏 leader 锁 + 11×P2)与回归测试。
2026-06-11 22:58:51 +08:00
shaw f9fac7acbe test(safety-net): Phase-0 回归安全网——特征化/不变量测试 + 基准基线 + CI 门禁
插件化改造前置:把'绝不回归'变成机器可验证的硬约束。
- 透传特征化:anthropic 逐字节/SSE 逐事件、openai、gemini 路径与错误透传
- 计费不变量:5m/1h 双档缓存价、倍率叠加顺序、overages、端到端金额与配额增量
- 调度不变量:粘性会话、failover 10/3/3 完整循环、并发槽配平、等待队列
- 热路径基准基线 + scripts/bench-baseline.sh(allocs 严格/ns 15%/基准缺失 FAIL)
- Makefile test-invariants 目标 + CI invariants job;修复失效的 test-e2e 脚本引用
- .gitignore:放行 backend/scripts 与 docs/plugin-architecture(既有白名单模式)

注:本分支按整体门禁验证(部分测试夹具引用后续提交的签名,中间提交不保证独立编译)。
2026-06-11 22:57:54 +08:00
137 changed files with 12886 additions and 117 deletions
+17
View File
@@ -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:
+5
View File
@@ -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
View File
@@ -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/...
+16
View File
@@ -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) {
+18 -2
View File
@@ -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()
+28 -5
View File
@@ -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()
+1
View File
@@ -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
View File
@@ -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
+41
View File
@@ -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"])
})
}
+82
View File
@@ -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()
}
+130
View File
@@ -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())
}
+96
View File
@@ -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"
+31 -37
View File
@@ -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
}
+1
View File
@@ -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
}
+2 -2
View File
@@ -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])
}
+5
View File
@@ -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")
}
+154
View File
@@ -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]) == "" {
+6 -12
View File
@@ -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}))
}
+122
View File
@@ -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)
}
}
+135
View File
@@ -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))
})
}
}
+34
View File
@@ -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
}
+133
View File
@@ -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
}
+55
View File
@@ -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)
}
}
+103
View File
@@ -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
}
+113
View File
@@ -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)
}
+180
View File
@@ -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")
}
}
+291
View File
@@ -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
}
+389
View File
@@ -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)
}
+52
View File
@@ -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)
}
+8
View File
@@ -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")
{
+1 -27
View File
@@ -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>&amp;</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)
})
}
+102
View File
@@ -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
View File
@@ -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
+60
View File
@@ -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
+184
View File
@@ -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)
}
+148
View File
@@ -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) 为准,本指南只描述已落地的内核行为。
+5 -2
View File
@@ -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
+17
View File
@@ -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
+17 -1
View File
@@ -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)
+29
View File
@@ -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',
+29
View File
@@ -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: '优惠码管理',
+12
View File
@@ -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',
+1
View File
@@ -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'
+35
View File
@@ -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,
}
})
+13
View File
@@ -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 {
+128
View File
@@ -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)
})
})
+41
View File
@@ -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