From 543f46562f965d49b5e3aea66398ea0f8a8b5db5 Mon Sep 17 00:00:00 2001 From: phamnazage-jpg Date: Thu, 21 May 2026 14:19:41 +0800 Subject: [PATCH] fix(provision): replace duplicate accounts before closure probe --- internal/provision/import_service.go | 35 ++++-- internal/provision/import_service_test.go | 71 +++++++++++ scripts/check_deepseek_completion_split.sh | 137 +++++++++++++++++++++ scripts/test_real_host_scripts.sh | 105 +++++++++++++++- 4 files changed, 339 insertions(+), 9 deletions(-) create mode 100755 scripts/check_deepseek_completion_split.sh diff --git a/internal/provision/import_service.go b/internal/provision/import_service.go index 251411ef..fff1e76d 100644 --- a/internal/provision/import_service.go +++ b/internal/provision/import_service.go @@ -76,9 +76,10 @@ type hostAdapter interface { } type resolvedManagedResources struct { - Group sub2api.GroupRef - Channel sub2api.ChannelRef - Plan *sub2api.PlanRef + Group sub2api.GroupRef + Channel sub2api.ChannelRef + Plan *sub2api.PlanRef + Accounts []sub2api.NamedResource CreatedGroup bool CreatedChannel bool @@ -160,6 +161,11 @@ func (s *ImportService) Import(ctx context.Context, req ImportRequest) (report I failedAccounts++ } } + if failedAccounts == 0 { + if err := deleteNamedAccounts(ctx, s.host, resources.Accounts); err != nil { + return failOrDegrade(report, req.Mode, fmt.Errorf("cleanup existing accounts: %w", err)) + } + } if failedAccounts > 0 && req.Mode == ImportModeStrict { report.BatchStatus = BatchStatusFailed report.ProviderStatus = ProviderStatusFailed @@ -201,15 +207,16 @@ func (s *ImportService) Import(ctx context.Context, req ImportRequest) (report I func (s *ImportService) ensureManagedResources(ctx context.Context, provider pack.ProviderManifest, accessMode string) (resolvedManagedResources, error) { names := SuggestResourceNamesForMode(provider, accessMode) snapshot, err := s.host.ListManagedResources(ctx, sub2api.ListManagedResourcesRequest{ - GroupName: names.Group, - ChannelName: names.Channel, - PlanName: names.Plan, + GroupName: names.Group, + ChannelName: names.Channel, + PlanName: names.Plan, + AccountNamePrefix: SuggestAccountNamePrefix(provider), }) if err != nil { return resolvedManagedResources{}, fmt.Errorf("list managed resources: %w", err) } - result := resolvedManagedResources{} + result := resolvedManagedResources{Accounts: append([]sub2api.NamedResource(nil), snapshot.Accounts...)} group, created, err := ensureGroup(ctx, s.host, snapshot.Groups, provider, accessMode, names.Group) if err != nil { return resolvedManagedResources{}, fmt.Errorf("ensure group: %w", err) @@ -361,6 +368,20 @@ func hasModel(models []sub2api.AccountModel, target string) bool { return false } +func deleteNamedAccounts(ctx context.Context, host hostAdapter, accounts []sub2api.NamedResource) error { + var errs []error + for index := len(accounts) - 1; index >= 0; index-- { + accountID := strings.TrimSpace(accounts[index].ID) + if accountID == "" { + continue + } + if err := host.DeleteAccount(ctx, accountID); err != nil { + errs = append(errs, fmt.Errorf("delete stale account %s: %w", accountID, err)) + } + } + return errors.Join(errs...) +} + type managedResourceRollback struct { host hostAdapter groupID string diff --git a/internal/provision/import_service_test.go b/internal/provision/import_service_test.go index 4a1ad3dc..7a146148 100644 --- a/internal/provision/import_service_test.go +++ b/internal/provision/import_service_test.go @@ -294,6 +294,74 @@ func TestImportReconcilesExistingChannelConfiguration(t *testing.T) { } } +func TestImportDeletesExistingProviderAccountsBeforeGatewayClosure(t *testing.T) { + host := &fakeHostAdapter{ + batchAccounts: []sub2api.AccountRef{{ID: "account_new_1", Name: "deepseek-01"}}, + testResults: map[string]sub2api.ProbeResult{ + "account_new_1": {OK: true, Status: "passed"}, + }, + models: map[string][]sub2api.AccountModel{ + "account_new_1": {{ID: "deepseek-chat"}}, + }, + gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}}, + managedSnapshot: sub2api.ManagedResourceSnapshot{ + Accounts: []sub2api.NamedResource{{ID: "account_old_1", Name: "deepseek-01"}, {ID: "account_old_2", Name: "deepseek-02"}}, + }, + } + + _, 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.listManagedReq.AccountNamePrefix != "deepseek-" { + t.Fatalf("AccountNamePrefix = %q, want %q", host.listManagedReq.AccountNamePrefix, "deepseek-") + } + wantDeleted := []string{"account:account_old_2", "account:account_old_1"} + if !reflect.DeepEqual(host.deletedResources, wantDeleted) { + t.Fatalf("deleted resources = %#v, want %#v", host.deletedResources, wantDeleted) + } + if !reflect.DeepEqual(host.callSequence, []string{"deleteAccount:account_old_2", "deleteAccount:account_old_1", "gateway"}) { + t.Fatalf("call sequence = %#v, want stale-account cleanup before gateway probe", host.callSequence) + } +} + +func TestImportKeepsExistingAccountsWhenReplacementValidationFails(t *testing.T) { + host := &fakeHostAdapter{ + batchAccounts: []sub2api.AccountRef{{ID: "account_new_1", Name: "deepseek-01"}}, + testResults: map[string]sub2api.ProbeResult{ + "account_new_1": {OK: false, Status: "failed", Message: "bad key"}, + }, + models: map[string][]sub2api.AccountModel{ + "account_new_1": {{ID: "deepseek-chat"}}, + }, + gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}}, + managedSnapshot: sub2api.ManagedResourceSnapshot{ + Accounts: []sub2api.NamedResource{{ID: "account_old_1", Name: "deepseek-01"}}, + }, + } + + report, 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 report.BatchStatus != BatchStatusPartial { + t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusPartial) + } + if len(host.deletedResources) != 0 { + t.Fatalf("deleted resources = %#v, want no stale-account cleanup when replacement validation fails", host.deletedResources) + } +} + type fakeHostAdapter struct { batchAccounts []sub2api.AccountRef batchCreateReq sub2api.BatchCreateAccountsRequest @@ -317,6 +385,7 @@ type fakeHostAdapter struct { createChannelReq sub2api.CreateChannelRequest updateChannelID string updateChannelReq sub2api.CreateChannelRequest + callSequence []string } func (f *fakeHostAdapter) GetHostVersion(context.Context) (string, error) { @@ -371,6 +440,7 @@ func (f *fakeHostAdapter) BatchCreateAccounts(_ context.Context, req sub2api.Bat return f.batchAccounts, nil } func (f *fakeHostAdapter) DeleteAccount(_ context.Context, accountID string) error { + f.callSequence = append(f.callSequence, "deleteAccount:"+accountID) f.deletedResources = append(f.deletedResources, "account:"+accountID) return nil } @@ -399,6 +469,7 @@ func (f *fakeHostAdapter) AssignSubscription(_ context.Context, req sub2api.Assi return sub2api.SubscriptionRef{ID: "subscription_1"}, nil } func (f *fakeHostAdapter) CheckGatewayAccess(_ context.Context, req sub2api.GatewayAccessCheckRequest) (sub2api.GatewayAccessResult, error) { + f.callSequence = append(f.callSequence, "gateway") f.gatewayProbe = req if f.gatewayErr != nil { return sub2api.GatewayAccessResult{}, f.gatewayErr diff --git a/scripts/check_deepseek_completion_split.sh b/scripts/check_deepseek_completion_split.sh new file mode 100755 index 00000000..d2d8bb1f --- /dev/null +++ b/scripts/check_deepseek_completion_split.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +set -euo pipefail + +require_var() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "missing required env: $name" >&2 + exit 1 + fi +} + +json_has_model() { + local file="$1" + local model="$2" + python3 - "$file" "$model" <<'PY' +import json, pathlib, sys +path = pathlib.Path(sys.argv[1]) +model = sys.argv[2].strip() +obj = json.loads(path.read_text(encoding='utf-8')) +for item in obj.get('data', []): + if str(item.get('id', '')).strip() == model: + print('true') + raise SystemExit(0) +print('false') +PY +} + +status_from_headers() { + local file="$1" + python3 - "$file" <<'PY' +import pathlib, re, sys +text = pathlib.Path(sys.argv[1]).read_text(encoding='utf-8') +for line in text.splitlines(): + m = re.match(r'^HTTP/\S+\s+(\d{3})\b', line.strip()) + if m: + print(m.group(1)) + raise SystemExit(0) +print('0') +PY +} + +content_type_from_headers() { + local file="$1" + python3 - "$file" <<'PY' +import pathlib, sys +text = pathlib.Path(sys.argv[1]).read_text(encoding='utf-8') +for line in text.splitlines(): + if ':' not in line: + continue + k, v = line.split(':', 1) + if k.strip().lower() == 'content-type': + print(v.strip()) + raise SystemExit(0) +print('') +PY +} + +require_var ARTIFACT_DIR +require_var HOST_BASE +require_var HOST_MANAGED_KEY +require_var UPSTREAM_BASE +require_var UPSTREAM_API_KEY + +MODEL="${MODEL:-deepseek-v4-flash}" +PROMPT="${PROMPT:-ping}" +ARTIFACT_DIR="${ARTIFACT_DIR%/}" +mkdir -p "$ARTIFACT_DIR" + +host_models_headers="$ARTIFACT_DIR/01-host-models.headers.txt" +host_models_body="$ARTIFACT_DIR/02-host-models.body.json" +host_chat_headers="$ARTIFACT_DIR/03-host-chat.headers.txt" +host_chat_body="$ARTIFACT_DIR/04-host-chat.body.json" +upstream_chat_headers="$ARTIFACT_DIR/05-upstream-chat.headers.txt" +upstream_chat_body="$ARTIFACT_DIR/06-upstream-chat.body.txt" +summary_file="$ARTIFACT_DIR/summary.json" + +chat_payload="$(python3 - "$MODEL" "$PROMPT" <<'PY' +import json, sys +print(json.dumps({ + 'model': sys.argv[1], + 'messages': [{'role': 'user', 'content': sys.argv[2]}], + 'max_tokens': 8, + 'temperature': 0, +}, ensure_ascii=False)) +PY +)" + +curl -sS -D "$host_models_headers" -o "$host_models_body" \ + -H "Authorization: Bearer $HOST_MANAGED_KEY" \ + "${HOST_BASE%/}/v1/models" + +curl -sS -D "$host_chat_headers" -o "$host_chat_body" \ + -H "Authorization: Bearer $HOST_MANAGED_KEY" \ + -H 'Content-Type: application/json' \ + "${HOST_BASE%/}/v1/chat/completions" \ + -d "$chat_payload" + +curl -sS -D "$upstream_chat_headers" -o "$upstream_chat_body" \ + -H "Authorization: Bearer $UPSTREAM_API_KEY" \ + -H 'Content-Type: application/json' \ + "${UPSTREAM_BASE%/}/chat/completions" \ + -d "$chat_payload" + +host_models_status="$(status_from_headers "$host_models_headers")" +host_chat_status="$(status_from_headers "$host_chat_headers")" +upstream_chat_status="$(status_from_headers "$upstream_chat_headers")" +host_has_expected_model="$(json_has_model "$host_models_body" "$MODEL")" +upstream_content_type="$(content_type_from_headers "$upstream_chat_headers")" + +python3 - "$summary_file" "$host_models_status" "$host_has_expected_model" "$host_chat_status" "$upstream_chat_status" "$upstream_content_type" "$host_chat_body" "$upstream_chat_body" <<'PY' +import json, pathlib, sys +summary_path = pathlib.Path(sys.argv[1]) +host_models_status = int(sys.argv[2]) +host_has_expected_model = sys.argv[3].strip().lower() == 'true' +host_chat_status = int(sys.argv[4]) +upstream_chat_status = int(sys.argv[5]) +upstream_content_type = sys.argv[6].strip() +host_chat_body = pathlib.Path(sys.argv[7]).read_text(encoding='utf-8').strip() +upstream_chat_body = pathlib.Path(sys.argv[8]).read_text(encoding='utf-8').strip() +classification = 'unknown' +if host_models_status == 200 and host_has_expected_model and host_chat_status == 502 and upstream_chat_status == 200: + classification = 'host_compatibility_gap' +elif host_models_status == 200 and host_has_expected_model and upstream_chat_status == 403 and 'insufficient_user_quota' in upstream_chat_body: + classification = 'upstream_key_quota_issue' +summary = { + 'host_models_status': host_models_status, + 'host_has_expected_model': host_has_expected_model, + 'host_chat_status': host_chat_status, + 'upstream_chat_status': upstream_chat_status, + 'upstream_chat_content_type': upstream_content_type, + 'classification': classification, + 'host_chat_body': host_chat_body, + 'upstream_chat_body_preview': upstream_chat_body[:400], +} +summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding='utf-8') +print(json.dumps(summary, ensure_ascii=False, indent=2)) +PY diff --git a/scripts/test_real_host_scripts.sh b/scripts/test_real_host_scripts.sh index 6ca4c6ac..f8340f35 100644 --- a/scripts/test_real_host_scripts.sh +++ b/scripts/test_real_host_scripts.sh @@ -46,12 +46,14 @@ run_test_build_subscription_access_prep_sql() { } run_test_real_host_acceptance_after_import_hook() { - local tmpdir fakebin artifact_dir hook_file + local tmpdir fakebin artifact_dir hook_file guide_file stdout_file tmpdir="$(mktemp -d)" trap 'rm -rf "$tmpdir"' RETURN fakebin="$tmpdir/bin" artifact_dir="$tmpdir/artifacts" hook_file="$artifact_dir/hook.txt" + guide_file="$artifact_dir/00-artifact-guide.txt" + stdout_file="$tmpdir/real_host_acceptance.stdout.txt" mkdir -p "$fakebin" cat > "$fakebin/curl" <<'EOF' @@ -133,13 +135,111 @@ EOF SUBSCRIPTION_USERS="42" \ SKIP_ROLLBACK="1" \ AFTER_IMPORT_HOOK_COMMAND='printf "%s\n" "$BATCH_ID:$BATCH_DETAIL_FILE:$ACCESS_MODE" > "$ARTIFACT_DIR/hook.txt"' \ - "$ROOT_DIR/scripts/real_host_acceptance.sh" >/dev/null + "$ROOT_DIR/scripts/real_host_acceptance.sh" >"$stdout_file" [[ -f "$hook_file" ]] || fail "after-import hook did not create $hook_file" + [[ -f "$guide_file" ]] || fail "artifact guide was not created" local hook_contents hook_contents="$(cat "$hook_file")" assert_contains "$hook_contents" "123:" assert_contains "$hook_contents" "05a-batch-detail-pre-access.json:subscription" + + local guide_contents stdout_contents + guide_contents="$(cat "$guide_file")" + stdout_contents="$(cat "$stdout_file")" + assert_contains "$guide_contents" "清单 4(必须分层留证据,不可混用)" + assert_contains "$guide_contents" "/api/v1/admin/accounts/:id/models 正确 ≠ /v1/models 正确" + assert_contains "$guide_contents" "/v1/models 正确 ≠ /v1/chat/completions 正确" + assert_contains "$stdout_contents" "artifact guide: $artifact_dir/00-artifact-guide.txt" + assert_contains "$stdout_contents" "checklist layered evidence: see 05b-after-import-hook.stdout.txt / 05b-after-import-hook.stderr.txt" +} + +run_test_check_deepseek_completion_split() { + local tmpdir fakebin artifact_dir summary_file stdout_file + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' RETURN + fakebin="$tmpdir/bin" + artifact_dir="$tmpdir/artifacts" + summary_file="$artifact_dir/summary.json" + stdout_file="$tmpdir/check_deepseek_completion_split.stdout.txt" + mkdir -p "$fakebin" "$artifact_dir" + + cat > "$fakebin/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +headers_file="" +body_file="" +url="" +prev="" +for arg in "$@"; do + case "$prev" in + -D) + headers_file="$arg" + prev="" + continue + ;; + -o) + body_file="$arg" + prev="" + continue + ;; + esac + case "$arg" in + -D|-o) + prev="$arg" + continue + ;; + http://*|https://*) + url="$arg" + ;; + esac +done +[[ -n "$headers_file" && -n "$body_file" && -n "$url" ]] || { + echo "missing curl capture args: $*" >&2 + exit 1 +} +case "$url" in + http://host.example.com/v1/models) + printf '%s +Content-Type: application/json +' 'HTTP/1.1 200 OK' > "$headers_file" + printf '%s +' '{"data":[{"id":"deepseek-v4-flash"},{"id":"deepseek-v4-pro"}]}' > "$body_file" + ;; + http://host.example.com/v1/chat/completions) + printf '%s +Content-Type: application/json +' 'HTTP/1.1 502 Bad Gateway' > "$headers_file" + printf '%s +' '{"error":{"message":"Upstream service temporarily unavailable","type":"upstream_error"}}' > "$body_file" + ;; + https://upstream.example.com/v1/chat/completions) + printf '%s +Content-Type: text/event-stream +' 'HTTP/1.1 200 OK' > "$headers_file" + printf '%s +' 'data: {"choices":[{"delta":{"content":"pong"}}]}' > "$body_file" + ;; + *) + echo "unexpected curl url: $url" >&2 + exit 1 + ;; + esac +EOF + chmod +x "$fakebin/curl" + + PATH="$fakebin:$PATH" ARTIFACT_DIR="$artifact_dir" HOST_BASE="http://host.example.com" HOST_MANAGED_KEY="managed-key" UPSTREAM_BASE="https://upstream.example.com/v1" UPSTREAM_API_KEY="upstream-key" MODEL="deepseek-v4-flash" bash "$ROOT_DIR/scripts/check_deepseek_completion_split.sh" >"$stdout_file" + + [[ -f "$summary_file" ]] || fail "missing summary file: $summary_file" + local summary stdout_contents + summary="$(cat "$summary_file")" + stdout_contents="$(cat "$stdout_file")" + assert_contains "$summary" '"classification": "host_compatibility_gap"' + assert_contains "$summary" '"host_models_status": 200' + assert_contains "$summary" '"host_chat_status": 502' + assert_contains "$summary" '"upstream_chat_status": 200' + assert_contains "$summary" '"upstream_chat_content_type": "text/event-stream"' + assert_contains "$stdout_contents" '"classification": "host_compatibility_gap"' } run_test_import_remote43_provider_subscription_prep() { @@ -387,6 +487,7 @@ EOF run_test_build_subscription_access_prep_sql run_test_real_host_acceptance_after_import_hook +run_test_check_deepseek_completion_split run_test_import_remote43_provider_subscription_prep echo "PASS: real host script regression checks"