diff --git a/internal/access/closure_test.go b/internal/access/closure_test.go index 8585f38c..d963923b 100644 --- a/internal/access/closure_test.go +++ b/internal/access/closure_test.go @@ -65,6 +65,29 @@ func TestServiceCloseAssignsSubscriptionsAndProbesGateway(t *testing.T) { } } +func TestServiceCloseSubscriptionManagedKeyOverridesExplicitProbeAPIKey(t *testing.T) { + host := &fakeClosureHost{ + gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}}, + managedAccess: map[string]sub2api.SubscriptionAccessRef{ + "user-1": {UserID: "host-user-1", APIKey: "managed-user-key"}, + }, + } + service := NewService(host) + _, err := service.Close(context.Background(), ClosureRequest{ + Mode: "subscription", + ProbeAPIKey: "caller-supplied-key", + GroupID: "group-1", + ExpectedModel: "deepseek-chat", + Subscriptions: []SubscriptionTarget{{UserID: "user-1", DurationDays: 30}}, + }) + if err != nil { + t.Fatalf("Close() error = %v", err) + } + if host.gatewayProbe.APIKey != "managed-user-key" { + t.Fatalf("gateway probe api key = %q, want managed-user-key override", host.gatewayProbe.APIKey) + } +} + func TestServiceCloseReturnsSubscriptionErrorBeforeGatewayProbe(t *testing.T) { host := &fakeClosureHost{assignErr: errors.New("assign failed")} service := NewService(host) diff --git a/internal/host/sub2api/sub2api_test.go b/internal/host/sub2api/sub2api_test.go index f2e55ced..d27cd7df 100644 --- a/internal/host/sub2api/sub2api_test.go +++ b/internal/host/sub2api/sub2api_test.go @@ -592,10 +592,10 @@ func TestCreateGroupWithMock(t *testing.T) { func TestCreateChannelWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { - Name string `json:"name"` - GroupIDs []int64 `json:"group_ids"` - ModelMapping map[string]map[string]string `json:"model_mapping"` - ModelPricing []struct { + Name string `json:"name"` + GroupIDs []int64 `json:"group_ids"` + ModelMapping map[string]map[string]string `json:"model_mapping"` + ModelPricing []struct { Platform string `json:"platform"` Models []string `json:"models"` BillingMode string `json:"billing_mode"` @@ -607,8 +607,8 @@ func TestCreateChannelWithMock(t *testing.T) { PerRequestPrice *float64 `json:"per_request_price"` Intervals []any `json:"intervals"` } `json:"model_pricing"` - RestrictModels bool `json:"restrict_models"` - BillingModelSource string `json:"billing_model_source"` + RestrictModels bool `json:"restrict_models"` + BillingModelSource string `json:"billing_model_source"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode request: %v", err) diff --git a/internal/provision/runtime_import_service_test.go b/internal/provision/runtime_import_service_test.go index cfe7b82c..9aa8ec0b 100644 --- a/internal/provision/runtime_import_service_test.go +++ b/internal/provision/runtime_import_service_test.go @@ -276,6 +276,83 @@ func TestRuntimeImportServiceRepeatedImportReusesManagedResources(t *testing.T) } } +func TestRuntimeImportServiceImportReconcilesExistingChannelConfiguration(t *testing.T) { + store := openProvisionTestStore(t) + defer closeProvisionTestStore(t, store) + + seedProvisionHost(t, store, "host-1", "https://sub2api.example.com") + + host := &fakeHostAdapter{ + batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "minimax-01"}}, + testResults: map[string]sub2api.ProbeResult{ + "account_1": {OK: true, Status: "passed"}, + }, + models: map[string][]sub2api.AccountModel{ + "account_1": {{ID: "MiniMax-M2.7-highspeed"}}, + }, + gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"MiniMax-M2.5-highspeed", "MiniMax-M2.7-highspeed"}}, + managedSnapshot: sub2api.ManagedResourceSnapshot{ + Groups: []sub2api.NamedResource{{ID: "group_existing", Name: "MiniMax 默认分组-self-service"}}, + Channels: []sub2api.NamedResource{{ID: "channel_existing", Name: "MiniMax 默认渠道-self-service"}}, + Plans: []sub2api.NamedResource{{ID: "plan_existing", Name: "MiniMax 默认套餐-self-service"}}, + }, + } + + provider := sampleProviderManifest() + provider.ProviderID = "minimax" + provider.DisplayName = "MiniMax OpenAI Compatible" + provider.BaseURL = "https://v2.aicodee.com/v1" + provider.DefaultModels = []string{"MiniMax-M2.5-highspeed", "MiniMax-M2.7-highspeed"} + provider.SmokeTestModel = "MiniMax-M2.7-highspeed" + provider.GroupTemplate.Name = "MiniMax 默认分组" + provider.ChannelTemplate = pack.ChannelTemplate{ + Name: "MiniMax 默认渠道", + ModelMapping: map[string]string{"MiniMax-M2.5-highspeed": "MiniMax-M2.5-highspeed", "MiniMax-M2.7-highspeed": "MiniMax-M2.7-highspeed"}, + } + provider.PlanTemplate.Name = "MiniMax 默认套餐" + + svc := NewRuntimeImportService(store, host) + result, err := svc.Import(context.Background(), RuntimeImportRequest{ + HostID: "host-1", + HostBaseURL: "https://sub2api.example.com", + Pack: pack.LoadedPack{ + Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"}, + Checksum: "checksum-1", + }, + Provider: provider, + Mode: ImportModePartial, + Keys: []string{"key-1"}, + Access: AccessRequest{ + Mode: AccessModeSelfService, + ProbeAPIKey: "user-key", + }, + }) + if err != nil { + t.Fatalf("RuntimeImportService.Import() error = %v", err) + } + if result.Report.Channel.ID != "channel_existing" { + t.Fatalf("Channel.ID = %q, want reused channel_existing", result.Report.Channel.ID) + } + if host.createChannelCalls != 0 { + t.Fatalf("CreateChannel() calls = %d, want 0 when channel already exists", host.createChannelCalls) + } + if host.updateChannelCalls != 1 { + t.Fatalf("UpdateChannel() calls = %d, want 1", host.updateChannelCalls) + } + if host.updateChannelID != "channel_existing" { + t.Fatalf("UpdateChannel() id = %q, want channel_existing", host.updateChannelID) + } + if len(host.updateChannelReq.ModelPricing) != 1 { + t.Fatalf("UpdateChannel().ModelPricing len = %d, want 1", len(host.updateChannelReq.ModelPricing)) + } + if got := host.updateChannelReq.ModelPricing[0].Models; len(got) != 2 || got[0] != "MiniMax-M2.5-highspeed" || got[1] != "MiniMax-M2.7-highspeed" { + t.Fatalf("UpdateChannel().ModelPricing[0].Models = %v, want minimax default models", got) + } + if host.updateChannelReq.ModelPricing[0].BillingMode != "token" { + t.Fatalf("UpdateChannel().ModelPricing[0].BillingMode = %q, want token", host.updateChannelReq.ModelPricing[0].BillingMode) + } +} + func openProvisionTestStore(t *testing.T) *sqlite.DB { t.Helper() diff --git a/scripts/import_remote43_provider.sh b/scripts/import_remote43_provider.sh index 1b2435ba..324f4d80 100755 --- a/scripts/import_remote43_provider.sh +++ b/scripts/import_remote43_provider.sh @@ -19,7 +19,7 @@ HOST_NAME="${HOST_NAME:-remote43-current-host}" REMOTE_HOST_ENV_FILE="${REMOTE_HOST_ENV_FILE:-/home/ubuntu/sub2api-host-validation-fresh-deepseek-20260519_115244/.env}" REMOTE_PG_CONTAINER="${REMOTE_PG_CONTAINER:-sub2api-relaymgr-pg}" REMOTE_REDIS_CONTAINER="${REMOTE_REDIS_CONTAINER:-sub2api-relaymgr-redis}" -PACK_PATH="${PACK_PATH:-/home/ubuntu/sub2api-cn-relay-manager/packs/openai-cn-pack}" +PACK_PATH="${PACK_PATH:-$ROOT_DIR/packs/openai-cn-pack}" ROOT="${ROOT:-$ROOT_DIR/artifacts/real-host-acceptance}" ART="${ART:-$ROOT/$(date +%Y%m%d_%H%M%S)_remote43_${provider_id}_key_import}" MIN_BALANCE="${MIN_BALANCE:-10}" @@ -46,6 +46,53 @@ ssh_cmd() { ssh -i "$KEY" -o StrictHostKeyChecking=no "$REMOTE" "$cmd" } +build_managed_subscription_identity_json() { + local selector="$1" + local group_id="$2" + python3 - "$selector" "$group_id" <<'PY' +import hashlib, json, sys + +selector, group_id = sys.argv[1:3] + +def sanitize(value: str) -> str: + value = value.strip().lower() + chars = [] + last_dash = False + for ch in value: + if ('a' <= ch <= 'z') or ('0' <= ch <= '9'): + chars.append(ch) + last_dash = False + elif not last_dash: + chars.append('-') + last_dash = True + return ''.join(chars).strip('-') + +def truncate(value: str, max_len: int) -> str: + if len(value) <= max_len: + return value + return value[:max_len].strip('-') + +normalized = selector.strip().lower() + '|' + group_id.strip() +digest = hashlib.sha256(normalized.encode('utf-8')).hexdigest() +prefix = sanitize(selector) or 'relay-sub' +prefix = truncate(prefix, 24) +short_hash = digest[:16] +key_hash = digest[:32] +username = truncate(f"{prefix}-{short_hash[:8]}", 32) +print(json.dumps({ + 'email': f"{prefix}-{short_hash}@sub2api.local", + 'username': username, + 'custom_key': 'sk-relay-' + key_hash, + 'key_name': truncate(username + '-key', 48), +}, ensure_ascii=False)) +PY +} + +remote_lookup_managed_subscription_user_id() { + local email="$1" + remote_pg_query "select id from users where email = $(sql_literal "$email") order by id desc limit 1;" +} + crm_curl_json() { local method="$1" local path="$2" @@ -344,6 +391,11 @@ for item in batch_obj.get('managed_resources', []): raise SystemExit('missing managed group in import response and batch detail') PY )" +managed_identity_json="$(build_managed_subscription_identity_json "$sub_uid" "$subscription_group_id")" +managed_user_email="$(printf '%s' "$managed_identity_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["email"])')" +managed_probe_key="$(printf '%s' "$managed_identity_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["custom_key"])')" +managed_user_id="$(remote_lookup_managed_subscription_user_id "$managed_user_email")" +managed_user_id="${managed_user_id##*$'\n'}" auth_cache_key="$(build_api_key_auth_cache_key "$sub_key")" balance_cache_key="$(build_user_balance_cache_key "$sub_uid")" subscription_cache_key="$(build_subscription_billing_cache_key "$sub_uid" "$subscription_group_id")" @@ -360,11 +412,15 @@ remote_pg_exec "$prep_sql" > "$ART/06-subscription-access-prep.psql.txt" printf 'subscription_cache_key=%s\n' "$subscription_cache_key" ssh_cmd "sudo -n docker exec $REMOTE_REDIS_CONTAINER_Q redis-cli DEL $auth_cache_key $balance_cache_key $subscription_cache_key" } > "$ART/07-redis-targeted-invalidation.txt" -remote_fetch_group_state "$subscription_group_id" "$sub_uid" "$sub_key" "$ART/08-subscription-group-state.json" +if [[ -n "$managed_user_id" ]]; then + remote_fetch_group_state "$subscription_group_id" "$managed_user_id" "$managed_probe_key" "$ART/08-subscription-group-state.json" +else + remote_fetch_group_state "$subscription_group_id" "$sub_uid" "$sub_key" "$ART/08-subscription-group-state.json" +fi -python3 - "$ART/01-runtime-context.json" "$CRM_BASE" "$HOST_BASE" "$CRM_HOST_BASE" "$provider_id" "$sub_uid" "$sub_key" "$subscription_group_id" "$admin_uid" <<'PY' +python3 - "$ART/01-runtime-context.json" "$CRM_BASE" "$HOST_BASE" "$CRM_HOST_BASE" "$provider_id" "$sub_uid" "$sub_key" "$subscription_group_id" "$admin_uid" "$managed_user_email" "$managed_probe_key" "$managed_user_id" <<'PY' import json, sys, pathlib -path, crm, host, crm_host, provider_id, sub_uid, sub_key, group_id, admin_uid = sys.argv[1:10] +path, crm, host, crm_host, provider_id, sub_uid, sub_key, group_id, admin_uid, managed_user_email, managed_probe_key, managed_user_id = sys.argv[1:13] pathlib.Path(path).write_text(json.dumps({ 'crm_base': crm, 'host_base': host, @@ -374,6 +430,9 @@ pathlib.Path(path).write_text(json.dumps({ 'subscription_user_key_prefix': sub_key[:12], 'subscription_group_id': group_id, 'admin_user_id': admin_uid, + 'managed_user_email': managed_user_email, + 'managed_user_id': managed_user_id, + 'managed_probe_key_prefix': managed_probe_key[:18], }, ensure_ascii=False, indent=2), encoding='utf-8') PY @@ -387,11 +446,11 @@ print(json.dumps({ }, ensure_ascii=False)) PY )" -ssh_cmd "curl -sS -D /tmp/models_headers.txt -o /tmp/models_body.json -H 'Authorization: Bearer $sub_key' $HOST_BASE/v1/models" +ssh_cmd "curl -sS -D /tmp/models_headers.txt -o /tmp/models_body.json -H 'Authorization: Bearer $managed_probe_key' $HOST_BASE/v1/models" ssh_cmd "cat /tmp/models_headers.txt" > "$ART/09-models.headers.txt" ssh_cmd "cat /tmp/models_body.json" > "$ART/10-models.body.json" -ssh_cmd "curl -sS -D /tmp/chat_headers.txt -o /tmp/chat_body.json -H 'Authorization: Bearer $sub_key' -H 'Content-Type: application/json' $HOST_BASE/v1/chat/completions -d $(printf %q "$probe_payload")" +ssh_cmd "curl -sS -D /tmp/chat_headers.txt -o /tmp/chat_body.json -H 'Authorization: Bearer $managed_probe_key' -H 'Content-Type: application/json' $HOST_BASE/v1/chat/completions -d $(printf %q "$probe_payload")" ssh_cmd "cat /tmp/chat_headers.txt" > "$ART/11-chat.headers.txt" ssh_cmd "cat /tmp/chat_body.json" > "$ART/12-chat.body.json"