diff --git a/docs/EXECUTION_BOARD.md b/docs/EXECUTION_BOARD.md index 037520cc..652251cc 100644 --- a/docs/EXECUTION_BOARD.md +++ b/docs/EXECUTION_BOARD.md @@ -195,6 +195,11 @@ - 已补充重复导入自动复用策略:按 `provider_id + api_key_fingerprint + canonical_model_family` 判断 `reused / patch_only / replace` - 已补充同模型别名归一化契约:例如 `kimi 2.6 / kimi-2.6 / kimi-k2.6` 可归并到同一模型家族并快速复用 - 已补充多账号重复导入与弃用账号再启用策略:active 账号提示“重复已启用”,disabled/deprecated 账号显示原状态并走 `reactivated` 快速启用路径 + - 已修正 access closure artifact 的 probe key 语义漂移:`subscription` 现在持久化 `requested_probe_api_key`、`effective_probe_key_source`、`effective_probe_key_fingerprint`,不再把外部 raw key 伪装成 `probe_api_key`;`probe_api_key` 仅继续用于 `self_service` 向后兼容 + - 最新干净本地 fresh-host 验收 `artifacts/real-host-acceptance/20260523_local_clean_minimax_subscription_probe_semantics` 已验证: + - `subscription` closure 会正确区分 `requested_probe_api_key` 与 `managed_subscription` 实际 probe 来源 + - 同一轮 raw key 直打宿主仍返回 `403 not assigned to any group` + - provider completion 仍受 MiniMax 官方 upstream `429 rate_limit_error` 影响,但这已不再会被 artifact 误读成 raw key 可用 **本轮实现状态(T1 ~ T13)**: - [x] `internal/batch` canonical types / reuse policy / service / confirmation / validation / projection 已落地 diff --git a/docs/REAL_HOST_ACCEPTANCE_LEARNINGS.md b/docs/REAL_HOST_ACCEPTANCE_LEARNINGS.md index c208d93b..93ab38a7 100644 --- a/docs/REAL_HOST_ACCEPTANCE_LEARNINGS.md +++ b/docs/REAL_HOST_ACCEPTANCE_LEARNINGS.md @@ -31,6 +31,17 @@ - closure 最终用于宿主 `/v1/models` 探测的,不是外部传入的原始 `access_api_key` - 真正使用的是 CRM 在宿主侧创建/查找出来的 managed key(`sk-relay-*` 风格) - 因此 subscription 验收如果直接拿调用方原始 probe key 去打 `/v1/models`,出现 `403 not assigned to any group` 并不代表 CRM 主链路失败,而是 probe key 用错了 + - latest-head 当前实现已把 artifact 语义拆开: + - `requested_probe_api_key` 记录调用方传入原始 key + - `effective_probe_key_source=managed_subscription` 记录实际 gateway probe 来源 + - `effective_probe_key_fingerprint` 记录实际 probe key 指纹 + - `probe_api_key` 只继续保留给 `self_service`,不再在 `subscription` closure 里复用 + - 2026-05-23 的干净本地 fresh-host 验收 `artifacts/real-host-acceptance/20260523_local_clean_minimax_subscription_probe_semantics` 已再次证明这层语义修复生效: + - closure 里 `requested_probe_api_key=sk-raw-probe-20260523b` + - `effective_probe_key_source=managed_subscription` + - 不再出现 legacy `probe_api_key` + - 同一轮 raw key 直打宿主 `/v1/models` 与 `/v1/chat/completions` 仍都是 `403 permission_error` + - 这轮 provider 最终仍是 `completion_status=429`,说明剩余阻断是 MiniMax 官方 upstream rate limit,不是 probe key 语义再次混淆 4. self_service 场景的 gateway probe 认证语义已经确认 - 真实宿主的普通用户 gateway key 访问 `/v1/models` / `/v1/chat/completions` 时,使用的是 `Authorization: Bearer ` diff --git a/docs/SOURCE_OF_TRUTH.md b/docs/SOURCE_OF_TRUTH.md index 346d0a0e..9685a27a 100644 --- a/docs/SOURCE_OF_TRUTH.md +++ b/docs/SOURCE_OF_TRUTH.md @@ -162,6 +162,10 @@ 2. subscription 场景的 gateway probe 语义必须保持: - 最终 probe key 是宿主 managed key - 不是外部原始 `access_api_key` + - closure artifact 必须把“请求传入的 key”和“实际探测使用的 key”分开表达: + - `requested_probe_api_key` = 调用方传入原始 key + - `effective_probe_key_source=managed_subscription` = 实际 gateway probe 走宿主 managed key + - `probe_api_key` 仅继续保留给 `self_service` 向后兼容,不再用于 `subscription` 3. 任何 live 结论都必须先确认: - 在线 CRM 进程启动时间 @@ -205,4 +209,5 @@ - ❌ 把历史 PASS artifact 当当前 latest-head 真相 - ❌ 把 `/v1/models` 通过当成 completion 已通过 - ❌ 把 subscription 场景原始 `access_api_key` 当成最终 probe key +- ❌ 把 `subscription` closure 里的 `requested_probe_api_key` 误读成实际 gateway probe key - ❌ 把 harness 参数错误(`PACK_PATH`、容器目标、probe auth)当成产品源码失败 diff --git a/internal/access/closure_test.go b/internal/access/closure_test.go index 538bb4f6..adac2f3c 100644 --- a/internal/access/closure_test.go +++ b/internal/access/closure_test.go @@ -70,6 +70,12 @@ func TestServiceCloseAssignsSubscriptionsAndProbesGateway(t *testing.T) { if !result.CompletionOK { t.Fatalf("completion result = %+v, want success", result) } + if result.EffectiveProbeAPIKey != "managed-user-key" { + t.Fatalf("effective probe api key = %q, want managed-user-key", result.EffectiveProbeAPIKey) + } + if result.EffectiveProbeKeySource != ProbeKeySourceManagedSubscription { + t.Fatalf("effective probe key source = %q, want %q", result.EffectiveProbeKeySource, ProbeKeySourceManagedSubscription) + } } func TestServiceCloseSubscriptionManagedKeyOverridesExplicitProbeAPIKey(t *testing.T) { @@ -81,7 +87,7 @@ func TestServiceCloseSubscriptionManagedKeyOverridesExplicitProbeAPIKey(t *testi }, } service := NewService(host) - _, err := service.Close(context.Background(), ClosureRequest{ + result, err := service.Close(context.Background(), ClosureRequest{ Mode: "subscription", ProbeAPIKey: "caller-supplied-key", GroupID: "group-1", @@ -94,6 +100,12 @@ func TestServiceCloseSubscriptionManagedKeyOverridesExplicitProbeAPIKey(t *testi if host.gatewayProbe.APIKey != "managed-user-key" { t.Fatalf("gateway probe api key = %q, want managed-user-key override", host.gatewayProbe.APIKey) } + if result.EffectiveProbeAPIKey != "managed-user-key" { + t.Fatalf("effective probe api key = %q, want managed-user-key", result.EffectiveProbeAPIKey) + } + if result.EffectiveProbeKeySource != ProbeKeySourceManagedSubscription { + t.Fatalf("effective probe key source = %q, want %q", result.EffectiveProbeKeySource, ProbeKeySourceManagedSubscription) + } } func TestServiceCloseReturnsSubscriptionErrorBeforeGatewayProbe(t *testing.T) { diff --git a/internal/access/gateway_validation.go b/internal/access/gateway_validation.go index 79a125f4..641ca1c1 100644 --- a/internal/access/gateway_validation.go +++ b/internal/access/gateway_validation.go @@ -10,19 +10,21 @@ import ( ) func (s *Service) verifyGatewayClosure(ctx context.Context, req ClosureRequest, plan closurePlan) (sub2api.GatewayAccessResult, error) { - if plan.probeAPIKey == "" { + if plan.effectiveProbeAPIKey == "" { return sub2api.GatewayAccessResult{}, fmt.Errorf("access probe api key is required to verify gateway closure") } result, err := s.host.CheckGatewayAccess(ctx, sub2api.GatewayAccessCheckRequest{ - APIKey: plan.probeAPIKey, + APIKey: plan.effectiveProbeAPIKey, ExpectedModel: req.ExpectedModel, }) if err != nil { return sub2api.GatewayAccessResult{}, fmt.Errorf("check gateway access: %w", err) } + result.EffectiveProbeAPIKey = plan.effectiveProbeAPIKey + result.EffectiveProbeKeySource = plan.effectiveProbeKeySource if result.OK && result.HasExpectedModel && strings.TrimSpace(req.ExpectedModel) != "" { completionReq := sub2api.GatewayCompletionCheckRequest{ - APIKey: plan.probeAPIKey, + APIKey: plan.effectiveProbeAPIKey, Model: req.ExpectedModel, Prompt: req.Prompt, MaxTokens: req.MaxTokens, diff --git a/internal/access/planner.go b/internal/access/planner.go index 519f7d80..b1fdba52 100644 --- a/internal/access/planner.go +++ b/internal/access/planner.go @@ -6,11 +6,18 @@ import ( ) type closurePlan struct { - probeAPIKey string + effectiveProbeAPIKey string + effectiveProbeKeySource string } func (s *Service) prepareClosurePlan(ctx context.Context, req ClosureRequest) (closurePlan, error) { - plan := closurePlan{probeAPIKey: strings.TrimSpace(req.ProbeAPIKey)} + requestedProbeAPIKey := strings.TrimSpace(req.ProbeAPIKey) + plan := closurePlan{ + effectiveProbeAPIKey: requestedProbeAPIKey, + } + if requestedProbeAPIKey != "" { + plan.effectiveProbeKeySource = ProbeKeySourceRequestedProbeAPIKey + } if strings.TrimSpace(req.Mode) != ModeSubscription { return plan, nil } diff --git a/internal/access/subscription.go b/internal/access/subscription.go index cbf1c9d6..e0245259 100644 --- a/internal/access/subscription.go +++ b/internal/access/subscription.go @@ -22,7 +22,8 @@ func (s *Service) prepareSubscriptionPlan(ctx context.Context, req ClosureReques resolvedTarget = accessRef.UserID } if strings.TrimSpace(accessRef.APIKey) != "" { - plan.probeAPIKey = strings.TrimSpace(accessRef.APIKey) + plan.effectiveProbeAPIKey = strings.TrimSpace(accessRef.APIKey) + plan.effectiveProbeKeySource = ProbeKeySourceManagedSubscription } if _, err := s.host.AssignSubscription(ctx, sub2api.AssignSubscriptionRequest{ UserID: resolvedTarget, diff --git a/internal/access/types.go b/internal/access/types.go index ef15a2ca..fbdaafd9 100644 --- a/internal/access/types.go +++ b/internal/access/types.go @@ -11,6 +11,9 @@ const ( ModeSubscription = "subscription" ModeSelfService = "self_service" + ProbeKeySourceRequestedProbeAPIKey = "requested_probe_api_key" + ProbeKeySourceManagedSubscription = "managed_subscription" + gatewayCompletionRetryAttempts = 3 gatewayCompletionRetryDelay = 300 * time.Millisecond ) diff --git a/internal/app/coverage_helpers_test.go b/internal/app/coverage_helpers_test.go index cefc1448..6d30ab45 100644 --- a/internal/app/coverage_helpers_test.go +++ b/internal/app/coverage_helpers_test.go @@ -3,6 +3,7 @@ package app import ( "context" "database/sql" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -2300,6 +2301,7 @@ func TestActionSetAssignAccessSubscriptionsClosure(t *testing.T) { HostBaseURL: server.URL, PackPath: packPath, ProviderID: "deepseek", + AccessAPIKey: "caller-probe-key", SubscriptionUsers: []string{"crm-user-1"}, SubscriptionDays: 30, }) @@ -2317,6 +2319,19 @@ func TestActionSetAssignAccessSubscriptionsClosure(t *testing.T) { if len(closures) != 1 || closures[0].Status != provision.AccessStatusSubscriptionReady { t.Fatalf("AccessClosures() = %+v, want persisted subscription_ready closure", closures) } + var payload map[string]any + if err := json.Unmarshal([]byte(closures[0].DetailsJSON), &payload); err != nil { + t.Fatalf("decode access closure payload: %v", err) + } + if _, ok := payload["probe_api_key"]; ok { + t.Fatalf("subscription access closure should omit probe_api_key, got %#v", payload["probe_api_key"]) + } + if got, _ := payload["requested_probe_api_key"].(string); got != "caller-probe-key" { + t.Fatalf("requested_probe_api_key = %q, want caller-probe-key", got) + } + if got, _ := payload["effective_probe_key_source"].(string); got != "managed_subscription" { + t.Fatalf("effective_probe_key_source = %q, want managed_subscription", got) + } batchRow, err := store.ImportBatches().GetByID(context.Background(), batchID) if err != nil { t.Fatalf("ImportBatches().GetByID() error = %v", err) diff --git a/internal/app/http_api.go b/internal/app/http_api.go index af84ce4f..b1c226fd 100644 --- a/internal/app/http_api.go +++ b/internal/app/http_api.go @@ -1367,16 +1367,11 @@ func NewActionSet(sqliteDSN string) ActionSet { } accessStatus := deriveAccessStatus(gwResult) - accessPayload, _ := json.Marshal(map[string]any{ - "status_code": gwResult.StatusCode, - "ok": gwResult.OK, - "has_expected_model": gwResult.HasExpectedModel, - "models": gwResult.Models, - "completion_ok": gwResult.CompletionOK, - "completion_status": gwResult.CompletionStatus, - "completion_type": gwResult.CompletionType, - "completion_preview": gwResult.CompletionBody, - }) + accessPayload, _ := json.Marshal(provision.BuildAccessClosureDetails(provision.AccessRequest{ + Mode: provision.AccessModeSubscription, + ProbeAPIKey: req.AccessAPIKey, + Subscriptions: subscriptionTargets(req.SubscriptionUsers, req.SubscriptionDays), + }, gwResult)) if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: batch.ID, ClosureType: access.ModeSubscription, Status: accessStatus, DetailsJSON: string(accessPayload)}); err != nil { return AssignAccessSubscriptionsResult{}, fmt.Errorf("record access closure: %w", err) } @@ -1621,3 +1616,14 @@ func deriveAccessStatus(gw sub2api.GatewayAccessResult) string { } return provision.AccessStatusBroken } + +func subscriptionTargets(userIDs []string, durationDays int) []provision.SubscriptionTarget { + targets := make([]provision.SubscriptionTarget, 0, len(userIDs)) + for _, userID := range userIDs { + targets = append(targets, provision.SubscriptionTarget{ + UserID: strings.TrimSpace(userID), + DurationDays: durationDays, + }) + } + return targets +} diff --git a/internal/host/sub2api/gateway_probe.go b/internal/host/sub2api/gateway_probe.go index c92a8bc7..2db2499a 100644 --- a/internal/host/sub2api/gateway_probe.go +++ b/internal/host/sub2api/gateway_probe.go @@ -13,14 +13,16 @@ type GatewayAccessCheckRequest struct { } type GatewayAccessResult struct { - OK bool `json:"ok"` - StatusCode int `json:"status_code"` - Models []string `json:"models"` - HasExpectedModel bool `json:"has_expected_model"` - CompletionOK bool `json:"completion_ok"` - CompletionStatus int `json:"completion_status"` - CompletionType string `json:"completion_content_type,omitempty"` - CompletionBody string `json:"completion_body_preview,omitempty"` + OK bool `json:"ok"` + StatusCode int `json:"status_code"` + Models []string `json:"models"` + HasExpectedModel bool `json:"has_expected_model"` + CompletionOK bool `json:"completion_ok"` + CompletionStatus int `json:"completion_status"` + CompletionType string `json:"completion_content_type,omitempty"` + CompletionBody string `json:"completion_body_preview,omitempty"` + EffectiveProbeAPIKey string `json:"-"` + EffectiveProbeKeySource string `json:"-"` } func (c *Client) CheckGatewayAccess(ctx context.Context, req GatewayAccessCheckRequest) (GatewayAccessResult, error) { diff --git a/internal/provision/access_closure_details.go b/internal/provision/access_closure_details.go new file mode 100644 index 00000000..09895c20 --- /dev/null +++ b/internal/provision/access_closure_details.go @@ -0,0 +1,61 @@ +package provision + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + + "sub2api-cn-relay-manager/internal/access" + "sub2api-cn-relay-manager/internal/host/sub2api" +) + +func BuildAccessClosureDetails(accessReq AccessRequest, gateway sub2api.GatewayAccessResult) map[string]any { + details := map[string]any{ + "subscription_users": subscriptionUserIDs(accessReq.Subscriptions), + "subscription_days": subscriptionDurationDays(accessReq.Subscriptions), + "status_code": gateway.StatusCode, + "ok": gateway.OK, + "has_expected_model": gateway.HasExpectedModel, + "models": gateway.Models, + "completion_ok": gateway.CompletionOK, + "completion_status": gateway.CompletionStatus, + "completion_type": gateway.CompletionType, + "completion_preview": gateway.CompletionBody, + } + + requestedProbeAPIKey := strings.TrimSpace(accessReq.ProbeAPIKey) + if requestedProbeAPIKey != "" { + details["requested_probe_api_key"] = requestedProbeAPIKey + } + + effectiveProbeKeySource := strings.TrimSpace(gateway.EffectiveProbeKeySource) + if effectiveProbeKeySource != "" { + details["effective_probe_key_source"] = effectiveProbeKeySource + } + + effectiveProbeAPIKey := strings.TrimSpace(gateway.EffectiveProbeAPIKey) + if effectiveProbeAPIKey != "" { + details["effective_probe_key_fingerprint"] = fingerprintSecret(effectiveProbeAPIKey) + } + + if strings.TrimSpace(accessReq.Mode) == AccessModeSelfService && requestedProbeAPIKey != "" { + details["probe_api_key"] = requestedProbeAPIKey + if effectiveProbeKeySource == "" { + details["effective_probe_key_source"] = access.ProbeKeySourceRequestedProbeAPIKey + } + if effectiveProbeAPIKey == "" { + details["effective_probe_key_fingerprint"] = fingerprintSecret(requestedProbeAPIKey) + } + } + + return details +} + +func fingerprintSecret(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + sum := sha256.Sum256([]byte(trimmed)) + return "sha256:" + hex.EncodeToString(sum[:]) +} diff --git a/internal/provision/runtime_import_service.go b/internal/provision/runtime_import_service.go index 7aad020b..7606e065 100644 --- a/internal/provision/runtime_import_service.go +++ b/internal/provision/runtime_import_service.go @@ -221,19 +221,7 @@ func (s *RuntimeImportService) persistRuntimeArtifacts(ctx context.Context, batc } } - accessPayload, err := json.Marshal(map[string]any{ - "probe_api_key": strings.TrimSpace(access.ProbeAPIKey), - "subscription_users": subscriptionUserIDs(access.Subscriptions), - "subscription_days": subscriptionDurationDays(access.Subscriptions), - "status_code": report.Gateway.StatusCode, - "ok": report.Gateway.OK, - "has_expected_model": report.Gateway.HasExpectedModel, - "models": report.Gateway.Models, - "completion_ok": report.Gateway.CompletionOK, - "completion_status": report.Gateway.CompletionStatus, - "completion_type": report.Gateway.CompletionType, - "completion_preview": report.Gateway.CompletionBody, - }) + accessPayload, err := json.Marshal(BuildAccessClosureDetails(access, report.Gateway)) if err != nil { return fmt.Errorf("marshal gateway access summary: %w", err) } diff --git a/internal/provision/runtime_import_service_test.go b/internal/provision/runtime_import_service_test.go index 254bf5e5..a46079f6 100644 --- a/internal/provision/runtime_import_service_test.go +++ b/internal/provision/runtime_import_service_test.go @@ -403,6 +403,15 @@ func TestRuntimeImportServicePersistsSelfServiceProbeKeyInAccessClosure(t *testi if got, _ := payload["probe_api_key"].(string); got != "user-probe-key" { t.Fatalf("probe_api_key = %q, want user-probe-key", got) } + if got, _ := payload["requested_probe_api_key"].(string); got != "user-probe-key" { + t.Fatalf("requested_probe_api_key = %q, want user-probe-key", got) + } + if got, _ := payload["effective_probe_key_source"].(string); got != "requested_probe_api_key" { + t.Fatalf("effective_probe_key_source = %q, want requested_probe_api_key", got) + } + if got, _ := payload["effective_probe_key_fingerprint"].(string); got != fingerprintSecret("user-probe-key") { + t.Fatalf("effective_probe_key_fingerprint = %q, want hashed user-probe-key", got) + } } func TestRuntimeImportServicePersistsSubscriptionMetadataInAccessClosure(t *testing.T) { @@ -459,6 +468,15 @@ func TestRuntimeImportServicePersistsSubscriptionMetadataInAccessClosure(t *test if got, _ := payload["subscription_days"].(float64); int(got) != 30 { t.Fatalf("subscription_days = %v, want 30", got) } + if _, ok := payload["probe_api_key"]; ok { + t.Fatalf("probe_api_key should be omitted for subscription closure, got %#v", payload["probe_api_key"]) + } + if got, _ := payload["effective_probe_key_source"].(string); got != "managed_subscription" { + t.Fatalf("effective_probe_key_source = %q, want managed_subscription", got) + } + if got, _ := payload["effective_probe_key_fingerprint"].(string); got != fingerprintSecret("managed-subscription-key") { + t.Fatalf("effective_probe_key_fingerprint = %q, want managed-subscription-key fingerprint", got) + } } func TestRuntimeImportServiceRepeatedImportReusesManagedResources(t *testing.T) {