feat(accounts): add provider account admin view

This commit is contained in:
phamnazage-jpg
2026-05-29 15:50:28 +08:00
parent 82f3636521
commit c982c595b8
14 changed files with 1352 additions and 93 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)
}
}

View File

@@ -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