fix(provision): reconcile channel pricing and hosted access

This commit is contained in:
phamnazage-jpg
2026-05-20 22:09:40 +08:00
parent 83ee216a4d
commit ca1d448cc0
27 changed files with 1344 additions and 154 deletions

View File

@@ -0,0 +1,128 @@
package provision
import (
"context"
"strings"
"sub2api-cn-relay-manager/internal/pack"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
type ModeAccessStatuses struct {
Subscription string
SelfService string
}
func SuggestResourceNamesForMode(provider pack.ProviderManifest, accessMode string) ResourceNames {
base := SuggestResourceNames(provider)
suffix := accessModeResourceSuffix(accessMode)
if suffix == "" {
return base
}
return ResourceNames{
Group: appendResourceNameSuffix(base.Group, suffix),
Channel: appendResourceNameSuffix(base.Channel, suffix),
Plan: appendResourceNameSuffix(base.Plan, suffix),
}
}
func accessModeResourceSuffix(accessMode string) string {
switch strings.TrimSpace(accessMode) {
case AccessModeSubscription:
return "subscription"
case AccessModeSelfService:
return "self-service"
default:
return ""
}
}
func appendResourceNameSuffix(name, suffix string) string {
name = strings.TrimSpace(name)
suffix = strings.TrimSpace(suffix)
if name == "" || suffix == "" {
return name
}
if strings.HasSuffix(name, "-"+suffix) {
return name
}
return name + "-" + suffix
}
func LatestModeAccessStatuses(ctx context.Context, store *sqlite.DB, batches []sqlite.ImportBatch) (ModeAccessStatuses, error) {
var statuses ModeAccessStatuses
for _, batch := range batches {
if statuses.Subscription != "" && statuses.SelfService != "" {
break
}
closures, err := store.AccessClosures().GetByBatchID(ctx, batch.ID)
if err != nil {
return ModeAccessStatuses{}, err
}
batchStatuses := modeAccessStatusesForBatch(batch, closures)
if statuses.Subscription == "" && strings.TrimSpace(batchStatuses.Subscription) != "" {
statuses.Subscription = strings.TrimSpace(batchStatuses.Subscription)
}
if statuses.SelfService == "" && strings.TrimSpace(batchStatuses.SelfService) != "" {
statuses.SelfService = strings.TrimSpace(batchStatuses.SelfService)
}
}
return statuses, nil
}
func modeAccessStatusesForBatch(batch sqlite.ImportBatch, closures []sqlite.AccessClosureRecord) ModeAccessStatuses {
statuses := ModeAccessStatuses{}
for _, closure := range closures {
status := strings.TrimSpace(closure.Status)
switch strings.TrimSpace(closure.ClosureType) {
case AccessModeSubscription:
statuses.Subscription = status
case AccessModeSelfService:
statuses.SelfService = status
}
}
if statuses.Subscription == "" && statuses.SelfService == "" {
return seedModeAccessStatuses(batch.AccessStatus)
}
return statuses
}
func seedModeAccessStatuses(accessStatus string) ModeAccessStatuses {
switch strings.TrimSpace(accessStatus) {
case AccessStatusFullyReady:
return ModeAccessStatuses{Subscription: AccessStatusSubscriptionReady, SelfService: AccessStatusSelfServiceReady}
case AccessStatusSubscriptionReady:
return ModeAccessStatuses{Subscription: AccessStatusSubscriptionReady}
case AccessStatusSelfServiceReady:
return ModeAccessStatuses{SelfService: AccessStatusSelfServiceReady}
default:
return ModeAccessStatuses{}
}
}
func AggregateAccessStatus(statuses ModeAccessStatuses) string {
subscriptionReady := isReadyAccessStatus(statuses.Subscription, AccessModeSubscription)
selfServiceReady := isReadyAccessStatus(statuses.SelfService, AccessModeSelfService)
switch {
case subscriptionReady && selfServiceReady:
return AccessStatusFullyReady
case subscriptionReady:
return AccessStatusSubscriptionReady
case selfServiceReady:
return AccessStatusSelfServiceReady
default:
return AccessStatusBroken
}
}
func isReadyAccessStatus(status, mode string) bool {
status = strings.TrimSpace(status)
switch mode {
case AccessModeSubscription:
return status == AccessStatusSubscriptionReady || status == AccessStatusFullyReady
case AccessModeSelfService:
return status == AccessStatusSelfServiceReady || status == AccessStatusFullyReady
default:
return status != "" && status != AccessStatusBroken
}
}

View File

@@ -278,7 +278,7 @@ func accessClosureType(accessClosures []sqlite.AccessClosureRecord) string {
}
func buildManagedResourceListRequest(provider pack.ProviderManifest, accessMode string) sub2api.ListManagedResourcesRequest {
names := SuggestResourceNames(provider)
names := SuggestResourceNamesForMode(provider, accessMode)
req := sub2api.ListManagedResourcesRequest{
GroupName: names.Group,
ChannelName: names.Channel,

View File

@@ -215,10 +215,11 @@ func TestDeriveProviderStatus(t *testing.T) {
tests := []struct {
name string
batchStatus string
accessStatus string
reconcileStatus string
want string
}{
{name: "reconcile wins", batchStatus: BatchStatusSucceeded, reconcileStatus: "degraded", want: "degraded"},
{name: "recovered success beats stale reconcile", batchStatus: BatchStatusSucceeded, accessStatus: AccessStatusSelfServiceReady, reconcileStatus: "degraded", want: ProviderStatusActive},
{name: "succeeded batch", batchStatus: BatchStatusSucceeded, reconcileStatus: "not_run", want: ProviderStatusActive},
{name: "failed batch", batchStatus: BatchStatusFailed, want: ProviderStatusFailed},
{name: "running batch", batchStatus: "running", want: "running"},
@@ -226,13 +227,60 @@ func TestDeriveProviderStatus(t *testing.T) {
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := deriveProviderStatus(tc.batchStatus, tc.reconcileStatus); got != tc.want {
t.Fatalf("deriveProviderStatus(%q, %q) = %q, want %q", tc.batchStatus, tc.reconcileStatus, got, tc.want)
if got := deriveProviderStatus(tc.batchStatus, tc.accessStatus, tc.reconcileStatus); got != tc.want {
t.Fatalf("deriveProviderStatus(%q, %q, %q) = %q, want %q", tc.batchStatus, tc.accessStatus, tc.reconcileStatus, got, tc.want)
}
})
}
}
func TestProviderStatusServiceAggregatesLatestAccessModesAcrossBatches(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
ctx := context.Background()
hostID := seedProvisionHost(t, store, "host-1", "https://sub2api.example.com")
packID, err := store.Packs().Create(ctx, sqlite.Pack{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", Checksum: "checksum-1"})
if err != nil {
t.Fatalf("Packs().Create() error = %v", err)
}
providerID, err := store.Providers().Create(ctx, sqlite.Provider{PackID: packID, ProviderID: "deepseek", DisplayName: "DeepSeek", BaseURL: "https://api.deepseek.com", Platform: "openai"})
if err != nil {
t.Fatalf("Providers().Create() error = %v", err)
}
batchSubscription, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: hostID, PackID: packID, ProviderID: providerID, Mode: ImportModePartial, BatchStatus: BatchStatusSucceeded, AccessStatus: AccessStatusSubscriptionReady})
if err != nil {
t.Fatalf("ImportBatches().Create(subscription) error = %v", err)
}
if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: batchSubscription, ClosureType: AccessModeSubscription, Status: AccessStatusSubscriptionReady, DetailsJSON: "{}"}); err != nil {
t.Fatalf("AccessClosures().Create(subscription) error = %v", err)
}
batchSelfService, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: hostID, PackID: packID, ProviderID: providerID, Mode: ImportModePartial, BatchStatus: BatchStatusSucceeded, AccessStatus: AccessStatusSelfServiceReady})
if err != nil {
t.Fatalf("ImportBatches().Create(self_service) error = %v", err)
}
if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: batchSelfService, ClosureType: AccessModeSelfService, Status: AccessStatusSelfServiceReady, DetailsJSON: "{}"}); err != nil {
t.Fatalf("AccessClosures().Create(self_service) error = %v", err)
}
if _, err := store.ReconcileRuns().Create(ctx, sqlite.ReconcileRun{BatchID: batchSelfService, HostID: hostID, ProviderID: providerID, Status: "drifted", SummaryJSON: `{"missing_count":1}`}); err != nil {
t.Fatalf("ReconcileRuns().Create() error = %v", err)
}
snapshot, err := NewProviderStatusService(store).GetStatus(ctx, ProviderQuery{ProviderID: "deepseek", PackID: "openai-cn-pack", HostID: "host-1"})
if err != nil {
t.Fatalf("GetStatus() error = %v", err)
}
if snapshot.LatestAccessStatus != AccessStatusFullyReady {
t.Fatalf("LatestAccessStatus = %q, want %q", snapshot.LatestAccessStatus, AccessStatusFullyReady)
}
if snapshot.ProviderStatus != ProviderStatusActive {
t.Fatalf("ProviderStatus = %q, want %q", snapshot.ProviderStatus, ProviderStatusActive)
}
if snapshot.LatestReconcileStatus != "drifted" {
t.Fatalf("LatestReconcileStatus = %q, want drifted", snapshot.LatestReconcileStatus)
}
}
func TestBuildPackAndProviderRecord(t *testing.T) {
packRow, err := buildPackRecord(sampleLoadedPack())
if err != nil {

View File

@@ -199,7 +199,7 @@ 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 := SuggestResourceNames(provider)
names := SuggestResourceNamesForMode(provider, accessMode)
snapshot, err := s.host.ListManagedResources(ctx, sub2api.ListManagedResourcesRequest{
GroupName: names.Group,
ChannelName: names.Channel,
@@ -210,14 +210,14 @@ func (s *ImportService) ensureManagedResources(ctx context.Context, provider pac
}
result := resolvedManagedResources{}
group, created, err := ensureGroup(ctx, s.host, snapshot.Groups, provider, accessMode)
group, created, err := ensureGroup(ctx, s.host, snapshot.Groups, provider, accessMode, names.Group)
if err != nil {
return resolvedManagedResources{}, fmt.Errorf("ensure group: %w", err)
}
result.Group = group
result.CreatedGroup = created
channel, created, err := ensureChannel(ctx, s.host, snapshot.Channels, provider, group.ID)
channel, created, err := ensureChannel(ctx, s.host, snapshot.Channels, provider, group.ID, names.Channel)
if err != nil {
return resolvedManagedResources{}, fmt.Errorf("ensure channel: %w", err)
}
@@ -225,7 +225,7 @@ func (s *ImportService) ensureManagedResources(ctx context.Context, provider pac
result.CreatedChannel = created
if accessMode == AccessModeSubscription {
plan, created, err := ensurePlan(ctx, s.host, snapshot.Plans, provider, group.ID)
plan, created, err := ensurePlan(ctx, s.host, snapshot.Plans, provider, group.ID, names.Plan)
if err != nil {
return resolvedManagedResources{}, fmt.Errorf("ensure plan: %w", err)
}
@@ -236,10 +236,10 @@ func (s *ImportService) ensureManagedResources(ctx context.Context, provider pac
return result, nil
}
func ensureGroup(ctx context.Context, host hostAdapter, existing []sub2api.NamedResource, provider pack.ProviderManifest, accessMode string) (sub2api.GroupRef, bool, error) {
func ensureGroup(ctx context.Context, host hostAdapter, existing []sub2api.NamedResource, provider pack.ProviderManifest, accessMode, groupName string) (sub2api.GroupRef, bool, error) {
switch len(existing) {
case 0:
groupReq := sub2api.CreateGroupRequest{Name: provider.GroupTemplate.Name, Platform: provider.Platform, RateMultiplier: provider.GroupTemplate.RateMultiplier}
groupReq := sub2api.CreateGroupRequest{Name: groupName, Platform: provider.Platform, RateMultiplier: provider.GroupTemplate.RateMultiplier}
if accessMode == AccessModeSubscription {
groupReq.SubscriptionType = "subscription"
}
@@ -248,38 +248,52 @@ func ensureGroup(ctx context.Context, host hostAdapter, existing []sub2api.Named
case 1:
return sub2api.GroupRef{ID: existing[0].ID, Name: existing[0].Name}, false, nil
default:
return sub2api.GroupRef{}, false, fmt.Errorf("multiple groups already exist for %q", provider.GroupTemplate.Name)
return sub2api.GroupRef{}, false, fmt.Errorf("multiple groups already exist for %q", groupName)
}
}
func ensureChannel(ctx context.Context, host hostAdapter, existing []sub2api.NamedResource, provider pack.ProviderManifest, groupID string) (sub2api.ChannelRef, bool, error) {
func ensureChannel(ctx context.Context, host hostAdapter, existing []sub2api.NamedResource, provider pack.ProviderManifest, groupID, channelName string) (sub2api.ChannelRef, bool, error) {
channelReq := buildChannelRequest(provider, groupID, channelName)
switch len(existing) {
case 0:
channelReq := sub2api.CreateChannelRequest{
Name: provider.ChannelTemplate.Name,
GroupIDs: []string{groupID},
ModelMapping: provider.ChannelTemplate.ModelMapping,
RestrictModels: true,
BillingModelSource: "channel_mapped",
}
channel, err := host.CreateChannel(ctx, channelReq)
return channel, true, err
case 1:
if err := host.UpdateChannel(ctx, existing[0].ID, channelReq); err != nil {
return sub2api.ChannelRef{}, false, err
}
return sub2api.ChannelRef{ID: existing[0].ID, Name: existing[0].Name}, false, nil
default:
return sub2api.ChannelRef{}, false, fmt.Errorf("multiple channels already exist for %q", provider.ChannelTemplate.Name)
return sub2api.ChannelRef{}, false, fmt.Errorf("multiple channels already exist for %q", channelName)
}
}
func ensurePlan(ctx context.Context, host hostAdapter, existing []sub2api.NamedResource, provider pack.ProviderManifest, groupID string) (sub2api.PlanRef, bool, error) {
func buildChannelRequest(provider pack.ProviderManifest, groupID, channelName string) sub2api.CreateChannelRequest {
return sub2api.CreateChannelRequest{
Name: channelName,
GroupIDs: []string{groupID},
ModelMapping: provider.ChannelTemplate.ModelMapping,
ModelPricing: []sub2api.ChannelModelPricing{{
Platform: provider.Platform,
Models: append([]string(nil), provider.DefaultModels...),
BillingMode: "token",
Intervals: []sub2api.ChannelPricingTier{},
}},
Platform: provider.Platform,
RestrictModels: true,
BillingModelSource: "channel_mapped",
}
}
func ensurePlan(ctx context.Context, host hostAdapter, existing []sub2api.NamedResource, provider pack.ProviderManifest, groupID, planName string) (sub2api.PlanRef, bool, error) {
switch len(existing) {
case 0:
plan, err := host.CreatePlan(ctx, sub2api.CreatePlanRequest{GroupID: groupID, Name: provider.PlanTemplate.Name, Price: provider.PlanTemplate.Price, ValidityDays: provider.PlanTemplate.ValidityDays, ValidityUnit: provider.PlanTemplate.ValidityUnit})
plan, err := host.CreatePlan(ctx, sub2api.CreatePlanRequest{GroupID: groupID, Name: planName, Price: provider.PlanTemplate.Price, ValidityDays: provider.PlanTemplate.ValidityDays, ValidityUnit: provider.PlanTemplate.ValidityUnit})
return plan, true, err
case 1:
return sub2api.PlanRef{ID: existing[0].ID, Name: existing[0].Name}, false, nil
default:
return sub2api.PlanRef{}, false, fmt.Errorf("multiple plans already exist for %q", provider.PlanTemplate.Name)
return sub2api.PlanRef{}, false, fmt.Errorf("multiple plans already exist for %q", planName)
}
}
@@ -329,8 +343,9 @@ func buildBatchAccountsRequest(provider pack.ProviderManifest, groupID string, k
Type: provider.AccountType,
GroupIDs: []string{groupID},
Credentials: map[string]any{
"base_url": provider.BaseURL,
"api_key": key,
"base_url": provider.BaseURL,
"api_key": key,
"model_mapping": provider.ChannelTemplate.ModelMapping,
},
})
}

View File

@@ -152,7 +152,7 @@ func TestImportReusesExistingGroup(t *testing.T) {
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
managedSnapshot: sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_existing", Name: "DeepSeek 默认分组"}},
Groups: []sub2api.NamedResource{{ID: "group_existing", Name: "DeepSeek 默认分组-self-service"}},
},
}
@@ -198,8 +198,8 @@ func TestImportCreatesChannelWithManifestModelMapping(t *testing.T) {
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if host.createChannelReq.Name != "DeepSeek 默认渠道" {
t.Fatalf("CreateChannel().Name = %q, want DeepSeek 默认渠道", host.createChannelReq.Name)
if host.createChannelReq.Name != "DeepSeek 默认渠道-self-service" {
t.Fatalf("CreateChannel().Name = %q, want DeepSeek 默认渠道-self-service", host.createChannelReq.Name)
}
if len(host.createChannelReq.GroupIDs) != 1 || host.createChannelReq.GroupIDs[0] != "group_1" {
t.Fatalf("CreateChannel().GroupIDs = %v, want [group_1]", host.createChannelReq.GroupIDs)
@@ -213,6 +213,31 @@ func TestImportCreatesChannelWithManifestModelMapping(t *testing.T) {
if host.createChannelReq.BillingModelSource != "channel_mapped" {
t.Fatalf("CreateChannel().BillingModelSource = %q, want channel_mapped", host.createChannelReq.BillingModelSource)
}
if len(host.createChannelReq.ModelPricing) != 1 {
t.Fatalf("CreateChannel().ModelPricing len = %d, want 1", len(host.createChannelReq.ModelPricing))
}
if len(host.createChannelReq.ModelPricing[0].Models) != 2 {
t.Fatalf("CreateChannel().ModelPricing[0].Models = %v, want default model coverage", host.createChannelReq.ModelPricing[0].Models)
}
if host.createChannelReq.ModelPricing[0].BillingMode != "token" {
t.Fatalf("CreateChannel().ModelPricing[0].BillingMode = %q, want token", host.createChannelReq.ModelPricing[0].BillingMode)
}
if len(host.batchCreateReq.Accounts) != 1 {
t.Fatalf("BatchCreateAccounts().Accounts len = %d, want 1", len(host.batchCreateReq.Accounts))
}
credentials := host.batchCreateReq.Accounts[0].Credentials
switch rawMapping := credentials["model_mapping"].(type) {
case map[string]string:
if got := rawMapping["deepseek-chat"]; got != "deepseek-chat" {
t.Fatalf("BatchCreateAccounts().Credentials.model_mapping = %+v, want deepseek-chat passthrough", rawMapping)
}
case map[string]any:
if got, _ := rawMapping["deepseek-chat"].(string); got != "deepseek-chat" {
t.Fatalf("BatchCreateAccounts().Credentials.model_mapping = %+v, want deepseek-chat passthrough", rawMapping)
}
default:
t.Fatalf("BatchCreateAccounts().Credentials = %+v, want model_mapping map", credentials)
}
}
func sampleProviderManifest() pack.ProviderManifest {
@@ -230,8 +255,48 @@ func sampleProviderManifest() pack.ProviderManifest {
}
}
func TestImportReconcilesExistingChannelConfiguration(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "ready"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
managedSnapshot: sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_existing", Name: "DeepSeek 默认分组-self-service"}},
Channels: []sub2api.NamedResource{{ID: "channel_existing", Name: "DeepSeek 默认渠道-self-service"}},
},
}
_, 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.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))
}
}
type fakeHostAdapter struct {
batchAccounts []sub2api.AccountRef
batchCreateReq sub2api.BatchCreateAccountsRequest
testResults map[string]sub2api.ProbeResult
models map[string][]sub2api.AccountModel
gatewayResult sub2api.GatewayAccessResult
@@ -246,9 +311,12 @@ type fakeHostAdapter struct {
listManagedReq sub2api.ListManagedResourcesRequest
createGroupCalls int
createChannelCalls int
updateChannelCalls int
createPlanCalls int
createGroupReq sub2api.CreateGroupRequest
createChannelReq sub2api.CreateChannelRequest
updateChannelID string
updateChannelReq sub2api.CreateChannelRequest
}
func (f *fakeHostAdapter) GetHostVersion(context.Context) (string, error) {
@@ -274,6 +342,12 @@ func (f *fakeHostAdapter) CreateChannel(_ context.Context, req sub2api.CreateCha
f.createChannelReq = req
return sub2api.ChannelRef{ID: "channel_1", Name: "c"}, nil
}
func (f *fakeHostAdapter) UpdateChannel(_ context.Context, channelID string, req sub2api.CreateChannelRequest) error {
f.updateChannelCalls++
f.updateChannelID = channelID
f.updateChannelReq = req
return nil
}
func (f *fakeHostAdapter) DeleteChannel(_ context.Context, channelID string) error {
f.deletedResources = append(f.deletedResources, "channel:"+channelID)
return nil
@@ -289,7 +363,8 @@ func (f *fakeHostAdapter) DeletePlan(_ context.Context, planID string) error {
func (f *fakeHostAdapter) CreateAccount(context.Context, sub2api.CreateAccountRequest) (sub2api.AccountRef, error) {
return sub2api.AccountRef{}, errors.New("unused")
}
func (f *fakeHostAdapter) BatchCreateAccounts(_ context.Context, _ sub2api.BatchCreateAccountsRequest) ([]sub2api.AccountRef, error) {
func (f *fakeHostAdapter) BatchCreateAccounts(_ context.Context, req sub2api.BatchCreateAccountsRequest) ([]sub2api.AccountRef, error) {
f.batchCreateReq = req
if f.batchCreateErr != nil {
return nil, f.batchCreateErr
}
@@ -313,6 +388,9 @@ func (f *fakeHostAdapter) GetAccountModels(_ context.Context, accountID string)
}
return models, nil
}
func (f *fakeHostAdapter) EnsureSubscriptionAccess(_ context.Context, req sub2api.EnsureSubscriptionAccessRequest) (sub2api.SubscriptionAccessRef, error) {
return sub2api.SubscriptionAccessRef{UserID: req.UserSelector, APIKey: "managed-subscription-key"}, nil
}
func (f *fakeHostAdapter) AssignSubscription(_ context.Context, req sub2api.AssignSubscriptionRequest) (sub2api.SubscriptionRef, error) {
if f.assignErr != nil {
return sub2api.SubscriptionRef{}, f.assignErr

View File

@@ -57,7 +57,7 @@ func (s *PreviewService) PreviewImport(ctx context.Context, req PreviewRequest)
return PreviewReport{}, fmt.Errorf("preview host is required")
}
names := SuggestResourceNames(req.Provider)
names := SuggestResourceNamesForMode(req.Provider, req.Mode)
snapshot, err := s.host.ListManagedResources(ctx, sub2api.ListManagedResourcesRequest{
GroupName: names.Group,
ChannelName: names.Channel,

View File

@@ -23,6 +23,23 @@ func TestSuggestResourceNames(t *testing.T) {
}
}
func TestSuggestResourceNamesIncludesAccessModeSuffix(t *testing.T) {
provider := sampleProviderManifest()
provider.GroupTemplate.Name = ""
provider.ChannelTemplate.Name = ""
provider.PlanTemplate.Name = ""
names := SuggestResourceNamesForMode(provider, AccessModeSubscription)
want := ResourceNames{
Group: "crm-deepseek-group-subscription",
Channel: "crm-deepseek-channel-subscription",
Plan: "crm-deepseek-plan-subscription",
}
if !reflect.DeepEqual(names, want) {
t.Fatalf("SuggestResourceNamesForMode() = %#v, want %#v", names, want)
}
}
func TestPreviewServiceReportsCreateActionsWhenHostHasNoResources(t *testing.T) {
host := &fakePreviewHost{}
svc := NewPreviewService(host)

View File

@@ -69,13 +69,18 @@ func (s *ProviderStatusService) snapshot(ctx context.Context, query ProviderQuer
if err != nil {
return ProviderSnapshot{}, err
}
reconcileRuns, err := s.store.ReconcileRuns().GetByBatchID(ctx, batchRow.ID)
batches, err := s.store.ImportBatches().ListByProviderIDAndHostID(ctx, provider.ID, hostRow.ID)
if err != nil {
return ProviderSnapshot{}, err
}
latestAccessStatus := batchRow.AccessStatus
if len(accessClosures) > 0 {
latestAccessStatus = firstNonEmpty(accessClosures[len(accessClosures)-1].Status, latestAccessStatus)
modeStatuses, err := LatestModeAccessStatuses(ctx, s.store, batches)
if err != nil {
return ProviderSnapshot{}, err
}
latestAccessStatus := AggregateAccessStatus(modeStatuses)
reconcileRuns, err := s.store.ReconcileRuns().GetByBatchID(ctx, batchRow.ID)
if err != nil {
return ProviderSnapshot{}, err
}
latestReconcileStatus := "not_run"
latestReconcileSummary := map[string]any{}
@@ -87,7 +92,7 @@ func (s *ProviderStatusService) snapshot(ctx context.Context, query ProviderQuer
}
}
}
providerStatus := deriveProviderStatus(batchRow.BatchStatus, latestReconcileStatus)
providerStatus := deriveProviderStatus(batchRow.BatchStatus, latestAccessStatus, latestReconcileStatus)
return ProviderSnapshot{
Host: hostRow,
Pack: packRow,
@@ -162,8 +167,12 @@ func (s *ProviderStatusService) resolveHostAndBatch(ctx context.Context, provide
return hostRow, batches[0], nil
}
func deriveProviderStatus(batchStatus, reconcileStatus string) string {
func deriveProviderStatus(batchStatus, accessStatus, reconcileStatus string) string {
reconcileStatus = strings.TrimSpace(reconcileStatus)
accessStatus = strings.TrimSpace(accessStatus)
if strings.TrimSpace(batchStatus) == BatchStatusSucceeded && accessStatus != "" && accessStatus != AccessStatusBroken {
return ProviderStatusActive
}
if reconcileStatus != "" && reconcileStatus != "not_run" {
return reconcileStatus
}

View File

@@ -54,8 +54,8 @@ func TestProviderStatusServiceReturnsLatestSnapshot(t *testing.T) {
if snapshot.Provider.ProviderID != "deepseek" {
t.Fatalf("Provider.ProviderID = %q, want deepseek", snapshot.Provider.ProviderID)
}
if snapshot.ProviderStatus != "drifted" {
t.Fatalf("ProviderStatus = %q, want drifted", snapshot.ProviderStatus)
if snapshot.ProviderStatus != ProviderStatusActive {
t.Fatalf("ProviderStatus = %q, want %q", snapshot.ProviderStatus, ProviderStatusActive)
}
if snapshot.LatestAccessStatus != AccessStatusSelfServiceReady {
t.Fatalf("LatestAccessStatus = %q, want %q", snapshot.LatestAccessStatus, AccessStatusSelfServiceReady)

View File

@@ -28,8 +28,8 @@ func TestReconcileServiceReturnsActiveAfterProbeRerun(t *testing.T) {
batchID := seedRuntimeImportForReconcile(t, store, host)
host.managedSnapshot = sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道"}},
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组-self-service"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道-self-service"}},
Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
}
@@ -82,8 +82,8 @@ func TestReconcileServiceReturnsDegradedWhenProbeRerunFails(t *testing.T) {
seedRuntimeImportForReconcile(t, store, host)
host.managedSnapshot = sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道"}},
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组-self-service"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道-self-service"}},
Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
}
@@ -124,8 +124,8 @@ func TestReconcileServiceReturnsDriftedWhenManagedResourceMissing(t *testing.T)
seedRuntimeImportForReconcile(t, store, host)
host.managedSnapshot = sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道"}},
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组-self-service"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道-self-service"}},
Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}},
}
@@ -166,8 +166,8 @@ func TestReconcileServiceIgnoresSubscriptionPlanForSelfServiceBatch(t *testing.T
seedRuntimeImportForReconcile(t, store, host)
host.managedSnapshot = sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道"}},
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组-self-service"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道-self-service"}},
Plans: []sub2api.NamedResource{{ID: "plan_1", Name: "DeepSeek 默认套餐"}},
Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
}
@@ -212,8 +212,8 @@ func TestReconcileServicePassesAccountNamePrefixToManagedResourceSnapshot(t *tes
seedRuntimeImportForReconcile(t, store, host)
host.managedSnapshot = sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道"}},
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组-self-service"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道-self-service"}},
Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
}