fix: 账号列表与环境解耦;网关清理收敛已消失的旧代

- 社媒账号列表页不再拉取运行环境数据:账号是资源池,环境故障
  不应阻塞账号展示,409 横幅随之消失;「固定资源」「可调度」
  两列移除,绑定状态去运行环境页查看。详情页保持原行为。
- docker-gateway DELETE 清理:容器与登记网络均已不存在时返回
  204 而非 409,控制面 runtime_cleanup_pending 可收敛,修复
  外部清理过的旧代永久卡死;容器存活而网络消失仍 fail-closed。
This commit is contained in:
2026-09-07 09:44:22 +08:00
parent 1ce0375414
commit 83397927c2
4 changed files with 89 additions and 67 deletions
+10 -1
View File
@@ -714,7 +714,16 @@ func (api gateway) remove(c fiber.Ctx) error {
if networkExists {
err = api.removeTenantNetwork(id, input.BindingVersion, input.RuntimeID, networkGeneration, &input, containerID, exists)
} else if expectedNetworkID != "" {
return writeError(c, http.StatusConflict, errGenerationConflict)
if exists {
// 容器仍在但登记的隔离网络已消失:代状态异常,fail-closed 交由人工对账。
return writeError(c, http.StatusConflict, errGenerationConflict)
}
// 容器与登记网络均已不存在:请求指向的代在 Docker 侧已无残留,清理视为完成。
// 否则被外部清理过的旧代会永远 409,控制面的 runtime_cleanup_pending 无法收敛。
if !api.proxies.remove(id, input.BindingVersion, input.RuntimeID, input.NetworkID) {
return writeError(c, http.StatusConflict, errGenerationConflict)
}
return c.SendStatus(http.StatusNoContent)
}
if err != nil {
if errors.Is(err, errGenerationConflict) {
+54
View File
@@ -1263,6 +1263,60 @@ func TestGatewayDeleteWithoutContainerOrNetworkGenerationFailsClosed(t *testing.
}
}
func TestGatewayDeleteConvergesWhenGenerationAlreadyGone(t *testing.T) {
networkRequests := 0
server := httptest.NewServer(withAliasReservations("gateway-self", func(response http.ResponseWriter, request *http.Request) {
switch {
case request.Method == http.MethodGet && request.URL.Path == "/containers/"+namePrefix+"account-a/json":
response.WriteHeader(http.StatusNotFound)
case request.Method == http.MethodGet && request.URL.Path == "/networks/network-old":
networkRequests++
response.WriteHeader(http.StatusNotFound)
case strings.HasPrefix(request.URL.Path, "/networks/"):
t.Fatalf("network lookup leaked beyond the requested generation: %s %s", request.Method, request.URL.Path)
default:
t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
}
}))
defer server.Close()
handler := newGatewayWithSelf(dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()},
"creatorhub_browser", testToken, "gateway-self")
response := httptest.NewRecorder()
body := `{"binding_version":1,"runtime_id":"container-old","network_id":"network-old"}`
adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodDelete, "/v1/browsers/account-a", strings.NewReader(body)))
if response.Code != http.StatusNoContent || networkRequests != 1 {
t.Fatalf("already-gone generation did not converge: status=%d networkRequests=%d body=%s",
response.Code, networkRequests, response.Body.String())
}
}
func TestGatewayDeleteFailsClosedWhenContainerOutlivesNetwork(t *testing.T) {
containerDeleted := false
server := httptest.NewServer(withAliasReservations("gateway-self", func(response http.ResponseWriter, request *http.Request) {
switch {
case request.Method == http.MethodGet && request.URL.Path == "/containers/"+namePrefix+"account-a/json":
_, _ = response.Write([]byte(`{"Id":"container-old","Config":{"Labels":{"` + managedLabel + `":"true","` + idLabel + `":"account-a","` + bindingVersionLabel + `":"1","` + networkIDLabel + `":"network-old"}}}`))
case request.Method == http.MethodGet && request.URL.Path == "/networks/network-old":
response.WriteHeader(http.StatusNotFound)
case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/containers/"):
containerDeleted = true
response.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
}
}))
defer server.Close()
handler := newGatewayWithSelf(dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()},
"creatorhub_browser", testToken, "gateway-self")
response := httptest.NewRecorder()
body := `{"binding_version":1,"runtime_id":"container-old","network_id":"network-old"}`
adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodDelete, "/v1/browsers/account-a", strings.NewReader(body)))
if response.Code != http.StatusConflict || containerDeleted {
t.Fatalf("container outliving its network must fail closed: status=%d containerDeleted=%v body=%s",
response.Code, containerDeleted, response.Body.String())
}
}
func TestGatewayRejectsStaleProxyRestoreAfterReplacementGeneration(t *testing.T) {
type dockerState struct {
sync.Mutex
+6 -66
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { useDataProvider, useList, useOne } from "@refinedev/core";
import {
@@ -265,22 +265,6 @@ export function AccountList() {
const error = query.error;
const isPending = query.isPending;
const accounts = result.data ?? [];
const { result: bindingResult, query: bindingsQuery } = useList({
resource: "browsers",
queryOptions: { retry: false },
});
const bindings = bindingResult.data ?? [];
const bindingsError = bindingsQuery.error;
const refetchBindings = bindingsQuery.refetch;
const bindingByAccount = useMemo(
() =>
new Map(
bindings
.filter((item) => item.account_id)
.map((item) => [item.account_id, item]),
),
[bindings],
);
useTitle("CreatorHub · 社媒账号");
async function createAccount(data) {
@@ -322,15 +306,6 @@ export function AccountList() {
{notice.text}
</Alert>
) : null}
{bindingsError ? (
<Alert variant="destructive" className="mb-4">
环境不可用{bindingsError.status ? `(${bindingsError.status})` : ""}
:无法读取运行环境绑定与 readiness,状态暂时未知。
<Button size="sm" onClick={() => refetchBindings()}>
重试环境状态
</Button>
</Alert>
) : null}
<PageState
pending={isPending}
error={error}
@@ -341,7 +316,7 @@ export function AccountList() {
columns={[
{
header: "账号",
width: "22%",
width: "32%",
render: (account) => (
<div className="min-w-0">
<Link
@@ -358,12 +333,12 @@ export function AccountList() {
},
{
header: "平台",
width: "10%",
width: "16%",
render: (account) => platformLabel(account.platform),
},
{
header: "授权",
width: "10%",
width: "16%",
render: (account) =>
account.authorization_status === "authorized"
? "已授权"
@@ -371,49 +346,14 @@ export function AccountList() {
},
{
header: "账号状态",
width: "14%",
width: "20%",
render: (account) =>
account.runtime_status === "active" ? "启用" : "暂停",
},
{
header: "固定资源",
width: "24%",
render: (account) => {
const binding = bindingByAccount.get(account.id);
return bindingsError ? (
<span className="text-sm text-muted">
状态未知(环境数据不可用)
</span>
) : (
<div className="min-w-0">
<p className="anywhere">
{binding?.name || "未绑定运行环境"}
</p>
<p className="anywhere text-xs text-muted">
{binding?.network_exit_id || "未绑定出口"}
</p>
</div>
);
},
},
{
header: "可调度",
width: "20%",
render: (account) => (
<ReadinessPill
readiness={accountReadiness(
account,
bindingByAccount.get(account.id),
!!bindingsError,
)}
paused={account.runtime_status === "paused"}
/>
),
},
]}
rows={accounts}
rowKey={(account) => account.id}
minWidth="760px"
minWidth="560px"
/>
</PageState>
<AccountCreateModal
+19
View File
@@ -221,6 +221,25 @@ describe("AccountList", () => {
canResume: false,
});
});
it("renders accounts without touching runtime environments", async () => {
const dataProvider = provider({
getList: vi.fn(({ resource }) =>
resource === "accounts"
? { data: [account], total: 1 }
: Promise.reject(httpError("environment down", 409)),
),
});
renderList(dataProvider);
expect(await screen.findAllByText("店铺一号")).toBeTruthy();
expect(screen.queryByText(/环境不可用/)).toBeNull();
expect(
dataProvider.getList.mock.calls.filter(
([call]) => call.resource === "browsers",
),
).toHaveLength(0);
});
});
describe("AccountDetail", () => {