Clarify subscription probe key semantics

This commit is contained in:
phamnazage-jpg
2026-05-23 17:06:52 +08:00
parent 728ed9a064
commit 80fd9dd873
14 changed files with 174 additions and 38 deletions

View File

@@ -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 已落地

View File

@@ -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 <gateway-key>`

View File

@@ -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当成产品源码失败

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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,

View File

@@ -11,6 +11,9 @@ const (
ModeSubscription = "subscription"
ModeSelfService = "self_service"
ProbeKeySourceRequestedProbeAPIKey = "requested_probe_api_key"
ProbeKeySourceManagedSubscription = "managed_subscription"
gatewayCompletionRetryAttempts = 3
gatewayCompletionRetryDelay = 300 * time.Millisecond
)

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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[:])
}

View File

@@ -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)
}

View File

@@ -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) {