fix(agent): apply review findings (backend data-integrity + frontend error UX)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 14:24:25 +08:00
co-authored by Claude Opus 4.8
parent 479cd61a07
commit 7a48155568
7 changed files with 128 additions and 38 deletions
+12 -4
View File
@@ -154,8 +154,10 @@ func buildAgentEnvFromBindings(userID int, bindingIDs []int) (map[string]string,
for _, id := range bindingIDs { for _, id := range bindingIDs {
var binding model.ResourceBinding var binding model.ResourceBinding
if err := model.DB.Where("id = ? AND user_id = ?", id, userID).First(&binding).Error; err != nil { // Only active bindings — a revoked/disabled resource must not be resolved
return nil, fmt.Errorf("resource binding %d not found for user", id) // (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) 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 { if value, ok := src[f.Key]; ok {
str := fmt.Sprintf("%v", value) str := fmt.Sprintf("%v", value)
if strings.TrimSpace(str) != "" { if strings.TrimSpace(str) == "" {
env[f.Env] = 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
} }
} }
} }
@@ -183,11 +183,17 @@ func refreshAgentStatus(c *gin.Context, row *model.AgentDeployment) {
if err != nil || strings.TrimSpace(status) == "" || status == row.Status { if err != nil || strings.TrimSpace(status) == "" || status == row.Status {
return return
} }
now := agentNow()
nowMs := time.Now().UnixMilli()
row.Status = status row.Status = status
row.UpdatedAtText = agentNow() row.UpdatedAtText = now
row.UpdatedAtMs = time.Now().UnixMilli() 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 { 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
} }
} }
+20
View File
@@ -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) { func TestBuildAgentEnvFromBindings_OwnershipEnforced(t *testing.T) {
setupResourceControllerTestDB(t) setupResourceControllerTestDB(t)
b := model.ResourceBinding{UserId: 7, Name: "x", ResourceType: "blob", Provider: "blob", Metadata: `{}`} b := model.ResourceBinding{UserId: 7, Name: "x", ResourceType: "blob", Provider: "blob", Metadata: `{}`}
+1 -1
View File
@@ -20,7 +20,7 @@
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta name="theme-color" content="#7B6BE3" /> <meta name="theme-color" content="#7B6BE3" />
<link rel="icon" href="/favicon.ico"><script defer src="/static/js/vendor-radix.829c7e3fad.js"></script><script defer src="/static/js/vendor-tanstack.632dbe8908.js"></script><script defer src="/static/js/lib-react.5c8909c28c.js"></script><script defer src="/static/js/6374.bc21d8b214.js"></script><script defer src="/static/js/index.deee68f4fa.js"></script><link href="/static/css/index.592272f49e.css" rel="stylesheet"></head> <link rel="icon" href="/favicon.ico"><script defer src="/static/js/vendor-radix.829c7e3fad.js"></script><script defer src="/static/js/vendor-tanstack.632dbe8908.js"></script><script defer src="/static/js/lib-react.5c8909c28c.js"></script><script defer src="/static/js/6374.bc21d8b214.js"></script><script defer src="/static/js/index.1b6c679c84.js"></script><link href="/static/css/index.592272f49e.css" rel="stylesheet"></head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -13,7 +13,7 @@ import {
Trash2, Trash2,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { api } from '@/lib/api' import { api, noBusinessError, okOrThrow } from '@/lib/api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
@@ -87,23 +87,10 @@ async function listAgents(): Promise<AgentItem[]> {
return res.data?.data?.items ?? [] 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<string, string> = { const FRIENDLY: Record<string, string> = {
RUNTIME_UNAVAILABLE: '云端服务暂时不可用,请稍后再试', RUNTIME_UNAVAILABLE: '云端服务暂时不可用,请稍后再试',
RESOURCE_BINDING_INVALID: '所选资源无效,请重新选择', 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() { export function DeploySubAgentPage() {
const qc = useQueryClient() const qc = useQueryClient()
@@ -129,11 +116,12 @@ export function DeploySubAgentPage() {
const deploy = useMutation({ const deploy = useMutation({
mutationFn: async () => { mutationFn: async () => {
const res = await api.post('/api/heicode/agents', { const res = await api.post(
template_id: selectedTemplate, '/api/heicode/agents',
binding_ids: selectedBindings, { template_id: selectedTemplate, binding_ids: selectedBindings },
}) noBusinessError
okOrThrow(res) )
okOrThrow(res, FRIENDLY)
}, },
onSuccess: () => { onSuccess: () => {
toast.success('Agent 已开始部署') toast.success('Agent 已开始部署')
@@ -330,7 +318,13 @@ function AgentRow({
const stop = useMutation({ const stop = useMutation({
mutationFn: async () => { 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: () => { onSuccess: () => {
toast.success('已停止') toast.success('已停止')
@@ -340,7 +334,9 @@ function AgentRow({
}) })
const del = useMutation({ const del = useMutation({
mutationFn: async () => { mutationFn: async () => {
okOrThrow(await api.delete(`/api/heicode/agents/${agent.agent_id}`)) okOrThrow(
await api.delete(`/api/heicode/agents/${agent.agent_id}`, noBusinessError)
)
}, },
onSuccess: () => { onSuccess: () => {
toast.success('已删除') toast.success('已删除')
@@ -349,10 +345,14 @@ function AgentRow({
onError: (e) => toast.error(e instanceof Error ? e.message : '操作失败'), onError: (e) => toast.error(e instanceof Error ? e.message : '操作失败'),
}) })
const copy = () => { const copy = async () => {
if (!agent.subdomain) return if (!agent.subdomain) return
void navigator.clipboard.writeText(agent.subdomain) try {
toast.success('已复制访问地址') await navigator.clipboard.writeText(agent.subdomain)
toast.success('已复制访问地址')
} catch {
toast.error('复制失败,请手动复制地址')
}
} }
return ( return (
@@ -374,7 +374,7 @@ function AgentRow({
</p> </p>
</div> </div>
<div className='flex shrink-0 items-center gap-1'> <div className='flex shrink-0 items-center gap-1'>
<Button variant='outline' size='sm' className='h-8 gap-1 text-xs' onClick={copy} disabled={!agent.subdomain}> <Button variant='outline' size='sm' className='h-8 gap-1 text-xs' onClick={() => void copy()} disabled={!agent.subdomain}>
<Copy className='h-3.5 w-3.5' /> 复制地址 <Copy className='h-3.5 w-3.5' /> 复制地址
</Button> </Button>
<Button <Button
@@ -11,7 +11,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { api } from '@/lib/api' import { api, noBusinessError, okOrThrow } from '@/lib/api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -131,6 +131,13 @@ const KINDS: Kind[] = [
}, },
] ]
const RESOURCE_TYPE_ZH: Record<string, string> = {
git: '代码仓库',
vm: '虚拟机',
database: '数据库',
blob: '对象存储',
}
function maskRef(ref: string): string { function maskRef(ref: string): string {
if (!ref) return '—' if (!ref) return '—'
const i = ref.lastIndexOf('/') const i = ref.lastIndexOf('/')
@@ -159,7 +166,9 @@ export function ResourceBindingsPage() {
}) })
const revoke = useMutation({ const revoke = useMutation({
mutationFn: (id: number) => api.delete(`/api/resources/${id}`), mutationFn: async (id: number) => {
okOrThrow(await api.delete(`/api/resources/${id}`, noBusinessError))
},
onSuccess: () => { onSuccess: () => {
toast.success(t('Resource unbound')) toast.success(t('Resource unbound'))
void qc.invalidateQueries({ queryKey: ['resources'] }) void qc.invalidateQueries({ queryKey: ['resources'] })
@@ -224,7 +233,7 @@ export function ResourceBindingsPage() {
{Object.entries(grouped).map(([type, items]) => ( {Object.entries(grouped).map(([type, items]) => (
<div key={type}> <div key={type}>
<p className='text-muted-foreground mb-2 text-[11px] font-semibold tracking-[0.14em] uppercase'> <p className='text-muted-foreground mb-2 text-[11px] font-semibold tracking-[0.14em] uppercase'>
{type} · {items.length} {RESOURCE_TYPE_ZH[type] ?? type} · {items.length}
</p> </p>
<div className='grid gap-3 sm:grid-cols-2'> <div className='grid gap-3 sm:grid-cols-2'>
{items.map((r) => ( {items.map((r) => (
@@ -316,8 +325,10 @@ function BindSheet({
metadata, metadata,
permission_scope: {}, permission_scope: {},
constraints: {}, constraints: {},
} },
noBusinessError
) )
okOrThrow(created)
const id = created.data?.data?.id const id = created.data?.data?.id
if (!id) throw new Error(t('Failed to create binding')) if (!id) throw new Error(t('Failed to create binding'))
// 2) write the credential to Key Vault (only secret fields) // 2) write the credential to Key Vault (only secret fields)
@@ -327,7 +338,15 @@ function BindSheet({
if (v) secretData[s.k] = v if (v) secretData[s.k] = v
} }
if (Object.keys(secretData).length > 0) { if (Object.keys(secretData).length > 0) {
await api.post(`/api/resources/${id}/secret`, { data: secretData }) // Must verify success — a silent failure here means the credential
// never reached Key Vault while the binding row exists (unusable).
okOrThrow(
await api.post(
`/api/resources/${id}/secret`,
{ data: secretData },
noBusinessError
)
)
} }
}, },
onSuccess: () => { onSuccess: () => {
+37
View File
@@ -158,6 +158,43 @@ api.interceptors.request.use((config) => {
return config return config
}) })
// ============================================================================
// Business-envelope helpers
// ============================================================================
// Pass as the axios config to suppress the global interceptor's auto error
// toast (which shows the raw backend message) — the caller handles the error
// itself (e.g. with a friendly localized message via okOrThrow + onError).
export const noBusinessError = {
skipBusinessError: true,
skipErrorHandler: true,
} as Record<string, unknown>
type BizEnvelope = {
data?: {
success?: boolean
message?: string
error?: { code?: string; message?: string }
}
}
// The backend returns HTTP 200 with { success:false, error } for business
// failures (new-api convention), so axios resolves — callers MUST check the
// envelope and throw, otherwise a failed call looks like a success. Optionally
// map an error code to a friendly (localized) message.
export function okOrThrow(res: BizEnvelope, friendly?: Record<string, string>) {
const d = res?.data
if (d && d.success === false) {
const code = d.error?.code ?? ''
throw new Error(
(friendly && friendly[code]) ||
d.error?.message ||
d.message ||
'操作失败'
)
}
}
// ============================================================================ // ============================================================================
// Common API Functions // Common API Functions
// ============================================================================ // ============================================================================