feat(accounts): add provider account admin view
This commit is contained in:
@@ -2,6 +2,7 @@ package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
@@ -207,6 +208,66 @@ func (r *LogicalGroupRoutesRepo) DeleteByRouteID(ctx context.Context, routeID st
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LogicalGroupRoutesRepo) GetByShadowBinding(ctx context.Context, shadowHostID, shadowGroupID string) (LogicalGroupRoute, error) {
|
||||
shadowHostID = strings.TrimSpace(shadowHostID)
|
||||
shadowGroupID = strings.TrimSpace(shadowGroupID)
|
||||
if shadowHostID == "" {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("shadow_host_id is required")
|
||||
}
|
||||
if shadowGroupID == "" {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("shadow_group_id is required")
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(
|
||||
ctx,
|
||||
`SELECT id, route_id, logical_group_id, name, status, priority, weight, shadow_group_id, shadow_host_id, upstream_base_url_hint, cooldown_until, created_at, updated_at
|
||||
FROM logical_group_routes
|
||||
WHERE shadow_host_id = ? AND shadow_group_id = ?
|
||||
ORDER BY priority ASC, id ASC
|
||||
LIMIT 2`,
|
||||
shadowHostID,
|
||||
shadowGroupID,
|
||||
)
|
||||
if err != nil {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("get logical group route by shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
routes := make([]LogicalGroupRoute, 0, 2)
|
||||
for rows.Next() {
|
||||
var route LogicalGroupRoute
|
||||
if err := rows.Scan(
|
||||
&route.ID,
|
||||
&route.RouteID,
|
||||
&route.LogicalGroupID,
|
||||
&route.Name,
|
||||
&route.Status,
|
||||
&route.Priority,
|
||||
&route.Weight,
|
||||
&route.ShadowGroupID,
|
||||
&route.ShadowHostID,
|
||||
&route.UpstreamBaseURLHint,
|
||||
&route.CooldownUntil,
|
||||
&route.CreatedAt,
|
||||
&route.UpdatedAt,
|
||||
); err != nil {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("scan logical group route by shadow binding: %w", err)
|
||||
}
|
||||
routes = append(routes, route)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("iterate logical group route by shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
|
||||
}
|
||||
switch len(routes) {
|
||||
case 0:
|
||||
return LogicalGroupRoute{}, sql.ErrNoRows
|
||||
case 1:
|
||||
return routes[0], nil
|
||||
default:
|
||||
return LogicalGroupRoute{}, fmt.Errorf("multiple logical group routes match shadow binding %q/%q", shadowHostID, shadowGroupID)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLogicalGroupRoute(route LogicalGroupRoute) (LogicalGroupRoute, error) {
|
||||
route.RouteID = strings.TrimSpace(route.RouteID)
|
||||
route.LogicalGroupID = strings.TrimSpace(route.LogicalGroupID)
|
||||
|
||||
@@ -32,32 +32,37 @@ type ProviderAccount struct {
|
||||
}
|
||||
|
||||
type ProviderAccountListFilter struct {
|
||||
HostID string
|
||||
ProviderID string
|
||||
RouteID string
|
||||
ShadowGroupID string
|
||||
AccountStatus string
|
||||
Query string
|
||||
Limit int
|
||||
HostID string
|
||||
ProviderID string
|
||||
LogicalGroupID string
|
||||
RouteID string
|
||||
ShadowGroupID string
|
||||
AccountStatus string
|
||||
Query string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type ProviderAccountView struct {
|
||||
ID int64 `json:"id"`
|
||||
HostID string `json:"host_id"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
RouteID string `json:"route_id,omitempty"`
|
||||
LogicalGroupID string `json:"logical_group_id,omitempty"`
|
||||
ShadowGroupID string `json:"shadow_group_id,omitempty"`
|
||||
HostAccountID string `json:"host_account_id"`
|
||||
KeyFingerprint string `json:"key_fingerprint"`
|
||||
AccountName string `json:"account_name"`
|
||||
AccountStatus string `json:"account_status"`
|
||||
LastProbeStatus string `json:"last_probe_status,omitempty"`
|
||||
LastProbeAt string `json:"last_probe_at,omitempty"`
|
||||
DisabledReason string `json:"disabled_reason,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
HostID string `json:"host_id"`
|
||||
HostBaseURL string `json:"host_base_url"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
RouteName string `json:"route_name,omitempty"`
|
||||
RouteID string `json:"route_id,omitempty"`
|
||||
LogicalGroupID string `json:"logical_group_id,omitempty"`
|
||||
ShadowGroupID string `json:"shadow_group_id,omitempty"`
|
||||
ShadowHostID string `json:"shadow_host_id,omitempty"`
|
||||
UpstreamBaseURLHint string `json:"upstream_base_url_hint,omitempty"`
|
||||
HostAccountID string `json:"host_account_id"`
|
||||
KeyFingerprint string `json:"key_fingerprint"`
|
||||
AccountName string `json:"account_name"`
|
||||
AccountStatus string `json:"account_status"`
|
||||
LastProbeStatus string `json:"last_probe_status,omitempty"`
|
||||
LastProbeAt string `json:"last_probe_at,omitempty"`
|
||||
DisabledReason string `json:"disabled_reason,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type ProviderAccountsRepo struct {
|
||||
@@ -171,11 +176,15 @@ func (r *ProviderAccountsRepo) GetViewByID(ctx context.Context, id int64) (Provi
|
||||
return r.scanViewOne(ctx, `SELECT
|
||||
pa.id,
|
||||
h.host_id,
|
||||
h.base_url,
|
||||
p.provider_id,
|
||||
p.display_name,
|
||||
COALESCE(lgr.name, ''),
|
||||
COALESCE(pa.route_id, ''),
|
||||
COALESCE(lgr.logical_group_id, ''),
|
||||
COALESCE(pa.shadow_group_id, ''),
|
||||
COALESCE(lgr.shadow_host_id, ''),
|
||||
COALESCE(lgr.upstream_base_url_hint, ''),
|
||||
pa.host_account_id,
|
||||
pa.key_fingerprint,
|
||||
pa.account_name,
|
||||
@@ -252,11 +261,15 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
|
||||
query := `SELECT
|
||||
pa.id,
|
||||
h.host_id,
|
||||
h.base_url,
|
||||
p.provider_id,
|
||||
p.display_name,
|
||||
COALESCE(lgr.name, ''),
|
||||
COALESCE(pa.route_id, ''),
|
||||
COALESCE(lgr.logical_group_id, ''),
|
||||
COALESCE(pa.shadow_group_id, ''),
|
||||
COALESCE(lgr.shadow_host_id, ''),
|
||||
COALESCE(lgr.upstream_base_url_hint, ''),
|
||||
pa.host_account_id,
|
||||
pa.key_fingerprint,
|
||||
pa.account_name,
|
||||
@@ -280,6 +293,10 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
|
||||
query += ` AND p.provider_id = ?`
|
||||
args = append(args, value)
|
||||
}
|
||||
if value := strings.TrimSpace(filter.LogicalGroupID); value != "" {
|
||||
query += ` AND lgr.logical_group_id = ?`
|
||||
args = append(args, value)
|
||||
}
|
||||
if value := strings.TrimSpace(filter.RouteID); value != "" {
|
||||
query += ` AND pa.route_id = ?`
|
||||
args = append(args, value)
|
||||
@@ -299,9 +316,11 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
|
||||
LOWER(pa.account_name) LIKE ? OR
|
||||
LOWER(pa.key_fingerprint) LIKE ? OR
|
||||
LOWER(p.provider_id) LIKE ? OR
|
||||
LOWER(h.host_id) LIKE ?
|
||||
LOWER(h.host_id) LIKE ? OR
|
||||
LOWER(COALESCE(lgr.logical_group_id, '')) LIKE ? OR
|
||||
LOWER(COALESCE(lgr.name, '')) LIKE ?
|
||||
)`
|
||||
args = append(args, like, like, like, like, like)
|
||||
args = append(args, like, like, like, like, like, like, like)
|
||||
}
|
||||
query += ` ORDER BY pa.updated_at DESC, pa.id DESC`
|
||||
limit := filter.Limit
|
||||
@@ -323,11 +342,15 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
|
||||
if err := rows.Scan(
|
||||
&view.ID,
|
||||
&view.HostID,
|
||||
&view.HostBaseURL,
|
||||
&view.ProviderID,
|
||||
&view.ProviderName,
|
||||
&view.RouteName,
|
||||
&view.RouteID,
|
||||
&view.LogicalGroupID,
|
||||
&view.ShadowGroupID,
|
||||
&view.ShadowHostID,
|
||||
&view.UpstreamBaseURLHint,
|
||||
&view.HostAccountID,
|
||||
&view.KeyFingerprint,
|
||||
&view.AccountName,
|
||||
@@ -376,11 +399,15 @@ func (r *ProviderAccountsRepo) scanViewOne(ctx context.Context, query string, ar
|
||||
if err := r.db.QueryRowContext(ctx, query, args...).Scan(
|
||||
&view.ID,
|
||||
&view.HostID,
|
||||
&view.HostBaseURL,
|
||||
&view.ProviderID,
|
||||
&view.ProviderName,
|
||||
&view.RouteName,
|
||||
&view.RouteID,
|
||||
&view.LogicalGroupID,
|
||||
&view.ShadowGroupID,
|
||||
&view.ShadowHostID,
|
||||
&view.UpstreamBaseURLHint,
|
||||
&view.HostAccountID,
|
||||
&view.KeyFingerprint,
|
||||
&view.AccountName,
|
||||
|
||||
@@ -87,12 +87,13 @@ func TestProviderAccountsRepoCRUDAndFilters(t *testing.T) {
|
||||
}
|
||||
|
||||
rows, err := accountRepo.List(ctx, ProviderAccountListFilter{
|
||||
HostID: "host-" + sanitizeTestName(t.Name()),
|
||||
ProviderID: "deepseek-official",
|
||||
RouteID: "route-1",
|
||||
ShadowGroupID: "shadow-group-1",
|
||||
AccountStatus: ProviderAccountStatusBroken,
|
||||
Query: "deepseek",
|
||||
HostID: "host-" + sanitizeTestName(t.Name()),
|
||||
ProviderID: "deepseek-official",
|
||||
LogicalGroupID: "lg-1",
|
||||
RouteID: "route-1",
|
||||
ShadowGroupID: "shadow-group-1",
|
||||
AccountStatus: ProviderAccountStatusBroken,
|
||||
Query: "deepseek",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ProviderAccounts().List() error = %v", err)
|
||||
@@ -100,6 +101,9 @@ func TestProviderAccountsRepoCRUDAndFilters(t *testing.T) {
|
||||
if len(rows) != 1 || rows[0].ID != accountID {
|
||||
t.Fatalf("ProviderAccounts().List() = %+v, want one row for account_id %d", rows, accountID)
|
||||
}
|
||||
if rows[0].LogicalGroupID != "lg-1" || rows[0].RouteName != "Route 1" || rows[0].ShadowHostID != "shadow-host-1" {
|
||||
t.Fatalf("ProviderAccounts().List() relationship view = %+v", rows[0])
|
||||
}
|
||||
|
||||
if err := accountRepo.UpdateStatusByID(ctx, accountID, ProviderAccountStatusDisabled, "manual_disable"); err != nil {
|
||||
t.Fatalf("ProviderAccounts().UpdateStatusByID() error = %v", err)
|
||||
@@ -284,3 +288,90 @@ func TestSyncProviderAccountsFromImportBatchPreservesManualDisabledStatus(t *tes
|
||||
t.Fatalf("account after resync = %+v, want disabled manual_disable preserved", account)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncProviderAccountsFromImportBatchInfersRouteFromShadowBinding(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := openTestDBWithFK(t)
|
||||
ctx := context.Background()
|
||||
hostID := createTestHost(t, store)
|
||||
hostRow, err := store.Hosts().GetByID(ctx, hostID)
|
||||
if err != nil {
|
||||
t.Fatalf("Hosts().GetByID() error = %v", err)
|
||||
}
|
||||
packID := createTestPack(t, store)
|
||||
providerID, err := store.Providers().Create(ctx, Provider{
|
||||
PackID: packID,
|
||||
ProviderID: "asxs-provider",
|
||||
DisplayName: "ASXS Provider",
|
||||
BaseURL: "https://api.asxs.top/v1",
|
||||
Platform: "openai",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Providers().Create() error = %v", err)
|
||||
}
|
||||
if _, err := store.LogicalGroups().Create(ctx, LogicalGroup{
|
||||
LogicalGroupID: "gpt-shared",
|
||||
DisplayName: "GPT Shared",
|
||||
Status: "active",
|
||||
}); err != nil {
|
||||
t.Fatalf("LogicalGroups().Create() error = %v", err)
|
||||
}
|
||||
if _, err := store.LogicalGroupRoutes().Create(ctx, LogicalGroupRoute{
|
||||
RouteID: "route-shadow-1",
|
||||
LogicalGroupID: "gpt-shared",
|
||||
Name: "Shadow Route",
|
||||
Status: "active",
|
||||
Priority: 10,
|
||||
Weight: 100,
|
||||
ShadowGroupID: "group-1",
|
||||
ShadowHostID: hostRow.HostID,
|
||||
}); err != nil {
|
||||
t.Fatalf("LogicalGroupRoutes().Create() error = %v", err)
|
||||
}
|
||||
batchID, err := store.ImportBatches().Create(ctx, ImportBatch{
|
||||
HostID: hostID,
|
||||
PackID: packID,
|
||||
ProviderID: providerID,
|
||||
Mode: "strict",
|
||||
BatchStatus: "succeeded",
|
||||
AccessStatus: "subscription_ready",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ImportBatches().Create() error = %v", err)
|
||||
}
|
||||
if _, err := store.ImportBatchItems().Create(ctx, ImportBatchItem{
|
||||
BatchID: batchID,
|
||||
KeyFingerprint: "sha256:key1",
|
||||
AccountStatus: "passed",
|
||||
ProbeSummaryJSON: `{"account_id":"account-1","probe_status":"passed"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("ImportBatchItems().Create() error = %v", err)
|
||||
}
|
||||
for _, resource := range []ManagedResource{
|
||||
{BatchID: batchID, HostID: hostID, ResourceType: "group", HostResourceID: "group-1", ResourceName: "ASXS Group"},
|
||||
{BatchID: batchID, HostID: hostID, ResourceType: "account", HostResourceID: "account-1", ResourceName: "asxs-01"},
|
||||
} {
|
||||
if _, err := store.ManagedResources().Create(ctx, resource); err != nil {
|
||||
t.Fatalf("ManagedResources().Create(%s) error = %v", resource.ResourceType, err)
|
||||
}
|
||||
}
|
||||
if err := SyncProviderAccountsFromImportBatch(ctx, store, batchID); err != nil {
|
||||
t.Fatalf("SyncProviderAccountsFromImportBatch() error = %v", err)
|
||||
}
|
||||
|
||||
account, err := store.ProviderAccounts().GetByHostIDAndAccountID(ctx, hostID, "account-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ProviderAccounts().GetByHostIDAndAccountID() error = %v", err)
|
||||
}
|
||||
if account.RouteID != "route-shadow-1" || account.ShadowGroupID != "group-1" {
|
||||
t.Fatalf("provider account route binding = %+v, want route-shadow-1/group-1", account)
|
||||
}
|
||||
view, err := store.ProviderAccounts().GetViewByID(ctx, account.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ProviderAccounts().GetViewByID() error = %v", err)
|
||||
}
|
||||
if view.LogicalGroupID != "gpt-shared" || view.RouteName != "Shadow Route" || view.ShadowHostID != hostRow.HostID {
|
||||
t.Fatalf("provider account view route binding = %+v", view)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -54,12 +55,21 @@ func SyncProviderAccountsFromImportBatch(ctx context.Context, store *DB, batchID
|
||||
|
||||
nowText := time.Now().UTC().Format(time.RFC3339)
|
||||
shadowGroupID := ""
|
||||
shadowHostID := ""
|
||||
for _, resource := range resources {
|
||||
if strings.TrimSpace(resource.ResourceType) == "group" {
|
||||
shadowGroupID = strings.TrimSpace(resource.HostResourceID)
|
||||
break
|
||||
}
|
||||
}
|
||||
hostRow, err := store.Hosts().GetByID(ctx, batch.HostID)
|
||||
if err == nil {
|
||||
shadowHostID = strings.TrimSpace(hostRow.HostID)
|
||||
}
|
||||
matchedRoute, routeErr := resolveProviderAccountRouteBinding(ctx, store, shadowHostID, shadowGroupID)
|
||||
if routeErr != nil && routeErr != sql.ErrNoRows {
|
||||
return routeErr
|
||||
}
|
||||
|
||||
accountResources := make([]ManagedResource, 0)
|
||||
for _, resource := range resources {
|
||||
@@ -82,6 +92,7 @@ func SyncProviderAccountsFromImportBatch(ctx context.Context, store *DB, batchID
|
||||
row := ProviderAccount{
|
||||
HostID: batch.HostID,
|
||||
ProviderID: batch.ProviderID,
|
||||
RouteID: matchedRoute.RouteID,
|
||||
ShadowGroupID: shadowGroupID,
|
||||
HostAccountID: hostAccountID,
|
||||
KeyFingerprint: fallbackString(match.KeyFingerprint, "legacy:"+hostAccountID),
|
||||
@@ -109,6 +120,25 @@ func SyncProviderAccountsFromImportBatch(ctx context.Context, store *DB, batchID
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveProviderAccountRouteBinding(ctx context.Context, store *DB, shadowHostID, shadowGroupID string) (LogicalGroupRoute, error) {
|
||||
if store == nil {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("store is required")
|
||||
}
|
||||
shadowHostID = strings.TrimSpace(shadowHostID)
|
||||
shadowGroupID = strings.TrimSpace(shadowGroupID)
|
||||
if shadowHostID == "" || shadowGroupID == "" {
|
||||
return LogicalGroupRoute{}, sql.ErrNoRows
|
||||
}
|
||||
route, err := store.LogicalGroupRoutes().GetByShadowBinding(ctx, shadowHostID, shadowGroupID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return LogicalGroupRoute{}, err
|
||||
}
|
||||
return LogicalGroupRoute{}, fmt.Errorf("resolve logical route for provider account shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
|
||||
}
|
||||
return route, nil
|
||||
}
|
||||
|
||||
type legacyBatchAccountProjection struct {
|
||||
KeyFingerprint string
|
||||
AccountStatus string
|
||||
|
||||
Reference in New Issue
Block a user