package app import ( "context" "database/sql" "encoding/json" "fmt" "strconv" "strings" "sub2api-cn-relay-manager/internal/batch" "sub2api-cn-relay-manager/internal/pack" "sub2api-cn-relay-manager/internal/probe" "sub2api-cn-relay-manager/internal/store/sqlite" ) type batchImportReuseInspector struct { store *sqlite.DB hostRow sqlite.Host currentRunID string } func (i batchImportReuseInspector) Inspect(ctx context.Context, input batch.ReuseLookupInput) (batch.ReuseLookupResult, error) { if i.store == nil { return batch.ReuseLookupResult{}, fmt.Errorf("store is required") } if i.hostRow.ID <= 0 { return batch.ReuseLookupResult{}, fmt.Errorf("host row is required") } if reuse, ok, err := i.lookupPriorRunItem(ctx, input); err != nil { return batch.ReuseLookupResult{}, err } else if ok { return reuse, nil } return i.lookupLegacyImportBatch(ctx, input) } func (i batchImportReuseInspector) lookupPriorRunItem(ctx context.Context, input batch.ReuseLookupInput) (batch.ReuseLookupResult, bool, error) { runs, err := i.store.ImportRuns().List(ctx, 1000) if err != nil { return batch.ReuseLookupResult{}, false, err } for _, run := range runs { if strings.TrimSpace(run.HostID) != strings.TrimSpace(i.hostRow.HostID) { continue } if strings.TrimSpace(run.RunID) == strings.TrimSpace(i.currentRunID) { continue } items, err := i.store.ImportRunItems().ListByRunID(ctx, run.RunID) if err != nil { return batch.ReuseLookupResult{}, false, err } for _, item := range items { if strings.TrimSpace(item.ProviderID) != strings.TrimSpace(input.ProviderID) { continue } if !apiKeyFingerprintMatches(item.APIKeyFingerprint, input.APIKeyFingerprint) { continue } return i.reuseFromRunItem(ctx, item) } } return batch.ReuseLookupResult{}, false, nil } func (i batchImportReuseInspector) reuseFromRunItem(ctx context.Context, item sqlite.ImportRunItem) (batch.ReuseLookupResult, bool, error) { modelMapping, err := i.loadExistingModelMapping(ctx, item.ProviderID) if err != nil { return batch.ReuseLookupResult{}, false, err } reusedAccountID := int64(0) if item.AccountID != nil { reusedAccountID = *item.AccountID } else if item.ReusedFromAccountID != nil { reusedAccountID = *item.ReusedFromAccountID } state := strings.TrimSpace(item.MatchedAccountState) if state == "" { state = string(batch.MatchedAccountStateNone) } return batch.ReuseLookupResult{ ProviderMatched: true, ExistingProviderID: strings.TrimSpace(item.ProviderID), ExistingAccessStatus: normalizeRunItemAccessStatus(item.AccessStatus), ExistingCanonicalFamilys: parseStringArrayJSON(item.CanonicalFamiliesJSON), MatchedAccountID: reusedAccountID, MatchedAccountState: batch.MatchedAccountState(state), ExistingModelMapping: modelMapping, LegacyBatchID: item.LegacyBatchID, }, true, nil } func (i batchImportReuseInspector) lookupLegacyImportBatch(ctx context.Context, input batch.ReuseLookupInput) (batch.ReuseLookupResult, error) { providers, err := i.lookupLegacyProviders(ctx, input) if err != nil { return batch.ReuseLookupResult{}, err } type candidate struct { provider sqlite.Provider batch sqlite.ImportBatch item sqlite.ImportBatchItem resources []sqlite.ManagedResource } var best *candidate for _, providerRow := range providers { batches, err := i.store.ImportBatches().ListByProviderIDAndHostID(ctx, providerRow.ID, i.hostRow.ID) if err != nil { return batch.ReuseLookupResult{}, err } for _, batchRow := range batches { items, err := i.store.ImportBatchItems().GetByBatchID(ctx, batchRow.ID) if err != nil { return batch.ReuseLookupResult{}, err } for _, item := range items { if !apiKeyFingerprintMatches(item.KeyFingerprint, input.APIKeyFingerprint) { continue } resources, err := i.store.ManagedResources().GetByBatchID(ctx, batchRow.ID) if err != nil { return batch.ReuseLookupResult{}, err } best = &candidate{ provider: providerRow, batch: batchRow, item: item, resources: resources, } break } if best != nil && best.batch.ID == batchRow.ID { break } } if best != nil { break } } if best == nil { return batch.ReuseLookupResult{}, nil } modelMapping, err := providerModelMapping(best.provider) if err != nil { return batch.ReuseLookupResult{}, err } canonicalFamilies, err := providerCanonicalFamilies(best.provider) if err != nil { return batch.ReuseLookupResult{}, err } accountHostID, err := accountIDFromProbeSummary(best.item.ProbeSummaryJSON) if err != nil { return batch.ReuseLookupResult{}, err } return batch.ReuseLookupResult{ ProviderMatched: true, ExistingProviderID: strings.TrimSpace(best.provider.ProviderID), ExistingAccessStatus: normalizeLegacyBatchAccessStatus(best.batch.AccessStatus), ExistingCanonicalFamilys: canonicalFamilies, MatchedAccountID: resolveManagedAccountNumericID(accountHostID, best.resources), MatchedAccountState: normalizeLegacyMatchedAccountState(best.item.AccountStatus, best.batch.AccessStatus), ExistingModelMapping: modelMapping, LegacyBatchID: int64Ptr(best.batch.ID), }, nil } func (i batchImportReuseInspector) lookupLegacyProviders(ctx context.Context, input batch.ReuseLookupInput) ([]sqlite.Provider, error) { seen := make(map[int64]struct{}) providers := make([]sqlite.Provider, 0) appendUnique := func(rows []sqlite.Provider) { for _, row := range rows { if _, ok := seen[row.ID]; ok { continue } seen[row.ID] = struct{}{} providers = append(providers, row) } } if strings.TrimSpace(input.ProviderID) != "" { rows, err := i.store.Providers().ListByProviderID(ctx, input.ProviderID) if err != nil { return nil, err } appendUnique(rows) } if strings.TrimSpace(input.BaseURL) != "" { rows, err := i.store.Providers().ListByBaseURL(ctx, strings.TrimSpace(input.BaseURL)) if err != nil { return nil, err } appendUnique(rows) } return providers, nil } func (i batchImportReuseInspector) loadExistingModelMapping(ctx context.Context, providerID string) (map[string]string, error) { providers, err := i.store.Providers().ListByProviderID(ctx, providerID) if err != nil { return nil, err } if len(providers) == 0 { return nil, nil } for idx := len(providers) - 1; idx >= 0; idx-- { providerRow := providers[idx] batchRow, err := i.store.ImportBatches().GetLatestByProviderIDAndHostID(ctx, providerRow.ID, i.hostRow.ID) if err != nil { if err == sql.ErrNoRows { continue } return nil, err } if batchRow.ID <= 0 { continue } return providerModelMapping(providerRow) } return nil, nil } func providerModelMapping(providerRow sqlite.Provider) (map[string]string, error) { type channelTemplatePayload struct { ModelMapping map[string]string `json:"model_mapping"` } var manifest pack.ProviderManifest if strings.TrimSpace(providerRow.ManifestJSON) != "" && strings.TrimSpace(providerRow.ManifestJSON) != "{}" { if err := json.Unmarshal([]byte(providerRow.ManifestJSON), &manifest); err != nil { return nil, fmt.Errorf("decode provider manifest for %q: %w", providerRow.ProviderID, err) } if len(manifest.ChannelTemplate.ModelMapping) > 0 { return cloneStringMap(manifest.ChannelTemplate.ModelMapping), nil } } if strings.TrimSpace(providerRow.ChannelTemplateJSON) != "" && strings.TrimSpace(providerRow.ChannelTemplateJSON) != "{}" { var payload channelTemplatePayload if err := json.Unmarshal([]byte(providerRow.ChannelTemplateJSON), &payload); err != nil { return nil, fmt.Errorf("decode provider channel template for %q: %w", providerRow.ProviderID, err) } return cloneStringMap(payload.ModelMapping), nil } return map[string]string{}, nil } func providerCanonicalFamilies(providerRow sqlite.Provider) ([]string, error) { models := make([]string, 0) var manifest pack.ProviderManifest if strings.TrimSpace(providerRow.ManifestJSON) != "" && strings.TrimSpace(providerRow.ManifestJSON) != "{}" { if err := json.Unmarshal([]byte(providerRow.ManifestJSON), &manifest); err != nil { return nil, fmt.Errorf("decode provider manifest for %q: %w", providerRow.ProviderID, err) } models = append(models, manifest.DefaultModels...) for _, mapped := range manifest.ChannelTemplate.ModelMapping { models = append(models, mapped) } } models = append(models, parseStringArrayJSON(providerRow.DefaultModelsJSON)...) seen := make(map[string]struct{}, len(models)) families := make([]string, 0, len(models)) for _, modelID := range models { canonical := probe.CanonicalModelFamily(modelID) if canonical == "" { continue } if _, ok := seen[canonical]; ok { continue } seen[canonical] = struct{}{} families = append(families, canonical) } return families, nil } func normalizeRunItemAccessStatus(raw string) batch.AccessStatus { switch strings.TrimSpace(raw) { case string(batch.AccessStatusActive): return batch.AccessStatusActive case string(batch.AccessStatusDegraded): return batch.AccessStatusDegraded case string(batch.AccessStatusBroken): return batch.AccessStatusBroken default: return batch.AccessStatusUnknown } } func normalizeLegacyBatchAccessStatus(raw string) batch.AccessStatus { switch strings.TrimSpace(raw) { case "subscription_ready", "self_service_ready", "fully_ready": return batch.AccessStatusActive case "degraded": return batch.AccessStatusDegraded case "broken": return batch.AccessStatusBroken default: return batch.AccessStatusUnknown } } func normalizeLegacyMatchedAccountState(accountStatus string, batchAccessStatus string) batch.MatchedAccountState { if normalizeLegacyBatchAccessStatus(batchAccessStatus) == batch.AccessStatusBroken { return batch.MatchedAccountStateBroken } switch strings.TrimSpace(accountStatus) { case "passed", "warning": return batch.MatchedAccountStateActive case "disabled": return batch.MatchedAccountStateDisabled case "deprecated": return batch.MatchedAccountStateDeprecated case "failed": return batch.MatchedAccountStateBroken default: return batch.MatchedAccountStateNone } } func accountIDFromProbeSummary(summaryJSON string) (string, error) { if strings.TrimSpace(summaryJSON) == "" { return "", nil } var payload map[string]any if err := json.Unmarshal([]byte(summaryJSON), &payload); err != nil { return "", err } accountID, _ := payload["account_id"].(string) return strings.TrimSpace(accountID), nil } func resolveManagedAccountNumericID(accountHostID string, resources []sqlite.ManagedResource) int64 { accountHostID = strings.TrimSpace(accountHostID) if accountHostID == "" { return 0 } if numericID, err := strconv.ParseInt(accountHostID, 10, 64); err == nil && numericID > 0 { return numericID } for _, resource := range resources { if strings.TrimSpace(resource.ResourceType) != "account" { continue } if strings.TrimSpace(resource.HostResourceID) == accountHostID { return resource.ID } } return 0 } func apiKeyFingerprintMatches(stored string, lookup string) bool { stored = normalizeFingerprint(stored) lookup = normalizeFingerprint(lookup) if stored == "" || lookup == "" { return false } if stored == lookup { return true } return strings.HasPrefix(stored, lookup) || strings.HasPrefix(lookup, stored) } func normalizeFingerprint(raw string) string { trimmed := strings.TrimSpace(raw) if trimmed == "" { return "" } return strings.TrimPrefix(trimmed, "sha256:") } func parseStringArrayJSON(raw string) []string { values := []string{} if strings.TrimSpace(raw) == "" { return values } if err := json.Unmarshal([]byte(raw), &values); err != nil { return []string{} } return values } func cloneStringMap(input map[string]string) map[string]string { if len(input) == 0 { return map[string]string{} } cloned := make(map[string]string, len(input)) for key, value := range input { cloned[key] = value } return cloned } func int64Ptr(value int64) *int64 { if value <= 0 { return nil } cloned := value return &cloned }