Files
MailUI4Agents/gateway/internal/handler/helpers.go
JianFeeeee 9ebb8dfb41 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 拒绝。
2026-09-05 00:28:05 +08:00

220 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"encoding/json"
"errors"
"io"
"net/http"
"reflect"
"strconv"
"strings"
"unicode/utf8"
"github.com/agentmail/gateway/internal/repo"
)
// JSON 写入 JSON 响应
func JSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
// Error 写入错误响应
func Error(w http.ResponseWriter, status int, msg string) {
JSON(w, status, map[string]string{"error": msg})
}
// Decode 从请求体解析 JSON
func Decode(r *http.Request, v interface{}) error {
return json.NewDecoder(r.Body).Decode(v)
}
// DecodeBody 解析请求体,失败时直接写 400 并返回 false。
//
// 与直接用 Decode 的区别是错误信息**指向具体字段**。原先 22 处调用点
// 一律回一句固定的 "Invalid JSON",客户端只知道「有问题」却不知道哪里有问题 ——
// 实测踩过一次:`workspaces` 要的是 `[{name, path}]`,传字符串数组得到的
// 就是那句固定文案,只能靠翻服务端结构体才发现。第三方客户端没有这个条件。
func DecodeBody(w http.ResponseWriter, r *http.Request, v interface{}) bool {
if err := Decode(r, v); err != nil {
Error(w, http.StatusBadRequest, decodeErrMsg(err))
return false
}
return true
}
// decodeErrMsg 把 json 解码错误翻成一句能照着改的话。
//
// 刻意不回显 json 包的原文:它带 Go 的类型名(如 models.Workspace
// 那是本侧的实现细节,对调用方没有意义,也不该出现在公开 API 的响应里。
func decodeErrMsg(err error) string {
if errors.Is(err, io.EOF) {
return "请求体为空"
}
// 截断的 JSON 走的不是 SyntaxError 而是 ErrUnexpectedEOF ——
// 不单独处理的话会落到最后那句笼统的兜底文案里
if errors.Is(err, io.ErrUnexpectedEOF) {
return "JSON 语法错误:请求体在解析完成前就结束了(可能被截断)"
}
var typeErr *json.UnmarshalTypeError
if errors.As(err, &typeErr) {
if typeErr.Field != "" {
return "字段 \"" + typeErr.Field + "\" 类型不对:期望 " +
jsonKindName(typeErr.Type) + ",收到 " + typeErr.Value
}
return "请求体类型不对:期望 " + jsonKindName(typeErr.Type) + ",收到 " + typeErr.Value
}
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
return "JSON 语法错误(第 " + strconv.FormatInt(syntaxErr.Offset, 10) + " 字节处)"
}
return "请求体不是合法 JSON"
}
// jsonKindName 把 Go 类型说成 JSON 的说法。
// 调用方写的是 JSON用 []models.Workspace 去解释它要的是什么毫无帮助。
func jsonKindName(t reflect.Type) string {
if t == nil {
return "未知类型"
}
switch t.Kind() {
case reflect.Slice, reflect.Array:
return jsonKindName(t.Elem()) + " 数组"
case reflect.Map, reflect.Struct:
return "object"
case reflect.String:
return "string"
case reflect.Bool:
return "boolean"
case reflect.Ptr:
return jsonKindName(t.Elem())
default:
if k := t.Kind(); k >= reflect.Int && k <= reflect.Float64 {
return "number"
}
return t.Kind().String()
}
}
// httpError 携带 HTTP 状态码的错误
type httpError struct {
status int
msg string
}
func (e httpError) Error() string { return e.msg }
func errBadRequest(msg string) error { return httpError{http.StatusBadRequest, msg} }
func errNotFound(msg string) error { return httpError{http.StatusNotFound, msg} }
func errConflict(msg string) error { return httpError{http.StatusConflict, msg} }
// errRateLimited 用于新建会话速率限制。用 429 而不是 403
// 前者表示「稍后再来」,后者表示「你没这个权限」——语义完全不同,
// 客户端据此决定是重试还是放弃。
func errRateLimited(msg string) error { return httpError{http.StatusTooManyRequests, msg} }
// writeKeyErr 把 repo 层的密钥错误映射成 HTTP 响应。
// 「已使用 / 已过期」与「无效」分开报,便于运维判断是重签还是查配置。
func writeKeyErr(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repo.ErrKeyUsed):
Error(w, http.StatusUnauthorized, "密钥已使用(一次性密钥只能用一次)")
case errors.Is(err, repo.ErrKeyExpired):
Error(w, http.StatusUnauthorized, "密钥已过期")
case errors.Is(err, repo.ErrKeyNotFound):
Error(w, http.StatusUnauthorized, "密钥无效")
case errors.Is(err, repo.ErrKeyTypeInvalid):
Error(w, http.StatusBadRequest, "密钥类型非法,应为 permanent / one_time / timed")
case errors.Is(err, repo.ErrKeyNeedsExpiry):
Error(w, http.StatusBadRequest, "timed 密钥必须给出正的 expires_hours")
case errors.Is(err, repo.ErrKeyTooShort):
Error(w, http.StatusBadRequest, "密钥太短(至少 32 位)")
case errors.Is(err, repo.ErrKeyTokenTaken):
Error(w, http.StatusConflict, "该密钥已登记过")
default:
if strings.Contains(err.Error(), "已退役") {
Error(w, http.StatusConflict, err.Error())
} else {
Error(w, http.StatusInternalServerError, "密钥操作失败")
}
}
}
// validateSessionAlias 校验会话别名是否可安全出现在三维地址 name@path.<alias> 的末段。
// "new" 是寻址保留字;含 . 会让 path/session 切分歧义;含 @ 与空白同理。
func validateSessionAlias(alias string) error {
if alias == "new" {
return errBadRequest(`会话别名不可为 "new":该词已作为寻址保留字`)
}
if strings.ContainsAny(alias, ". \t/@") {
return errBadRequest("会话别名不可含 . 空白 / 或 @(会与三维地址解析冲突)")
}
return nil
}
// normalizeAlias 把 Agent 平台侧的 slug/标题改写为合法的寻址别名。
//
// 平台侧命名不一定遵守本侧的寻址约束(可能含 . / @ 空白),直接入库会让
// name@path.session 切分歧义,因此非法字符统一换成 -,并压缩连续的 -。
// 保留字 "new" 加前缀避开;全部不可用时返回空串交由调用方报错。
func normalizeAlias(s string) string {
var b strings.Builder
lastDash := false
for _, r := range s {
switch {
case r == '.' || r == '/' || r == '@' || r == ' ' || r == '\t' || r == '\n' || r == '\r':
if !lastDash && b.Len() > 0 {
b.WriteByte('-')
lastDash = true
}
default:
b.WriteRune(r)
lastDash = false
}
}
out := strings.Trim(b.String(), "-")
if out == "new" {
return "session-new"
}
// VARCHAR(128) 上限,按字节截断时不能切坏多字节字符
const maxBytes = 128
if len(out) > maxBytes {
cut := out[:maxBytes]
for len(cut) > 0 && !utf8.ValidString(cut) {
cut = cut[:len(cut)-1]
}
out = strings.Trim(cut, "-")
}
return out
}
// writeErr 将 httpError 按其状态码写出,其余错误统一 500 + fallback 文案
func writeErr(w http.ResponseWriter, err error, fallback string) {
if he, ok := err.(httpError); ok {
Error(w, he.status, he.msg)
return
}
Error(w, http.StatusInternalServerError, fallback)
}
// emptySlice 把 nil slice 转为空 JSON 数组 []
func emptySlice[T any](s []T) []T {
if s == nil {
return []T{}
}
return s
}
// agentLimiterKey 把「这是不是 Agent 发起的」翻译成速率限制的键。
// 人类返回空串 = 不限速(手工操作的频率天然受限)。
func agentLimiterKey(isAgent bool, actor string) string {
if isAgent {
return actor
}
return ""
}