From 7a4815556823b34cf8a589301872ea0516f11e64 Mon Sep 17 00:00:00 2001 From: chenchen Date: Thu, 4 Jun 2026 14:24:25 +0800 Subject: [PATCH] fix(agent): apply review findings (backend data-integrity + frontend error UX) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review agents (backend + frontend) audited the template-agent feature. Real findings fixed (verified the one false-positive — okOrThrow already reads the top-level success because the response interceptor returns response, not response.data): Backend: - buildAgentEnvFromBindings now filters status='active' so a revoked/disabled binding can't still have its KV secret resolved + injected. - same-type binding env collision (e.g. two git repos -> GIT_REPO_URL) now errors instead of silently overwriting. - refreshAgentStatus uses a field-level Updates (status/updated_at) instead of Save(full row) to avoid clobbering a concurrent stop/delete. - tests added: same-type collision rejected, inactive binding rejected. Frontend: - deploy/stop/delete now pass noBusinessError so the global interceptor stops double-toasting the raw English backend message; okOrThrow+onError give one friendly Chinese error. Extracted okOrThrow/noBusinessError into lib/api.ts. - resources page revoke + create + KV-secret-write now check success (okOrThrow) so a failed unbind / credential write no longer falsely reports success. - clipboard copy wrapped in try/catch (no false "copied" on failure). - resources group label shows Chinese resource-type names. Go + frontend builds/tests green. Co-Authored-By: Claude Opus 4.8 --- heicode/controller/agent_template_env.go | 16 ++++-- heicode/controller/agent_template_handlers.go | 12 +++-- heicode/controller/agent_template_test.go | 20 ++++++++ heicode/web/default/dist/index.html | 2 +- .../deploy-agent/deploy-agent-page.tsx | 50 +++++++++---------- .../src/features/resources/resources-page.tsx | 29 +++++++++-- heicode/web/default/src/lib/api.ts | 37 ++++++++++++++ 7 files changed, 128 insertions(+), 38 deletions(-) diff --git a/heicode/controller/agent_template_env.go b/heicode/controller/agent_template_env.go index 4f5a2788..61a67301 100644 --- a/heicode/controller/agent_template_env.go +++ b/heicode/controller/agent_template_env.go @@ -154,8 +154,10 @@ func buildAgentEnvFromBindings(userID int, bindingIDs []int) (map[string]string, for _, id := range bindingIDs { var binding model.ResourceBinding - if err := model.DB.Where("id = ? AND user_id = ?", id, userID).First(&binding).Error; err != nil { - return nil, fmt.Errorf("resource binding %d not found for user", id) + // Only active bindings — a revoked/disabled resource must not be resolved + // (its KV secret would otherwise still be read and injected). + if err := model.DB.Where("id = ? AND user_id = ? AND status = ?", id, userID, "active").First(&binding).Error; err != nil { + return nil, fmt.Errorf("resource binding %d not found or not active", id) } spec := builtinEnvSpec(binding.ResourceType, binding.Provider) @@ -196,9 +198,15 @@ func buildAgentEnvFromBindings(userID int, bindingIDs []int) (map[string]string, } if value, ok := src[f.Key]; ok { str := fmt.Sprintf("%v", value) - if strings.TrimSpace(str) != "" { - env[f.Env] = str + if strings.TrimSpace(str) == "" { + continue } + // Two bindings of the same type produce the same env name; refuse + // rather than silently let the later one overwrite the earlier. + if prev, dup := env[f.Env]; dup && prev != str { + return nil, fmt.Errorf("环境变量 %s 冲突:同类型资源(如两个 git/数据库)只能挂一个", f.Env) + } + env[f.Env] = str } } } diff --git a/heicode/controller/agent_template_handlers.go b/heicode/controller/agent_template_handlers.go index 746c3b15..fc078900 100644 --- a/heicode/controller/agent_template_handlers.go +++ b/heicode/controller/agent_template_handlers.go @@ -183,11 +183,17 @@ func refreshAgentStatus(c *gin.Context, row *model.AgentDeployment) { if err != nil || strings.TrimSpace(status) == "" || status == row.Status { return } + now := agentNow() + nowMs := time.Now().UnixMilli() row.Status = status - row.UpdatedAtText = agentNow() - row.UpdatedAtMs = time.Now().UnixMilli() + row.UpdatedAtText = now + row.UpdatedAtMs = nowMs + // Field-level update (not Save of the whole row) so a concurrent stop/delete + // or other column write isn't clobbered by a stale full-row save. if model.DB != nil { - _ = model.DB.Save(row).Error + _ = model.DB.Model(&model.AgentDeployment{}). + Where("deployment_id = ?", row.DeploymentID). + Updates(map[string]any{"status": status, "updated_at_text": now, "updated_at_ms": nowMs}).Error } } diff --git a/heicode/controller/agent_template_test.go b/heicode/controller/agent_template_test.go index 84fe3864..1ac54b31 100644 --- a/heicode/controller/agent_template_test.go +++ b/heicode/controller/agent_template_test.go @@ -79,6 +79,26 @@ func TestBuildAgentEnvFromBindings_GitNamesProviderAgnostic(t *testing.T) { } } +func TestBuildAgentEnvFromBindings_SameTypeCollisionRejected(t *testing.T) { + setupResourceControllerTestDB(t) + b1 := model.ResourceBinding{UserId: 7, Name: "g1", ResourceType: "git", Provider: "github", Status: "active", Metadata: `{"repo_url":"https://x/a"}`} + b2 := model.ResourceBinding{UserId: 7, Name: "g2", ResourceType: "git", Provider: "gitea", Status: "active", Metadata: `{"repo_url":"https://y/b"}`} + require.NoError(t, model.DB.Create(&b1).Error) + require.NoError(t, model.DB.Create(&b2).Error) + + _, err := buildAgentEnvFromBindings(7, []int{b1.Id, b2.Id}) // both set GIT_REPO_URL + require.Error(t, err) +} + +func TestBuildAgentEnvFromBindings_InactiveRejected(t *testing.T) { + setupResourceControllerTestDB(t) + b := model.ResourceBinding{UserId: 7, Name: "x", ResourceType: "blob", Provider: "blob", Status: "revoked", Metadata: `{"account":"a"}`} + require.NoError(t, model.DB.Create(&b).Error) + + _, err := buildAgentEnvFromBindings(7, []int{b.Id}) // not active + require.Error(t, err) +} + func TestBuildAgentEnvFromBindings_OwnershipEnforced(t *testing.T) { setupResourceControllerTestDB(t) b := model.ResourceBinding{UserId: 7, Name: "x", ResourceType: "blob", Provider: "blob", Metadata: `{}`} diff --git a/heicode/web/default/dist/index.html b/heicode/web/default/dist/index.html index d51fe906..d36de8fc 100644 --- a/heicode/web/default/dist/index.html +++ b/heicode/web/default/dist/index.html @@ -20,7 +20,7 @@ - +
diff --git a/heicode/web/default/src/features/deploy-agent/deploy-agent-page.tsx b/heicode/web/default/src/features/deploy-agent/deploy-agent-page.tsx index d9a6f4f1..fc9b2d34 100644 --- a/heicode/web/default/src/features/deploy-agent/deploy-agent-page.tsx +++ b/heicode/web/default/src/features/deploy-agent/deploy-agent-page.tsx @@ -13,7 +13,7 @@ import { Trash2, } from 'lucide-react' import { toast } from 'sonner' -import { api } from '@/lib/api' +import { api, noBusinessError, okOrThrow } from '@/lib/api' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' @@ -87,23 +87,10 @@ async function listAgents(): Promise { return res.data?.data?.items ?? [] } -// The backend returns HTTP 200 with {success:false, error:{code,message}} for -// business failures (new-api convention), so axios resolves — we must check the -// envelope ourselves and throw, otherwise a failed call looks like a success. -type Envelope = { - data?: { success?: boolean; message?: string; error?: { code?: string; message?: string } } -} const FRIENDLY: Record = { RUNTIME_UNAVAILABLE: '云端服务暂时不可用,请稍后再试', RESOURCE_BINDING_INVALID: '所选资源无效,请重新选择', } -function okOrThrow(res: Envelope) { - const d = res?.data - if (d && d.success === false) { - const code = d.error?.code ?? '' - throw new Error(FRIENDLY[code] || d.error?.message || d.message || '操作失败') - } -} export function DeploySubAgentPage() { const qc = useQueryClient() @@ -129,11 +116,12 @@ export function DeploySubAgentPage() { const deploy = useMutation({ mutationFn: async () => { - const res = await api.post('/api/heicode/agents', { - template_id: selectedTemplate, - binding_ids: selectedBindings, - }) - okOrThrow(res) + const res = await api.post( + '/api/heicode/agents', + { template_id: selectedTemplate, binding_ids: selectedBindings }, + noBusinessError + ) + okOrThrow(res, FRIENDLY) }, onSuccess: () => { toast.success('Agent 已开始部署') @@ -330,7 +318,13 @@ function AgentRow({ const stop = useMutation({ mutationFn: async () => { - okOrThrow(await api.post(`/api/heicode/agents/${agent.agent_id}/stop`)) + okOrThrow( + await api.post( + `/api/heicode/agents/${agent.agent_id}/stop`, + undefined, + noBusinessError + ) + ) }, onSuccess: () => { toast.success('已停止') @@ -340,7 +334,9 @@ function AgentRow({ }) const del = useMutation({ mutationFn: async () => { - okOrThrow(await api.delete(`/api/heicode/agents/${agent.agent_id}`)) + okOrThrow( + await api.delete(`/api/heicode/agents/${agent.agent_id}`, noBusinessError) + ) }, onSuccess: () => { toast.success('已删除') @@ -349,10 +345,14 @@ function AgentRow({ onError: (e) => toast.error(e instanceof Error ? e.message : '操作失败'), }) - const copy = () => { + const copy = async () => { if (!agent.subdomain) return - void navigator.clipboard.writeText(agent.subdomain) - toast.success('已复制访问地址') + try { + await navigator.clipboard.writeText(agent.subdomain) + toast.success('已复制访问地址') + } catch { + toast.error('复制失败,请手动复制地址') + } } return ( @@ -374,7 +374,7 @@ function AgentRow({

-