diff --git a/docs/EXECUTION_BOARD.md b/docs/EXECUTION_BOARD.md index 106ef772..aa158f4a 100644 --- a/docs/EXECUTION_BOARD.md +++ b/docs/EXECUTION_BOARD.md @@ -1,7 +1,7 @@ # sub2api-cn-relay-manager 执行板 日期:2026-05-19 -当前 Gate:CONDITIONAL_APPROVED(代码门禁通过;2026-05-18 fresh redeploy 验证确认 self_service / subscription 访问链路可打通,但 2026-05-19 current-code remote43 DeepSeek 验收仍未闭环,失败根因已收敛为上游 key/模型能力与预期不匹配) +当前 Gate:CONDITIONAL_APPROVED(代码门禁通过;2026-05-18 fresh redeploy 验证确认 self_service / subscription 访问链路可打通;2026-05-19 current-code remote43 追踪后发现 DeepSeek/MiniMax 的 channel 创建请求漏传 model_mapping / restrict_models / billing_model_source,已补代码与测试,但真实宿主 access gate 仍需重新验收) 目标:实现独立控制面、零侵入宿主、可导入国产模型并具备可运维的导入/回滚/访问闭环。 ## 本轮已完成 @@ -36,7 +36,7 @@ 9. current-code remote43 导入链路已补齐 tunnel-aware 验证能力 - `scripts/import_remote43_provider.sh` 新增 `CRM_HOST_BASE`,允许把“operator 访问 host 地址”和“CRM 进程访问 host 地址”分离 - latest artifact:`/home/long/artifacts/real-host-acceptance/20260519_195827_remote43_deepseek_key_import` - - 结论:import / batch detail / managed resources 已真实落库,但 DeepSeek subscription access gate 仍失败 + - 结论:import / batch detail / managed resources 已真实落库;本轮定位到 channel 创建缺少 model_mapping / restrict_models / billing_model_source,已补齐实现与测试,待重新跑真实宿主验收 ## 已验证门禁 @@ -82,9 +82,10 @@ ## 剩余项(含当前外部门禁) -1. DeepSeek real-host access gate 仍未闭环(外部门禁) - - 需要满足 smoke model 的真实 key / group 绑定,确保普通用户 `/v1/models` 暴露 DeepSeek 目标模型而不是 GPT 系模型 - - 在 current-code remote43 路径上,这一项仍是上线前 NO-go 条件 +1. DeepSeek / MiniMax real-host access gate 仍需复验(外部门禁) + - 真实宿主曾出现普通用户 `/v1/models` 暴露 GPT 系模型的漂移;本轮已补齐 channel 侧 model_mapping / restrict_models / billing_model_source 传参 + - 53hk 中转 key 当前未验证可用,不能当作主结论 + - 在 current-code remote43 路径上,这一项仍需重新跑真实验收 2. 结构债务仍存在 - access / reconcile 尚未完全按 implementation plan 物理拆分 - 无内置 scheduler/jobs diff --git a/docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md b/docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md index b7a3b0b8..af109b3e 100644 --- a/docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md +++ b/docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md @@ -180,4 +180,4 @@ SKIP_ROLLBACK=1 scripts/real_host_acceptance.sh 9. 若需要验证 `reconcile` 收敛,优先在干净宿主场景或隔离 group 下执行,避免历史残留资源把结果污染成 `status=drifted` / `extra_count>0`。 10. `scripts/import_remote43_provider.sh` 现已内置 remote43 的 subscription 验收补全动作:会根据 import batch 自动解析目标 group,执行“普通用户最低余额补齐 + key/group 绑定 + user_subscriptions upsert + 定向 Redis 缓存失效(auth / balance / subscription)”,并把 SQL / host state 证据写入 artifact 目录。 11. 当 CRM 进程与 operator 到 host 的访问地址不一致时,优先显式设置 `CRM_HOST_BASE`,避免把 CRM 侧探测地址和本地运维隧道地址混用。 -12. 对 `Upstream service temporarily unavailable` 一类 502,不要先认定是上游聊天链路故障;先看脚本落盘的 `09-models.headers.txt` / `10-models.body.json`。若 `/v1/models` 已返回了别的 provider 模型集(例如 GPT 系列而不是预期的 DeepSeek/Minimax 模型),应优先归类为“普通用户 key/group 绑定漂移或命中了错误 group”,而不是 reopen provider 路由问题。 +12. 对 `Upstream service temporarily unavailable` 一类 502,不要先认定是上游聊天链路故障;先看脚本落盘的 `09-models.headers.txt` / `10-models.body.json`。若 `/v1/models` 已返回了别的 provider 模型集(例如 GPT 系列而不是预期的 DeepSeek/Minimax 模型),先检查普通用户 key/group 绑定,也要检查 CRM 导入时是否把 provider 的 `channel_template.model_mapping`、`restrict_models`、`billing_model_source` 一并下发到宿主 channel。 diff --git a/internal/host/sub2api/client.go b/internal/host/sub2api/client.go index bdc5fd25..b3752b02 100644 --- a/internal/host/sub2api/client.go +++ b/internal/host/sub2api/client.go @@ -54,8 +54,11 @@ type GroupRef struct { } type CreateChannelRequest struct { - Name string `json:"name"` - GroupIDs []string `json:"group_ids"` + Name string `json:"name"` + GroupIDs []string `json:"group_ids"` + ModelMapping map[string]string `json:"model_mapping,omitempty"` + RestrictModels bool `json:"restrict_models,omitempty"` + BillingModelSource string `json:"billing_model_source,omitempty"` } type ChannelRef struct { diff --git a/internal/provision/import_service.go b/internal/provision/import_service.go index 54878f1a..31516b91 100644 --- a/internal/provision/import_service.go +++ b/internal/provision/import_service.go @@ -255,7 +255,14 @@ func ensureGroup(ctx context.Context, host hostAdapter, existing []sub2api.Named func ensureChannel(ctx context.Context, host hostAdapter, existing []sub2api.NamedResource, provider pack.ProviderManifest, groupID string) (sub2api.ChannelRef, bool, error) { switch len(existing) { case 0: - channel, err := host.CreateChannel(ctx, sub2api.CreateChannelRequest{Name: provider.ChannelTemplate.Name, GroupIDs: []string{groupID}}) + channelReq := sub2api.CreateChannelRequest{ + Name: provider.ChannelTemplate.Name, + GroupIDs: []string{groupID}, + ModelMapping: provider.ChannelTemplate.ModelMapping, + RestrictModels: true, + BillingModelSource: "channel_mapped", + } + channel, err := host.CreateChannel(ctx, channelReq) return channel, true, err case 1: return sub2api.ChannelRef{ID: existing[0].ID, Name: existing[0].Name}, false, nil diff --git a/internal/provision/import_service_test.go b/internal/provision/import_service_test.go index 9c241ef1..a1082e3d 100644 --- a/internal/provision/import_service_test.go +++ b/internal/provision/import_service_test.go @@ -141,11 +141,11 @@ func TestImportServiceStrictModeRollsBackCreatedResources(t *testing.T) { } } -func TestImportServiceReusesExistingManagedResources(t *testing.T) { +func TestImportReusesExistingGroup(t *testing.T) { host := &fakeHostAdapter{ - batchAccounts: []sub2api.AccountRef{{ID: "account_1"}}, + batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}}, testResults: map[string]sub2api.ProbeResult{ - "account_1": {OK: true, Status: "passed"}, + "account_1": {OK: true, Status: "ready"}, }, models: map[string][]sub2api.AccountModel{ "account_1": {{ID: "deepseek-chat"}}, @@ -177,6 +177,44 @@ func TestImportServiceReusesExistingManagedResources(t *testing.T) { } } +func TestImportCreatesChannelWithManifestModelMapping(t *testing.T) { + host := &fakeHostAdapter{ + batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}}, + testResults: map[string]sub2api.ProbeResult{ + "account_1": {OK: true, Status: "ready"}, + }, + models: map[string][]sub2api.AccountModel{ + "account_1": {{ID: "deepseek-chat"}}, + }, + gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}}, + } + + _, err := NewImportService(host).Import(context.Background(), ImportRequest{ + Provider: sampleProviderManifest(), + Mode: ImportModePartial, + Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"}, + Keys: []string{"key-1"}, + }) + if err != nil { + t.Fatalf("Import() error = %v", err) + } + if host.createChannelReq.Name != "DeepSeek 默认渠道" { + t.Fatalf("CreateChannel().Name = %q, want DeepSeek 默认渠道", host.createChannelReq.Name) + } + if len(host.createChannelReq.GroupIDs) != 1 || host.createChannelReq.GroupIDs[0] != "group_1" { + t.Fatalf("CreateChannel().GroupIDs = %v, want [group_1]", host.createChannelReq.GroupIDs) + } + if got := host.createChannelReq.ModelMapping["deepseek-chat"]; got != "deepseek-chat" { + t.Fatalf("CreateChannel().ModelMapping = %+v, want deepseek-chat passthrough", host.createChannelReq.ModelMapping) + } + if !host.createChannelReq.RestrictModels { + t.Fatal("CreateChannel().RestrictModels = false, want true") + } + if host.createChannelReq.BillingModelSource != "channel_mapped" { + t.Fatalf("CreateChannel().BillingModelSource = %q, want channel_mapped", host.createChannelReq.BillingModelSource) + } +} + func sampleProviderManifest() pack.ProviderManifest { return pack.ProviderManifest{ ProviderID: "deepseek", @@ -210,6 +248,7 @@ type fakeHostAdapter struct { createChannelCalls int createPlanCalls int createGroupReq sub2api.CreateGroupRequest + createChannelReq sub2api.CreateChannelRequest } func (f *fakeHostAdapter) GetHostVersion(context.Context) (string, error) { @@ -230,8 +269,9 @@ func (f *fakeHostAdapter) DeleteGroup(_ context.Context, groupID string) error { f.deletedResources = append(f.deletedResources, "group:"+groupID) return nil } -func (f *fakeHostAdapter) CreateChannel(context.Context, sub2api.CreateChannelRequest) (sub2api.ChannelRef, error) { +func (f *fakeHostAdapter) CreateChannel(_ context.Context, req sub2api.CreateChannelRequest) (sub2api.ChannelRef, error) { f.createChannelCalls++ + f.createChannelReq = req return sub2api.ChannelRef{ID: "channel_1", Name: "c"}, nil } func (f *fakeHostAdapter) DeleteChannel(_ context.Context, channelID string) error {