feat(accounts): add provider account inventory api
This commit is contained in:
@@ -61,6 +61,10 @@ type ActionSet struct {
|
||||
GetRouteFailure func(context.Context, GetRouteFailureRequest) (RouteFailureInfo, error)
|
||||
SetRouteCooldown func(context.Context, SetRouteCooldownRequest) (RouteCooldownInfo, error)
|
||||
GetRouteCooldown func(context.Context, GetRouteCooldownRequest) (RouteCooldownInfo, error)
|
||||
ListProviderAccounts func(context.Context, ListProviderAccountsRequest) ([]ProviderAccountInfo, error)
|
||||
EnableProviderAccount func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)
|
||||
DisableProviderAccount func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)
|
||||
RetireProviderAccount func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)
|
||||
CreateProviderDraft func(context.Context, CreateProviderDraftRequest) (ProviderDraftInfo, error)
|
||||
ListProviderDrafts func(context.Context, ListProviderDraftsRequest) ([]ProviderDraftInfo, error)
|
||||
GetProviderDraft func(context.Context, string) (ProviderDraftInfo, error)
|
||||
@@ -432,6 +436,18 @@ func NewAPIHandlerWithAuth(adminAuth AdminAuthConfig, actions ActionSet) http.Ha
|
||||
mux.Handle("GET /api/routing/sticky/cooldowns", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleGetRouteCooldown(w, r, actions.GetRouteCooldown)
|
||||
})))
|
||||
mux.Handle("GET /api/provider-accounts", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleListProviderAccounts(w, r, actions.ListProviderAccounts)
|
||||
})))
|
||||
mux.Handle("POST /api/provider-accounts/{accountID}/enable", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleEnableProviderAccount(w, r, actions.EnableProviderAccount)
|
||||
})))
|
||||
mux.Handle("POST /api/provider-accounts/{accountID}/disable", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleDisableProviderAccount(w, r, actions.DisableProviderAccount)
|
||||
})))
|
||||
mux.Handle("POST /api/provider-accounts/{accountID}/retire", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleRetireProviderAccount(w, r, actions.RetireProviderAccount)
|
||||
})))
|
||||
mux.Handle("POST /api/provider-drafts", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleCreateProviderDraft(w, r, actions.CreateProviderDraft)
|
||||
})))
|
||||
@@ -1280,6 +1296,10 @@ func NewActionSetWithStickyRuntime(sqliteDSN string, stickyRuntime stickyStoreRu
|
||||
GetRouteFailure: buildGetRouteFailureAction(stickyRuntime),
|
||||
SetRouteCooldown: buildSetRouteCooldownAction(stickyRuntime),
|
||||
GetRouteCooldown: buildGetRouteCooldownAction(stickyRuntime),
|
||||
ListProviderAccounts: buildListProviderAccountsAction(sqliteDSN),
|
||||
EnableProviderAccount: buildUpdateProviderAccountStatusAction(sqliteDSN, sqlite.ProviderAccountStatusActive),
|
||||
DisableProviderAccount: buildUpdateProviderAccountStatusAction(sqliteDSN, sqlite.ProviderAccountStatusDisabled),
|
||||
RetireProviderAccount: buildUpdateProviderAccountStatusAction(sqliteDSN, sqlite.ProviderAccountStatusDeprecated),
|
||||
CreateProviderDraft: func(ctx context.Context, req CreateProviderDraftRequest) (ProviderDraftInfo, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
|
||||
188
internal/app/provider_accounts_api.go
Normal file
188
internal/app/provider_accounts_api.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"sub2api-cn-relay-manager/internal/store/sqlite"
|
||||
)
|
||||
|
||||
type ListProviderAccountsRequest struct {
|
||||
HostID string
|
||||
ProviderID string
|
||||
RouteID string
|
||||
ShadowGroupID string
|
||||
AccountStatus string
|
||||
Query string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type UpdateProviderAccountStatusRequest struct {
|
||||
AccountID int64 `json:"-"`
|
||||
AccountStatus string `json:"-"`
|
||||
DisabledReason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func handleListProviderAccounts(w http.ResponseWriter, r *http.Request, fn func(context.Context, ListProviderAccountsRequest) ([]ProviderAccountInfo, error)) {
|
||||
if fn == nil {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "list-provider-accounts action is not configured"})
|
||||
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")),
|
||||
})
|
||||
if err != nil {
|
||||
writeHTTPError(w, classifyError(err))
|
||||
return
|
||||
}
|
||||
if accounts == nil {
|
||||
accounts = []ProviderAccountInfo{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"provider_accounts": accounts})
|
||||
}
|
||||
|
||||
func handleEnableProviderAccount(w http.ResponseWriter, r *http.Request, fn func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)) {
|
||||
handleUpdateProviderAccountStatus(w, r, fn, sqlite.ProviderAccountStatusActive)
|
||||
}
|
||||
|
||||
func handleDisableProviderAccount(w http.ResponseWriter, r *http.Request, fn func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)) {
|
||||
handleUpdateProviderAccountStatus(w, r, fn, sqlite.ProviderAccountStatusDisabled)
|
||||
}
|
||||
|
||||
func handleRetireProviderAccount(w http.ResponseWriter, r *http.Request, fn func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)) {
|
||||
handleUpdateProviderAccountStatus(w, r, fn, sqlite.ProviderAccountStatusDeprecated)
|
||||
}
|
||||
|
||||
func handleUpdateProviderAccountStatus(w http.ResponseWriter, r *http.Request, fn func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error), accountStatus string) {
|
||||
if fn == nil {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "update-provider-account-status action is not configured"})
|
||||
return
|
||||
}
|
||||
rawID := strings.TrimSpace(r.PathValue("accountID"))
|
||||
accountID, err := strconv.ParseInt(rawID, 10, 64)
|
||||
if err != nil || accountID <= 0 {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "invalid_request", Message: "account_id must be a positive integer"})
|
||||
return
|
||||
}
|
||||
req := UpdateProviderAccountStatusRequest{
|
||||
AccountID: accountID,
|
||||
AccountStatus: accountStatus,
|
||||
}
|
||||
if r.ContentLength != 0 {
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
req.AccountID = accountID
|
||||
req.AccountStatus = accountStatus
|
||||
}
|
||||
account, err := fn(r.Context(), req)
|
||||
if err != nil {
|
||||
writeHTTPError(w, classifyError(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"provider_account": account})
|
||||
}
|
||||
|
||||
func buildListProviderAccountsAction(sqliteDSN string) func(context.Context, ListProviderAccountsRequest) ([]ProviderAccountInfo, error) {
|
||||
return func(ctx context.Context, req ListProviderAccountsRequest) ([]ProviderAccountInfo, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if err := sqlite.SyncProviderAccountsFromLatestImportBatches(ctx, store); err != nil {
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]ProviderAccountInfo, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
result = append(result, providerAccountViewToInfo(row))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildUpdateProviderAccountStatusAction(sqliteDSN, accountStatus string) func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error) {
|
||||
return func(ctx context.Context, req UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return ProviderAccountInfo{}, err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if err := store.ProviderAccounts().UpdateStatusByID(ctx, req.AccountID, accountStatus, strings.TrimSpace(req.DisabledReason)); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return ProviderAccountInfo{}, fmt.Errorf("provider account %d not found", req.AccountID)
|
||||
}
|
||||
return ProviderAccountInfo{}, err
|
||||
}
|
||||
updated, err := store.ProviderAccounts().GetViewByID(ctx, req.AccountID)
|
||||
if err != nil {
|
||||
return ProviderAccountInfo{}, err
|
||||
}
|
||||
return providerAccountViewToInfo(updated), nil
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
151
internal/app/provider_accounts_api_test.go
Normal file
151
internal/app/provider_accounts_api_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"sub2api-cn-relay-manager/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestAPIListProviderAccountsReturnsRows(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
ListProviderAccounts: func(_ context.Context, req ListProviderAccountsRequest) ([]ProviderAccountInfo, error) {
|
||||
if req.ProviderID != "deepseek-official" {
|
||||
t.Fatalf("ProviderID = %q, want deepseek-official", req.ProviderID)
|
||||
}
|
||||
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",
|
||||
}}, nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, "GET", "/api/provider-accounts?provider_id=deepseek-official&account_status=disabled", nil, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, 200)
|
||||
var payload map[string][]ProviderAccountInfo
|
||||
if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
accounts := payload["provider_accounts"]
|
||||
if len(accounts) != 1 || accounts[0].ID != 7 || accounts[0].AccountStatus != "disabled" {
|
||||
t.Fatalf("provider_accounts = %+v, want one disabled row id=7", accounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDisableProviderAccountUsesPathID(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
DisableProviderAccount: func(_ context.Context, req UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error) {
|
||||
if req.AccountID != 42 {
|
||||
t.Fatalf("AccountID = %d, want 42", req.AccountID)
|
||||
}
|
||||
if req.AccountStatus != "disabled" {
|
||||
t.Fatalf("AccountStatus = %q, want disabled", req.AccountStatus)
|
||||
}
|
||||
if req.DisabledReason != "manual_disable" {
|
||||
t.Fatalf("DisabledReason = %q, want manual_disable", req.DisabledReason)
|
||||
}
|
||||
return ProviderAccountInfo{ID: req.AccountID, AccountStatus: req.AccountStatus, DisabledReason: req.DisabledReason}, nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, "POST", "/api/provider-accounts/42/disable", map[string]any{"reason": "manual_disable"}, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, 200)
|
||||
assertJSONContains(t, response.Body().Bytes(), "provider_account.id", float64(42))
|
||||
assertJSONContains(t, response.Body().Bytes(), "provider_account.account_status", "disabled")
|
||||
}
|
||||
|
||||
func TestNewActionSetProviderAccountListAndStatusFlow(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "provider-accounts.db")
|
||||
dsn := "file:" + filepath.ToSlash(dbPath) + "?_busy_timeout=5000"
|
||||
actions := NewActionSet(dsn)
|
||||
ctx := context.Background()
|
||||
|
||||
store, err := sqlite.Open(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite.Open() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
hostID, err := store.Hosts().Create(ctx, sqlite.Host{
|
||||
HostID: "remote43",
|
||||
BaseURL: "https://host.example.com",
|
||||
HostVersion: "0.1.129",
|
||||
CapabilityProbeJSON: `{"accounts":true}`,
|
||||
AuthType: "apikey",
|
||||
AuthToken: "host-key",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Hosts().Create() error = %v", err)
|
||||
}
|
||||
hostRow, err := store.Hosts().GetByID(ctx, hostID)
|
||||
if err != nil {
|
||||
t.Fatalf("Hosts().GetByID() error = %v", err)
|
||||
}
|
||||
packID, err := store.Packs().Create(ctx, sqlite.Pack{PackID: "openai-cn-pack", Version: "1.0.0", Checksum: "chk"})
|
||||
if err != nil {
|
||||
t.Fatalf("Packs().Create() error = %v", err)
|
||||
}
|
||||
providerRowID, err := store.Providers().Create(ctx, sqlite.Provider{
|
||||
PackID: packID,
|
||||
ProviderID: "deepseek-official",
|
||||
DisplayName: "DeepSeek Official",
|
||||
BaseURL: "https://api.deepseek.com",
|
||||
Platform: "openai",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Providers().Create() error = %v", err)
|
||||
}
|
||||
providerAccountID, err := store.ProviderAccounts().Create(ctx, sqlite.ProviderAccount{
|
||||
HostID: hostRow.ID,
|
||||
ProviderID: providerRowID,
|
||||
HostAccountID: "9",
|
||||
KeyFingerprint: "sha256:abc",
|
||||
AccountName: "deepseek-01",
|
||||
AccountStatus: sqlite.ProviderAccountStatusActive,
|
||||
LastProbeStatus: "passed",
|
||||
LastProbeAt: "2026-05-29T00:00:00Z",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ProviderAccounts().Create() error = %v", err)
|
||||
}
|
||||
|
||||
listed, err := actions.ListProviderAccounts(ctx, ListProviderAccountsRequest{HostID: "remote43", ProviderID: "deepseek-official"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListProviderAccounts() error = %v", err)
|
||||
}
|
||||
if len(listed) != 1 || listed[0].ID != providerAccountID {
|
||||
t.Fatalf("ListProviderAccounts() = %+v, want one row for id %d", listed, providerAccountID)
|
||||
}
|
||||
|
||||
disabled, err := actions.DisableProviderAccount(ctx, UpdateProviderAccountStatusRequest{
|
||||
AccountID: providerAccountID,
|
||||
DisabledReason: "manual_disable",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DisableProviderAccount() error = %v", err)
|
||||
}
|
||||
if disabled.AccountStatus != sqlite.ProviderAccountStatusDisabled || disabled.DisabledReason != "manual_disable" {
|
||||
t.Fatalf("DisableProviderAccount() = %+v", disabled)
|
||||
}
|
||||
|
||||
enabled, err := actions.EnableProviderAccount(ctx, UpdateProviderAccountStatusRequest{AccountID: providerAccountID})
|
||||
if err != nil {
|
||||
t.Fatalf("EnableProviderAccount() error = %v", err)
|
||||
}
|
||||
if enabled.AccountStatus != sqlite.ProviderAccountStatusActive {
|
||||
t.Fatalf("EnableProviderAccount() = %+v, want active", enabled)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user