Files
sub2api-cn-relay-manager/docs/2026-05-21-BATCH_AUTO_IMPORT_TDD_PLAN.md

270 lines
8.4 KiB
Markdown
Raw Normal View 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 格式响应。
```go
// 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 错误
```
**单测**
```go
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。
```go
type ProbeResult struct {
URL string
HTTPStatus int
Models []string
Classification string // "auth_failed" | "rate_limited" | "unreachable" | "ok"
LatencyMs int64
Error string
}
```
**单测**
```go
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。
```go
// 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)
}
```
**单测**
```go
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}`)。
```go
// 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
}
```
**单测**
```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) // v1 vs v2 不同 hash
func TestNormalizeProviderID_SanitizesPort(t *testing.T)
```
### 2.2 `internal/batch/channel_evolution.go`
**职责**:计算 channel 现有 model_mapping 与新探测模型的差异,返回需要 patch 的内容。
```go
// 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
```
**单测**
```go
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。
```go
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
```go
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`
新增:
```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
```go
func TestPatchChannel_AddsModelMappingEntries(t *testing.T)
func TestPatchChannel_ChannelNotFound_ReturnsError(t *testing.T)
```
---
## Stage 4: CLI
### 4.1 `cmd/cli/batch_import.go`
```bash
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-file`CSV每行 `base_url,api_key`(逗号分隔,空行忽略,`#` 开头为注释)
**输出格式**
```json
{
"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
```go
func TestBatchImport_FullPipeline(t *testing.T)
func TestBatchImport_StrictStopsOnFailure(t *testing.T)
func TestBatchImport_PartialContinuesOnFailure(t *testing.T)
func TestBatchImport_IdempotentOnDuplicateURLKey(t *testing.T)
```
---
## 验收命令
```bash
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