Files
sub2api-cn-relay-manager/docs/2026-05-21-BATCH_AUTO_IMPORT_TDD_PLAN.md
phamnazage-jpg afce3da3df docs(v2): refine batch import architecture
Expand the batch auto-import V2 spec and TDD plan with stability requirements, result state persistence, and result page design. Add a dedicated architecture document for run state, APIs, pages, and UI field layout, and sync the execution board to the new V2 scope.
2026-05-22 13:18:51 +08:00

591 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TDD 实施计划 v2 — Batch Auto-Import
日期2026-05-21
技术架构:`docs/2026-05-22-BATCH_AUTO_IMPORT_V2_ARCHITECTURE.md`
## 目标
让管理员只提供 `(base_url, api_key)`,系统即可自动完成:
1. 上游模型发现
2. 模型名归一化与纠错
3. 兼容能力画像生成
4. 宿主资源创建与 channel 演化
5. 异步确认账号与宿主稳定状态
6. 最终 `/v1/chat/completions` 闭环验证
7. 运行态状态持久化与结果恢复
8. 结果查看 API 与页面
本计划与 [2026-05-21-BATCH_AUTO_IMPORT_SPEC.md](./2026-05-21-BATCH_AUTO_IMPORT_SPEC.md) 保持一致,重点把“多次真实验收中踩出的经验”落实成可测试的实现顺序。
## 依赖顺序
必须按以下顺序实现,前一个未完成前不开始后一个:
```text
probe/models + probe/aliases
probe/capability + probe/completion
batch/provider_id + batch/capability_profile
host/channel_patch + batch/run_state
batch/service
batch/confirmation
app/http_batch_import + app/http_batch_runs
cmd/cli/batch_import
tests/integration/batch_import
```
关键原则:
- 先把“上游真实返回什么”查清楚,再决定写入宿主什么
- 先把“兼容能力”显式建模,再决定 `/responses``/chat/completions`、Anthropic 兼容入口如何分流
- 先把“异步确认窗口”建模,再讨论最终 `active/degraded/broken`
- 先把“状态如何持久化和投影”建模,再做结果页;页面只读运行态状态库,不直接拼接宿主实时返回
## Stage 1: probe 模块(上游发现)
### 1.1 `internal/probe/models.go`
**职责**:调用 `GET {base_url}/v1/models`,解析 OpenAI-compatible 响应,返回原始模型 ID 列表。
```go
type ModelsResult struct {
RawModels []string
HTTPStatus int
LatencyMs int64
Error string
}
func ProviderModels(ctx context.Context, baseURL, apiKey string) (*ModelsResult, error)
```
错误分类:
- `ErrAuthFailed`401/403
- `ErrRateLimited`429
- `ErrUpstreamUnreachable`502/503/timeout/connection
- `ErrUnexpected`:其他 HTTP / decode 错误
**单测**
```go
func TestProviderModels_OpenAIFormat_ReturnsModelList(t *testing.T)
func TestProviderModels_EmptyData_ReturnsEmptySlice(t *testing.T)
func TestProviderModels_AuthFailed_ReturnsErrAuthFailed(t *testing.T)
func TestProviderModels_Timeout_ReturnsErrUpstreamUnreachable(t *testing.T)
func TestProviderModels_RecordsLatency(t *testing.T)
```
### 1.2 `internal/probe/aliases.go`
**职责**:归一化模型名,消除大小写、供应商前缀、常见格式差异,并生成推荐模型。
```go
type AliasResult struct {
Raw string
Normalized string
Canonical string
}
func NormalizeModelID(raw string) string
func CanonicalModelID(raw string) string
func BuildAliasTable(rawModels []string) map[string]AliasResult
func ResolveRequestedModel(requested string, rawModels []string) (resolved string, ok bool)
```
归一化最少覆盖:
- 大小写归一
- `vendor/model` 前缀剥离
- 点号/连字符差异
- 典型人工误写场景,例如 `m27` vs `m2.7`
**单测**
```go
func TestNormalizeModelID_MinimaxCanonical(t *testing.T)
func TestNormalizeModelID_DeepSeekVendorPrefix(t *testing.T)
func TestCanonicalModelID_KimiCaseInsensitive(t *testing.T)
func TestResolveRequestedModel_ExactHit(t *testing.T)
func TestResolveRequestedModel_UsesNormalizedAlias(t *testing.T)
func TestResolveRequestedModel_MissReturnsFalse(t *testing.T)
```
### 1.3 `internal/probe/capability.go`
**职责**:对同一把 key 进行最小兼容探测,生成 capability profile。
探测对象最少包括:
- `GET /v1/models`
- `POST /v1/chat/completions`
- `POST /v1/responses`
- `POST /v1/messages`Anthropic compatible
```go
type CapabilityProfile struct {
SupportsOpenAIModels bool
SupportsOpenAIChatCompletions bool
SupportsOpenAIResponses bool
SupportsAnthropicMessages bool
SupportsStream string
SupportsTools string
SupportsReasoningFields string
AuthStyle string
ModelIDStyle string
KnownAdvisories []string
}
func ProbeCapabilities(ctx context.Context, baseURL, apiKey string, rawModels []string) (*CapabilityProfile, error)
```
约束:
- 对第三方 OpenAI-compatible 上游,`/responses=403` 不得机械判成 `supported`
- 要能记录 `responses_unsupported_but_chat_ok`
- 要能记录 `initial_probe_race_expected`
**单测**
```go
func TestProbeCapabilities_Responses403Chat200_MarksResponsesUnsupported(t *testing.T)
func TestProbeCapabilities_AnthropicMessages200_MarksSupported(t *testing.T)
func TestProbeCapabilities_ModelsOnly_MarksPartialProfile(t *testing.T)
func TestProbeCapabilities_RecordsKnownAdvisories(t *testing.T)
```
### 1.4 `internal/probe/completion.go`
**职责**:从 discovered models 中选择 smoke model执行最小 completion 测试。
```go
type CompletionResult struct {
Model string
HTTPStatus int
LatencyMs int64
Classification string
Error string
}
func ResolveSmokeModel(requested []string, rawModels []string, profile *CapabilityProfile) (string, error)
func SmokeCompletion(ctx context.Context, baseURL, apiKey, model string, profile *CapabilityProfile) (*CompletionResult, error)
```
规则:
- 优先使用 `ResolveRequestedModel`
- 若人工指定模型无效,则自动退回上游真实可用模型
- 若 profile 已知不支持 `/responses`,必须直接走 raw `/chat/completions`
**单测**
```go
func TestResolveSmokeModel_UsesRequestedAliasWhenMatched(t *testing.T)
func TestResolveSmokeModel_FallsBackToDiscoveredModel(t *testing.T)
func TestSmokeCompletion_ResponsesUnsupported_UsesChatCompletions(t *testing.T)
func TestSmokeCompletion_AllCandidatesFail_ReturnsErrNoUsableModel(t *testing.T)
```
## Stage 2: batch 模块(批量导入编排)
### 2.1 `internal/batch/provider_id.go`
**职责**:把 URL 规范化成稳定 `provider_id`
```go
func NormalizeProviderID(baseURL string) string
```
策略:
- 取 host 为主体
- 用完整 URL hash 防碰撞
- 同 host 不同 path 生成不同 ID
**单测**
```go
func TestNormalizeProviderID_Basic(t *testing.T)
func TestNormalizeProviderID_WithPath_IncludesPathHash(t *testing.T)
func TestNormalizeProviderID_Idempotent(t *testing.T)
func TestNormalizeProviderID_DifferentPaths_DifferentIDs(t *testing.T)
```
### 2.2 `internal/batch/capability_profile.go`
**职责**:将 Stage 1 的 capability profile 映射为导入策略。
```go
type ImportRoutingStrategy struct {
UseRawChatCompletions bool
SkipResponsesChecks bool
RetryInitial503 bool
TreatProbe403AsAdvisory bool
}
func BuildImportRoutingStrategy(profile *probe.CapabilityProfile) ImportRoutingStrategy
```
**单测**
```go
func TestBuildImportRoutingStrategy_ResponsesUnsupported_UsesRawChat(t *testing.T)
func TestBuildImportRoutingStrategy_ProbeRaceAdvisory_EnablesProbe403Advisory(t *testing.T)
func TestBuildImportRoutingStrategy_WarmupExpected_Enables503Retry(t *testing.T)
```
### 2.3 `internal/batch/channel_evolution.go`
**职责**:对比 channel 现有模型和新探测模型,计算 patch。
```go
func ModelMappingDelta(existing []string, discovered []string) (add []string)
func BuildPatchModelMapping(existing map[string]string, aliases map[string]probe.AliasResult) map[string]string
func BuildPatchModelPricing(models []string) map[string]any
```
要求:
- upstream raw model 和 gateway canonical model 的映射必须同时可追踪
- patch 后不得破坏旧模型
**单测**
```go
func TestModelMappingDelta_NoOverlap_AddsAll(t *testing.T)
func TestModelMappingDelta_PartialOverlap_AddsMissingOnly(t *testing.T)
func TestBuildPatchModelMapping_PreservesExistingEntries(t *testing.T)
func TestBuildPatchModelMapping_AddsCanonicalAliases(t *testing.T)
```
### 2.4 `internal/batch/service.go`
**职责**:编排 Probe → Provision → Async Confirm → Validate 四阶段。
```go
type BatchImportRequest struct {
BaseURL string
APIKey string
RequestedModels []string
AccessMode string
}
type BatchImportService struct {
Host hostadapter.HostAdapter
Probe *probe.Client
Provision *provision.ImportService
Confirm *ConfirmationService
}
func (s *BatchImportService) ImportBatch(ctx context.Context, req BatchImportRequest) (*BatchImportResult, error)
```
职责细化:
- 不再信任 `requested_models` 为最终事实
- 必须把 `raw_models` / `normalized_models` / `resolved_smoke_model` 写入结果
- 首次 account test 的 `403 Forbidden` 允许按 advisory 处理
- 首次 gateway completion 的短暂 `503 no available accounts` 允许短时重试
**单测**
```go
func TestBatchImport_AllProbeOk_ProvisionsAndValidates(t *testing.T)
func TestBatchImport_RequestedModelMiss_UsesDiscoveredModel(t *testing.T)
func TestBatchImport_Probe403Race_DowngradesToWarning(t *testing.T)
func TestBatchImport_Initial503Warmup_RetriesBeforeBroken(t *testing.T)
func TestBatchImport_PartialMode_ContinuesOnFailure(t *testing.T)
func TestBatchImport_Idempotent_SkipsExistingAccount(t *testing.T)
func TestBatchImport_PersistsRunAndItemState(t *testing.T)
```
### 2.5 `internal/batch/run_state.go`
**职责**:持久化 import run / item 的阶段结果,并生成页面读取所需的统计投影。
```go
type ImportRunState struct {
RunID string
State string
TotalItems int
ActiveItems int
DegradedItems int
BrokenItems int
StartedAt time.Time
UpdatedAt time.Time
FinishedAt *time.Time
}
type ImportRunItemState struct {
RunID string
ItemID string
BaseURL string
ProviderID string
CurrentStage string
StageStatus string
RetryCount int
AdvisoryMessages []string
LastErrorStage string
LastError string
}
type RunStateStore interface {
CreateRun(ctx context.Context, run ImportRunState) error
UpdateRun(ctx context.Context, run ImportRunState) error
UpsertItem(ctx context.Context, item ImportRunItemState) error
ListRuns(ctx context.Context, limit int) ([]ImportRunState, error)
ListRunItems(ctx context.Context, runID string) ([]ImportRunItemState, error)
}
```
**单测**
```go
func TestRunStateStore_CreateAndUpdateRun(t *testing.T)
func TestRunStateStore_UpsertItemPreservesLatestStage(t *testing.T)
func TestRunStateStore_ListRunsReturnsSummary(t *testing.T)
func TestRunStateStore_ListRunItemsReturnsRetryAndAdvisory(t *testing.T)
```
## Stage 3: host adapter 扩展
### 3.1 `internal/host/sub2api/channel.go`
新增:
```go
func (h *HostAdapter) PatchChannel(ctx context.Context, channelID int64, addModels []string) error
func (h *HostAdapter) PatchChannelPricing(ctx context.Context, channelID int64, pricing map[string]any) error
```
**单测**
```go
func TestPatchChannel_AddsModelMappingEntries(t *testing.T)
func TestPatchChannel_PreservesExistingEntries(t *testing.T)
func TestPatchChannelPricing_AddsNewModels(t *testing.T)
```
### 3.2 `internal/host/sub2api/accounts.go`
若当前 host adapter 的 account test / models 读取逻辑无法暴露 advisory 信息,需要最小增强:
```go
type AccountProbeSnapshot struct {
Models []string
ProbeStatus string
ProbeAdvisory bool
ProbeMessage string
}
func (h *HostAdapter) GetAccountProbeSnapshot(ctx context.Context, accountID int64) (*AccountProbeSnapshot, error)
```
**单测**
```go
func TestGetAccountProbeSnapshot_403RaceCapturedAsAdvisory(t *testing.T)
func TestGetAccountProbeSnapshot_ReturnsModelsAndMessage(t *testing.T)
```
## Stage 4: async confirm 模块
### 4.1 `internal/batch/confirmation.go`
**职责**:把“账号刚建好但宿主异步行为未稳定”的窗口显式建模。
```go
type ConfirmationStatus string
const (
ConfirmationPending ConfirmationStatus = "pending"
ConfirmationActive ConfirmationStatus = "confirmed_active"
ConfirmationWarning ConfirmationStatus = "confirmed_warning"
ConfirmationBroken ConfirmationStatus = "confirmed_broken"
)
type ConfirmationService struct {
Host hostadapter.HostAdapter
}
func (s *ConfirmationService) ConfirmAccount(ctx context.Context, req ConfirmationRequest) (*ConfirmationResult, error)
```
行为:
- 先查 `/accounts/:id/models`
- 若 models 已正确,但 `/test` 为首次 `403 Forbidden` 且 profile 指示 third-party responses unsupported则判为 advisory
- 若 completion 首次 `503 no available accounts`,在预算内短暂重试
- 最终将结果归入:
- `confirmed_active`
- `confirmed_warning`
- `confirmed_broken`
**单测**
```go
func TestConfirmAccount_ModelsOkProbe403Race_ReturnsWarning(t *testing.T)
func TestConfirmAccount_Initial503Then200_ReturnsActive(t *testing.T)
func TestConfirmAccount_AllRetriesExhausted_ReturnsBroken(t *testing.T)
func TestConfirmAccount_RecordsRetryAttempts(t *testing.T)
```
## Stage 5: 结果 API 与页面
### 5.1 `internal/app/http_batch_import.go`
**职责**:暴露运行结果查询 API。
```go
func (a *App) listBatchImportRuns(w http.ResponseWriter, r *http.Request)
func (a *App) getBatchImportRun(w http.ResponseWriter, r *http.Request)
func (a *App) listBatchImportRunItems(w http.ResponseWriter, r *http.Request)
func (a *App) getBatchImportRunItem(w http.ResponseWriter, r *http.Request)
```
返回内容至少包含:
- run summary
- item current stage
- normalized/resolved model 信息
- confirmation / access 状态
- advisory / retry / last_error
**单测**
```go
func TestListBatchImportRuns_ReturnsSummary(t *testing.T)
func TestGetBatchImportRun_ReturnsRunDetail(t *testing.T)
func TestListBatchImportRunItems_ReturnsProjectedItems(t *testing.T)
func TestGetBatchImportRunItem_ReturnsAdvisoryAndRetryTrail(t *testing.T)
```
### 5.2 `internal/app/http_batch_runs.go`
**职责**:提供最小页面输出,不要求复杂前端,但必须让人直接看懂结果。
页面:
- `/batch-import/runs`
- `/batch-import/runs/{run_id}`
**页面要求**
- 列表页可见 run 总状态
- 详情页可见 item 级状态、模型纠错结果、capability profile 摘要、warning / broken 原因
**单测**
```go
func TestBatchImportRunsPage_RendersRunSummary(t *testing.T)
func TestBatchImportRunDetailPage_RendersItemStates(t *testing.T)
```
## Stage 6: CLI
### 6.1 `cmd/cli/batch_import.go`
```bash
go run ./cmd/cli batch-import \
--host-base-url string \
--host-api-key string \
--entry "url,key" \
--batch-file string \
--mode "strict|partial" \
--access-mode "subscription|self_service" \
--requested-model string \
--confirm-timeout duration
```
补充要求:
- 输出必须包含:
- `run_id`
- `result_page`
- `raw_models`
- `normalized_models`
- `resolved_smoke_model`
- `capability_profile`
- `confirmation_status`
- 如果人工输入模型名不匹配CLI 要明确给出“推荐模型名”
**CLI 集成测试**
```go
func TestBatchImportCLI_ReportsResolvedModel(t *testing.T)
func TestBatchImportCLI_ReportsCapabilityProfile(t *testing.T)
func TestBatchImportCLI_ReportsConfirmationStatus(t *testing.T)
func TestBatchImportCLI_ReportsRunResultPage(t *testing.T)
```
## Stage 7: 集成测试
### `tests/integration/batch_import_test.go`
使用真实 SQLite + fake/httptest upstream覆盖
1. 标准 OpenAI-compatible 上游成功导入
2. 人工输入模型名错误,但通过 alias 解析成功
3. `/responses=403``/chat/completions=200` 的第三方兼容场景
4. 首次 `/accounts/:id/test=403`,稍后 advisory 翻正
5. 首次 `/v1/chat/completions=503 no available accounts`,重试后 `200`
6. capability profile 驱动路由分流
7. 导入进行中即可查询 run / item 状态
8. 控制面重启后,历史 run 结果仍可查看
```go
func TestBatchImport_FullPipeline(t *testing.T)
func TestBatchImport_RequestedModelTypo_IsAutoCorrected(t *testing.T)
func TestBatchImport_ThirdPartyResponsesUnsupported_StillSucceeds(t *testing.T)
func TestBatchImport_ProbeRace_BecomesWarningNotBroken(t *testing.T)
func TestBatchImport_Initial503Warmup_RetrySucceeds(t *testing.T)
func TestBatchImport_RunStatusIsQueryableDuringExecution(t *testing.T)
func TestBatchImport_RunResultSurvivesRestart(t *testing.T)
```
## 验收命令
```bash
go test ./internal/probe/... -v -count=1
go test ./internal/batch/... -v -count=1
go test ./internal/app/... -v -count=1
go test ./internal/host/sub2api/... -v -count=1
go test ./tests/integration/... -count=1
go test -cover ./internal/... -count=1
go vet ./...
gofmt -l .
```
覆盖率目标:
- `internal/probe`: >= 80%
- `internal/batch`: >= 75%
- `internal/provision`: >= 75%
## 任务清单
- [ ] `internal/probe/models.go`
- [ ] `internal/probe/aliases.go`
- [ ] `internal/probe/capability.go`
- [ ] `internal/probe/completion.go`
- [ ] `internal/batch/provider_id.go`
- [ ] `internal/batch/capability_profile.go`
- [ ] `internal/batch/channel_evolution.go`
- [ ] `internal/batch/service.go`
- [ ] `internal/batch/confirmation.go`
- [ ] `internal/batch/run_state.go`
- [ ] `internal/host/sub2api/channel.go`
- [ ] `internal/host/sub2api/accounts.go`
- [ ] `internal/app/http_batch_import.go`
- [ ] `internal/app/http_batch_runs.go`
- [ ] `cmd/cli/batch_import.go`
- [ ] `tests/integration/batch_import_test.go`
- [ ] 更新 `EXECUTION_BOARD.md` 跟踪 V2 实施状态