Files
sub2api-cn-relay-manager/docs/2026-05-21-BATCH_AUTO_IMPORT_TDD_PLAN.md
phamnazage-jpg 66da64dbe3 docs(v2): add batch auto-import spec and tdd plan with resolved open questions
- Add BATCH_AUTO_IMPORT_SPEC.md: 3-stage pipeline (probe/provision/validate),
  provider_id=host+hash, smoke_model=find-first-usable, pricing=defaults
- Add BATCH_AUTO_IMPORT_TDD_PLAN.md: 5-stage implementation plan, 10 tasks
- Update EXECUTION_BOARD.md: add v2 section with resolved open questions
2026-05-22 06:51:44 +08:00

8.4 KiB
Raw Blame History

TDD 实施计划 v2 — Batch Auto-Import

日期2026-05-21

依赖顺序

必须按以下顺序实现,前一个未完成前不开始后一个:

probe/models          → probe/classifier
     ↓                    ↓
     └──────→ batch/service ←── host/channel_patch
                                   ↓
                              cmd/cli/batch_import
                                   ↓
                         tests/integration/batch_import

Stage 1: probe 模块(上游探测)

1.1 internal/probe/models.go

职责:调用 GET {base_url}/v1/models,解析 OpenAI 格式响应。

// ProviderModels returns the list of model IDs from a provider's /v1/models endpoint.
func ProviderModels(ctx context.Context, baseURL, apiKey string) ([]string, error)

// Classifier errors into:
//   - ErrAuthFailed      : 401/403
//   - ErrRateLimited     : 429
//   - ErrUpstreamUnreachable : 502/503/timeout/connection
//   - ErrUnexpected      : 其他 HTTP 错误

单测

func TestProviderModels_OpenAIFormat_ReturnsModelList(t *testing.T)
func TestProviderModels_FilterOutNonChatModels(t *testing.T)
func TestProviderModels_EmptyData_ReturnsEmptySlice(t *testing.T)
func TestProviderModels_AuthFailed_ReturnsErrAuthFailed(t *testing.T)
func TestProviderModels_Timeout_ReturnsErrUpstreamUnreachable(t *testing.T)

1.2 internal/probe/classifier.go

职责:对 HTTP 响应/错误进行分类,返回结构化 ProbeResult。

type ProbeResult struct {
    URL           string
    HTTPStatus    int
    Models        []string
    Classification string  // "auth_failed" | "rate_limited" | "unreachable" | "ok"
    LatencyMs     int64
    Error         string
}

单测

func TestClassify_401_ReturnsAuthFailed(t *testing.T)
func TestClassify_429_ReturnsRateLimited(t *testing.T)
func TestClassify_502_ReturnsUpstreamUnreachable(t *testing.T)
func TestClassify_200_ReturnsOk(t *testing.T)

1.3 internal/probe/completion.go

职责:遍历 /v1/models 返回的 data找第一个能完成 chat completion 的模型并执行 smoke test。

// FindSmokeModel traverses the model list and returns the first model
// that successfully completes a chat completion request.
func FindSmokeModel(ctx context.Context, baseURL, apiKey string, models []string) (model string, result *CompletionResult, err error)

// DefaultModelPricing returns a minimal pricing entry for a model
// (used when upstream has no pricing data).
type DefaultModelPricing struct {
    Model         string
    PricePer1M    float64  // default: 0 (unset)
    MaxBatch      int      // default: 0 (unset)
}

单测

func TestFindSmokeModel_FirstModelSucceeds_ReturnsIt(t *testing.T)
func TestFindSmokeModel_FirstFailsSecondSucceeds_SkipsFirst(t *testing.T)
func TestFindSmokeModel_AllFail_ReturnsErrNoUsableModel(t *testing.T)
func TestFindSmokeModel_TimeoutBudget_StopsAfterLimit(t *testing.T)
func TestDefaultModelPricing_ReturnsZeroValues(t *testing.T)

Stage 2: batch 模块(批量导入编排)

2.1 internal/batch/provider_id.go

决策:选 B完整 URL 作为 provider_id 一部分({normalized_host}-{url_hash_last8})。

// NormalizeProviderID converts a base URL into a stable provider ID using host + hash.
// https://api.deepseek.com/v1 → api-deepseek-<last8-of-url-hash>
// Collision-resistant: same full URL always produces the same ID.
func NormalizeProviderID(baseURL string) string {
    u, _ := url.Parse(baseURL)
    host := strings.ToLower(strings.ReplaceAll(u.Host, ":", "-"))
    hash := fmt.Sprintf("%x", md5.Sum([]byte(baseURL)))[:8]
    return host + "-" + hash
}

单测

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)  // v1 vs v2 不同 hash
func TestNormalizeProviderID_SanitizesPort(t *testing.T)

2.2 internal/batch/channel_evolution.go

职责:计算 channel 现有 model_mapping 与新探测模型的差异,返回需要 patch 的内容。

// ModelMappingDelta computes which models need to be added to an existing channel.
func ModelMappingDelta(existing []string, discovered []string) (add []string)

// BuildPatchModelMapping returns the full patched model_mapping for a channel.
func BuildPatchModelMapping(existing models map[string]string, add []string) map[string]string

单测

func TestModelMappingDelta_NoOverlap_AddsAll(t *testing.T)
func TestModelMappingDelta_FullOverlap_ReturnsEmpty(t *testing.T)
func TestModelMappingDelta_PartialOverlap_AddsMissingOnly(t *testing.T)
func TestBuildPatchModelMapping_AddsWithIdentityMapping(t *testing.T)

2.3 internal/batch/service.go

职责:编排 Stage 1 + 2 + 3 管道,调用 probe + provision + access。

type BatchImportService struct {
    host   hostadapter.HostAdapter
    probe  *probe.Client
    provision *provision.ImportService
}

func (s *BatchImportService) ImportBatch(ctx context.Context, req BatchImportRequest) (*BatchImportResult, error)

单测mock 外部 HTTP

func TestBatchImport_AllProbeOk_ProvisionsAndValidates(t *testing.T)
func TestBatchImport_ProbeFails_SkipsProvision(t *testing.T)
func TestBatchImport_CompletionFail_ReportsBroken(t *testing.T)
func TestBatchImport_StrictMode_StopsOnFirstFailure(t *testing.T)
func TestBatchImport_PartialMode_ContinuesOnFailure(t *testing.T)
func TestBatchImport_Idempotent_SkipsExistingAccount(t *testing.T)

Stage 3: host adapter 扩展

3.1 internal/host/sub2api/channel.go

新增:

// PatchChannel extends an existing channel's model_mapping with additional models.
func (h *HostAdapter) PatchChannel(ctx context.Context, channelID int64, addModels []string) error

单测httptest

func TestPatchChannel_AddsModelMappingEntries(t *testing.T)
func TestPatchChannel_ChannelNotFound_ReturnsError(t *testing.T)

Stage 4: CLI

4.1 cmd/cli/batch_import.go

go run ./cmd/cli batch-import \
  --host-base-url string       (required)
  --host-api-key string        (required)
  --entry "url,key"           (单条,与 --batch-file 互斥)
  --batch-file string          (批量文件路径)
  --mode "strict" | "partial"  (default: partial)
  --access-mode "subscription" | "self_service"  (default: subscription)

文件格式

  • --batch-fileCSV每行 base_url,api_key(逗号分隔,空行忽略,# 开头为注释)

输出格式

{
  "batch_id": "batch-20260521-001",
  "total": 3,
  "active": 2,
  "broken": 1,
  "degraded": 0,
  "results": [
    {"url": "https://api.deepseek.com", "provider_id": "api-deepseek",
     "upstream_models": ["deepseek-chat", "deepseek-reasoner"],
     "channel_id": 10, "account_id": 20,
     "probe_ok": true, "access_status": "active", "error": null},
    {"url": "https://api.fail.com", "provider_id": "api-fail",
     "upstream_models": [], "probe_ok": false,
     "access_status": "broken", "error": "upstream_unreachable"}
  ]
}

Stage 5: 集成测试

tests/integration/batch_import_test.go

使用真实 httptest server 模拟上游 provider

func TestBatchImport_FullPipeline(t *testing.T)
func TestBatchImport_StrictStopsOnFailure(t *testing.T)
func TestBatchImport_PartialContinuesOnFailure(t *testing.T)
func TestBatchImport_IdempotentOnDuplicateURLKey(t *testing.T)

验收命令

go test ./internal/probe/...      -v -count=1
go test ./internal/batch/...      -v -count=1
go test ./internal/host/sub2api/... -v -count=1 -run TestPatchChannel
go test ./tests/integration/batch_import_test.go -v -count=1
go vet ./...
gofmt -l .

覆盖率目标:

  • internal/probe: >= 80%
  • internal/batch: >= 75%

任务清单

  • internal/probe/models.go + models_test.go
  • internal/probe/classifier.go + classifier_test.go
  • internal/probe/completion.go + completion_test.go
  • internal/batch/provider_id.go + provider_id_test.go
  • internal/batch/channel_evolution.go + channel_evolution_test.go
  • internal/host/sub2api/channel.go PatchChannel + test
  • internal/batch/service.go + service_test.go
  • cmd/cli/batch_import.go
  • tests/integration/batch_import_test.go
  • 全量门禁gofmt / vet / test / race / cover