导入供应商帐号
diff --git a/deploy/tksea-portal/admin/logical-groups.html b/deploy/tksea-portal/admin/logical-groups.html
index 5b18d1bf..d12b52ff 100644
--- a/deploy/tksea-portal/admin/logical-groups.html
+++ b/deploy/tksea-portal/admin/logical-groups.html
@@ -370,6 +370,7 @@
管理首页
逻辑分组 / 路由
Route 健康视图
+ 帐号资产
新增模型 / 供应商目录
导入供应商帐号
用户 Portal
diff --git a/deploy/tksea-portal/admin/providers.html b/deploy/tksea-portal/admin/providers.html
index d8eacc53..b947d9d0 100644
--- a/deploy/tksea-portal/admin/providers.html
+++ b/deploy/tksea-portal/admin/providers.html
@@ -361,6 +361,7 @@
管理首页
逻辑分组 / 路由
Route 健康视图
+ 帐号资产
新增模型 / 供应商目录
导入供应商帐号
用户 Portal
diff --git a/deploy/tksea-portal/admin/route-health.html b/deploy/tksea-portal/admin/route-health.html
index fcf0f648..6230ef64 100644
--- a/deploy/tksea-portal/admin/route-health.html
+++ b/deploy/tksea-portal/admin/route-health.html
@@ -343,6 +343,7 @@
管理首页
逻辑分组 / 路由
Route 健康视图
+ 帐号资产
新增模型 / 供应商目录
导入供应商帐号
用户 Portal
diff --git a/internal/app/provider_accounts_api.go b/internal/app/provider_accounts_api.go
index aaad71ad..0a5edbd9 100644
--- a/internal/app/provider_accounts_api.go
+++ b/internal/app/provider_accounts_api.go
@@ -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,
}
}
diff --git a/internal/app/provider_accounts_api_test.go b/internal/app/provider_accounts_api_test.go
index 69108ee9..889e01b4 100644
--- a/internal/app/provider_accounts_api_test.go
+++ b/internal/app/provider_accounts_api_test.go
@@ -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,
diff --git a/internal/store/sqlite/logical_group_routes_repo.go b/internal/store/sqlite/logical_group_routes_repo.go
index c95b7f90..7bd9fe50 100644
--- a/internal/store/sqlite/logical_group_routes_repo.go
+++ b/internal/store/sqlite/logical_group_routes_repo.go
@@ -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)
diff --git a/internal/store/sqlite/provider_accounts_repo.go b/internal/store/sqlite/provider_accounts_repo.go
index b097eec4..956bd27d 100644
--- a/internal/store/sqlite/provider_accounts_repo.go
+++ b/internal/store/sqlite/provider_accounts_repo.go
@@ -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,
diff --git a/internal/store/sqlite/provider_accounts_repo_test.go b/internal/store/sqlite/provider_accounts_repo_test.go
index 86831fd1..d00abe10 100644
--- a/internal/store/sqlite/provider_accounts_repo_test.go
+++ b/internal/store/sqlite/provider_accounts_repo_test.go
@@ -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)
+ }
+}
diff --git a/internal/store/sqlite/provider_accounts_sync.go b/internal/store/sqlite/provider_accounts_sync.go
index 1da7cd20..341c5f55 100644
--- a/internal/store/sqlite/provider_accounts_sync.go
+++ b/internal/store/sqlite/provider_accounts_sync.go
@@ -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
diff --git a/scripts/deploy/deploy_tksea_portal.sh b/scripts/deploy/deploy_tksea_portal.sh
index 6c174a57..7eb49c96 100755
--- a/scripts/deploy/deploy_tksea_portal.sh
+++ b/scripts/deploy/deploy_tksea_portal.sh
@@ -171,6 +171,7 @@ portal url: https://sub.tksea.top/portal/
portal admin home url: https://sub.tksea.top/portal/admin/
logical groups admin url: https://sub.tksea.top/portal/admin/logical-groups.html
route health admin url: https://sub.tksea.top/portal/admin/route-health.html
+accounts admin url: https://sub.tksea.top/portal/admin/accounts.html
provider admin url: https://sub.tksea.top/portal/admin/providers.html
batch import admin url: https://sub.tksea.top/portal/admin/batch-import.html
batch import admin url: https://sub.tksea.top/portal/admin-batch-import.html
diff --git a/scripts/test/test_tksea_portal_assets.sh b/scripts/test/test_tksea_portal_assets.sh
index 28615074..7482d9b5 100755
--- a/scripts/test/test_tksea_portal_assets.sh
+++ b/scripts/test/test_tksea_portal_assets.sh
@@ -7,6 +7,7 @@ ADMIN_HTML_FILE="$ROOT_DIR/deploy/tksea-portal/admin-batch-import.html"
ADMIN_HOME_FILE="$ROOT_DIR/deploy/tksea-portal/admin/index.html"
ADMIN_LOGICAL_GROUPS_FILE="$ROOT_DIR/deploy/tksea-portal/admin/logical-groups.html"
ADMIN_ROUTE_HEALTH_FILE="$ROOT_DIR/deploy/tksea-portal/admin/route-health.html"
+ADMIN_ACCOUNTS_FILE="$ROOT_DIR/deploy/tksea-portal/admin/accounts.html"
ADMIN_PROVIDERS_FILE="$ROOT_DIR/deploy/tksea-portal/admin/providers.html"
ADMIN_BATCH_FILE="$ROOT_DIR/deploy/tksea-portal/admin/batch-import.html"
NGINX_FILE="$ROOT_DIR/deploy/tksea-portal/nginx.sub.tksea.top.conf.example"
@@ -30,6 +31,7 @@ assert_contains_file() {
[[ -f "$ADMIN_HOME_FILE" ]] || fail "missing $ADMIN_HOME_FILE"
[[ -f "$ADMIN_LOGICAL_GROUPS_FILE" ]] || fail "missing $ADMIN_LOGICAL_GROUPS_FILE"
[[ -f "$ADMIN_ROUTE_HEALTH_FILE" ]] || fail "missing $ADMIN_ROUTE_HEALTH_FILE"
+[[ -f "$ADMIN_ACCOUNTS_FILE" ]] || fail "missing $ADMIN_ACCOUNTS_FILE"
[[ -f "$ADMIN_PROVIDERS_FILE" ]] || fail "missing $ADMIN_PROVIDERS_FILE"
[[ -f "$ADMIN_BATCH_FILE" ]] || fail "missing $ADMIN_BATCH_FILE"
[[ -f "$NGINX_FILE" ]] || fail "missing $NGINX_FILE"
@@ -58,6 +60,7 @@ assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/"
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/logical-groups.html"
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/providers.html"
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/batch-import.html"
+assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_HTML_FILE" "/portal-admin-api"
assert_contains_file "$ADMIN_HTML_FILE" "matched_account_state"
assert_contains_file "$ADMIN_HTML_FILE" "account_resolution"
@@ -72,17 +75,21 @@ assert_contains_file "$ADMIN_HTML_FILE" "reactivated"
assert_contains_file "$ADMIN_HOME_FILE" "Admin Portal"
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/logical-groups.html"
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/route-health.html"
+assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/providers.html"
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/batch-import.html"
assert_contains_file "$ADMIN_HOME_FILE" "/portal-admin-api"
assert_contains_file "$ADMIN_HOME_FILE" "浏览器提交到 CRM"
assert_contains_file "$ADMIN_HOME_FILE" "逻辑分组 / 路由"
assert_contains_file "$ADMIN_HOME_FILE" "Route 健康视图"
+assert_contains_file "$ADMIN_HOME_FILE" "帐号资产"
+assert_contains_file "$ADMIN_HOME_FILE" "/accounts"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "Logical Group Admin"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/logical-groups.html"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/route-health.html"
+assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/providers.html"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/batch-import.html"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/api/admin/session/login"
@@ -100,6 +107,7 @@ assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "Route Health Admin"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/logical-groups.html"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/route-health.html"
+assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/providers.html"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/batch-import.html"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/api/admin/session/login"
@@ -113,10 +121,33 @@ assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "disabled"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" 'credentials: "include"'
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal-admin-api"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "Provider Accounts Admin"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/logical-groups.html"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/route-health.html"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/accounts.html"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/providers.html"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/batch-import.html"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/api/admin/session/login"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/api/admin/session/logout"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/api/admin/session"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/api/provider-accounts"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/enable"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/disable"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/retire"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "logical_group_id"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "route_id"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "shadow_group_id"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "shadow_host_id"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "provider_accounts"
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" 'credentials: "include"'
+assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal-admin-api"
+
assert_contains_file "$ADMIN_PROVIDERS_FILE" "Provider Admin"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "管理员登录"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal/admin/logical-groups.html"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal/admin/route-health.html"
+assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session/login"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session/logout"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session"
@@ -145,6 +176,7 @@ assert_contains_file "$ADMIN_PROVIDERS_FILE" "modelConflicts"
assert_contains_file "$ADMIN_BATCH_FILE" "/portal/admin-batch-import.html"
assert_contains_file "$ADMIN_HTML_FILE" "管理员登录"
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/route-health.html"
+assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session/login"
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session/logout"
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session"
@@ -162,6 +194,7 @@ assert_contains_file "$DEPLOY_SCRIPT" "portal url: https://sub.tksea.top/portal/
assert_contains_file "$DEPLOY_SCRIPT" "portal admin home url: https://sub.tksea.top/portal/admin/"
assert_contains_file "$DEPLOY_SCRIPT" "logical groups admin url: https://sub.tksea.top/portal/admin/logical-groups.html"
assert_contains_file "$DEPLOY_SCRIPT" "route health admin url: https://sub.tksea.top/portal/admin/route-health.html"
+assert_contains_file "$DEPLOY_SCRIPT" "accounts admin url: https://sub.tksea.top/portal/admin/accounts.html"
assert_contains_file "$DEPLOY_SCRIPT" "provider admin url: https://sub.tksea.top/portal/admin/providers.html"
assert_contains_file "$DEPLOY_SCRIPT" "batch import admin url: https://sub.tksea.top/portal/admin/batch-import.html"
assert_contains_file "$DEPLOY_SCRIPT" "batch import admin url: https://sub.tksea.top/portal/admin-batch-import.html"