feat(accounts): add explicit route binding workflow

This commit is contained in:
phamnazage-jpg
2026-05-29 19:07:01 +08:00
parent ad94feab72
commit 649eb13f30
8 changed files with 768 additions and 210 deletions

View File

@@ -343,6 +343,13 @@
line-height: 1.6;
word-break: break-word;
}
.binding-box {
margin-top: 16px;
padding: 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.84);
}
.empty {
padding: 18px;
border-radius: 18px;
@@ -483,7 +490,16 @@
</select>
</label>
</div>
<div class="field-grid two" style="margin-top:12px;">
<div class="field-grid three" style="margin-top:12px;">
<label>
binding_state
<select id="filter-binding-state">
<option value="">全部归属状态</option>
<option value="assigned">assigned</option>
<option value="unassigned">unassigned</option>
<option value="conflict">conflict</option>
</select>
</label>
<label>
搜索
<input id="filter-query" type="text" placeholder="provider / logical_group / host_account / fingerprint">
@@ -520,7 +536,9 @@
当前显式使用的动作接口是:
<code>/api/provider-accounts/{account_id}/enable</code>
<code>/api/provider-accounts/{account_id}/disable</code>
<code>/api/provider-accounts/{account_id}/retire</code>
<code>/api/provider-accounts/{account_id}/retire</code>
<code>/api/provider-accounts/{account_id}/binding-candidates</code>
<code>/api/provider-accounts/{account_id}/binding</code>
</p>
<div class="field-grid" style="margin-top:12px;">
<label>
@@ -535,6 +553,32 @@
</div>
<div class="statusbar" id="action-status">请选择左侧一条帐号记录。</div>
<div class="binding-box">
<h2 style="margin:0 0 8px; font-size:20px;">显式整理归属</h2>
<p class="panel-desc">
当帐号因为同一 <code>shadow_host_id + shadow_group_id</code> 对应多条 route 而显示为
<code>conflict</code> 时,直接在这里挑一条 route 绑定;也可以清空 binding保留为未归属。
</p>
<div class="field-grid two" style="margin-top:12px;">
<label>
route 候选
<select id="binding-route-select" disabled>
<option value="">请先选择帐号</option>
</select>
</label>
<label>
当前 binding_state
<input id="binding-state-view" type="text" readonly value="-">
</label>
</div>
<div class="actions">
<button class="ghost" id="refresh-binding-btn" type="button" disabled>刷新候选 route</button>
<button class="secondary" id="apply-binding-btn" type="button" disabled>绑定到所选 route</button>
<button class="ghost" id="clear-binding-btn" type="button" disabled>清空 route 归属</button>
</div>
<div class="statusbar" id="binding-status">选择左侧一条帐号后,这里会加载 route 候选。</div>
</div>
<div id="detail-empty" class="empty" style="margin-top:16px;">选择左侧一条帐号后,这里会显示 route / shadow group / logical group 归属详情。</div>
<div id="detail-panel" hidden>
<div class="detail-grid" id="detail-grid"></div>
@@ -549,6 +593,7 @@
const state = {
accounts: [],
selectedAccountID: 0,
bindingCandidates: [],
};
const apiBaseInput = document.getElementById("api-base");
@@ -561,6 +606,7 @@
const routeFilterInput = document.getElementById("filter-route-id");
const shadowGroupFilterInput = document.getElementById("filter-shadow-group-id");
const statusFilterInput = document.getElementById("filter-status");
const bindingStateFilterInput = document.getElementById("filter-binding-state");
const queryFilterInput = document.getElementById("filter-query");
const limitFilterInput = document.getElementById("filter-limit");
const actionReasonInput = document.getElementById("action-reason");
@@ -573,6 +619,9 @@
const detailPanel = document.getElementById("detail-panel");
const detailGrid = document.getElementById("detail-grid");
const detailJSON = document.getElementById("detail-json");
const bindingRouteSelect = document.getElementById("binding-route-select");
const bindingStateView = document.getElementById("binding-state-view");
const bindingStatus = document.getElementById("binding-status");
const metricApiRoot = document.getElementById("metric-api-root");
const metricTotal = document.getElementById("metric-total");
@@ -582,6 +631,9 @@
const enableButton = document.getElementById("enable-btn");
const disableButton = document.getElementById("disable-btn");
const retireButton = document.getElementById("retire-btn");
const refreshBindingButton = document.getElementById("refresh-binding-btn");
const applyBindingButton = document.getElementById("apply-binding-btn");
const clearBindingButton = document.getElementById("clear-binding-btn");
function readConfig() {
try {
@@ -603,6 +655,7 @@
routeID: routeFilterInput.value.trim(),
shadowGroupID: shadowGroupFilterInput.value.trim(),
accountStatus: statusFilterInput.value,
bindingState: bindingStateFilterInput.value,
query: queryFilterInput.value.trim(),
limit: limitFilterInput.value.trim(),
};
@@ -621,6 +674,7 @@
routeFilterInput.value = config.routeID || "";
shadowGroupFilterInput.value = config.shadowGroupID || "";
statusFilterInput.value = config.accountStatus || "";
bindingStateFilterInput.value = config.bindingState || "";
queryFilterInput.value = config.query || "";
limitFilterInput.value = config.limit || "200";
}
@@ -707,6 +761,7 @@
if (routeFilterInput.value.trim()) params.set("route_id", routeFilterInput.value.trim());
if (shadowGroupFilterInput.value.trim()) params.set("shadow_group_id", shadowGroupFilterInput.value.trim());
if (statusFilterInput.value) params.set("account_status", statusFilterInput.value);
if (bindingStateFilterInput.value) params.set("binding_state", bindingStateFilterInput.value);
if (queryFilterInput.value.trim()) params.set("q", queryFilterInput.value.trim());
if (limitFilterInput.value.trim()) params.set("limit", limitFilterInput.value.trim());
const query = params.toString();
@@ -718,19 +773,27 @@
try {
const payload = await requestJSON(buildListQuery(), { headers: authHeaders() });
state.accounts = Array.isArray(payload.provider_accounts) ? payload.provider_accounts : [];
state.bindingCandidates = [];
if (!state.accounts.some((item) => item.id === state.selectedAccountID)) {
state.selectedAccountID = state.accounts[0]?.id || 0;
}
renderMetrics();
renderCatalog();
renderDetail();
if (state.selectedAccountID) {
await loadBindingCandidates();
} else {
renderBindingCandidates();
}
setStatus(tableStatus, `已加载 ${state.accounts.length} 条帐号资产记录。`, "success");
} catch (error) {
state.accounts = [];
state.selectedAccountID = 0;
state.bindingCandidates = [];
renderMetrics();
renderCatalog();
renderDetail();
renderBindingCandidates();
setStatus(tableStatus, `读取帐号资产失败:${error.message}`, "danger");
}
}
@@ -780,6 +843,7 @@
<span class="badge ${statusClass(account.account_status)}">${escapeHTML(account.logical_group_id || "未归属 logical_group")}</span>
<span class="badge ${statusClass(account.account_status)}">${escapeHTML(account.route_id || "未归属 route")}</span>
<span class="badge ${statusClass(account.account_status)}">shadow_group: ${escapeHTML(account.shadow_group_id || "-")}</span>
<span class="badge ${statusClass(account.account_status)}">binding: ${escapeHTML(account.binding_state || "unassigned")} / candidates: ${escapeHTML(account.binding_candidate_count || 0)}</span>
</div>
<div class="meta-list">
<span>route_name: <code>${escapeHTML(account.route_name || "-")}</code></span>
@@ -791,6 +855,7 @@
state.selectedAccountID = account.id;
renderCatalog();
renderDetail();
loadBindingCandidates();
});
accountsCatalog.appendChild(card);
});
@@ -804,9 +869,12 @@
enableButton.disabled = !hasSelection;
disableButton.disabled = !hasSelection;
retireButton.disabled = !hasSelection;
refreshBindingButton.disabled = !hasSelection;
clearBindingButton.disabled = !hasSelection;
if (!account) {
detailGrid.innerHTML = "";
detailJSON.textContent = "{}";
bindingStateView.value = "-";
setStatus(actionStatus, "请选择左侧一条帐号记录。");
return;
}
@@ -825,6 +893,8 @@
["host_account_id", account.host_account_id],
["key_fingerprint", account.key_fingerprint],
["account_status", account.account_status],
["binding_state", account.binding_state || "unassigned"],
["binding_candidate_count", String(account.binding_candidate_count || 0)],
["last_probe_status", account.last_probe_status || "-"],
["last_probe_at", account.last_probe_at || "-"],
["disabled_reason", account.disabled_reason || "-"],
@@ -837,9 +907,56 @@
</div>
`).join("");
detailJSON.textContent = JSON.stringify(account, null, 2);
bindingStateView.value = account.binding_state || "unassigned";
setStatus(actionStatus, `当前选中帐号 #${account.id},操作只会修改插件 provider_accounts 库存状态。`);
}
async function loadBindingCandidates() {
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
if (!account) {
state.bindingCandidates = [];
renderBindingCandidates();
return;
}
setStatus(bindingStatus, `正在读取帐号 #${account.id} 的 route 候选…`);
try {
const payload = await requestJSON(`/api/provider-accounts/${encodeURIComponent(account.id)}/binding-candidates`, { headers: authHeaders() });
state.bindingCandidates = Array.isArray(payload.candidate_routes) ? payload.candidate_routes : [];
if (payload.provider_account) {
state.accounts = state.accounts.map((item) => item.id === account.id ? payload.provider_account : item);
}
renderCatalog();
renderDetail();
renderBindingCandidates();
setStatus(bindingStatus, `已加载 ${state.bindingCandidates.length} 条 route 候选。`, "success");
} catch (error) {
state.bindingCandidates = [];
renderBindingCandidates();
setStatus(bindingStatus, `读取 route 候选失败:${error.message}`, "danger");
}
}
function renderBindingCandidates() {
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
const hasSelection = Boolean(account);
bindingRouteSelect.disabled = !hasSelection;
applyBindingButton.disabled = !hasSelection;
if (!hasSelection) {
bindingRouteSelect.innerHTML = '<option value="">请先选择帐号</option>';
bindingStateView.value = "-";
return;
}
const options = ['<option value="">请选择一个 route</option>'];
state.bindingCandidates.forEach((route) => {
const selected = route.route_id === account.route_id ? " selected" : "";
options.push(`<option value="${escapeHTML(route.route_id)}"${selected}>${escapeHTML(route.route_id)} / ${escapeHTML(route.logical_group_id)} / ${escapeHTML(route.name || "-")}</option>`);
});
if (!state.bindingCandidates.length) {
options.push('<option value="">当前 shadow binding 下没有候选 route</option>');
}
bindingRouteSelect.innerHTML = options.join("");
}
async function updateAccountStatus(action) {
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
if (!account) {
@@ -865,6 +982,37 @@
}
}
async function updateAccountBinding(mode) {
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
if (!account) {
setStatus(bindingStatus, "请先选择一条帐号记录。", "warn");
return;
}
let payload = {};
if (mode === "assign") {
const routeID = bindingRouteSelect.value.trim();
if (!routeID) {
setStatus(bindingStatus, "请先选择要绑定的 route。", "warn");
return;
}
payload = { route_id: routeID };
} else {
payload = { clear: true };
}
try {
const response = await requestJSON(`/api/provider-accounts/${encodeURIComponent(account.id)}/binding`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(payload),
});
const updated = response.provider_account;
setStatus(bindingStatus, `帐号 #${updated.id} 已更新归属binding_state=${updated.binding_state || "unassigned"} route=${updated.route_id || "-"}`, "success");
await loadAccounts();
} catch (error) {
setStatus(bindingStatus, `更新帐号归属失败:${error.message}`, "danger");
}
}
function clearFilters() {
hostFilterInput.value = "";
providerFilterInput.value = "";
@@ -872,6 +1020,7 @@
routeFilterInput.value = "";
shadowGroupFilterInput.value = "";
statusFilterInput.value = "";
bindingStateFilterInput.value = "";
queryFilterInput.value = "";
limitFilterInput.value = "200";
}
@@ -922,6 +1071,9 @@
enableButton.addEventListener("click", () => updateAccountStatus("enable"));
disableButton.addEventListener("click", () => updateAccountStatus("disable"));
retireButton.addEventListener("click", () => updateAccountStatus("retire"));
refreshBindingButton.addEventListener("click", loadBindingCandidates);
applyBindingButton.addEventListener("click", () => updateAccountBinding("assign"));
clearBindingButton.addEventListener("click", () => updateAccountBinding("clear"));
hydrateConfig();
refreshSession();

View File

@@ -26,72 +26,74 @@ import (
)
type ActionSet struct {
CreateBatchImportRun func(context.Context, CreateBatchImportRunRequest) (BatchImportRunCreateResponse, error)
ListBatchImportRuns func(context.Context, ListBatchImportRunsRequest) (ListBatchImportRunsResponse, error)
GetBatchImportRun func(context.Context, string) (batch.RunSummaryProjection, error)
ListBatchImportRunItems func(context.Context, ListBatchImportRunItemsRequest) (ListBatchImportRunItemsResponse, error)
GetBatchImportRunItem func(context.Context, GetBatchImportRunItemRequest) (batch.ItemDetailProjection, error)
CreateLogicalGroup func(context.Context, CreateLogicalGroupRequest) (LogicalGroupInfo, error)
ListLogicalGroups func(context.Context) ([]LogicalGroupInfo, error)
GetLogicalGroup func(context.Context, string) (LogicalGroupInfo, error)
UpdateLogicalGroup func(context.Context, UpdateLogicalGroupRequest) (LogicalGroupInfo, error)
DeleteLogicalGroup func(context.Context, string) error
CreateLogicalGroupModel func(context.Context, CreateLogicalGroupModelRequest) (LogicalGroupModelInfo, error)
ListLogicalGroupModels func(context.Context, string) ([]LogicalGroupModelInfo, error)
DeleteLogicalGroupModel func(context.Context, DeleteLogicalGroupModelRequest) error
CreateLogicalGroupRoute func(context.Context, CreateLogicalGroupRouteRequest) (LogicalGroupRouteInfo, error)
ListLogicalGroupRoutes func(context.Context, string) ([]LogicalGroupRouteInfo, error)
UpdateLogicalGroupRoute func(context.Context, UpdateLogicalGroupRouteRequest) (LogicalGroupRouteInfo, error)
DeleteLogicalGroupRoute func(context.Context, DeleteLogicalGroupRouteRequest) error
CreateLogicalGroupRouteModel func(context.Context, CreateLogicalGroupRouteModelRequest) (LogicalGroupRouteModelInfo, error)
ListLogicalGroupRouteModels func(context.Context, ListLogicalGroupRouteModelsRequest) ([]LogicalGroupRouteModelInfo, error)
AppendRouteDecisionLog func(context.Context, AppendRouteDecisionLogRequest) (RouteDecisionLogInfo, error)
ListRouteDecisionLogs func(context.Context, ListRouteDecisionLogsRequest) ([]RouteDecisionLogInfo, error)
AppendRouteFailoverEvent func(context.Context, AppendRouteFailoverEventRequest) (RouteFailoverEventInfo, error)
ListRouteFailoverEvents func(context.Context, ListRouteFailoverEventsRequest) ([]RouteFailoverEventInfo, error)
AppendRouteStickyAudit func(context.Context, AppendRouteStickyAuditRequest) (RouteStickyAuditInfo, error)
ListRouteStickyAudit func(context.Context, ListRouteStickyAuditRequest) ([]RouteStickyAuditInfo, error)
ListRouteHealth func(context.Context, ListRouteHealthRequest) ([]RouteHealthInfo, error)
ResolveRoute func(context.Context, ResolveRouteRequest) (ResolveRouteInfo, error)
RouteChatCompletions func(context.Context, RouteChatCompletionsRequest) (RouteChatCompletionsResult, error)
ProxyRouteChatCompletions func(context.Context, ProxyRouteChatCompletionsRequest) (ProxyRouteChatCompletionsResult, error)
SetStickyBinding func(context.Context, SetStickyBindingRequest) (StickyBindingInfo, error)
GetStickyBinding func(context.Context, GetStickyBindingRequest) (StickyBindingInfo, error)
SetRouteFailure func(context.Context, SetRouteFailureRequest) (RouteFailureInfo, error)
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)
UpdateProviderDraft func(context.Context, UpdateProviderDraftRequest) (ProviderDraftInfo, error)
DeleteProviderDraft func(context.Context, string) error
PublishProviderDraft func(context.Context, PublishProviderDraftRequest) (PublishProviderDraftResult, error)
InstallPack func(context.Context, InstallPackRequest) (provision.PackInstallResult, error)
BatchDetail func(context.Context, BatchDetailRequest) (provision.BatchDetailResult, error)
GetProviderStatus func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)
GetProviderResources func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)
GetProviderAccessStatus func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)
ListProviderImportBatches func(context.Context, ProviderQueryRequest) ([]ImportBatchInfo, error)
PreviewProvider func(context.Context, PreviewProviderRequest) (provision.PreviewReport, error)
ImportProvider func(context.Context, ImportProviderRequest) (provision.RuntimeImportResult, error)
RollbackProvider func(context.Context, RollbackProviderRequest) (provision.RollbackReport, error)
RollbackBatch func(context.Context, RollbackBatchRequest) (provision.RollbackReport, error)
ReconcileProvider func(context.Context, ReconcileProviderRequest) (reconcile.Result, error)
CreateHost func(context.Context, CreateHostRequest) (HostInfo, error)
ProbeHost func(context.Context, ProbeHostRequest) (HostInfo, error)
ListHosts func(context.Context) ([]HostInfo, error)
GetHost func(context.Context, string) (HostInfo, error)
DeleteHost func(context.Context, string) error
ListPacks func(context.Context) ([]PackInfo, error)
GetPack func(context.Context, string) (PackInfo, error)
ListPackProviders func(context.Context, string) ([]PackProviderInfo, error)
AssignAccessSubscriptions func(context.Context, AssignAccessSubscriptionsRequest) (AssignAccessSubscriptionsResult, error)
AccessPreview func(context.Context, AccessPreviewRequest) (AccessPreviewResult, error)
CreateBatchImportRun func(context.Context, CreateBatchImportRunRequest) (BatchImportRunCreateResponse, error)
ListBatchImportRuns func(context.Context, ListBatchImportRunsRequest) (ListBatchImportRunsResponse, error)
GetBatchImportRun func(context.Context, string) (batch.RunSummaryProjection, error)
ListBatchImportRunItems func(context.Context, ListBatchImportRunItemsRequest) (ListBatchImportRunItemsResponse, error)
GetBatchImportRunItem func(context.Context, GetBatchImportRunItemRequest) (batch.ItemDetailProjection, error)
CreateLogicalGroup func(context.Context, CreateLogicalGroupRequest) (LogicalGroupInfo, error)
ListLogicalGroups func(context.Context) ([]LogicalGroupInfo, error)
GetLogicalGroup func(context.Context, string) (LogicalGroupInfo, error)
UpdateLogicalGroup func(context.Context, UpdateLogicalGroupRequest) (LogicalGroupInfo, error)
DeleteLogicalGroup func(context.Context, string) error
CreateLogicalGroupModel func(context.Context, CreateLogicalGroupModelRequest) (LogicalGroupModelInfo, error)
ListLogicalGroupModels func(context.Context, string) ([]LogicalGroupModelInfo, error)
DeleteLogicalGroupModel func(context.Context, DeleteLogicalGroupModelRequest) error
CreateLogicalGroupRoute func(context.Context, CreateLogicalGroupRouteRequest) (LogicalGroupRouteInfo, error)
ListLogicalGroupRoutes func(context.Context, string) ([]LogicalGroupRouteInfo, error)
UpdateLogicalGroupRoute func(context.Context, UpdateLogicalGroupRouteRequest) (LogicalGroupRouteInfo, error)
DeleteLogicalGroupRoute func(context.Context, DeleteLogicalGroupRouteRequest) error
CreateLogicalGroupRouteModel func(context.Context, CreateLogicalGroupRouteModelRequest) (LogicalGroupRouteModelInfo, error)
ListLogicalGroupRouteModels func(context.Context, ListLogicalGroupRouteModelsRequest) ([]LogicalGroupRouteModelInfo, error)
AppendRouteDecisionLog func(context.Context, AppendRouteDecisionLogRequest) (RouteDecisionLogInfo, error)
ListRouteDecisionLogs func(context.Context, ListRouteDecisionLogsRequest) ([]RouteDecisionLogInfo, error)
AppendRouteFailoverEvent func(context.Context, AppendRouteFailoverEventRequest) (RouteFailoverEventInfo, error)
ListRouteFailoverEvents func(context.Context, ListRouteFailoverEventsRequest) ([]RouteFailoverEventInfo, error)
AppendRouteStickyAudit func(context.Context, AppendRouteStickyAuditRequest) (RouteStickyAuditInfo, error)
ListRouteStickyAudit func(context.Context, ListRouteStickyAuditRequest) ([]RouteStickyAuditInfo, error)
ListRouteHealth func(context.Context, ListRouteHealthRequest) ([]RouteHealthInfo, error)
ResolveRoute func(context.Context, ResolveRouteRequest) (ResolveRouteInfo, error)
RouteChatCompletions func(context.Context, RouteChatCompletionsRequest) (RouteChatCompletionsResult, error)
ProxyRouteChatCompletions func(context.Context, ProxyRouteChatCompletionsRequest) (ProxyRouteChatCompletionsResult, error)
SetStickyBinding func(context.Context, SetStickyBindingRequest) (StickyBindingInfo, error)
GetStickyBinding func(context.Context, GetStickyBindingRequest) (StickyBindingInfo, error)
SetRouteFailure func(context.Context, SetRouteFailureRequest) (RouteFailureInfo, error)
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)
GetProviderAccountBindingCandidates func(context.Context, GetProviderAccountBindingCandidatesRequest) (ProviderAccountBindingCandidatesResult, error)
UpdateProviderAccountBinding func(context.Context, UpdateProviderAccountBindingRequest) (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)
UpdateProviderDraft func(context.Context, UpdateProviderDraftRequest) (ProviderDraftInfo, error)
DeleteProviderDraft func(context.Context, string) error
PublishProviderDraft func(context.Context, PublishProviderDraftRequest) (PublishProviderDraftResult, error)
InstallPack func(context.Context, InstallPackRequest) (provision.PackInstallResult, error)
BatchDetail func(context.Context, BatchDetailRequest) (provision.BatchDetailResult, error)
GetProviderStatus func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)
GetProviderResources func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)
GetProviderAccessStatus func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)
ListProviderImportBatches func(context.Context, ProviderQueryRequest) ([]ImportBatchInfo, error)
PreviewProvider func(context.Context, PreviewProviderRequest) (provision.PreviewReport, error)
ImportProvider func(context.Context, ImportProviderRequest) (provision.RuntimeImportResult, error)
RollbackProvider func(context.Context, RollbackProviderRequest) (provision.RollbackReport, error)
RollbackBatch func(context.Context, RollbackBatchRequest) (provision.RollbackReport, error)
ReconcileProvider func(context.Context, ReconcileProviderRequest) (reconcile.Result, error)
CreateHost func(context.Context, CreateHostRequest) (HostInfo, error)
ProbeHost func(context.Context, ProbeHostRequest) (HostInfo, error)
ListHosts func(context.Context) ([]HostInfo, error)
GetHost func(context.Context, string) (HostInfo, error)
DeleteHost func(context.Context, string) error
ListPacks func(context.Context) ([]PackInfo, error)
GetPack func(context.Context, string) (PackInfo, error)
ListPackProviders func(context.Context, string) ([]PackProviderInfo, error)
AssignAccessSubscriptions func(context.Context, AssignAccessSubscriptionsRequest) (AssignAccessSubscriptionsResult, error)
AccessPreview func(context.Context, AccessPreviewRequest) (AccessPreviewResult, error)
}
const maxJSONBodyBytes int64 = 1 << 20
@@ -439,6 +441,12 @@ func NewAPIHandlerWithAuth(adminAuth AdminAuthConfig, actions ActionSet) http.Ha
mux.Handle("GET /api/provider-accounts", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListProviderAccounts(w, r, actions.ListProviderAccounts)
})))
mux.Handle("GET /api/provider-accounts/{accountID}/binding-candidates", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleGetProviderAccountBindingCandidates(w, r, actions.GetProviderAccountBindingCandidates)
})))
mux.Handle("POST /api/provider-accounts/{accountID}/binding", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleUpdateProviderAccountBinding(w, r, actions.UpdateProviderAccountBinding)
})))
mux.Handle("POST /api/provider-accounts/{accountID}/enable", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleEnableProviderAccount(w, r, actions.EnableProviderAccount)
})))
@@ -1261,45 +1269,47 @@ func NewActionSetWithStickyRuntime(sqliteDSN string, stickyRuntime stickyStoreRu
proxyRouteChatCompletions := buildProxyRouteChatCompletionsAction(sqliteDSN, resolveRoute, routeLogWriter)
routeChatCompletions := buildRouteChatCompletionsAction(proxyRouteChatCompletions)
return ActionSet{
CreateBatchImportRun: buildCreateBatchImportRunAction(sqliteDSN),
ListBatchImportRuns: buildListBatchImportRunsAction(sqliteDSN),
GetBatchImportRun: buildGetBatchImportRunAction(sqliteDSN),
ListBatchImportRunItems: buildListBatchImportRunItemsAction(sqliteDSN),
GetBatchImportRunItem: buildGetBatchImportRunItemAction(sqliteDSN),
CreateLogicalGroup: buildCreateLogicalGroupAction(sqliteDSN),
ListLogicalGroups: buildListLogicalGroupsAction(sqliteDSN),
GetLogicalGroup: buildGetLogicalGroupAction(sqliteDSN),
UpdateLogicalGroup: buildUpdateLogicalGroupAction(sqliteDSN),
DeleteLogicalGroup: buildDeleteLogicalGroupAction(sqliteDSN),
CreateLogicalGroupModel: buildCreateLogicalGroupModelAction(sqliteDSN),
ListLogicalGroupModels: buildListLogicalGroupModelsAction(sqliteDSN),
DeleteLogicalGroupModel: buildDeleteLogicalGroupModelAction(sqliteDSN),
CreateLogicalGroupRoute: buildCreateLogicalGroupRouteAction(sqliteDSN),
ListLogicalGroupRoutes: buildListLogicalGroupRoutesAction(sqliteDSN),
UpdateLogicalGroupRoute: buildUpdateLogicalGroupRouteAction(sqliteDSN),
DeleteLogicalGroupRoute: buildDeleteLogicalGroupRouteAction(sqliteDSN),
CreateLogicalGroupRouteModel: buildCreateLogicalGroupRouteModelAction(sqliteDSN),
ListLogicalGroupRouteModels: buildListLogicalGroupRouteModelsAction(sqliteDSN),
AppendRouteDecisionLog: buildAppendRouteDecisionLogAction(routeLogWriter, sqliteDSN),
ListRouteDecisionLogs: buildListRouteDecisionLogsAction(sqliteDSN),
AppendRouteFailoverEvent: buildAppendRouteFailoverEventAction(routeLogWriter, sqliteDSN),
ListRouteFailoverEvents: buildListRouteFailoverEventsAction(sqliteDSN),
AppendRouteStickyAudit: buildAppendRouteStickyAuditAction(routeLogWriter, sqliteDSN),
ListRouteStickyAudit: buildListRouteStickyAuditAction(sqliteDSN),
ListRouteHealth: buildListRouteHealthAction(sqliteDSN, stickyRuntime),
ResolveRoute: resolveRoute,
RouteChatCompletions: routeChatCompletions,
ProxyRouteChatCompletions: proxyRouteChatCompletions,
SetStickyBinding: buildSetStickyBindingAction(stickyRuntime),
GetStickyBinding: buildGetStickyBindingAction(stickyRuntime),
SetRouteFailure: buildSetRouteFailureAction(stickyRuntime),
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),
CreateBatchImportRun: buildCreateBatchImportRunAction(sqliteDSN),
ListBatchImportRuns: buildListBatchImportRunsAction(sqliteDSN),
GetBatchImportRun: buildGetBatchImportRunAction(sqliteDSN),
ListBatchImportRunItems: buildListBatchImportRunItemsAction(sqliteDSN),
GetBatchImportRunItem: buildGetBatchImportRunItemAction(sqliteDSN),
CreateLogicalGroup: buildCreateLogicalGroupAction(sqliteDSN),
ListLogicalGroups: buildListLogicalGroupsAction(sqliteDSN),
GetLogicalGroup: buildGetLogicalGroupAction(sqliteDSN),
UpdateLogicalGroup: buildUpdateLogicalGroupAction(sqliteDSN),
DeleteLogicalGroup: buildDeleteLogicalGroupAction(sqliteDSN),
CreateLogicalGroupModel: buildCreateLogicalGroupModelAction(sqliteDSN),
ListLogicalGroupModels: buildListLogicalGroupModelsAction(sqliteDSN),
DeleteLogicalGroupModel: buildDeleteLogicalGroupModelAction(sqliteDSN),
CreateLogicalGroupRoute: buildCreateLogicalGroupRouteAction(sqliteDSN),
ListLogicalGroupRoutes: buildListLogicalGroupRoutesAction(sqliteDSN),
UpdateLogicalGroupRoute: buildUpdateLogicalGroupRouteAction(sqliteDSN),
DeleteLogicalGroupRoute: buildDeleteLogicalGroupRouteAction(sqliteDSN),
CreateLogicalGroupRouteModel: buildCreateLogicalGroupRouteModelAction(sqliteDSN),
ListLogicalGroupRouteModels: buildListLogicalGroupRouteModelsAction(sqliteDSN),
AppendRouteDecisionLog: buildAppendRouteDecisionLogAction(routeLogWriter, sqliteDSN),
ListRouteDecisionLogs: buildListRouteDecisionLogsAction(sqliteDSN),
AppendRouteFailoverEvent: buildAppendRouteFailoverEventAction(routeLogWriter, sqliteDSN),
ListRouteFailoverEvents: buildListRouteFailoverEventsAction(sqliteDSN),
AppendRouteStickyAudit: buildAppendRouteStickyAuditAction(routeLogWriter, sqliteDSN),
ListRouteStickyAudit: buildListRouteStickyAuditAction(sqliteDSN),
ListRouteHealth: buildListRouteHealthAction(sqliteDSN, stickyRuntime),
ResolveRoute: resolveRoute,
RouteChatCompletions: routeChatCompletions,
ProxyRouteChatCompletions: proxyRouteChatCompletions,
SetStickyBinding: buildSetStickyBindingAction(stickyRuntime),
GetStickyBinding: buildGetStickyBindingAction(stickyRuntime),
SetRouteFailure: buildSetRouteFailureAction(stickyRuntime),
GetRouteFailure: buildGetRouteFailureAction(stickyRuntime),
SetRouteCooldown: buildSetRouteCooldownAction(stickyRuntime),
GetRouteCooldown: buildGetRouteCooldownAction(stickyRuntime),
ListProviderAccounts: buildListProviderAccountsAction(sqliteDSN),
GetProviderAccountBindingCandidates: buildGetProviderAccountBindingCandidatesAction(sqliteDSN),
UpdateProviderAccountBinding: buildUpdateProviderAccountBindingAction(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 {

View File

@@ -18,6 +18,7 @@ type ListProviderAccountsRequest struct {
RouteID string
ShadowGroupID string
AccountStatus string
BindingState string
Query string
Limit int
}
@@ -28,27 +29,44 @@ type UpdateProviderAccountStatusRequest struct {
DisabledReason string `json:"reason,omitempty"`
}
type GetProviderAccountBindingCandidatesRequest struct {
AccountID int64
}
type UpdateProviderAccountBindingRequest struct {
AccountID int64 `json:"-"`
RouteID string `json:"route_id,omitempty"`
Clear bool `json:"clear,omitempty"`
}
type ProviderAccountBindingCandidatesResult struct {
ProviderAccount ProviderAccountInfo `json:"provider_account"`
CandidateRoutes []LogicalGroupRouteInfo `json:"candidate_routes"`
}
type ProviderAccountInfo struct {
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"`
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"`
BindingState string `json:"binding_state,omitempty"`
BindingCandidateCount int `json:"binding_candidate_count,omitempty"`
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)) {
@@ -63,6 +81,7 @@ func handleListProviderAccounts(w http.ResponseWriter, r *http.Request, fn func(
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")),
BindingState: strings.TrimSpace(r.URL.Query().Get("binding_state")),
Query: strings.TrimSpace(r.URL.Query().Get("q")),
Limit: parsePositiveInt(r.URL.Query().Get("limit")),
})
@@ -76,6 +95,24 @@ func handleListProviderAccounts(w http.ResponseWriter, r *http.Request, fn func(
writeJSON(w, http.StatusOK, map[string]any{"provider_accounts": accounts})
}
func handleGetProviderAccountBindingCandidates(w http.ResponseWriter, r *http.Request, fn func(context.Context, GetProviderAccountBindingCandidatesRequest) (ProviderAccountBindingCandidatesResult, error)) {
if fn == nil {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "get-provider-account-binding-candidates action is not configured"})
return
}
accountID, parseErr := parseProviderAccountID(r.PathValue("accountID"))
if parseErr != nil {
writeHTTPError(w, parseErr)
return
}
result, actionErr := fn(r.Context(), GetProviderAccountBindingCandidatesRequest{AccountID: accountID})
if actionErr != nil {
writeHTTPError(w, classifyError(actionErr))
return
}
writeJSON(w, http.StatusOK, result)
}
func handleEnableProviderAccount(w http.ResponseWriter, r *http.Request, fn func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)) {
handleUpdateProviderAccountStatus(w, r, fn, sqlite.ProviderAccountStatusActive)
}
@@ -88,15 +125,40 @@ func handleRetireProviderAccount(w http.ResponseWriter, r *http.Request, fn func
handleUpdateProviderAccountStatus(w, r, fn, sqlite.ProviderAccountStatusDeprecated)
}
func handleUpdateProviderAccountBinding(w http.ResponseWriter, r *http.Request, fn func(context.Context, UpdateProviderAccountBindingRequest) (ProviderAccountInfo, error)) {
if fn == nil {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "update-provider-account-binding action is not configured"})
return
}
accountID, err := parseProviderAccountID(r.PathValue("accountID"))
if err != nil {
writeHTTPError(w, err)
return
}
var req UpdateProviderAccountBindingRequest
if r.ContentLength != 0 {
if err := decodeJSON(r, &req); err != nil {
writeHTTPError(w, err)
return
}
}
req.AccountID = accountID
account, actionErr := fn(r.Context(), req)
if actionErr != nil {
writeHTTPError(w, classifyError(actionErr))
return
}
writeJSON(w, http.StatusOK, map[string]any{"provider_account": account})
}
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"})
accountID, err := parseProviderAccountID(r.PathValue("accountID"))
if err != nil {
writeHTTPError(w, err)
return
}
req := UpdateProviderAccountStatusRequest{
@@ -111,14 +173,22 @@ func handleUpdateProviderAccountStatus(w http.ResponseWriter, r *http.Request, f
req.AccountID = accountID
req.AccountStatus = accountStatus
}
account, err := fn(r.Context(), req)
if err != nil {
writeHTTPError(w, classifyError(err))
account, actionErr := fn(r.Context(), req)
if actionErr != nil {
writeHTTPError(w, classifyError(actionErr))
return
}
writeJSON(w, http.StatusOK, map[string]any{"provider_account": account})
}
func parseProviderAccountID(rawID string) (int64, *httpError) {
accountID, err := strconv.ParseInt(strings.TrimSpace(rawID), 10, 64)
if err != nil || accountID <= 0 {
return 0, &httpError{StatusCode: http.StatusBadRequest, Code: "invalid_request", Message: "account_id must be a positive integer"}
}
return accountID, nil
}
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)
@@ -137,6 +207,7 @@ func buildListProviderAccountsAction(sqliteDSN string) func(context.Context, Lis
RouteID: req.RouteID,
ShadowGroupID: req.ShadowGroupID,
AccountStatus: req.AccountStatus,
BindingState: req.BindingState,
Query: req.Query,
Limit: req.Limit,
})
@@ -151,6 +222,38 @@ func buildListProviderAccountsAction(sqliteDSN string) func(context.Context, Lis
}
}
func buildGetProviderAccountBindingCandidatesAction(sqliteDSN string) func(context.Context, GetProviderAccountBindingCandidatesRequest) (ProviderAccountBindingCandidatesResult, error) {
return func(ctx context.Context, req GetProviderAccountBindingCandidatesRequest) (ProviderAccountBindingCandidatesResult, error) {
store, err := sqlite.Open(ctx, sqliteDSN)
if err != nil {
return ProviderAccountBindingCandidatesResult{}, err
}
defer store.Close()
account, err := store.ProviderAccounts().GetViewByID(ctx, req.AccountID)
if err != nil {
if err == sql.ErrNoRows {
return ProviderAccountBindingCandidatesResult{}, fmt.Errorf("provider account %d not found", req.AccountID)
}
return ProviderAccountBindingCandidatesResult{}, err
}
candidates := make([]LogicalGroupRouteInfo, 0)
if strings.TrimSpace(account.HostID) != "" && strings.TrimSpace(account.ShadowGroupID) != "" {
routes, routeErr := store.LogicalGroupRoutes().ListByShadowBinding(ctx, account.HostID, account.ShadowGroupID)
if routeErr != nil {
return ProviderAccountBindingCandidatesResult{}, routeErr
}
for _, route := range routes {
candidates = append(candidates, logicalGroupRouteRowToInfo(route, nil))
}
}
return ProviderAccountBindingCandidatesResult{
ProviderAccount: providerAccountViewToInfo(account),
CandidateRoutes: candidates,
}, 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)
@@ -173,27 +276,91 @@ func buildUpdateProviderAccountStatusAction(sqliteDSN, accountStatus string) fun
}
}
func providerAccountViewToInfo(row sqlite.ProviderAccountView) ProviderAccountInfo {
return ProviderAccountInfo{
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,
func buildUpdateProviderAccountBindingAction(sqliteDSN string) func(context.Context, UpdateProviderAccountBindingRequest) (ProviderAccountInfo, error) {
return func(ctx context.Context, req UpdateProviderAccountBindingRequest) (ProviderAccountInfo, error) {
store, err := sqlite.Open(ctx, sqliteDSN)
if err != nil {
return ProviderAccountInfo{}, err
}
defer store.Close()
account, err := store.ProviderAccounts().GetByID(ctx, req.AccountID)
if err != nil {
if err == sql.ErrNoRows {
return ProviderAccountInfo{}, fmt.Errorf("provider account %d not found", req.AccountID)
}
return ProviderAccountInfo{}, err
}
if req.Clear {
if err := store.ProviderAccounts().UpdateBindingByID(ctx, account.ID, "", account.ShadowGroupID); err != nil {
return ProviderAccountInfo{}, err
}
} else {
routeID := strings.TrimSpace(req.RouteID)
if routeID == "" {
return ProviderAccountInfo{}, fmt.Errorf("route_id is required")
}
route, routeErr := store.LogicalGroupRoutes().GetByRouteID(ctx, routeID)
if routeErr != nil {
if routeErr == sql.ErrNoRows {
return ProviderAccountInfo{}, fmt.Errorf("logical group route %q not found", routeID)
}
return ProviderAccountInfo{}, routeErr
}
if strings.TrimSpace(route.ShadowHostID) != strings.TrimSpace(accountHostIDForBinding(store, ctx, account)) {
return ProviderAccountInfo{}, fmt.Errorf("route %q shadow_host_id does not match provider account host", routeID)
}
if strings.TrimSpace(account.ShadowGroupID) != "" && strings.TrimSpace(route.ShadowGroupID) != strings.TrimSpace(account.ShadowGroupID) {
return ProviderAccountInfo{}, fmt.Errorf("route %q shadow_group_id does not match provider account shadow_group_id", routeID)
}
if err := store.ProviderAccounts().UpdateBindingByID(ctx, account.ID, route.RouteID, route.ShadowGroupID); err != nil {
return ProviderAccountInfo{}, err
}
}
updated, err := store.ProviderAccounts().GetViewByID(ctx, account.ID)
if err != nil {
return ProviderAccountInfo{}, err
}
return providerAccountViewToInfo(updated), nil
}
}
func accountHostIDForBinding(store *sqlite.DB, ctx context.Context, account sqlite.ProviderAccount) string {
if store == nil {
return ""
}
hostRow, err := store.Hosts().GetByID(ctx, account.HostID)
if err != nil {
return ""
}
return strings.TrimSpace(hostRow.HostID)
}
func providerAccountViewToInfo(row sqlite.ProviderAccountView) ProviderAccountInfo {
return ProviderAccountInfo{
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,
BindingState: row.BindingState,
BindingCandidateCount: row.BindingCandidateCount,
LastProbeStatus: row.LastProbeStatus,
LastProbeAt: row.LastProbeAt,
DisabledReason: row.DisabledReason,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}

View File

@@ -21,27 +21,32 @@ func TestAPIListProviderAccountsReturnsRows(t *testing.T) {
if req.AccountStatus != "disabled" {
t.Fatalf("AccountStatus = %q, want disabled", req.AccountStatus)
}
if req.BindingState != "conflict" {
t.Fatalf("BindingState = %q, want conflict", req.BindingState)
}
return []ProviderAccountInfo{{
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",
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",
BindingState: "conflict",
BindingCandidateCount: 2,
DisabledReason: "manual_disable",
UpstreamBaseURLHint: "https://api.deepseek.com",
}}, nil
},
})
request := httptestRequest(t, "GET", "/api/provider-accounts?provider_id=deepseek-official&logical_group_id=gpt-shared&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&binding_state=conflict", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, 200)
var payload map[string][]ProviderAccountInfo
@@ -57,6 +62,53 @@ func TestAPIListProviderAccountsReturnsRows(t *testing.T) {
}
}
func TestAPIGetProviderAccountBindingCandidatesUsesPathID(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
GetProviderAccountBindingCandidates: func(_ context.Context, req GetProviderAccountBindingCandidatesRequest) (ProviderAccountBindingCandidatesResult, error) {
if req.AccountID != 7 {
t.Fatalf("AccountID = %d, want 7", req.AccountID)
}
return ProviderAccountBindingCandidatesResult{
ProviderAccount: ProviderAccountInfo{ID: 7, BindingState: "conflict"},
CandidateRoutes: []LogicalGroupRouteInfo{{RouteID: "route-a"}, {RouteID: "route-b"}},
}, nil
},
})
request := httptestRequest(t, "GET", "/api/provider-accounts/7/binding-candidates", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, 200)
var payload struct {
ProviderAccount ProviderAccountInfo `json:"provider_account"`
CandidateRoutes []LogicalGroupRouteInfo `json:"candidate_routes"`
}
if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if payload.ProviderAccount.ID != 7 || len(payload.CandidateRoutes) != 2 || payload.CandidateRoutes[0].RouteID != "route-a" {
t.Fatalf("binding candidates payload = %+v", payload)
}
}
func TestAPIUpdateProviderAccountBindingUsesPathID(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
UpdateProviderAccountBinding: func(_ context.Context, req UpdateProviderAccountBindingRequest) (ProviderAccountInfo, error) {
if req.AccountID != 42 {
t.Fatalf("AccountID = %d, want 42", req.AccountID)
}
if req.RouteID != "route-9" || req.Clear {
t.Fatalf("request = %+v, want route-9 clear=false", req)
}
return ProviderAccountInfo{ID: req.AccountID, RouteID: req.RouteID, BindingState: "assigned"}, nil
},
})
request := httptestRequest(t, "POST", "/api/provider-accounts/42/binding", map[string]any{"route_id": "route-9"}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, 200)
assertJSONContains(t, response.Body().Bytes(), "provider_account.route_id", "route-9")
}
func TestAPIDisableProviderAccountUsesPathID(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
DisableProviderAccount: func(_ context.Context, req UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error) {
@@ -172,6 +224,42 @@ func TestNewActionSetProviderAccountListAndStatusFlow(t *testing.T) {
if listed[0].LogicalGroupID != "gpt-shared" || listed[0].RouteName != "Primary Route" || listed[0].ShadowHostID != "remote43" {
t.Fatalf("ListProviderAccounts() relationship fields = %+v", listed[0])
}
if listed[0].BindingState != sqlite.ProviderAccountBindingStateAssigned || listed[0].BindingCandidateCount != 1 {
t.Fatalf("ListProviderAccounts() binding view = %+v", listed[0])
}
candidates, err := actions.GetProviderAccountBindingCandidates(ctx, GetProviderAccountBindingCandidatesRequest{AccountID: providerAccountID})
if err != nil {
t.Fatalf("GetProviderAccountBindingCandidates() error = %v", err)
}
if len(candidates.CandidateRoutes) != 1 || candidates.CandidateRoutes[0].RouteID != "route-1" {
t.Fatalf("GetProviderAccountBindingCandidates() = %+v", candidates)
}
if _, err := store.LogicalGroupRoutes().Create(ctx, sqlite.LogicalGroupRoute{
RouteID: "route-2",
LogicalGroupID: "gpt-shared",
Name: "Fallback Route",
Status: "active",
Priority: 20,
Weight: 100,
ShadowGroupID: "group-9",
ShadowHostID: "remote43",
UpstreamBaseURLHint: "https://api.backup.example.com",
}); err != nil {
t.Fatalf("LogicalGroupRoutes().Create(route-2) error = %v", err)
}
updatedBinding, err := actions.UpdateProviderAccountBinding(ctx, UpdateProviderAccountBindingRequest{
AccountID: providerAccountID,
RouteID: "route-2",
})
if err != nil {
t.Fatalf("UpdateProviderAccountBinding() error = %v", err)
}
if updatedBinding.RouteID != "route-2" || updatedBinding.BindingState != sqlite.ProviderAccountBindingStateAssigned {
t.Fatalf("UpdateProviderAccountBinding() = %+v", updatedBinding)
}
disabled, err := actions.DisableProviderAccount(ctx, UpdateProviderAccountStatusRequest{
AccountID: providerAccountID,

View File

@@ -209,13 +209,28 @@ func (r *LogicalGroupRoutesRepo) DeleteByRouteID(ctx context.Context, routeID st
}
func (r *LogicalGroupRoutesRepo) GetByShadowBinding(ctx context.Context, shadowHostID, shadowGroupID string) (LogicalGroupRoute, error) {
routes, err := r.ListByShadowBinding(ctx, shadowHostID, shadowGroupID)
if err != nil {
return LogicalGroupRoute{}, 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", strings.TrimSpace(shadowHostID), strings.TrimSpace(shadowGroupID))
}
}
func (r *LogicalGroupRoutesRepo) ListByShadowBinding(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")
return nil, fmt.Errorf("shadow_host_id is required")
}
if shadowGroupID == "" {
return LogicalGroupRoute{}, fmt.Errorf("shadow_group_id is required")
return nil, fmt.Errorf("shadow_group_id is required")
}
rows, err := r.db.QueryContext(
@@ -223,17 +238,16 @@ func (r *LogicalGroupRoutesRepo) GetByShadowBinding(ctx context.Context, shadowH
`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`,
ORDER BY priority ASC, id ASC`,
shadowHostID,
shadowGroupID,
)
if err != nil {
return LogicalGroupRoute{}, fmt.Errorf("get logical group route by shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
return nil, fmt.Errorf("list logical group routes by shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
}
defer rows.Close()
routes := make([]LogicalGroupRoute, 0, 2)
routes := make([]LogicalGroupRoute, 0, 4)
for rows.Next() {
var route LogicalGroupRoute
if err := rows.Scan(
@@ -251,21 +265,14 @@ func (r *LogicalGroupRoutesRepo) GetByShadowBinding(ctx context.Context, shadowH
&route.CreatedAt,
&route.UpdatedAt,
); err != nil {
return LogicalGroupRoute{}, fmt.Errorf("scan logical group route by shadow binding: %w", err)
return nil, 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)
return nil, fmt.Errorf("iterate logical group route by shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
}
return routes, nil
}
func normalizeLogicalGroupRoute(route LogicalGroupRoute) (LogicalGroupRoute, error) {

View File

@@ -12,6 +12,10 @@ const (
ProviderAccountStatusDisabled = "disabled"
ProviderAccountStatusDeprecated = "deprecated"
ProviderAccountStatusBroken = "broken"
ProviderAccountBindingStateAssigned = "assigned"
ProviderAccountBindingStateUnassigned = "unassigned"
ProviderAccountBindingStateConflict = "conflict"
)
type ProviderAccount struct {
@@ -38,31 +42,34 @@ type ProviderAccountListFilter struct {
RouteID string
ShadowGroupID string
AccountStatus string
BindingState string
Query string
Limit int
}
type ProviderAccountView struct {
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"`
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"`
BindingState string `json:"binding_state,omitempty"`
BindingCandidateCount int `json:"binding_candidate_count,omitempty"`
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 {
@@ -189,6 +196,20 @@ func (r *ProviderAccountsRepo) GetViewByID(ctx context.Context, id int64) (Provi
pa.key_fingerprint,
pa.account_name,
pa.account_status,
CASE
WHEN COALESCE(pa.route_id, '') <> '' THEN 'assigned'
WHEN COALESCE((
SELECT COUNT(1)
FROM logical_group_routes lgrb
WHERE lgrb.shadow_host_id = h.host_id AND lgrb.shadow_group_id = pa.shadow_group_id
), 0) > 1 THEN 'conflict'
ELSE 'unassigned'
END,
COALESCE((
SELECT COUNT(1)
FROM logical_group_routes lgrb
WHERE lgrb.shadow_host_id = h.host_id AND lgrb.shadow_group_id = pa.shadow_group_id
), 0),
COALESCE(pa.last_probe_status, ''),
COALESCE(pa.last_probe_at, ''),
COALESCE(pa.disabled_reason, ''),
@@ -224,6 +245,28 @@ func (r *ProviderAccountsRepo) UpdateStatusByID(ctx context.Context, id int64, a
return nil
}
func (r *ProviderAccountsRepo) UpdateBindingByID(ctx context.Context, id int64, routeID, shadowGroupID string) error {
if id <= 0 {
return fmt.Errorf("id is required")
}
routeID = strings.TrimSpace(routeID)
shadowGroupID = strings.TrimSpace(shadowGroupID)
result, err := r.db.ExecContext(ctx, `UPDATE provider_accounts
SET route_id = ?, shadow_group_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`, routeID, shadowGroupID, id)
if err != nil {
return fmt.Errorf("update provider account %d binding: %w", id, err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("provider account %d binding rows affected: %w", id, err)
}
if rows == 0 {
return sql.ErrNoRows
}
return nil
}
func (r *ProviderAccountsRepo) DeprecateMissingForScope(ctx context.Context, providerID, hostID int64, keepHostAccountIDs []string, reason string) error {
if providerID <= 0 {
return fmt.Errorf("provider_id is required")
@@ -274,6 +317,20 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
pa.key_fingerprint,
pa.account_name,
pa.account_status,
CASE
WHEN COALESCE(pa.route_id, '') <> '' THEN 'assigned'
WHEN COALESCE((
SELECT COUNT(1)
FROM logical_group_routes lgrb
WHERE lgrb.shadow_host_id = h.host_id AND lgrb.shadow_group_id = pa.shadow_group_id
), 0) > 1 THEN 'conflict'
ELSE 'unassigned'
END,
COALESCE((
SELECT COUNT(1)
FROM logical_group_routes lgrb
WHERE lgrb.shadow_host_id = h.host_id AND lgrb.shadow_group_id = pa.shadow_group_id
), 0),
COALESCE(pa.last_probe_status, ''),
COALESCE(pa.last_probe_at, ''),
COALESCE(pa.disabled_reason, ''),
@@ -309,6 +366,24 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
query += ` AND pa.account_status = ?`
args = append(args, value)
}
if value := normalizeProviderAccountBindingState(filter.BindingState); value != "" {
switch value {
case ProviderAccountBindingStateAssigned:
query += ` AND COALESCE(pa.route_id, '') <> ''`
case ProviderAccountBindingStateConflict:
query += ` AND COALESCE(pa.route_id, '') = '' AND COALESCE((
SELECT COUNT(1)
FROM logical_group_routes lgrb
WHERE lgrb.shadow_host_id = h.host_id AND lgrb.shadow_group_id = pa.shadow_group_id
), 0) > 1`
case ProviderAccountBindingStateUnassigned:
query += ` AND COALESCE(pa.route_id, '') = '' AND COALESCE((
SELECT COUNT(1)
FROM logical_group_routes lgrb
WHERE lgrb.shadow_host_id = h.host_id AND lgrb.shadow_group_id = pa.shadow_group_id
), 0) <= 1`
}
}
if value := strings.TrimSpace(filter.Query); value != "" {
like := "%" + strings.ToLower(value) + "%"
query += ` AND (
@@ -355,6 +430,8 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
&view.KeyFingerprint,
&view.AccountName,
&view.AccountStatus,
&view.BindingState,
&view.BindingCandidateCount,
&view.LastProbeStatus,
&view.LastProbeAt,
&view.DisabledReason,
@@ -412,6 +489,8 @@ func (r *ProviderAccountsRepo) scanViewOne(ctx context.Context, query string, ar
&view.KeyFingerprint,
&view.AccountName,
&view.AccountStatus,
&view.BindingState,
&view.BindingCandidateCount,
&view.LastProbeStatus,
&view.LastProbeAt,
&view.DisabledReason,
@@ -463,3 +542,16 @@ func normalizeProviderAccountStatus(status string) string {
return ""
}
}
func normalizeProviderAccountBindingState(state string) string {
switch strings.TrimSpace(state) {
case ProviderAccountBindingStateAssigned:
return ProviderAccountBindingStateAssigned
case ProviderAccountBindingStateUnassigned:
return ProviderAccountBindingStateUnassigned
case ProviderAccountBindingStateConflict:
return ProviderAccountBindingStateConflict
default:
return ""
}
}

View File

@@ -33,7 +33,7 @@ func TestProviderAccountsRepoCRUDAndFilters(t *testing.T) {
Priority: 10,
Weight: 100,
ShadowGroupID: "shadow-group-1",
ShadowHostID: "shadow-host-1",
ShadowHostID: "host-" + sanitizeTestName(t.Name()),
}); err != nil {
t.Fatalf("LogicalGroupRoutes().Create() error = %v", err)
}
@@ -101,9 +101,12 @@ 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" {
if rows[0].LogicalGroupID != "lg-1" || rows[0].RouteName != "Route 1" || rows[0].ShadowHostID != "host-"+sanitizeTestName(t.Name()) {
t.Fatalf("ProviderAccounts().List() relationship view = %+v", rows[0])
}
if rows[0].BindingState != ProviderAccountBindingStateAssigned || rows[0].BindingCandidateCount != 1 {
t.Fatalf("ProviderAccounts().List() binding view = %+v", rows[0])
}
if err := accountRepo.UpdateStatusByID(ctx, accountID, ProviderAccountStatusDisabled, "manual_disable"); err != nil {
t.Fatalf("ProviderAccounts().UpdateStatusByID() error = %v", err)
@@ -115,6 +118,39 @@ func TestProviderAccountsRepoCRUDAndFilters(t *testing.T) {
if got.AccountStatus != ProviderAccountStatusDisabled || got.DisabledReason != "manual_disable" {
t.Fatalf("ProviderAccounts().GetByID() after status update = %+v", got)
}
if _, err := store.LogicalGroupRoutes().Create(ctx, LogicalGroupRoute{
RouteID: "route-2",
LogicalGroupID: "lg-1",
Name: "Route 2",
Status: "active",
Priority: 20,
Weight: 100,
ShadowGroupID: "shadow-group-1",
ShadowHostID: "host-" + sanitizeTestName(t.Name()),
}); err != nil {
t.Fatalf("LogicalGroupRoutes().Create(route-2) error = %v", err)
}
if err := accountRepo.UpdateBindingByID(ctx, accountID, "", "shadow-group-1"); err != nil {
t.Fatalf("ProviderAccounts().UpdateBindingByID(clear) error = %v", err)
}
conflictRows, err := accountRepo.List(ctx, ProviderAccountListFilter{BindingState: ProviderAccountBindingStateConflict})
if err != nil {
t.Fatalf("ProviderAccounts().List(conflict) error = %v", err)
}
if len(conflictRows) != 1 || conflictRows[0].ID != accountID || conflictRows[0].BindingState != ProviderAccountBindingStateConflict {
t.Fatalf("ProviderAccounts().List(conflict) = %+v", conflictRows)
}
if err := accountRepo.UpdateBindingByID(ctx, accountID, "route-2", "shadow-group-1"); err != nil {
t.Fatalf("ProviderAccounts().UpdateBindingByID(assign) error = %v", err)
}
view, err = accountRepo.GetViewByID(ctx, accountID)
if err != nil {
t.Fatalf("ProviderAccounts().GetViewByID(after binding) error = %v", err)
}
if view.RouteID != "route-2" || view.BindingState != ProviderAccountBindingStateAssigned {
t.Fatalf("ProviderAccounts().GetViewByID(after binding) = %+v", view)
}
}
func TestSyncProviderAccountsFromImportBatchCreatesAndDeprecatesInventory(t *testing.T) {

View File

@@ -132,14 +132,20 @@ 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" "/binding-candidates"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/binding"
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" "binding_state"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "binding_candidate_count"
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" "显式整理归属"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "conflict"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" 'credentials: "include"'
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal-admin-api"