270 lines
8.4 KiB
Markdown
270 lines
8.4 KiB
Markdown
|
|
# 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)
|