From c982c595b8bc0ad073d36b6505e67e124e3431bb Mon Sep 17 00:00:00 2001 From: phamnazage-jpg Date: Fri, 29 May 2026 15:50:28 +0800 Subject: [PATCH] feat(accounts): add provider account admin view --- deploy/tksea-portal/admin-batch-import.html | 1 + deploy/tksea-portal/admin/accounts.html | 931 ++++++++++++++++++ deploy/tksea-portal/admin/index.html | 27 + deploy/tksea-portal/admin/logical-groups.html | 1 + deploy/tksea-portal/admin/providers.html | 1 + deploy/tksea-portal/admin/route-health.html | 1 + internal/app/provider_accounts_api.go | 117 ++- internal/app/provider_accounts_api_test.go | 61 +- .../store/sqlite/logical_group_routes_repo.go | 61 ++ .../store/sqlite/provider_accounts_repo.go | 77 +- .../sqlite/provider_accounts_repo_test.go | 103 +- .../store/sqlite/provider_accounts_sync.go | 30 + scripts/deploy/deploy_tksea_portal.sh | 1 + scripts/test/test_tksea_portal_assets.sh | 33 + 14 files changed, 1352 insertions(+), 93 deletions(-) create mode 100644 deploy/tksea-portal/admin/accounts.html diff --git a/deploy/tksea-portal/admin-batch-import.html b/deploy/tksea-portal/admin-batch-import.html index cd7e94cb..62828a5a 100644 --- a/deploy/tksea-portal/admin-batch-import.html +++ b/deploy/tksea-portal/admin-batch-import.html @@ -374,6 +374,7 @@ 管理首页 逻辑分组 / 路由 Route 健康视图 + 帐号资产 新增模型 / 供应商目录 导入供应商帐号 用户 Portal diff --git a/deploy/tksea-portal/admin/accounts.html b/deploy/tksea-portal/admin/accounts.html new file mode 100644 index 00000000..7ec83358 --- /dev/null +++ b/deploy/tksea-portal/admin/accounts.html @@ -0,0 +1,931 @@ + + + + + + Provider Accounts Admin + + + +
+ + +
+
+
Provider Accounts
+

把导入结果升级成可读、可筛选、可启停的帐号资产库存

+

+ 这页直接消费 /api/provider-accounts 与三个启停动作,把每条供应商帐号摊开到 + provider / logical_group / route / shadow_group / shadow_host 维度。 + 当前首版明确只修改插件 SQLite 里的帐号资产状态,不假装已经联动修改宿主 account 记录。 +

+
    +
  • 默认 API Base:/portal-admin-api
  • +
  • 列表会先做一次 provider_accounts 回填
  • +
  • 人工 disabled / deprecated 不会被列表刷新刷回 active
  • +
+
+ + +
+ +
+
+
+

连接与过滤

+

+ 这页默认优先走管理员 session,也保留 Bearer token 兜底。过滤只影响读取列表,不会修改帐号状态。 +

+
+ + +
+
+ + +
+
+ + + + +
+
正在检查管理员会话…
+ +
+ + + +
+
+ + + +
+
+ + +
+
+ + +
+
帐号库存结果会显示在这里。
+
+ +
+

帐号资产清单

+

+ 选中一条帐号后,右侧会展示完整归属和当前启停操作。未补齐 route 的帐号不会被隐藏,而是明确显示为“未归属”。 +

+
+
还没有帐号库存数据。
+
+
+
+ +
+

帐号归属详情

+

+ 这里回答三个问题:这条帐号属于谁、挂到哪条 route、当前是人工停用还是自动探测异常。所有启停动作都只改插件库存状态。 +

+

+ 当前显式使用的动作接口是: + /api/provider-accounts/{account_id}/enable、 + /api/provider-accounts/{account_id}/disable、 + /api/provider-accounts/{account_id}/retire。 +

+
+ +
+
+ + + +
+
请选择左侧一条帐号记录。
+ +
选择左侧一条帐号后,这里会显示 route / shadow group / logical group 归属详情。
+ +
+
+
+ + + + diff --git a/deploy/tksea-portal/admin/index.html b/deploy/tksea-portal/admin/index.html index 56fdaf17..5f16377a 100644 --- a/deploy/tksea-portal/admin/index.html +++ b/deploy/tksea-portal/admin/index.html @@ -252,6 +252,7 @@ 管理首页 逻辑分组 / 路由 Route 健康视图 + 帐号资产 新增模型 / 供应商目录 导入供应商帐号 用户 Portal @@ -290,6 +291,10 @@
Route Health
/route-health
+
+
Accounts
+
/accounts
+
Batch Import
/batch-import
@@ -364,6 +369,28 @@ +
+

帐号资产

+

+ 这页把导入结果收成插件侧 provider_accounts 库存,直接展示帐号属于哪个 + logical_group / route / shadow_group / shadow_host,并提供人工 + enable / disable / retire 动作。 +

+ +
    +
  • + 适用动作 + 查看帐号库存、筛选 route 归属、执行人工启停与退役。 +
  • +
  • + 当前边界 + 启停动作当前只修改插件库存状态,不直接改宿主 account 记录。 +
  • +
+
+

导入供应商帐号

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"