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:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: `{}`}
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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: () => {
|
||||||
|
|||||||
Vendored
+37
@@ -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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user