Agent 删除:后端 DELETE /admin/agents/{name} + 名字退役保护

## 新增端点

DELETE /api/v1/admin/agents/{name} → 清 Agent 全部运行态,保留邮件历史。

删除范围(事务原子):
- agent_keys(全部撤销,计数回传)
- agent_platform_sessions(清镜像)
- agent_allowed_models / agent_model_catalog(模型范围)
- rate_limits(速率限制计数器)
- calendar_events(置 cancelled,不触发、不空转)
- agents 行本身

保留范围(审计凭据,不删):
- mails(历史邮件)
- sessions(线索与邮件一起组成线索)

内置管理员 jianf 不可删(二次保险)。

## 名字退役保护

`IsRetiredAgentName`:agents 表里没有 + mails 表里有引用 = 已退役。

注册路径(RegisterAgent)+ 密钥登记路径(CreateAgentKey)两处都拦。
后者原来会级联建 agents 行,绕过注册检查——现在名字退役时
CreateAgentKey 也返回 409。

## 错误信息

`writeKeyErr` 加 `strings.Contains(err, "已退役")` 分支,返回 409
而不是通用的「密钥操作失败」。

## 前端

QuotaPanel:每个 Agent 行右侧加「删除」按钮,二次确认里
明确说明「邮件保留,此名不可再用」。恢复与删除共用一套
confirming 状态,通过 confirmAction 区分。

## 测试

gateway build + vet + go test 全绿(预存农历 bug 不是本轮引入)。
生产验证:remotebot 删除后数据库四张表清空、-mails 保留;
同名建密钥被 409 拦截;jianf 删除被 403 拒绝。
This commit is contained in:
2026-09-05 00:28:05 +08:00
parent 784192d8c4
commit 9ebb8dfb41
7 changed files with 209 additions and 26 deletions

View File

@ -419,6 +419,21 @@ export async function adminSetAgentStatus(agentName: string, disabled: boolean)
);
}
/**
* 彻底删除一个 Agent。保留邮件历史与会话,清除密钥、镜像、日历。
* 名字今后不可再注册(防止同名新注册冒用历史署名)。
*/
export async function adminDeleteAgent(agentName: string) {
return request<{
agent_name: string;
keys_revoked: number;
detail: string;
}>(
'DELETE',
`/admin/agents/${encodeURIComponent(agentName)}`
);
}
/** 改该 Agent 的新任务默认预算(0 = 不限)。 */
export async function adminSetDefaultRounds(agentName: string, defaultRounds: number) {
return request<{ quota: AgentStats }>(

View File

@ -14,6 +14,7 @@ export default function QuotaPanel() {
const [busy, setBusy] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [confirming, setConfirming] = useState<string | null>(null);
const [confirmAction, setConfirmAction] = useState<'toggle' | 'delete' | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const load = useCallback(async () => {
@ -48,6 +49,26 @@ export default function QuotaPanel() {
}
};
const deleteAgent = async (name: string) => {
setBusy(name);
setError(null);
setNotice(null);
try {
const result = await api.adminDeleteAgent(name);
await load();
setConfirming(null);
setConfirmAction(null);
const revoked = typeof result.keys_revoked === 'number' && result.keys_revoked > 0
? `(已撤销 ${result.keys_revoked} 把密钥)`
: '';
setNotice(`${name} 已删除${revoked}`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
}
};
const toggleStatus = async (name: string, currentDisabled: boolean) => {
setBusy(name);
setError(null);
@ -97,10 +118,8 @@ export default function QuotaPanel() {
从地址补全与联系人里隐藏,并拒绝它重新注册。
<span className="text-gray-400">邮件、会话、模型范围全部保留,随时可恢复。</span>
<br />
<span className="text-gray-400">
没有「彻底删除」:Agent 名与人类用户名共用命名空间,删掉之后若有人注册同名,
历史邮件会看起来像是他发的。
</span>
<strong className="font-medium text-gray-600">删除</strong>:在停用的基础上清掉运行态(日历事件置 cancelled)。
<span className="text-gray-400">邮件与会话保留(历史是审计凭据),此名字今后不可再注册。</span>
</p>
{error && <div className="text-[11px] text-red-600 bg-red-50 rounded px-2 py-1.5">{error}</div>}
@ -150,40 +169,52 @@ export default function QuotaPanel() {
<CheckIcon className="w-4 h-4" />
</button>
{/* 停用/恢复按钮 */}
{/* 停用/恢复/删除 按钮 */}
{isConfirming ? (
<div className="flex items-center gap-1">
<span className="text-[10px] text-gray-500">确认?</span>
<span className="text-[10px] text-gray-500">确认{confirmAction === 'delete' ? '删除' : ''}?</span>
<button
onClick={() => toggleStatus(s.agent_name, isDisabled)}
onClick={() => confirmAction === 'delete'
? deleteAgent(s.agent_name)
: toggleStatus(s.agent_name, isDisabled)}
disabled={busy === s.agent_name}
className="tap text-[10px] px-1.5 py-0.5 rounded bg-red-50 text-red-600 hover:bg-red-100"
>
{isDisabled ? '恢复' : '停用'}
{confirmAction === 'delete' ? '删除' : (isDisabled ? '恢复' : '停用')}
</button>
<button
onClick={() => setConfirming(null)}
onClick={() => { setConfirming(null); setConfirmAction(null); }}
className="tap text-[10px] text-gray-400 hover:text-gray-600"
>
取消
</button>
</div>
) : (
<button
onClick={() => setConfirming(s.agent_name)}
disabled={busy === s.agent_name}
title={isDisabled
? '恢复此 Agent(恢复为离线;密钥需重新签发)'
: '停用此 Agent(撤销全部密钥并从补全里隐藏;邮件与会话保留,可恢复)'}
className={`tap shrink-0 inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded border ${
isDisabled
? 'border-green-200 text-green-700 hover:bg-green-50'
: 'border-gray-200 text-gray-500 hover:text-red-600 hover:border-red-200'
} disabled:opacity-30`}
>
<ArchiveIcon className="w-3 h-3" />
{isDisabled ? '恢复' : '停用'}
</button>
<div className="flex items-center gap-1">
<button
onClick={() => { setConfirming(s.agent_name); setConfirmAction('toggle'); }}
disabled={busy === s.agent_name}
title={isDisabled
? '恢复此 Agent(恢复为离线;密钥需重新签发)'
: '停用此 Agent(撤销全部密钥并从补全里隐藏;邮件与会话保留,可恢复)'}
className={`tap shrink-0 inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded border ${
isDisabled
? 'border-green-200 text-green-700 hover:bg-green-50'
: 'border-gray-200 text-gray-500 hover:text-red-600 hover:border-red-200'
} disabled:opacity-30`}
>
<ArchiveIcon className="w-3 h-3" />
{isDisabled ? '恢复' : '停用'}
</button>
<button
onClick={() => { setConfirming(s.agent_name); setConfirmAction('delete'); }}
disabled={busy === s.agent_name}
title="彻底删除此 Agent(清除密钥与运行态;邮件保留但此名今后不可再用)"
className="tap shrink-0 text-[10px] px-1.5 py-0.5 rounded border border-red-200 text-red-400 hover:text-red-600 hover:border-red-300 disabled:opacity-30"
>
删除
</button>
</div>
)}
</div>
);