feat(accounts): add provider account admin view
This commit is contained in:
@@ -12,13 +12,14 @@ import (
|
||||
)
|
||||
|
||||
type ListProviderAccountsRequest 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 UpdateProviderAccountStatusRequest struct {
|
||||
@@ -28,22 +29,26 @@ type UpdateProviderAccountStatusRequest struct {
|
||||
}
|
||||
|
||||
type ProviderAccountInfo 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"`
|
||||
}
|
||||
|
||||
func handleListProviderAccounts(w http.ResponseWriter, r *http.Request, fn func(context.Context, ListProviderAccountsRequest) ([]ProviderAccountInfo, error)) {
|
||||
@@ -52,13 +57,14 @@ func handleListProviderAccounts(w http.ResponseWriter, r *http.Request, fn func(
|
||||
return
|
||||
}
|
||||
accounts, err := fn(r.Context(), ListProviderAccountsRequest{
|
||||
HostID: strings.TrimSpace(r.URL.Query().Get("host_id")),
|
||||
ProviderID: strings.TrimSpace(r.URL.Query().Get("provider_id")),
|
||||
RouteID: strings.TrimSpace(r.URL.Query().Get("route_id")),
|
||||
ShadowGroupID: strings.TrimSpace(r.URL.Query().Get("shadow_group_id")),
|
||||
AccountStatus: strings.TrimSpace(r.URL.Query().Get("account_status")),
|
||||
Query: strings.TrimSpace(r.URL.Query().Get("q")),
|
||||
Limit: parsePositiveInt(r.URL.Query().Get("limit")),
|
||||
HostID: strings.TrimSpace(r.URL.Query().Get("host_id")),
|
||||
ProviderID: strings.TrimSpace(r.URL.Query().Get("provider_id")),
|
||||
LogicalGroupID: strings.TrimSpace(r.URL.Query().Get("logical_group_id")),
|
||||
RouteID: strings.TrimSpace(r.URL.Query().Get("route_id")),
|
||||
ShadowGroupID: strings.TrimSpace(r.URL.Query().Get("shadow_group_id")),
|
||||
AccountStatus: strings.TrimSpace(r.URL.Query().Get("account_status")),
|
||||
Query: strings.TrimSpace(r.URL.Query().Get("q")),
|
||||
Limit: parsePositiveInt(r.URL.Query().Get("limit")),
|
||||
})
|
||||
if err != nil {
|
||||
writeHTTPError(w, classifyError(err))
|
||||
@@ -125,13 +131,14 @@ func buildListProviderAccountsAction(sqliteDSN string) func(context.Context, Lis
|
||||
return nil, err
|
||||
}
|
||||
rows, err := store.ProviderAccounts().List(ctx, sqlite.ProviderAccountListFilter{
|
||||
HostID: req.HostID,
|
||||
ProviderID: req.ProviderID,
|
||||
RouteID: req.RouteID,
|
||||
ShadowGroupID: req.ShadowGroupID,
|
||||
AccountStatus: req.AccountStatus,
|
||||
Query: req.Query,
|
||||
Limit: req.Limit,
|
||||
HostID: req.HostID,
|
||||
ProviderID: req.ProviderID,
|
||||
LogicalGroupID: req.LogicalGroupID,
|
||||
RouteID: req.RouteID,
|
||||
ShadowGroupID: req.ShadowGroupID,
|
||||
AccountStatus: req.AccountStatus,
|
||||
Query: req.Query,
|
||||
Limit: req.Limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -168,21 +175,25 @@ func buildUpdateProviderAccountStatusAction(sqliteDSN, accountStatus string) fun
|
||||
|
||||
func providerAccountViewToInfo(row sqlite.ProviderAccountView) ProviderAccountInfo {
|
||||
return ProviderAccountInfo{
|
||||
ID: row.ID,
|
||||
HostID: row.HostID,
|
||||
ProviderID: row.ProviderID,
|
||||
ProviderName: row.ProviderName,
|
||||
RouteID: row.RouteID,
|
||||
LogicalGroupID: row.LogicalGroupID,
|
||||
ShadowGroupID: row.ShadowGroupID,
|
||||
HostAccountID: row.HostAccountID,
|
||||
KeyFingerprint: row.KeyFingerprint,
|
||||
AccountName: row.AccountName,
|
||||
AccountStatus: row.AccountStatus,
|
||||
LastProbeStatus: row.LastProbeStatus,
|
||||
LastProbeAt: row.LastProbeAt,
|
||||
DisabledReason: row.DisabledReason,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
ID: row.ID,
|
||||
HostID: row.HostID,
|
||||
HostBaseURL: row.HostBaseURL,
|
||||
ProviderID: row.ProviderID,
|
||||
ProviderName: row.ProviderName,
|
||||
RouteName: row.RouteName,
|
||||
RouteID: row.RouteID,
|
||||
LogicalGroupID: row.LogicalGroupID,
|
||||
ShadowGroupID: row.ShadowGroupID,
|
||||
ShadowHostID: row.ShadowHostID,
|
||||
UpstreamBaseURLHint: row.UpstreamBaseURLHint,
|
||||
HostAccountID: row.HostAccountID,
|
||||
KeyFingerprint: row.KeyFingerprint,
|
||||
AccountName: row.AccountName,
|
||||
AccountStatus: row.AccountStatus,
|
||||
LastProbeStatus: row.LastProbeStatus,
|
||||
LastProbeAt: row.LastProbeAt,
|
||||
DisabledReason: row.DisabledReason,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,23 +15,33 @@ func TestAPIListProviderAccountsReturnsRows(t *testing.T) {
|
||||
if req.ProviderID != "deepseek-official" {
|
||||
t.Fatalf("ProviderID = %q, want deepseek-official", req.ProviderID)
|
||||
}
|
||||
if req.LogicalGroupID != "gpt-shared" {
|
||||
t.Fatalf("LogicalGroupID = %q, want gpt-shared", req.LogicalGroupID)
|
||||
}
|
||||
if req.AccountStatus != "disabled" {
|
||||
t.Fatalf("AccountStatus = %q, want disabled", req.AccountStatus)
|
||||
}
|
||||
return []ProviderAccountInfo{{
|
||||
ID: 7,
|
||||
HostID: "remote43",
|
||||
ProviderID: "deepseek-official",
|
||||
ProviderName: "DeepSeek Official",
|
||||
HostAccountID: "9",
|
||||
AccountName: "deepseek-01",
|
||||
AccountStatus: "disabled",
|
||||
DisabledReason: "manual_disable",
|
||||
ID: 7,
|
||||
HostID: "remote43",
|
||||
HostBaseURL: "https://host.example.com",
|
||||
ProviderID: "deepseek-official",
|
||||
ProviderName: "DeepSeek Official",
|
||||
RouteID: "route-1",
|
||||
RouteName: "Primary Route",
|
||||
LogicalGroupID: "gpt-shared",
|
||||
ShadowGroupID: "group-9",
|
||||
ShadowHostID: "remote43",
|
||||
HostAccountID: "9",
|
||||
AccountName: "deepseek-01",
|
||||
AccountStatus: "disabled",
|
||||
DisabledReason: "manual_disable",
|
||||
UpstreamBaseURLHint: "https://api.deepseek.com",
|
||||
}}, nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, "GET", "/api/provider-accounts?provider_id=deepseek-official&account_status=disabled", nil, "secret-token")
|
||||
request := httptestRequest(t, "GET", "/api/provider-accounts?provider_id=deepseek-official&logical_group_id=gpt-shared&account_status=disabled", nil, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, 200)
|
||||
var payload map[string][]ProviderAccountInfo
|
||||
@@ -42,6 +52,9 @@ func TestAPIListProviderAccountsReturnsRows(t *testing.T) {
|
||||
if len(accounts) != 1 || accounts[0].ID != 7 || accounts[0].AccountStatus != "disabled" {
|
||||
t.Fatalf("provider_accounts = %+v, want one disabled row id=7", accounts)
|
||||
}
|
||||
if accounts[0].LogicalGroupID != "gpt-shared" || accounts[0].RouteName != "Primary Route" {
|
||||
t.Fatalf("provider_accounts relationship fields = %+v", accounts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDisableProviderAccountUsesPathID(t *testing.T) {
|
||||
@@ -108,9 +121,36 @@ func TestNewActionSetProviderAccountListAndStatusFlow(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Providers().Create() error = %v", err)
|
||||
}
|
||||
if _, err := store.LogicalGroups().Create(ctx, sqlite.LogicalGroup{
|
||||
LogicalGroupID: "gpt-shared",
|
||||
DisplayName: "GPT Shared",
|
||||
Status: "active",
|
||||
StickyMode: "conversation_preferred",
|
||||
ConversationTTLSeconds: 7200,
|
||||
UserModelTTLSeconds: 1800,
|
||||
FailoverThreshold: 2,
|
||||
CooldownSeconds: 600,
|
||||
}); err != nil {
|
||||
t.Fatalf("LogicalGroups().Create() error = %v", err)
|
||||
}
|
||||
if _, err := store.LogicalGroupRoutes().Create(ctx, sqlite.LogicalGroupRoute{
|
||||
RouteID: "route-1",
|
||||
LogicalGroupID: "gpt-shared",
|
||||
Name: "Primary Route",
|
||||
Status: "active",
|
||||
Priority: 10,
|
||||
Weight: 100,
|
||||
ShadowGroupID: "group-9",
|
||||
ShadowHostID: "remote43",
|
||||
UpstreamBaseURLHint: "https://api.deepseek.com",
|
||||
}); err != nil {
|
||||
t.Fatalf("LogicalGroupRoutes().Create() error = %v", err)
|
||||
}
|
||||
providerAccountID, err := store.ProviderAccounts().Create(ctx, sqlite.ProviderAccount{
|
||||
HostID: hostRow.ID,
|
||||
ProviderID: providerRowID,
|
||||
RouteID: "route-1",
|
||||
ShadowGroupID: "group-9",
|
||||
HostAccountID: "9",
|
||||
KeyFingerprint: "sha256:abc",
|
||||
AccountName: "deepseek-01",
|
||||
@@ -129,6 +169,9 @@ func TestNewActionSetProviderAccountListAndStatusFlow(t *testing.T) {
|
||||
if len(listed) != 1 || listed[0].ID != providerAccountID {
|
||||
t.Fatalf("ListProviderAccounts() = %+v, want one row for id %d", listed, providerAccountID)
|
||||
}
|
||||
if listed[0].LogicalGroupID != "gpt-shared" || listed[0].RouteName != "Primary Route" || listed[0].ShadowHostID != "remote43" {
|
||||
t.Fatalf("ListProviderAccounts() relationship fields = %+v", listed[0])
|
||||
}
|
||||
|
||||
disabled, err := actions.DisableProviderAccount(ctx, UpdateProviderAccountStatusRequest{
|
||||
AccountID: providerAccountID,
|
||||
|
||||
@@ -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