chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
176
server/internal/blob/list_test.go
Normal file
176
server/internal/blob/list_test.go
Normal file
@ -0,0 +1,176 @@
|
||||
package blob
|
||||
|
||||
// List() 的测试 —— 反向 GC 的前提。
|
||||
//
|
||||
// 事故背景:附件 GC 原先只从**库记录**出发(`WHERE mail_id IS NULL`),于是一旦
|
||||
// 记录本身消失(清库、手工 DELETE、迁移),对应文件就永远脱离了视野。本机实测
|
||||
// 磁盘 8 个 blob 里 7 个没有任何库记录,全部来自一次清库,之后一直占着盘。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestListReturnsStoredContents(t *testing.T) {
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
|
||||
sumA, _, err := s.Put(bytes.NewReader([]byte("alpha")), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("put a: %v", err)
|
||||
}
|
||||
sumB, _, err := s.Put(bytes.NewReader([]byte("beta")), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("put b: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("应列出 2 个内容,实际 %d:%v", len(got), got)
|
||||
}
|
||||
for _, sum := range []string{sumA, sumB} {
|
||||
mod, ok := got[sum]
|
||||
if !ok {
|
||||
t.Errorf("缺少 %s", sum[:8])
|
||||
continue
|
||||
}
|
||||
if mod.IsZero() {
|
||||
t.Errorf("%s 的修改时间为零值 —— GC 靠它判断「是否可能正在上传」", sum[:8])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListEmptyStore(t *testing.T) {
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
got, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("空库不该报错: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("空库应返回空,实际 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **关键用例**:`.upload-*` 临时文件绝不能进列表。
|
||||
//
|
||||
// 报给调用方会让 GC 去删一个正在写入的文件 —— 上传是「先落盘再入库」,
|
||||
// 那一瞬间的临时文件既没有库记录也不是合法 sha256 命名。
|
||||
func TestListSkipsTempUploads(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, err := New(root)
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
sum, _, err := s.Put(bytes.NewReader([]byte("real")), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
|
||||
// 模拟一个正在进行的上传
|
||||
tmp, err := os.CreateTemp(root, ".upload-*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp: %v", err)
|
||||
}
|
||||
tmp.WriteString("half written")
|
||||
tmp.Close()
|
||||
|
||||
got, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("只该列出 1 个真实内容,实际 %d:%v", len(got), got)
|
||||
}
|
||||
if _, ok := got[sum]; !ok {
|
||||
t.Errorf("真实内容 %s 应在列表里", sum[:8])
|
||||
}
|
||||
for k := range got {
|
||||
if strings.HasPrefix(k, ".upload-") {
|
||||
t.Errorf("临时文件 %q 不该出现在列表里 —— GC 会删掉一个正在写入的文件", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 非 sha256 命名的异物一律忽略(人手工丢进去的、别的程序留下的)。
|
||||
func TestListSkipsForeignFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, err := New(root)
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
if _, _, err := s.Put(bytes.NewReader([]byte("real")), 0); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
|
||||
// 放几个不合法命名的文件在两级目录里
|
||||
for _, name := range []string{"README", "ABCDEF", "notasha256"} {
|
||||
dir := filepath.Join(root, "ab", "cd")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("异物不该被列出,实际 %d:%v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// 修改时间必须是真实的文件时间 —— GC 用它跳过「可能正在上传」的文件。
|
||||
func TestListReportsRealModTime(t *testing.T) {
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
before := time.Now().Add(-time.Second)
|
||||
sum, _, err := s.Put(bytes.NewReader([]byte("timed")), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
after := time.Now().Add(time.Second)
|
||||
|
||||
got, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
mod := got[sum]
|
||||
if mod.Before(before) || mod.After(after) {
|
||||
t.Fatalf("修改时间 %v 不在 [%v, %v] 内", mod, before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// 根目录不存在时必须报错,不能返回空列表。
|
||||
//
|
||||
// 返回空会让调用方以为「库里什么都没有」—— 那个判断会传导到 GC 的计数上,
|
||||
// 让运维以为磁盘是干净的。
|
||||
func TestListFailsOnMissingRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, err := New(root)
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
if err := os.RemoveAll(root); err != nil {
|
||||
t.Fatalf("rm root: %v", err)
|
||||
}
|
||||
if _, err := s.List(); err == nil {
|
||||
t.Fatal("根目录不存在时必须报错,返回空列表会让调用方以为库是空的")
|
||||
}
|
||||
}
|
||||
191
server/internal/blob/store.go
Normal file
191
server/internal/blob/store.go
Normal file
@ -0,0 +1,191 @@
|
||||
// Package blob 提供附件文件的内容寻址存储。
|
||||
//
|
||||
// 设计取舍:文件内容存磁盘、数据库只存元数据。
|
||||
// 不把附件塞进 SQLite 的 BLOB —— 附件是「写一次读多次」的冷数据,
|
||||
// 塞进库会让 .db 膨胀、WAL 变大、备份变慢,而这些代价换不来任何好处。
|
||||
//
|
||||
// 路径由内容的 sha256 派生(ab/cdef...),因此:
|
||||
// - 相同内容天然去重,重复上传不占额外空间
|
||||
// - 路径与用户提供的 filename 完全无关,杜绝 ../ 穿越
|
||||
// - 两级目录前缀避免单目录塞进十万个文件
|
||||
package blob
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Store 是附件的磁盘存储。
|
||||
type Store struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// ErrTooLarge 表示写入的数据超过了给定上限。
|
||||
var ErrTooLarge = errors.New("attachment too large")
|
||||
|
||||
var sha256Re = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
// New 打开(必要时创建)一个位于 root 的附件库。
|
||||
func New(root string) (*Store, error) {
|
||||
if root == "" {
|
||||
return nil, errors.New("blob: root 不能为空")
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("blob: 创建 %s: %w", root, err)
|
||||
}
|
||||
return &Store{root: root}, nil
|
||||
}
|
||||
|
||||
// Root 返回存储根目录(用于日志与运维排查)。
|
||||
func (s *Store) Root() string { return s.root }
|
||||
|
||||
// pathFor 由 sha256 推出磁盘路径。
|
||||
// 调用前必须确认 sum 是合法的 64 位十六进制,否则可能被拼出库外路径。
|
||||
func (s *Store) pathFor(sum string) (string, error) {
|
||||
if !sha256Re.MatchString(sum) {
|
||||
return "", fmt.Errorf("blob: 非法的 sha256 %q", sum)
|
||||
}
|
||||
return filepath.Join(s.root, sum[:2], sum[2:4], sum), nil
|
||||
}
|
||||
|
||||
// Put 把 r 的内容写入存储,返回内容的 sha256 与字节数。
|
||||
//
|
||||
// maxBytes > 0 时超限即中止并清理临时文件(不会留下半个文件)。
|
||||
// 先写临时文件再按内容哈希 rename:写入过程中崩溃不会产生一个「哈希对不上内容」的文件。
|
||||
func (s *Store) Put(r io.Reader, maxBytes int64) (string, int64, error) {
|
||||
tmp, err := os.CreateTemp(s.root, ".upload-*")
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("blob: 创建临时文件: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
// 失败路径统一清理;成功时 rename 之后这个 Remove 是无害的 no-op
|
||||
defer func() {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
}()
|
||||
|
||||
h := sha256.New()
|
||||
src := r
|
||||
if maxBytes > 0 {
|
||||
// 多读 1 字节用于判断是否超限:LimitReader 到达上限时只会 EOF,
|
||||
// 无法区分「刚好等于上限」和「超过上限」。
|
||||
src = io.LimitReader(r, maxBytes+1)
|
||||
}
|
||||
|
||||
n, err := io.Copy(io.MultiWriter(tmp, h), src)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("blob: 写入: %w", err)
|
||||
}
|
||||
if maxBytes > 0 && n > maxBytes {
|
||||
return "", 0, ErrTooLarge
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return "", 0, fmt.Errorf("blob: sync: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", 0, fmt.Errorf("blob: close: %w", err)
|
||||
}
|
||||
|
||||
sum := hex.EncodeToString(h.Sum(nil))
|
||||
dst, err := s.pathFor(sum)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
|
||||
return "", 0, fmt.Errorf("blob: 创建目录: %w", err)
|
||||
}
|
||||
|
||||
// 已存在同内容文件:内容寻址下这就是同一个文件,直接复用
|
||||
if _, statErr := os.Stat(dst); statErr == nil {
|
||||
return sum, n, nil
|
||||
}
|
||||
if err := os.Rename(tmpName, dst); err != nil {
|
||||
return "", 0, fmt.Errorf("blob: rename: %w", err)
|
||||
}
|
||||
if err := os.Chmod(dst, 0o600); err != nil {
|
||||
return "", 0, fmt.Errorf("blob: chmod: %w", err)
|
||||
}
|
||||
return sum, n, nil
|
||||
}
|
||||
|
||||
// Open 打开某个内容的读取句柄。调用方负责 Close。
|
||||
func (s *Store) Open(sum string) (*os.File, error) {
|
||||
p, err := s.pathFor(sum)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.Open(p)
|
||||
}
|
||||
|
||||
// Exists 判断某内容是否已在库中。
|
||||
func (s *Store) Exists(sum string) bool {
|
||||
p, err := s.pathFor(sum)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, err = os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// List 枚举库中全部内容文件的 sha256 与修改时间。
|
||||
//
|
||||
// # 为什么需要它
|
||||
//
|
||||
// 附件 GC 原先只从**库记录**出发(`WHERE mail_id IS NULL`),于是一旦记录本身
|
||||
// 消失(清库、手工 DELETE、迁移),对应文件就永远脱离了视野:本机实测磁盘 8 个
|
||||
// blob 里 7 个没有任何库记录,全部来自 09-03 那次清库,之后一直占着盘。
|
||||
//
|
||||
// 反向清理必须能枚举磁盘,因此这个方法是 `repo.SweepUnreferencedBlobs` 的前提。
|
||||
//
|
||||
// 只认文件名是合法 sha256 的项:`.upload-*` 临时文件不属于内容库,
|
||||
// 把它们报给调用方会让 GC 去删一个正在写入的文件。
|
||||
func (s *Store) List() (map[string]time.Time, error) {
|
||||
out := map[string]time.Time{}
|
||||
err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// 单个子目录读不了不该让整次枚举失败 —— 但**根目录**读不了必须报:
|
||||
// 那时返回空 map 会让调用方以为「库里什么都没有」,
|
||||
// 于是把仍被引用的文件当成孤儿(这里不会删,但计数会骗人)。
|
||||
if path == s.root {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() || !sha256Re.MatchString(d.Name()) {
|
||||
return nil // .upload-* 临时文件与其他异物
|
||||
}
|
||||
info, iErr := d.Info()
|
||||
if iErr != nil {
|
||||
return nil
|
||||
}
|
||||
out[d.Name()] = info.ModTime()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("blob: 枚举 %s: %w", s.root, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Remove 删除某内容。
|
||||
//
|
||||
// 注意:内容寻址意味着多条附件记录可能指向同一个文件,
|
||||
// 因此调用方必须先确认没有其他记录引用该 sha256 才能删。
|
||||
func (s *Store) Remove(sum string) error {
|
||||
p, err := s.pathFor(sum)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
171
server/internal/blob/store_test.go
Normal file
171
server/internal/blob/store_test.go
Normal file
@ -0,0 +1,171 @@
|
||||
package blob
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestPutAndOpenRoundTrip(t *testing.T) {
|
||||
s := newStore(t)
|
||||
data := []byte("附件内容 with bytes \x00\x01")
|
||||
|
||||
sum, n, err := s.Put(bytes.NewReader(data), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != int64(len(data)) {
|
||||
t.Errorf("写入 %d 字节,报告 %d", len(data), n)
|
||||
}
|
||||
|
||||
h := sha256.Sum256(data)
|
||||
if sum != hex.EncodeToString(h[:]) {
|
||||
t.Errorf("sha256 = %s,与内容不符", sum)
|
||||
}
|
||||
|
||||
f, err := s.Open(sum)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
got, _ := io.ReadAll(f)
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Error("读回的内容与写入不一致")
|
||||
}
|
||||
}
|
||||
|
||||
// 相同内容重复上传必须复用同一个文件,不占额外空间。
|
||||
func TestPutDeduplicates(t *testing.T) {
|
||||
s := newStore(t)
|
||||
data := []byte("same content")
|
||||
|
||||
sum1, _, err := s.Put(bytes.NewReader(data), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum2, _, err := s.Put(bytes.NewReader(data), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sum1 != sum2 {
|
||||
t.Fatalf("同内容得到不同哈希: %s vs %s", sum1, sum2)
|
||||
}
|
||||
|
||||
// 目录里应当只有一个内容文件(外加两级目录)
|
||||
var files int
|
||||
filepath.Walk(s.Root(), func(_ string, info os.FileInfo, _ error) error {
|
||||
if info != nil && !info.IsDir() {
|
||||
files++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if files != 1 {
|
||||
t.Errorf("去重后应只剩 1 个文件,实际 %d", files)
|
||||
}
|
||||
}
|
||||
|
||||
// 超限必须拒绝,且不能留下半个临时文件。
|
||||
func TestPutTooLargeLeavesNoGarbage(t *testing.T) {
|
||||
s := newStore(t)
|
||||
data := bytes.Repeat([]byte("x"), 1024)
|
||||
|
||||
_, _, err := s.Put(bytes.NewReader(data), 512)
|
||||
if !errors.Is(err, ErrTooLarge) {
|
||||
t.Fatalf("期望 ErrTooLarge,得到 %v", err)
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(s.Root())
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), ".upload-") {
|
||||
t.Errorf("超限后残留临时文件 %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 恰好等于上限应当通过 —— 边界不能误杀。
|
||||
func TestPutExactlyAtLimit(t *testing.T) {
|
||||
s := newStore(t)
|
||||
data := bytes.Repeat([]byte("y"), 512)
|
||||
|
||||
if _, n, err := s.Put(bytes.NewReader(data), 512); err != nil {
|
||||
t.Fatalf("恰好等于上限被拒: %v", err)
|
||||
} else if n != 512 {
|
||||
t.Errorf("字节数 = %d,want 512", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 路径完全由 sha256 派生,任何非法 sum 都不能落到库外。
|
||||
func TestPathTraversalRejected(t *testing.T) {
|
||||
s := newStore(t)
|
||||
|
||||
for _, bad := range []string{
|
||||
"../../etc/passwd",
|
||||
"..",
|
||||
"/etc/passwd",
|
||||
"ABCDEF", // 大写非法
|
||||
strings.Repeat("g", 64), // 非十六进制
|
||||
strings.Repeat("a", 63), // 长度不足
|
||||
"",
|
||||
} {
|
||||
if _, err := s.pathFor(bad); err == nil {
|
||||
t.Errorf("pathFor(%q) 应报错", bad)
|
||||
}
|
||||
if _, err := s.Open(bad); err == nil {
|
||||
t.Errorf("Open(%q) 应报错", bad)
|
||||
}
|
||||
if s.Exists(bad) {
|
||||
t.Errorf("Exists(%q) 应为 false", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成的路径必须落在库根目录之内。
|
||||
func TestPathStaysInsideRoot(t *testing.T) {
|
||||
s := newStore(t)
|
||||
sum := strings.Repeat("ab", 32)
|
||||
|
||||
p, err := s.pathFor(sum)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rel, err := filepath.Rel(s.Root(), p)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
t.Errorf("路径逃出库根: %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemove(t *testing.T) {
|
||||
s := newStore(t)
|
||||
sum, _, err := s.Put(bytes.NewReader([]byte("z")), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.Exists(sum) {
|
||||
t.Fatal("写入后应存在")
|
||||
}
|
||||
if err := s.Remove(sum); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Exists(sum) {
|
||||
t.Error("删除后仍存在")
|
||||
}
|
||||
// 重复删除应当幂等,不报错
|
||||
if err := s.Remove(sum); err != nil {
|
||||
t.Errorf("重复删除报错: %v", err)
|
||||
}
|
||||
}
|
||||
94
server/internal/config/config.go
Normal file
94
server/internal/config/config.go
Normal file
@ -0,0 +1,94 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
DatabaseURL string
|
||||
CORSOrigins []string
|
||||
|
||||
// 首次启动时创建的默认管理员
|
||||
AdminUser string
|
||||
AdminPassword string
|
||||
|
||||
// Cookie 是否要求 HTTPS(生产环境置 true)
|
||||
SecureCookie bool
|
||||
CookieName string
|
||||
|
||||
// 附件:文件内容存盘,默认与 SQLite 同目录下的 attachments/
|
||||
AttachmentDir string
|
||||
// 单个附件上限(字节)。默认 25MB,与常见邮箱附件限额一致
|
||||
MaxAttachmentBytes int64
|
||||
}
|
||||
|
||||
var C *Config
|
||||
|
||||
func Load() *Config {
|
||||
C = &Config{
|
||||
Port: getEnv("PORT", "8180"),
|
||||
// 空值 = 用内置 SQLite(data/agentmail.db,可用 AGENTMAIL_DATA_DIR 改目录)。
|
||||
// 想接外部库就给 postgres://…;也接受 sqlite:///path/x.db 与裸路径。
|
||||
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||
CORSOrigins: splitEnv("CORS_ORIGINS", []string{
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
}),
|
||||
AdminUser: strings.ToLower(getEnv("ADMIN_USER", "admin")),
|
||||
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
|
||||
SecureCookie: getEnvBool("SECURE_COOKIE", false),
|
||||
CookieName: getEnv("COOKIE_NAME", "am_session"),
|
||||
|
||||
AttachmentDir: getEnv("AGENTMAIL_ATTACHMENT_DIR",
|
||||
filepath.Join(getEnv("AGENTMAIL_DATA_DIR", "data"), "attachments")),
|
||||
MaxAttachmentBytes: getEnvInt64("AGENTMAIL_MAX_ATTACHMENT_BYTES", 25<<20),
|
||||
}
|
||||
return C
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvBool(key string, fallback bool) bool {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvInt64(key string, fallback int64) int64 {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func splitEnv(key string, fallback []string) []string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if s := strings.TrimSpace(p); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return fallback
|
||||
}
|
||||
return out
|
||||
}
|
||||
226
server/internal/db/db.go
Normal file
226
server/internal/db/db.go
Normal file
@ -0,0 +1,226 @@
|
||||
// Package db 提供数据库连接与方言适配。
|
||||
//
|
||||
// AgentMail 默认用 SQLite(零依赖、单文件,配合 go:embed 的前端就是「一个二进制 + 一个 .db」),
|
||||
// 用户显式给出 DATABASE_URL 时切换到外部 PostgreSQL。
|
||||
//
|
||||
// 两种方言的差异集中在本包处理,repo 层只写一份 SQL:
|
||||
// - 占位符:SQLite 也支持 $1/$2,无需改写
|
||||
// - NOW() / gen_random_uuid():SQLite 侧注册同名函数补齐
|
||||
// - JSONB 包含判断:走 CCHas/CCArg 辅助函数(唯一必须分支的查询)
|
||||
// - 唯一约束冲突:IsUniqueViolation 统一识别
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
_ "github.com/jackc/pgx/v5/stdlib" // database/sql 驱动:pgx
|
||||
sqlite "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Dialect string
|
||||
|
||||
const (
|
||||
Postgres Dialect = "postgres"
|
||||
SQLite Dialect = "sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
DB *sql.DB
|
||||
// D 是当前生效的方言,repo 层据此选择 SQL 片段
|
||||
D Dialect
|
||||
)
|
||||
|
||||
func init() {
|
||||
// SQLite 没有 NOW() 与 gen_random_uuid(),注册同名函数使 repo 层 SQL 与 PG 保持一致。
|
||||
// 函数名在 SQLite 中大小写不敏感,注册小写即可匹配 SQL 里的 NOW()。
|
||||
sqlite.MustRegisterDeterministicScalarFunction("gen_random_uuid", 0,
|
||||
func(*sqlite.FunctionContext, []driver.Value) (driver.Value, error) {
|
||||
return uuid.NewString(), nil
|
||||
})
|
||||
|
||||
// NOW() 必须非确定性:同一语句内多次调用要各自取当前时刻。
|
||||
//
|
||||
// 精度到微秒而不是秒:SQLite 的 CURRENT_TIMESTAMP 只有秒,
|
||||
// 同一秒内插入的多封邮件排序就不确定 —— 「会话里最早那封」(决定联系人身份)
|
||||
// 与「最后那封」(决定最新进展)都会取错行。实测同秒插 5 封,
|
||||
// 按 created_at 排出来的顺序是乱的(由随机 UUID 决定)。
|
||||
//
|
||||
// 毫秒还不够:一次插入只要几十到几百微秒,循环里连插几封会落在同一毫秒。
|
||||
// 微秒是实测确认驱动能原样扫回 time.Time 的精度(纳秒也行,但没必要)。
|
||||
//
|
||||
// 格式仍是 SQLite 认得的 "YYYY-MM-DD HH:MM:SS.ffffff",因此:
|
||||
// - 驱动能扫进 time.Time(列声明为 DATETIME 时)
|
||||
// - 与老数据(秒精度)的文本比较依然正确:前缀相同时短的排前面,
|
||||
// 而 ":31" 确实早于 ":31.767000"
|
||||
sqlite.MustRegisterScalarFunction("now", 0,
|
||||
func(*sqlite.FunctionContext, []driver.Value) (driver.Value, error) {
|
||||
return time.Now().UTC().Format("2006-01-02 15:04:05.000000"), nil
|
||||
})
|
||||
}
|
||||
|
||||
// Connect 依据 DATABASE_URL 建立连接。空值时落到 SQLite。
|
||||
func Connect(ctx context.Context, dsn string) error {
|
||||
driverName, connStr, dialect, err := resolve(dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pool, err := sql.Open(driverName, connStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", dialect, err)
|
||||
}
|
||||
|
||||
switch dialect {
|
||||
case Postgres:
|
||||
pool.SetMaxOpenConns(20)
|
||||
pool.SetMaxIdleConns(4)
|
||||
pool.SetConnMaxLifetime(30 * time.Minute)
|
||||
pool.SetConnMaxIdleTime(5 * time.Minute)
|
||||
case SQLite:
|
||||
// SQLite 单写者:并发写靠 WAL + busy_timeout 排队,连接数放大只会加剧锁竞争。
|
||||
pool.SetMaxOpenConns(1)
|
||||
pool.SetMaxIdleConns(1)
|
||||
pool.SetConnMaxLifetime(0)
|
||||
}
|
||||
|
||||
if err := pool.PingContext(ctx); err != nil {
|
||||
pool.Close()
|
||||
return fmt.Errorf("ping %s: %w", dialect, err)
|
||||
}
|
||||
|
||||
DB = pool
|
||||
D = dialect
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolve 把 DATABASE_URL 解析为 (驱动名, 连接串, 方言)。
|
||||
func resolve(dsn string) (string, string, Dialect, error) {
|
||||
dsn = strings.TrimSpace(dsn)
|
||||
|
||||
if dsn == "" {
|
||||
return "sqlite", sqliteDSN(defaultDBPath()), SQLite, nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(dsn, "postgres://"), strings.HasPrefix(dsn, "postgresql://"):
|
||||
return "pgx", dsn, Postgres, nil
|
||||
|
||||
case strings.HasPrefix(dsn, "sqlite://"):
|
||||
return "sqlite", sqliteDSN(strings.TrimPrefix(dsn, "sqlite://")), SQLite, nil
|
||||
|
||||
case strings.HasPrefix(dsn, "sqlite:"):
|
||||
return "sqlite", sqliteDSN(strings.TrimPrefix(dsn, "sqlite:")), SQLite, nil
|
||||
|
||||
case strings.HasPrefix(dsn, "file:"):
|
||||
// 已是 SQLite URI,原样透传(调用方自带 pragma)
|
||||
return "sqlite", dsn, SQLite, nil
|
||||
|
||||
case strings.HasSuffix(dsn, ".db"), strings.HasSuffix(dsn, ".sqlite"), strings.HasSuffix(dsn, ".sqlite3"):
|
||||
return "sqlite", sqliteDSN(dsn), SQLite, nil
|
||||
}
|
||||
|
||||
return "", "", "", fmt.Errorf("无法识别的 DATABASE_URL %q:期望 postgres://…、sqlite:///path/x.db 或 /path/x.db", dsn)
|
||||
}
|
||||
|
||||
// defaultDBPath 返回默认 SQLite 文件位置(AGENTMAIL_DATA_DIR 可覆盖)。
|
||||
func defaultDBPath() string {
|
||||
dir := os.Getenv("AGENTMAIL_DATA_DIR")
|
||||
if dir == "" {
|
||||
dir = "data"
|
||||
}
|
||||
return filepath.Join(dir, "agentmail.db")
|
||||
}
|
||||
|
||||
// sqliteDSN 把文件路径包装为带 pragma 的 SQLite URI,并确保父目录存在。
|
||||
//
|
||||
// - journal_mode=WAL:读写不互斥,SSE 长连接查询不会被写入阻塞
|
||||
// - busy_timeout=5000:并发写时排队 5s 而不是立刻 SQLITE_BUSY
|
||||
// - foreign_keys=1:SQLite 默认不校验外键,必须显式打开
|
||||
func sqliteDSN(path string) string {
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
os.MkdirAll(dir, 0o755)
|
||||
}
|
||||
return "file:" + path +
|
||||
"?_pragma=journal_mode(WAL)" +
|
||||
"&_pragma=busy_timeout(5000)" +
|
||||
"&_pragma=foreign_keys(1)" +
|
||||
// _time_format / _timezone 决定 time.Time 参数怎么落成文本。
|
||||
//
|
||||
// 驱动的**默认行为是 Go 的 t.String()**,写出来是
|
||||
// 2026-09-10 15:10:36.122781994 +0800 HKT m=+607182.882153215
|
||||
// SQLite 的 datetime() 解析不了这种串(返回 NULL),而它跟我们注册的
|
||||
// NOW()("2006-01-02 15:04:05.000000" UTC)做的是**字符串**比较。
|
||||
// 后果实测有两条,都不是「显示不好看」级别的:
|
||||
// 1. 安全:`expires_at > NOW()` 比较两种格式且时区不同(+0800 vs UTC),
|
||||
// 用户会话永不过期,`DELETE ... WHERE expires_at < NOW()` 删 0 行。
|
||||
// 2. 功能:日历 `datetime(event_time,'-N minutes') <= NOW()` 恒为假,
|
||||
// 一条提醒都发不出去。
|
||||
//
|
||||
// _time_format=sqlite 给 "2006-01-02 15:04:05.999999999-07:00"(驱动
|
||||
// parseTimeFormats[0],读回时原样认得);_timezone=UTC 让偏移固定为
|
||||
// +00:00,与 NOW() 同一时间轴。
|
||||
//
|
||||
// 不用 _time_format=datetime("2006-01-02 15:04:05"):它把亚秒截断,
|
||||
// 会重新引入同秒插入多封邮件排序不确定的老问题 —— 「会话里最早那封」
|
||||
// (决定联系人身份)与「最后那封」(决定最新进展)都会取错行。
|
||||
"&_time_format=sqlite" +
|
||||
"&_timezone=UTC"
|
||||
}
|
||||
|
||||
func Close() {
|
||||
if DB != nil {
|
||||
DB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 方言差异 ----------
|
||||
|
||||
// CCHas 返回「cc_list 是否抄送了某人」的 SQL 片段,argN 是该人名对应的占位符序号。
|
||||
//
|
||||
// 两个方言的实参都是【纯人名字符串】,不是 JSON 探针——因为多处查询把同一个
|
||||
// 占位符同时用于 from_name/to_name 比较和抄送判断,两种实参约定必然出错。
|
||||
// PG 侧在 SQL 里用 jsonb_build_* 现场构造探针;SQLite 侧用 json_each 展开逐项比对。
|
||||
func CCHas(col string, argN int) string {
|
||||
if D == Postgres {
|
||||
return fmt.Sprintf("%s @> jsonb_build_array(jsonb_build_object('name', $%d::text))", col, argN)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"EXISTS (SELECT 1 FROM json_each(%s) WHERE json_extract(value, '$.name') = $%d)",
|
||||
col, argN)
|
||||
}
|
||||
|
||||
// JSONCast 返回把占位符转成 JSONB 的后缀(PG 需要 ::jsonb,SQLite 存 TEXT 无需转换)。
|
||||
func JSONCast() string {
|
||||
if D == Postgres {
|
||||
return "::jsonb"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsUniqueViolation 判断错误是否为唯一约束冲突(用于别名撞名重试)。
|
||||
func IsUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
return pgErr.Code == "23505"
|
||||
}
|
||||
var liteErr *sqlite.Error
|
||||
if errors.As(err, &liteErr) {
|
||||
// SQLITE_CONSTRAINT_UNIQUE = 2067、SQLITE_CONSTRAINT_PRIMARYKEY = 1555
|
||||
code := liteErr.Code()
|
||||
return code == 2067 || code == 1555
|
||||
}
|
||||
return false
|
||||
}
|
||||
169
server/internal/db/migrate.go
Normal file
169
server/internal/db/migrate.go
Normal file
@ -0,0 +1,169 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed migrations/init.sql
|
||||
var initSQLPostgres string
|
||||
|
||||
//go:embed migrations/init_sqlite.sql
|
||||
var initSQLSQLite string
|
||||
|
||||
// Migrate 建表建索引。两种方言各有一份 schema,语义保持一致。
|
||||
func Migrate(ctx context.Context) error {
|
||||
switch D {
|
||||
case Postgres:
|
||||
// PG 侧含 DO $$ … $$ 迁移块,必须整体提交
|
||||
if _, err := DB.ExecContext(ctx, initSQLPostgres); err != nil {
|
||||
return fmt.Errorf("migrate postgres: %w", err)
|
||||
}
|
||||
case SQLite:
|
||||
// modernc.org/sqlite 的 Exec 不接受多语句,逐条执行
|
||||
for i, stmt := range splitStatements(initSQLSQLite) {
|
||||
if _, err := DB.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("migrate sqlite (语句 #%d: %.60s): %w", i+1, stmt, err)
|
||||
}
|
||||
}
|
||||
// CREATE TABLE IF NOT EXISTS 不会给**已存在**的表补列,而 SQLite 又没有
|
||||
// ADD COLUMN IF NOT EXISTS。已部署的库靠这一步补齐新列。
|
||||
if err := addMissingColumns(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("migrate: 未初始化的方言")
|
||||
}
|
||||
|
||||
fmt.Printf("数据库迁移完成(%s)\n", D)
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitStatements 按分号切分 SQL 脚本并剔除注释行。
|
||||
// 本项目的 SQLite schema 只有 CREATE 语句,不含字符串字面量里的分号,
|
||||
// 因此按分号朴素切分是安全的;若将来加入含分号的字面量需改用真正的词法切分。
|
||||
func splitStatements(script string) []string {
|
||||
var out []string
|
||||
for _, raw := range strings.Split(script, ";") {
|
||||
var lines []string
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
if t := strings.TrimSpace(line); t == "" || strings.HasPrefix(t, "--") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if stmt := strings.TrimSpace(strings.Join(lines, "\n")); stmt != "" {
|
||||
out = append(out, stmt)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sqliteAddColumns 声明 SQLite 侧需要在已存在的表上补齐的列。
|
||||
//
|
||||
// 新库由 init_sqlite.sql 的 CREATE TABLE 一次建全,这里只服务**已部署的库**。
|
||||
// PG 侧用 ALTER TABLE ... ADD COLUMN IF NOT EXISTS 就够,SQLite 没有这个语法,
|
||||
// 只能先查 pragma 再决定加不加。
|
||||
//
|
||||
// 新增列时同时改两处:init_sqlite.sql 的 CREATE TABLE(给新库)与这张表(给老库)。
|
||||
var sqliteAddColumns = []struct{ table, column, ddl string }{
|
||||
{"mails", "rename_alias", "ALTER TABLE mails ADD COLUMN rename_alias TEXT"},
|
||||
{"mails", "rename_reason", "ALTER TABLE mails ADD COLUMN rename_reason TEXT"},
|
||||
{"sessions", "rename_dismissed", "ALTER TABLE sessions ADD COLUMN rename_dismissed TEXT"},
|
||||
{"sessions", "alias_source", "ALTER TABLE sessions ADD COLUMN alias_source TEXT NOT NULL DEFAULT 'platform'"},
|
||||
// 会话级往返预算(0 = 不限)。旧库默认 0:引入预算不应该把已在进行的会话卡死。
|
||||
{"sessions", "max_rounds", "ALTER TABLE sessions ADD COLUMN max_rounds INTEGER NOT NULL DEFAULT 0"},
|
||||
{"sessions", "used_rounds", "ALTER TABLE sessions ADD COLUMN used_rounds INTEGER NOT NULL DEFAULT 0"},
|
||||
// 会话所属的工作目录。旧库默认空串:历史会话的 workspace 无法可靠反推
|
||||
// (Agent 回信的 from_workspace 存的是 Agent 名而不是路径),强行回填只会
|
||||
// 造出一批看起来有值实际是错的数据。
|
||||
{"sessions", "workspace", "ALTER TABLE sessions ADD COLUMN workspace TEXT NOT NULL DEFAULT ''"},
|
||||
// 日历多收件人。旧库默认 '[]':读的时候由 EffectiveRecipients() 退回
|
||||
// to_address / agent_name,历史事件因此继续工作,不需要数据迁移。
|
||||
{"calendar_events", "recipients", "ALTER TABLE calendar_events ADD COLUMN recipients TEXT NOT NULL DEFAULT '[]'"},
|
||||
{"calendar_events", "delivery_mode", "ALTER TABLE calendar_events ADD COLUMN delivery_mode TEXT NOT NULL DEFAULT 'separate'"},
|
||||
// 日历事件已触发的 occurrence。旧库为 NULL:等价于「从未触发」,
|
||||
// 于是已过期的一次性事件会补发一次提醒 —— 这是可接受的,
|
||||
// 而反过来(默认成 event_time)会让正在等的提醒永远发不出去。
|
||||
{"calendar_events", "fired_for", "ALTER TABLE calendar_events ADD COLUMN fired_for DATETIME"},
|
||||
// 日历事件的权限档位(plan / workspace / full)。事件触发时若新建会话,
|
||||
// 用这一列定死档位;复用已有会话则取「会话现档 与 事件档」中更严那个。
|
||||
//
|
||||
// 旧库默认 'workspace':历史事件补发提醒不该静默升到 full(提权路径
|
||||
// 会被 P1 calendar 投递接线堵住,但这里默认值也得守住)。
|
||||
// 与 sessions.permission_mode 的默认取向一致。
|
||||
{"calendar_events", "permission_mode", "ALTER TABLE calendar_events ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'workspace'"},
|
||||
// 本侧会话接管的平台会话 id。旧库默认空串 = 「不是接管来的」,
|
||||
// 与新建会话的语义一致,不需要数据迁移。
|
||||
{"sessions", "platform_id", "ALTER TABLE sessions ADD COLUMN platform_id TEXT NOT NULL DEFAULT ''"},
|
||||
// 派给该 Agent 的新任务默认多少个来回。
|
||||
// 旧库也给 20:之前的 max_rounds 默认是 10 但那是终身额度,语义不同,
|
||||
// 不能直接搬过来当单任务预算。
|
||||
{"agents", "default_rounds", "ALTER TABLE agents ADD COLUMN default_rounds INTEGER NOT NULL DEFAULT 20"},
|
||||
// 会话级权限档位(plan / workspace / full)。
|
||||
//
|
||||
// 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」,
|
||||
// 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权,
|
||||
// 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。
|
||||
{"sessions", "permission_mode", "ALTER TABLE sessions ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'workspace'"},
|
||||
// 接收平台实际做到的强制力(native / advisory),由插件心跳自报后落到会话上。
|
||||
//
|
||||
// 旧库默认 'advisory':没自报过的插件,我们不能替它宣称「档位在这里是被强制的」。
|
||||
// 保守方向是承认做不到,而不是假装做到了。
|
||||
{"sessions", "permission_enforcement", "ALTER TABLE sessions ADD COLUMN permission_enforcement TEXT NOT NULL DEFAULT 'advisory'"},
|
||||
// Agent 自报的档位强制能力(native / advisory),随心跳更新。
|
||||
// 与 sessions.permission_enforcement 的区别:这里是平台的能力,那里是
|
||||
// 某条会话建立时的事实快照 —— 插件升级后能力会变,已结束的会话不该被改写。
|
||||
{"agents", "mode_enforcement", "ALTER TABLE agents ADD COLUMN mode_enforcement TEXT NOT NULL DEFAULT 'advisory'"},
|
||||
// 待决请求类型(permission/question)与多选语义,供 ask_user_question 桥接使用。
|
||||
// 旧库默认 'permission'/0:历史请求是危险工具审批,语义不变。
|
||||
{"permission_requests", "kind", "ALTER TABLE permission_requests ADD COLUMN kind TEXT NOT NULL DEFAULT 'permission'"},
|
||||
{"permission_requests", "multi_select", "ALTER TABLE permission_requests ADD COLUMN multi_select INTEGER NOT NULL DEFAULT 0"},
|
||||
// 邮件上的请求类型与多选标记(与 permission_requests 表一致)。
|
||||
// 旧库默认 ''/0:历史权限邮件按单选审批渲染。
|
||||
{"mails", "permission_kind", "ALTER TABLE mails ADD COLUMN permission_kind TEXT NOT NULL DEFAULT ''"},
|
||||
{"mails", "permission_multi_select", "ALTER TABLE mails ADD COLUMN permission_multi_select INTEGER NOT NULL DEFAULT 0"},
|
||||
}
|
||||
|
||||
// sqliteAddIndexes 是建表后才能建的索引(依赖上面补的列)。
|
||||
// CREATE INDEX IF NOT EXISTS 天然幂等,直接执行即可。
|
||||
var sqliteAddIndexes = []string{
|
||||
// 接管平台会话时按 platform_id 反查(依赖上面补的列)
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_platform ON sessions(platform_id) WHERE platform_id <> ''",
|
||||
// 人类决策后要按 mail_id 反查上游 permission id
|
||||
"CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id)",
|
||||
}
|
||||
|
||||
func addMissingColumns(ctx context.Context) error {
|
||||
for _, c := range sqliteAddColumns {
|
||||
has, err := columnExists(ctx, c.table, c.column)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate sqlite: 检查 %s.%s: %w", c.table, c.column, err)
|
||||
}
|
||||
if has {
|
||||
continue
|
||||
}
|
||||
if _, err := DB.ExecContext(ctx, c.ddl); err != nil {
|
||||
return fmt.Errorf("migrate sqlite: 补列 %s.%s: %w", c.table, c.column, err)
|
||||
}
|
||||
fmt.Printf("补列 %s.%s\n", c.table, c.column)
|
||||
}
|
||||
for _, ddl := range sqliteAddIndexes {
|
||||
if _, err := DB.ExecContext(ctx, ddl); err != nil {
|
||||
return fmt.Errorf("migrate sqlite: 建索引 %.60s: %w", ddl, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func columnExists(ctx context.Context, table, column string) (bool, error) {
|
||||
// pragma_table_info 是表函数形式的 PRAGMA,可以直接当表查(比解析 PRAGMA 输出干净)。
|
||||
// table 与 column 都来自上面的硬编码常量表,不存在注入面。
|
||||
var n int
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`,
|
||||
table, column).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
440
server/internal/db/migrations/init.sql
Normal file
440
server/internal/db/migrations/init.sql
Normal file
@ -0,0 +1,440 @@
|
||||
-- AgentMail MVP Schema
|
||||
-- PostgreSQL 14+
|
||||
|
||||
-- Users table(人类多用户;username 与 agents.agent_name 共用命名空间)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
display_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(16) NOT NULL DEFAULT 'user',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_login TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
token VARCHAR(64) PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
user_agent VARCHAR(256) DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_exp ON user_sessions(expires_at);
|
||||
|
||||
-- 用户权限边界:可调用的 Agent 与可访问的工作区目录
|
||||
-- allowed_agents:["deepseekharness","pi"],空数组 = 不限(继承系统默认)
|
||||
-- allowed_paths :["/program","/home/x"],空数组 = 不限;按前缀匹配
|
||||
-- agent_aliases :{"大龙":"deepseekharness","小派":"pi"},发信时自动解析真实名
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS allowed_agents JSONB NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS allowed_paths JSONB NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS agent_aliases JSONB NOT NULL DEFAULT '{}';
|
||||
|
||||
-- Agents table
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agent_name VARCHAR(64) NOT NULL UNIQUE,
|
||||
secret VARCHAR(128) NOT NULL,
|
||||
host_url VARCHAR(256) NOT NULL DEFAULT '',
|
||||
workspaces JSONB NOT NULL DEFAULT '[]',
|
||||
platform VARCHAR(32) NOT NULL DEFAULT 'pi',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'offline',
|
||||
max_rounds INT NOT NULL DEFAULT 10,
|
||||
used_rounds INT NOT NULL DEFAULT 0,
|
||||
last_seen TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Sessions table
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_alias VARCHAR(128),
|
||||
-- 这条会话所属的工作目录(见 init_sqlite.sql 里的设计说明)
|
||||
workspace VARCHAR(512) NOT NULL DEFAULT '',
|
||||
from_agent VARCHAR(64) NOT NULL,
|
||||
subject VARCHAR(512) NOT NULL,
|
||||
-- 接管的平台侧会话 id(见 init_sqlite.sql 的说明)
|
||||
platform_id VARCHAR(256) NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
owner_user_id UUID REFERENCES users(user_id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(session_alias);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
||||
|
||||
-- 会话别名负责寻址(name@path.<alias>),必须全局唯一。
|
||||
-- 部分唯一索引:未命名会话(NULL)不受约束,可以有任意多个。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_alias_uniq
|
||||
ON sessions(session_alias) WHERE session_alias IS NOT NULL;
|
||||
|
||||
-- 已存在的库补列(必须先于依赖该列的索引)
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS owner_user_id UUID REFERENCES users(user_id);
|
||||
|
||||
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS rename_dismissed TEXT;
|
||||
|
||||
-- 别名是谁定的:'platform'(Agent 平台自动同步,可被后续同步覆盖)
|
||||
-- 或 'manual'(人显式指定,平台同步不得覆盖)。
|
||||
-- 没有这个标记,平台的下一次 session.updated 会把人刚接受的名字冲掉。
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS alias_source TEXT NOT NULL DEFAULT 'platform';
|
||||
|
||||
-- 本次任务的往返预算(0 = 本会话不限,仅受 Agent 全局配额约束)。
|
||||
--
|
||||
-- 配额的真实语义是「这件事值得多少个来回」,那是任务的属性而不是 Agent 的属性:
|
||||
-- 只有 agents.max_rounds 一个全局计数器时,两个并行任务会互相抢额度,
|
||||
-- 且 used_rounds 单调递增,一旦跑满就得管理员手工重置才能再干活。
|
||||
-- 因此预算下沉到会话,由人在写信时给、在对话页里随时调。
|
||||
--
|
||||
-- Agent 全局配额仍然生效(两者都要过):否则 Agent 自己 .new 开一串会话,
|
||||
-- 每条都是全新预算,全局上限就形同虚设。
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS max_rounds INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS used_rounds INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- 会话级权限档位(plan / workspace / full)。
|
||||
--
|
||||
-- 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」,
|
||||
-- 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权,
|
||||
-- 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS permission_mode TEXT NOT NULL DEFAULT 'workspace';
|
||||
|
||||
-- 接收平台实际做到的强制力(native / advisory),由插件心跳自报后落到会话上。
|
||||
-- 旧库默认 'advisory':没自报过的插件,不能替它宣称「档位在这里是被强制的」。
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS permission_enforcement TEXT NOT NULL DEFAULT 'advisory';
|
||||
|
||||
-- Agent 自报的档位强制能力(native / advisory),随心跳更新。
|
||||
-- 与 sessions.permission_enforcement 的区别:这里是平台当下的能力,
|
||||
-- 那里是某条会话建立时的事实快照 —— 插件升级后能力会变,已结束的会话不该被改写。
|
||||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS mode_enforcement TEXT NOT NULL DEFAULT 'advisory';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id);
|
||||
|
||||
-- Mails table
|
||||
CREATE TABLE IF NOT EXISTS mails (
|
||||
mail_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES sessions(session_id),
|
||||
parent_mail_id UUID REFERENCES mails(mail_id),
|
||||
|
||||
from_name VARCHAR(64) NOT NULL,
|
||||
from_workspace VARCHAR(128) DEFAULT '',
|
||||
to_name VARCHAR(64) NOT NULL,
|
||||
to_workspace VARCHAR(128) DEFAULT '',
|
||||
|
||||
subject VARCHAR(512) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
|
||||
-- 拄送列表:[{"name":"pi","path":"root","session":"new","raw":"pi@root.new"}]
|
||||
cc_list JSONB NOT NULL DEFAULT '[]',
|
||||
|
||||
mail_type VARCHAR(32) NOT NULL DEFAULT 'normal',
|
||||
permission_options JSONB,
|
||||
permission_result VARCHAR(32),
|
||||
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'unread',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
hop_limit INT DEFAULT 5
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_session ON mails(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_to ON mails(to_name, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_parent ON mails(parent_mail_id);
|
||||
|
||||
-- 已存在的库补列(重复运行安全)
|
||||
ALTER TABLE mails ADD COLUMN IF NOT EXISTS cc_list JSONB NOT NULL DEFAULT '[]';
|
||||
|
||||
-- default_rounds 是【派给这个 Agent 的新任务】默认有多少个来回。
|
||||
-- 配额是任务的属性,真正的约束在 sessions.max_rounds 上;这里只提供默认值。
|
||||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS default_rounds INTEGER NOT NULL DEFAULT 20;
|
||||
|
||||
-- Agent 在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
|
||||
-- 存在邮件上而非会话上:邮件是不可篡改的历史记录,「谁在哪一封里提了什么」应当留痕。
|
||||
ALTER TABLE mails ADD COLUMN IF NOT EXISTS rename_alias TEXT;
|
||||
ALTER TABLE mails ADD COLUMN IF NOT EXISTS rename_reason TEXT;
|
||||
|
||||
-- 抄送检索:cc_list @> '[{"name":"pi"}]' 走 GIN
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_cc_list ON mails USING GIN (cc_list jsonb_path_ops);
|
||||
|
||||
-- 历史数据规整:早期写入过首字母大写的键(Raw/Name/Path/Session),统一成小写
|
||||
UPDATE mails
|
||||
SET cc_list = (
|
||||
SELECT COALESCE(jsonb_agg(
|
||||
jsonb_strip_nulls(jsonb_build_object(
|
||||
'name', COALESCE(e->>'name', e->>'Name'),
|
||||
'path', COALESCE(e->>'path', e->>'Path'),
|
||||
'session', COALESCE(e->>'session', e->>'Session'),
|
||||
'raw', COALESCE(e->>'raw', e->>'Raw')
|
||||
))
|
||||
), '[]'::jsonb)
|
||||
FROM jsonb_array_elements(cc_list) AS e
|
||||
)
|
||||
WHERE cc_list @? '$[*].Name';
|
||||
|
||||
-- Permission requests table
|
||||
CREATE TABLE IF NOT EXISTS permission_requests (
|
||||
request_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mail_id UUID NOT NULL REFERENCES mails(mail_id),
|
||||
session_id UUID NOT NULL REFERENCES sessions(session_id),
|
||||
agent_name VARCHAR(64) NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
options JSONB NOT NULL DEFAULT '["同意", "拒绝"]',
|
||||
context TEXT DEFAULT '',
|
||||
-- permission = 危险操作审批;question = Agent 主动补充询问。
|
||||
kind VARCHAR(16) NOT NULL DEFAULT 'permission',
|
||||
-- ask_user_question 的多选语义;普通权限审批恒为 false。
|
||||
multi_select BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
result TEXT,
|
||||
decided_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_perm_agent ON permission_requests(agent_name, result);
|
||||
CREATE INDEX IF NOT EXISTS idx_perm_pending ON permission_requests(result) WHERE result IS NULL;
|
||||
|
||||
-- 已部署库补齐主动询问元数据,并解除旧 result VARCHAR(32) 对长文本回答的限制。
|
||||
ALTER TABLE permission_requests ADD COLUMN IF NOT EXISTS kind VARCHAR(16) NOT NULL DEFAULT 'permission';
|
||||
ALTER TABLE permission_requests ADD COLUMN IF NOT EXISTS multi_select BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE permission_requests ALTER COLUMN result TYPE TEXT;
|
||||
|
||||
-- 历史邮件里的字面量 'human' 迁移到默认管理员账号
|
||||
-- (管理员由 Go 侧 EnsureAdminUser 首启创建,此处只做数据重写)
|
||||
DO $$
|
||||
DECLARE
|
||||
admin_name TEXT;
|
||||
BEGIN
|
||||
SELECT username INTO admin_name
|
||||
FROM users WHERE role = 'admin' AND status = 'active'
|
||||
ORDER BY created_at ASC LIMIT 1;
|
||||
|
||||
IF admin_name IS NULL THEN
|
||||
RETURN; -- 还没有管理员,下次迁移再试
|
||||
END IF;
|
||||
|
||||
UPDATE mails SET from_name = admin_name WHERE from_name = 'human';
|
||||
UPDATE mails SET to_name = admin_name WHERE to_name = 'human';
|
||||
UPDATE sessions SET from_agent = admin_name WHERE from_agent = 'human';
|
||||
|
||||
-- 拄送列表里的 human 一并重写
|
||||
UPDATE mails
|
||||
SET cc_list = (
|
||||
SELECT COALESCE(jsonb_agg(
|
||||
CASE WHEN e->>'name' = 'human'
|
||||
THEN jsonb_set(
|
||||
jsonb_set(e, '{name}', to_jsonb(admin_name)),
|
||||
'{raw}',
|
||||
to_jsonb(admin_name || '@' || COALESCE(e->>'path','') ||
|
||||
CASE WHEN COALESCE(e->>'session','') = '' THEN ''
|
||||
ELSE '.' || (e->>'session') END))
|
||||
ELSE e END
|
||||
), '[]'::jsonb)
|
||||
FROM jsonb_array_elements(cc_list) AS e
|
||||
)
|
||||
WHERE cc_list @> '[{"name":"human"}]';
|
||||
|
||||
-- 人类发起的会话补上 owner
|
||||
UPDATE sessions s
|
||||
SET owner_user_id = u.user_id
|
||||
FROM users u
|
||||
WHERE u.username = admin_name
|
||||
AND s.owner_user_id IS NULL
|
||||
AND s.from_agent = admin_name;
|
||||
END $$;
|
||||
|
||||
-- ---------- 密钥认证体系 ----------
|
||||
--
|
||||
-- 两类密钥,共享一个全局唯一的 token 命名空间(验证时先查 agent_keys 再查 user_keys):
|
||||
-- agent_keys:管理员签发,用于 Agent 注册/心跳/SSE
|
||||
-- user_keys :用户自助签发,仅用于 /me/* 人类邮箱接口,不可注册 Agent
|
||||
--
|
||||
-- key_type:
|
||||
-- permanent — 永不过期,可重复使用
|
||||
-- one_time — 首次验证后写 used_at,再用即拒
|
||||
-- timed — expires_at 之后失效
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_keys (
|
||||
key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key_token VARCHAR(128) NOT NULL UNIQUE,
|
||||
agent_name VARCHAR(64), -- NULL = 待绑定
|
||||
key_type VARCHAR(16) NOT NULL DEFAULT 'permanent',
|
||||
label VARCHAR(128) NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_by UUID REFERENCES users(user_id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_keys_token ON agent_keys(key_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_keys_agent ON agent_keys(agent_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_keys (
|
||||
key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key_token VARCHAR(128) NOT NULL UNIQUE,
|
||||
user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
label VARCHAR(128) NOT NULL DEFAULT '',
|
||||
key_type VARCHAR(16) NOT NULL DEFAULT 'permanent',
|
||||
expires_at TIMESTAMPTZ,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_keys_user ON user_keys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_keys_token ON user_keys(key_token);
|
||||
|
||||
-- ---------- 附件 ----------
|
||||
--
|
||||
-- 文件内容存磁盘(内容寻址:路径由 sha256 派生),数据库只存元数据。
|
||||
--
|
||||
-- mail_id 为 NULL 表示「已上传但还没挂到邮件上」的待用附件:
|
||||
-- 上传与发信是两步(Agent 工具是 JSON 接口,没法在发信时带 multipart),
|
||||
-- 中间态必须允许存在;超时未挂载的由 GC 清掉。
|
||||
|
||||
-- 插件自动转发的邮件登记表。
|
||||
--
|
||||
-- **配额约束的是模型的自主发信,不是 harness 的转发**(基本原则):
|
||||
-- 配额存在的意义是防止 Agent 无限自我循环。而「把平台原生的权限询问转给人」
|
||||
-- 与「把本轮的最终总结转给人」都是插件代劳的搬运,不是模型自己决定要发的信 ——
|
||||
-- 对它们收费会导致配额用尽时 Agent 连交代都做不了。
|
||||
--
|
||||
-- relay_key 是上游那条消息的稳定标识(opencode 的 permission id / assistant message id)。
|
||||
-- 唯一约束把「同一条上游消息只转一次」变成一条 INSERT 的成败:
|
||||
-- * 插件重试、SSE 重连后重放都不会产生第二封
|
||||
-- * 也顺带给免配额通道加了结构性上限 —— 想多转就得拿出不同的上游消息 id
|
||||
CREATE TABLE IF NOT EXISTS relayed_mails (
|
||||
agent_name VARCHAR(64) NOT NULL,
|
||||
relay_key VARCHAR(160) NOT NULL,
|
||||
mail_id UUID REFERENCES mails(mail_id),
|
||||
kind VARCHAR(32) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (agent_name, relay_key)
|
||||
);
|
||||
|
||||
-- 人类决策后要按 mail_id 反查上游 permission id
|
||||
CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
attachment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mail_id UUID REFERENCES mails(mail_id) ON DELETE CASCADE,
|
||||
uploader VARCHAR(64) NOT NULL,
|
||||
filename VARCHAR(512) NOT NULL,
|
||||
content_type VARCHAR(128) NOT NULL DEFAULT 'application/octet-stream',
|
||||
size_bytes BIGINT NOT NULL,
|
||||
sha256 CHAR(64) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_mail ON attachments(mail_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_sha ON attachments(sha256);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE mail_id IS NULL;
|
||||
|
||||
-- 速率限制(登录失败 + 新建会话)
|
||||
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||
bucket TEXT NOT NULL,
|
||||
ts TIMESTAMPTZ NOT NULL,
|
||||
expired BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_limits_bucket ON rate_limits(bucket, ts);
|
||||
|
||||
-- ---------- 邮件场景下的可用模型(见 init_sqlite.sql 里的设计说明) ----------
|
||||
CREATE TABLE IF NOT EXISTS agent_model_catalog (
|
||||
agent_name VARCHAR(128) NOT NULL,
|
||||
provider VARCHAR(128) NOT NULL,
|
||||
model VARCHAR(256) NOT NULL,
|
||||
display_name VARCHAR(256) NOT NULL DEFAULT '',
|
||||
reported_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (agent_name, provider, model)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_allowed_models (
|
||||
agent_name VARCHAR(128) NOT NULL,
|
||||
provider VARCHAR(128) NOT NULL,
|
||||
model VARCHAR(256) NOT NULL,
|
||||
rank INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (agent_name, provider, model)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_allowed_rank ON agent_allowed_models(agent_name, rank);
|
||||
|
||||
-- ---------- 平台会话镜像(见 init_sqlite.sql 里的设计说明) ----------
|
||||
CREATE TABLE IF NOT EXISTS agent_platform_sessions (
|
||||
agent_name VARCHAR(128) NOT NULL,
|
||||
platform_id VARCHAR(256) NOT NULL,
|
||||
workspace VARCHAR(512) NOT NULL DEFAULT '',
|
||||
slug VARCHAR(256) NOT NULL DEFAULT '',
|
||||
title VARCHAR(512) NOT NULL DEFAULT '',
|
||||
mail_driven BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ,
|
||||
reported_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (agent_name, platform_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_sessions_ws
|
||||
ON agent_platform_sessions(agent_name, workspace);
|
||||
|
||||
-- ---------- 日历(见 init_sqlite.sql 里的设计说明) ----------
|
||||
--
|
||||
-- 三层分离:事件是日历实体,提醒是触发器,邮件是投递通道。
|
||||
-- 这份 PG schema 曾经整块缺失 —— 后果是 DATABASE_URL 一旦非空,
|
||||
-- 所有 /calendar/* 端点在 relation does not exist 上 500,
|
||||
-- 而 SQLite 下一切正常,于是问题只在切外部库时才暴露。
|
||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(512) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
|
||||
-- 提醒邮件正文模板,支持 {title} {time} {description}
|
||||
reminder_text TEXT NOT NULL DEFAULT '',
|
||||
|
||||
-- 收件方:agent_name 是兜底,to_address 是权威(完整三维寻址)
|
||||
agent_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
to_address VARCHAR(512) NOT NULL DEFAULT '',
|
||||
|
||||
event_time TIMESTAMPTZ NOT NULL,
|
||||
remind_before INTEGER NOT NULL DEFAULT 0,
|
||||
recurrence VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
recurrence_end TIMESTAMPTZ,
|
||||
|
||||
-- 收件人列表与投递方式(见 init_sqlite.sql 的说明)
|
||||
recipients JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
delivery_mode VARCHAR(32) NOT NULL DEFAULT 'separate',
|
||||
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
last_fired_at TIMESTAMPTZ,
|
||||
-- 已触发的 occurrence(= 当时的 event_time)。见 init_sqlite.sql 的说明。
|
||||
fired_for TIMESTAMPTZ,
|
||||
-- 权限档位(plan / workspace / full)。见 init_sqlite.sql 的说明。
|
||||
permission_mode TEXT NOT NULL DEFAULT 'workspace',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_by VARCHAR(128) NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_next_fire
|
||||
ON calendar_events(status, event_time) WHERE status = 'active';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_time_range
|
||||
ON calendar_events(event_time, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_attachments (
|
||||
attachment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_id UUID NOT NULL REFERENCES calendar_events(event_id) ON DELETE CASCADE,
|
||||
filename VARCHAR(512) NOT NULL,
|
||||
sha256 VARCHAR(64) NOT NULL DEFAULT '',
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_att_event
|
||||
ON calendar_attachments(event_id);
|
||||
|
||||
-- 接管平台会话时按 platform_id 反查本侧会话。
|
||||
--
|
||||
-- 先 ALTER 再建索引:CREATE TABLE IF NOT EXISTS 不会给**已存在**的表补列,
|
||||
-- 而这个脚本在已部署的库上也要能跑。PG 支持 ADD COLUMN IF NOT EXISTS,
|
||||
-- 所以这里不需要像 SQLite 那样绕到代码里去(见 migrate.go 的 sqliteAddIndexes)。
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS platform_id VARCHAR(256) NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_platform
|
||||
ON sessions(platform_id) WHERE platform_id <> '';
|
||||
479
server/internal/db/migrations/init_sqlite.sql
Normal file
479
server/internal/db/migrations/init_sqlite.sql
Normal file
@ -0,0 +1,479 @@
|
||||
-- AgentMail Schema — SQLite(默认后端)
|
||||
--
|
||||
-- 与 init.sql(PostgreSQL)保持同一套表结构与语义,差异仅在方言:
|
||||
-- UUID → TEXT(Go 侧 uuid 或 gen_random_uuid() 注册函数生成)
|
||||
-- TIMESTAMPTZ → DATETIME(必须写 DATETIME,database/sql 才能扫进 time.Time)
|
||||
-- 默认值不用 CURRENT_TIMESTAMP:它只有【秒】精度,同一秒内插入的多行
|
||||
-- 按 created_at 排序结果不确定,
|
||||
-- 「会话里最早/最后那封邮件」都会取错行
|
||||
-- (实测同秒插 5 封,排出来的顺序是乱的)。
|
||||
-- 改用 strftime 的毫秒精度。mails 表另在 repo 层的
|
||||
-- INSERT 里显式传 NOW()(微秒精度)—— 一次插入只要
|
||||
-- 几十到几百微秒,毫秒仍可能撞车,而邮件顺序
|
||||
-- 直接决定 UI 上「最新进展」显示哪一封。
|
||||
-- JSONB → TEXT(存 JSON 字符串,用 json_each/json_extract 检索)
|
||||
-- VARCHAR(n) → TEXT(SQLite 不强制长度,长度约束由应用层负责)
|
||||
-- NOW() → 由 internal/db 注册的同名函数提供,与 PG 侧 SQL 一致
|
||||
--
|
||||
-- 本文件只建表建索引,不含数据迁移:SQLite 是新引入的默认后端,不存在历史库。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
last_login DATETIME,
|
||||
|
||||
-- 权限边界:空数组 = 不限
|
||||
allowed_agents TEXT NOT NULL DEFAULT '[]',
|
||||
allowed_paths TEXT NOT NULL DEFAULT '[]',
|
||||
agent_aliases TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
expires_at DATETIME NOT NULL,
|
||||
user_agent TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_exp ON user_sessions(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
agent_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
agent_name TEXT NOT NULL UNIQUE,
|
||||
secret TEXT NOT NULL,
|
||||
host_url TEXT NOT NULL DEFAULT '',
|
||||
workspaces TEXT NOT NULL DEFAULT '[]',
|
||||
platform TEXT NOT NULL DEFAULT 'pi',
|
||||
status TEXT NOT NULL DEFAULT 'offline',
|
||||
-- default_rounds 是【派给这个 Agent 的新任务】默认有多少个来回。
|
||||
-- 配额是任务的属性,所以真正的约束在 sessions.max_rounds 上;
|
||||
-- 这里只提供默认值 —— 不同 Agent 能力不同,默认值分开设才合理。
|
||||
default_rounds INTEGER NOT NULL DEFAULT 20,
|
||||
|
||||
-- max_rounds / used_rounds 是历史遗留的「终身额度」。
|
||||
-- 终身额度是错的工具:跑满就得管理员手工重置才能再干活,
|
||||
-- 而 Agent 是长期在线的。现已降级为纯统计(used_rounds 只累加、不拦请求),
|
||||
-- max_rounds 保留列但不再参与判断。
|
||||
max_rounds INTEGER NOT NULL DEFAULT 0,
|
||||
used_rounds INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- mode_enforcement 是该平台插件自报的权限档位强制能力(native / advisory),
|
||||
-- 随心跳上报(与模型目录同一条通道 —— I-1:平台自己说的才算)。
|
||||
--
|
||||
-- 为什么要存:发件人在派活前得知道 plan 档在对方那儿到底算不算。
|
||||
-- homeagent 的核心没有工具调用拦截点,档位只能写进提示词 ——
|
||||
-- 把这个事实藏起来比做不到本身更危险。
|
||||
--
|
||||
-- 默认 advisory 而不是 native:没自报过的插件,我们不能替它宣称
|
||||
-- 「档位在这里是被强制的」。
|
||||
mode_enforcement TEXT NOT NULL DEFAULT 'advisory',
|
||||
last_seen DATETIME,
|
||||
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
session_alias TEXT,
|
||||
-- workspace 是这条会话所属的工作目录(三维地址 name@path.session 的 path 位)。
|
||||
--
|
||||
-- 之前它只存在于 mails.to_workspace 上,于是「这个工作区下有哪些会话」
|
||||
-- 必须 JOIN mails 再从收发双方的 workspace 里猜,而 Agent 回信时
|
||||
-- from_workspace 填的是 Agent 名而不是路径 —— 猜出来的结果是错的,
|
||||
-- 别名候选列表因此列不出本工作区的历史会话。
|
||||
-- 会话归属哪个工作区是会话自己的属性,就该存在会话上。
|
||||
workspace TEXT NOT NULL DEFAULT '',
|
||||
from_agent TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
owner_user_id TEXT REFERENCES users(user_id),
|
||||
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
updated_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
|
||||
-- 绑定到平台侧的哪条会话(agent_platform_sessions.platform_id)。
|
||||
--
|
||||
-- 空 = 这条会话由邮件创建,平台侧的会话是桥按邮件开的。
|
||||
-- 非空 = 这条会话**接管**了一条平台上已经存在的会话(人在 TUI 里开的那种)。
|
||||
--
|
||||
-- 为什么需要它:TUI 与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界。
|
||||
-- 人在 TUI 里聊了一半想转到邮件上继续,或者想把一封邮件投进正在谈的那条
|
||||
-- 会话 —— 补全早就把平台会话列为候选(agent_platform_sessions),
|
||||
-- 但投递侧没有这一跳,选中后只能得到 404。这一列就是那一跳的落点。
|
||||
platform_id TEXT NOT NULL DEFAULT '',
|
||||
|
||||
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
|
||||
rename_dismissed TEXT,
|
||||
|
||||
-- 别名是谁定的:'platform'(Agent 平台自动同步,可被后续同步覆盖)
|
||||
-- 或 'manual'(人显式指定,平台同步不得覆盖)。
|
||||
-- 没有这个标记,平台的下一次 session.updated 会把人刚接受的名字冲掉,
|
||||
-- 人上一秒记住的寻址地址下一秒失效。
|
||||
alias_source TEXT NOT NULL DEFAULT 'platform',
|
||||
|
||||
-- 本次任务的往返预算(0 = 本会话不限,仅受 Agent 全局配额约束)。
|
||||
--
|
||||
-- 配额的真实语义是「这件事值得多少个来回」,那是任务的属性而不是 Agent 的属性:
|
||||
-- 只有 agents.max_rounds 一个全局计数器时,两个并行任务会互相抢额度,
|
||||
-- 且 used_rounds 单调递增,一旦跑满就得管理员手工重置才能再干活。
|
||||
-- 因此预算下沉到会话,由人在写信时给、在对话页里随时调。
|
||||
--
|
||||
-- Agent 全局配额仍然生效(两者都要过):否则 Agent 自己 .new 开一串会话,
|
||||
-- 每条都是全新预算,全局上限就形同虚设。
|
||||
max_rounds INTEGER NOT NULL DEFAULT 0,
|
||||
used_rounds INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- 会话级权限档位(plan / workspace / full)。
|
||||
-- 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」,
|
||||
-- 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权,
|
||||
-- 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。
|
||||
permission_mode TEXT NOT NULL DEFAULT 'workspace',
|
||||
|
||||
-- 接收平台实际做到的强制力(native / advisory)。
|
||||
-- 旧库默认 'advisory':没自报过的插件,我们不能替它宣称
|
||||
-- 「档位在这里是被强制的」。保守方向是承认做不到,而不是假装做到了。
|
||||
permission_enforcement TEXT NOT NULL DEFAULT 'advisory'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(session_alias);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id);
|
||||
|
||||
-- 会话别名负责寻址(name@path.<alias>),必须全局唯一。
|
||||
-- 部分唯一索引:未命名会话(NULL)不受约束,可以有任意多个。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_alias_uniq
|
||||
ON sessions(session_alias) WHERE session_alias IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mails (
|
||||
mail_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
||||
parent_mail_id TEXT REFERENCES mails(mail_id),
|
||||
|
||||
from_name TEXT NOT NULL,
|
||||
from_workspace TEXT DEFAULT '',
|
||||
to_name TEXT NOT NULL,
|
||||
to_workspace TEXT DEFAULT '',
|
||||
|
||||
subject TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
|
||||
-- 抄送列表:[{"name":"pi","path":"root","session":"new","raw":"pi@root.new"}]
|
||||
cc_list TEXT NOT NULL DEFAULT '[]',
|
||||
|
||||
mail_type TEXT NOT NULL DEFAULT 'normal',
|
||||
permission_options TEXT,
|
||||
permission_result TEXT,
|
||||
-- permission = 危险操作审批;question = Agent 主动补充询问。
|
||||
permission_kind TEXT NOT NULL DEFAULT '',
|
||||
permission_multi_select INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
status TEXT NOT NULL DEFAULT 'unread',
|
||||
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
|
||||
hop_limit INTEGER DEFAULT 5,
|
||||
|
||||
-- Agent 在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
|
||||
-- 存在邮件上而非会话上:邮件是不可篡改的历史记录,
|
||||
-- 「谁在哪一封里提了什么」应当留痕。
|
||||
rename_alias TEXT,
|
||||
rename_reason TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_session ON mails(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_to ON mails(to_name, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_parent ON mails(parent_mail_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_created ON mails(created_at);
|
||||
|
||||
-- 抄送检索无对应索引:SQLite 侧走 json_each 展开。
|
||||
-- 单机邮件量级(数千至数万)下全表展开是毫秒级,不值得为此加物化列。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS permission_requests (
|
||||
request_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
mail_id TEXT NOT NULL REFERENCES mails(mail_id),
|
||||
session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
||||
agent_name TEXT NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
options TEXT NOT NULL DEFAULT '["同意","拒绝"]',
|
||||
context TEXT DEFAULT '',
|
||||
-- permission = 危险操作审批;question = Agent 主动补充询问。
|
||||
kind TEXT NOT NULL DEFAULT 'permission',
|
||||
-- ask_user_question 的多选语义;普通权限审批恒为 0。
|
||||
multi_select INTEGER NOT NULL DEFAULT 0,
|
||||
result TEXT,
|
||||
decided_at DATETIME,
|
||||
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_perm_agent ON permission_requests(agent_name, result);
|
||||
CREATE INDEX IF NOT EXISTS idx_perm_pending ON permission_requests(result) WHERE result IS NULL;
|
||||
|
||||
-- ---------- 密钥认证体系 ----------
|
||||
--
|
||||
-- 两类密钥,共享一个全局唯一的 token 命名空间(验证时先查 agent_keys 再查 user_keys):
|
||||
-- agent_keys:管理员签发,用于 Agent 注册/心跳/SSE
|
||||
-- user_keys :用户自助签发,仅用于 /me/* 人类邮箱接口,不可注册 Agent
|
||||
--
|
||||
-- key_type:
|
||||
-- permanent — 永不过期,可重复使用
|
||||
-- one_time — 首次验证后写 used_at,再用即拒
|
||||
-- timed — expires_at 之后失效
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_keys (
|
||||
key_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
key_token TEXT NOT NULL UNIQUE,
|
||||
agent_name TEXT, -- NULL = 待绑定
|
||||
key_type TEXT NOT NULL DEFAULT 'permanent',
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
expires_at DATETIME,
|
||||
used_at DATETIME,
|
||||
created_by TEXT REFERENCES users(user_id),
|
||||
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_keys_token ON agent_keys(key_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_keys_agent ON agent_keys(agent_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_keys (
|
||||
key_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
key_token TEXT NOT NULL UNIQUE,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
key_type TEXT NOT NULL DEFAULT 'permanent',
|
||||
expires_at DATETIME,
|
||||
used_at DATETIME,
|
||||
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_keys_user ON user_keys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_keys_token ON user_keys(key_token);
|
||||
|
||||
-- ---------- 附件 ----------
|
||||
--
|
||||
-- 文件内容存磁盘(内容寻址:路径由 sha256 派生),数据库只存元数据。
|
||||
-- 不塞 BLOB:SQLite 的 BLOB 会让 .db 文件膨胀并拖慢 WAL,而附件是只写一次多次读的冷数据。
|
||||
--
|
||||
-- mail_id 为 NULL 表示「已上传但还没挂到邮件上」的待用附件:
|
||||
-- 上传与发信是两步(Agent 工具是 JSON 接口,没法在发信时带 multipart),
|
||||
-- 中间态必须允许存在;超时未挂载的由 GC 清掉。
|
||||
|
||||
-- 插件自动转发的邮件登记表。
|
||||
--
|
||||
-- **配额约束的是模型的自主发信,不是 harness 的转发**(基本原则):
|
||||
-- 配额存在的意义是防止 Agent 无限自我循环。而「把平台原生的权限询问转给人」
|
||||
-- 与「把本轮的最终总结转给人」都是插件代劳的搬运,不是模型自己决定要发的信 ——
|
||||
-- 对它们收费会导致配额用尽时 Agent 连交代都做不了。
|
||||
--
|
||||
-- relay_key 是上游那条消息的稳定标识(opencode 的 permission id / assistant message id)。
|
||||
-- 唯一约束把「同一条上游消息只转一次」变成一条 INSERT 的成败:
|
||||
-- * 插件重试、SSE 重连后重放都不会产生第二封
|
||||
-- * 也顺带给免配额通道加了结构性上限 —— 想多转就得拿出不同的上游消息 id
|
||||
CREATE TABLE IF NOT EXISTS relayed_mails (
|
||||
agent_name TEXT NOT NULL,
|
||||
relay_key TEXT NOT NULL,
|
||||
mail_id TEXT REFERENCES mails(mail_id),
|
||||
kind TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
PRIMARY KEY (agent_name, relay_key)
|
||||
);
|
||||
|
||||
-- 人类决策后要按 mail_id 反查上游 permission id
|
||||
CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
attachment_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
mail_id TEXT REFERENCES mails(mail_id) ON DELETE CASCADE,
|
||||
|
||||
-- 上传者(Agent 名或用户名),用于「只能挂自己上传的附件」校验
|
||||
uploader TEXT NOT NULL,
|
||||
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size_bytes INTEGER NOT NULL,
|
||||
-- sha256 既是去重依据也是磁盘路径来源,绝不用用户给的 filename 拼路径
|
||||
sha256 TEXT NOT NULL,
|
||||
|
||||
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_mail ON attachments(mail_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_sha ON attachments(sha256);
|
||||
-- GC 扫描待挂载附件用
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE mail_id IS NULL;
|
||||
|
||||
-- 速率限制(登录失败 + 新建会话),替代进程内内存计数器。
|
||||
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||
bucket TEXT NOT NULL,
|
||||
ts DATETIME NOT NULL,
|
||||
expired INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_limits_bucket ON rate_limits(bucket, ts);
|
||||
|
||||
-- ---------- 邮件场景下的可用模型 ----------
|
||||
--
|
||||
-- 拆成两张表,因为它们是两种不同的真相:
|
||||
--
|
||||
-- agent_model_catalog —— 平台**上报**它当前看得见哪些模型。每次注册整表替换。
|
||||
-- agent_allowed_models —— 管理员**选定**其中哪些可以在邮件场景下用,rank 即优先级。
|
||||
--
|
||||
-- 不合成一张带 allowed 标记的表:那样一来模型从平台目录里消失(换了 provider 配置、
|
||||
-- 上游下线了某个模型)就会连带把管理员的选择删掉,等模型回来还得重新配一遍。
|
||||
-- 分开存之后,选择是持久的,目录只决定「这一项现在是否可用」。
|
||||
CREATE TABLE IF NOT EXISTS agent_model_catalog (
|
||||
agent_name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
-- 人类可读名,平台给什么就存什么;为空时前端显示 model id
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
reported_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
PRIMARY KEY (agent_name, provider, model)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_allowed_models (
|
||||
agent_name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
-- rank 越小越先试。插件按它顺序降级,全部失败才回一封失败邮件。
|
||||
rank INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (agent_name, provider, model)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_allowed_rank ON agent_allowed_models(agent_name, rank);
|
||||
|
||||
-- ---------- 平台会话镜像 ----------
|
||||
--
|
||||
-- Agent 平台(opencode / DSH)自己也在开会话:有些经由邮件驱动,有些是人直接
|
||||
-- 在平台界面上开的。写信时想续谈某条会话,就得先知道那个工作区下有哪些会话
|
||||
-- 可以续 —— 而 Gateway 只看得见邮件驱动的那部分。
|
||||
--
|
||||
-- **由插件在心跳里上报,而不是 Gateway 反向拉取**:当前架构是单向的
|
||||
-- (Agent 持密钥主动连 Gateway,Gateway 从不外呼)。让 Gateway 去调平台接口
|
||||
-- 需要它保存各平台的地址与凭证,那是另一套信任模型,暂不引入。
|
||||
--
|
||||
-- 与 sessions 表分开存:这里是**别人家的**会话,其 id 属于平台的 id 空间,
|
||||
-- 没有本侧的 owner / 预算 / 邮件。混进 sessions 会让每一处
|
||||
-- 「按会话鉴权」都要先判断这条到底是不是真的本侧会话。
|
||||
CREATE TABLE IF NOT EXISTS agent_platform_sessions (
|
||||
agent_name TEXT NOT NULL,
|
||||
-- 平台侧的会话 id(opencode 的 ses_xxx / DSH 的 session id)
|
||||
platform_id TEXT NOT NULL,
|
||||
-- 平台侧 cwd,即三维地址的 path 位
|
||||
workspace TEXT NOT NULL DEFAULT '',
|
||||
-- 平台自己的可寻址短名(opencode 的 slug;DSH 由模型标题派生)
|
||||
slug TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
-- 该平台会话是否由 AgentMail 的邮件驱动。用来在候选列表里区分
|
||||
-- 「续谈已有邮件线索」与「接入一条平台侧已经在跑的会话」。
|
||||
mail_driven INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME,
|
||||
reported_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
PRIMARY KEY (agent_name, platform_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_sessions_ws
|
||||
ON agent_platform_sessions(agent_name, workspace);
|
||||
|
||||
-- ─── 日历事件 ───
|
||||
--
|
||||
-- Outlook 风格:事件 → 提醒 → 邮件通知 Agent。
|
||||
-- 事件本身是日历实体,提醒是定时触发器,邮件是投递通道。
|
||||
-- 三者分离:同一条事件可以有多个提醒(提前提醒 + 当天提醒),
|
||||
-- 同一条提醒只触发一封邮件(幂等由 fired_at 控制)。
|
||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
event_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
-- 事件标题(UI 显示 + 邮件主题前缀)
|
||||
title TEXT NOT NULL,
|
||||
-- 事件描述(UI 显示,可含 markdown)
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
-- 用户可编辑的提醒消息模板。支持变量:{title} {time} {description}
|
||||
reminder_text TEXT NOT NULL DEFAULT '',
|
||||
|
||||
-- 通知目标
|
||||
agent_name TEXT NOT NULL DEFAULT '',
|
||||
-- 收件人三维地址(空 = 用 agent_name 默认地址)
|
||||
to_address TEXT NOT NULL DEFAULT '',
|
||||
|
||||
-- 时间安排
|
||||
event_time DATETIME NOT NULL,
|
||||
-- 提前多少分钟提醒(0 = 事件触发时)
|
||||
remind_before INTEGER NOT NULL DEFAULT 0,
|
||||
-- 重复规则:none / daily / weekly / monthly
|
||||
-- lunar_monthly(每农历月同一日)/ lunar_yearly(每农历年同月同日)
|
||||
--
|
||||
-- 农历规则必须经 internal/lunar 推进,不能加固定天数 ——
|
||||
-- 农历月 29~30 天不定、农历年 353~385 天(闰年多一整月),
|
||||
-- 近似推进一年能偏半个月。
|
||||
recurrence TEXT NOT NULL DEFAULT 'none',
|
||||
-- 重复结束(空 = 永久)
|
||||
recurrence_end DATETIME,
|
||||
|
||||
-- 收件人列表(JSON 数组,每项是完整三维地址串)。
|
||||
--
|
||||
-- 存原始串而不是结构化地址:session 位的 new/别名三态该在**触发那一刻**
|
||||
-- 解析。存结构化的话「.new」这种一次性语义在建事件时就被固化,
|
||||
-- 而重复事件每次触发都该重新决定落到哪条会话。
|
||||
recipients TEXT NOT NULL DEFAULT '[]',
|
||||
|
||||
-- 多收件人的投递方式:
|
||||
-- separate(默认)= 各发一封、落各自会话、互不可见
|
||||
-- together = 首个为主收件人,其余进 cc_list、共享一条线索
|
||||
--
|
||||
-- 默认 separate 因为它的失败模式更轻:together 用错会让本该独立判断的
|
||||
-- Agent 互相看到回复而趋同,那种污染事后无法分离。
|
||||
delivery_mode TEXT NOT NULL DEFAULT 'separate',
|
||||
|
||||
-- 状态:active / paused / cancelled
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
-- 最后一次触发的墙上时钟(给 UI 显示「上次触发于」)
|
||||
last_fired_at DATETIME,
|
||||
-- 已触发的那个 occurrence,值 = 当时的 event_time。
|
||||
--
|
||||
-- 去重不能拿 last_fired_at 跟 event_time 比大小:DueEvents 有 60 秒
|
||||
-- lookahead,落在窗口内的**未来**事件被触发后 last_fired_at(now) 仍然
|
||||
-- 小于 event_time,于是每个 tick 重发一次,直到 event_time 真正过去。
|
||||
-- 生产实测:一条 12:53:17 的事件在 12:52:30 / 12:53:00 / 12:53:06 /
|
||||
-- 12:53:36 发了 4 封相同提醒。
|
||||
-- 按 occurrence 比相等则精确:AdvanceRecurrence 改了 event_time 就再触发,
|
||||
-- 没改就永不重发。
|
||||
fired_for DATETIME,
|
||||
-- 权限档位(plan / workspace / full)。事件触发时新建会话 → 用此档位定死;
|
||||
-- 复用已有会话 → 取「会话现档 与 事件档」中更严那个(ModeAtMost),
|
||||
-- 不允许通过重复事件提权(plan 档 Agent 建的日程触发时拿 workspace 就绕开了 plan)。
|
||||
permission_mode TEXT NOT NULL DEFAULT 'workspace',
|
||||
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
updated_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
|
||||
-- 创建者(人类用户)
|
||||
created_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- 调度器每分钟扫描 active 事件,按 event_time + remind_before 排序取下一个
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_next_fire
|
||||
ON calendar_events(status, event_time) WHERE status = 'active';
|
||||
|
||||
-- 按时间范围查(日历视图)
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_time_range
|
||||
ON calendar_events(event_time, status);
|
||||
|
||||
-- 附件:事件触发时随提醒邮件一起发出
|
||||
CREATE TABLE IF NOT EXISTS calendar_attachments (
|
||||
attachment_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
event_id TEXT NOT NULL REFERENCES calendar_events(event_id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL DEFAULT '',
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_att_event
|
||||
ON calendar_attachments(event_id);
|
||||
|
||||
-- 注意:idx_sessions_platform 不在这里。
|
||||
-- 这个脚本在 addMissingColumns **之前**执行,而已部署的库里 sessions 表已经
|
||||
-- 存在 —— CREATE TABLE IF NOT EXISTS 不会给它补 platform_id 列,于是这里建
|
||||
-- 索引会以 "no such column" 失败,整个迁移中断(实测过一次)。
|
||||
-- 依赖补出来的列的索引一律放 migrate.go 的 sqliteAddIndexes。
|
||||
454
server/internal/handler/agent_calendar.go
Normal file
454
server/internal/handler/agent_calendar.go
Normal file
@ -0,0 +1,454 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
// Agent 侧的日历能力。
|
||||
//
|
||||
// # 为什么 Agent 需要建日程
|
||||
//
|
||||
// 在这之前日历是纯人类功能:`/calendar/*` 全挂在 `middleware.UserAuth` 后面,
|
||||
// Agent 密钥一律 401。于是「明天九点提醒我看 CI 结果」这件事,Agent 只能
|
||||
// 在自己进程里 setTimeout —— 而它的进程随时会重启,定时器一并消失,
|
||||
// 那条提醒静默不见,没有任何地方留下痕迹。
|
||||
//
|
||||
// 把日程放进 Gateway 之后,它由数据库与调度器保证:插件重启、Agent 换机器、
|
||||
// 甚至换平台,提醒照样按时到达。
|
||||
//
|
||||
// # 与人类端点的三处差异
|
||||
//
|
||||
// 1. **只能看自己建的**(`ListCalendarEventsCreatedBy`)。别人的日程里
|
||||
// 可能有它无权知道的会议与地址。
|
||||
// 2. **只能改自己建的**。人建的提醒不该被 Agent 悄悄改时间或删掉 ——
|
||||
// 那等于让它绕过人的安排。
|
||||
// 3. **有速率与总量双重上限**。见下面 `guardAgentQuota` 的说明。
|
||||
//
|
||||
// # 为什么允许发给别人
|
||||
//
|
||||
// 「让 pi 提醒 dsh 明天交周报」是真实需求:跨 Agent 的任务交接本来就是
|
||||
// 这个平台的主题。收件人一律走完整三维寻址,与 send_mail 同一套解析,
|
||||
// 因此 Agent 能设的目标不会超出它本来就能发信的范围。
|
||||
//
|
||||
// **但不能设给人类**:见 `rejectHumanRecipients`。
|
||||
|
||||
// ─── 配额守卫 ───
|
||||
|
||||
// maxActiveEventsPerAgent 是单个 Agent 同时生效的事件总量上限。
|
||||
//
|
||||
// 为什么速率限制不够:`calendar:` 桶压住的是「一小时内建几条」,
|
||||
// 压不住「每小时建 19 条、连建一周」。而日历事件是**长效**的 ——
|
||||
// 一条每日重复提醒会一直发下去直到有人删它。攒下 300 条之后,
|
||||
// 即使 Agent 早已停止建新的,每天仍有 300 封提醒邮件涌出来。
|
||||
//
|
||||
// 50 条:正常用法下一个 Agent 手上的长期日程是个位数;
|
||||
// 撞到 50 说明它在无意义地攒任务,此时报错比继续接受更有用。
|
||||
const maxActiveEventsPerAgent = 50
|
||||
|
||||
// guardAgentQuota 检查速率与总量两道闸。
|
||||
//
|
||||
// 返回 false 时已经写好响应,调用方直接 return。
|
||||
// 第二个返回值是「本次已记账」,创建失败时调用方要 Release 归还。
|
||||
func guardAgentQuota(w http.ResponseWriter, r *http.Request, agentName string) (ok bool, charged bool) {
|
||||
// 总量先查:它不消耗速率名额,撞上限时不该顺手扣一次
|
||||
active, err := repo.CountActiveEventsBy(r.Context(), agentName)
|
||||
if err == nil && active >= maxActiveEventsPerAgent {
|
||||
Error(w, http.StatusTooManyRequests,
|
||||
"你当前已有 "+strconv.Itoa(active)+" 条生效中的日程(上限 "+strconv.Itoa(maxActiveEventsPerAgent)+
|
||||
")。请先删掉不需要的,或把多条合并成一条重复日程。")
|
||||
return false, false
|
||||
}
|
||||
|
||||
if allowed, retry := repo.AllowAgentCalendarEvent(r.Context(), agentName); !allowed {
|
||||
Error(w, http.StatusTooManyRequests,
|
||||
"建日程过于频繁(1 小时内已建 "+strconv.Itoa(repo.CalendarRateLimit())+" 条)。"+
|
||||
strconv.Itoa(retry)+" 秒后再试;如果只是想改时间,请用 PUT 改已有那条而不是新建。")
|
||||
return false, false
|
||||
}
|
||||
return true, true
|
||||
}
|
||||
|
||||
// rejectHumanRecipients 拒绝把人类放进收件人列表。
|
||||
//
|
||||
// 理由是**投递通道不对等**。Agent 之间的提醒是任务信号:收到就干活,
|
||||
// 干完回信,人不在环里也能推进。而发给人的提醒是打扰 —— 它会进人的收件箱、
|
||||
// 触发未读徽标,而人无法「回信让它停下」(提醒是日历实体,不是对话)。
|
||||
//
|
||||
// 一个 Agent 建一条「每 10 分钟提醒 jianf 检查进度」的日程,人就只能去
|
||||
// WebUI 里找出那条事件删掉。给 Agent 这个能力,收益(它其实可以直接发邮件)
|
||||
// 远小于代价。
|
||||
//
|
||||
// 人自己在界面上给自己设提醒不受此限 —— 那是人类端点的事。
|
||||
func rejectHumanRecipients(w http.ResponseWriter, r *http.Request, recipients []string) bool {
|
||||
for _, raw := range recipients {
|
||||
addr, err := models.ParseAddress(raw)
|
||||
if err != nil {
|
||||
continue // 地址合法性由 normalizeRecipients 负责报错
|
||||
}
|
||||
human, hErr := repo.IsHumanUser(r.Context(), addr.Name)
|
||||
if hErr != nil {
|
||||
// 查不动就放行:这道闸是防滥用,不该因为 DB 抖动而挡住正常请求
|
||||
continue
|
||||
}
|
||||
if human {
|
||||
Error(w, http.StatusForbidden,
|
||||
"不能把日程提醒设给人类用户("+addr.Name+")。"+
|
||||
"要通知人请直接 send_mail —— 那样他能回信,而定时提醒他只能去界面上删。")
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// requireOwnEvent 读出事件并确认它是本 Agent 建的。
|
||||
//
|
||||
// 返回 nil 时已写好响应。刻意对「不存在」与「不属于我」都回 404 ——
|
||||
// 回 403 会泄漏「这个 id 存在」,让 Agent 能枚举出别人有多少条日程。
|
||||
func requireOwnEvent(w http.ResponseWriter, r *http.Request, agentName string) *models.CalendarEvent {
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if id == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing event id")
|
||||
return nil
|
||||
}
|
||||
e, err := repo.GetCalendarEvent(r.Context(), id)
|
||||
if errors.Is(err, repo.ErrEventNotFound) {
|
||||
Error(w, http.StatusNotFound, "日程不存在")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "读取日程失败")
|
||||
return nil
|
||||
}
|
||||
if e.CreatedBy != agentName {
|
||||
Error(w, http.StatusNotFound, "日程不存在")
|
||||
return nil
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// ─── 端点 ───
|
||||
|
||||
// POST /api/v1/agent/calendar/events
|
||||
func AgentCreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ReminderText string `json:"reminder_text"`
|
||||
Recipients []string `json:"recipients"`
|
||||
DeliveryMode string `json:"delivery_mode"`
|
||||
EventTime time.Time `json:"event_time"`
|
||||
RemindBefore int `json:"remind_before"`
|
||||
Recurrence string `json:"recurrence"`
|
||||
RecurrenceEnd *time.Time `json:"recurrence_end"`
|
||||
}
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Title) == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing title")
|
||||
return
|
||||
}
|
||||
if req.EventTime.IsZero() {
|
||||
Error(w, http.StatusBadRequest, "Missing event_time(RFC3339,例如 2026-09-10T09:00:00+08:00)")
|
||||
return
|
||||
}
|
||||
if req.Recurrence == "" {
|
||||
req.Recurrence = models.RecurNone
|
||||
}
|
||||
if !validRecurrence(req.Recurrence) {
|
||||
Error(w, http.StatusBadRequest,
|
||||
"recurrence 必须是 none/daily/weekly/monthly/yearly/lunar_monthly/lunar_yearly 之一")
|
||||
return
|
||||
}
|
||||
|
||||
// 收件人默认是自己:「提醒我明天看 CI」是最常见的用法,
|
||||
// 每次都要求写出自己的名字只会让模型忘记然后拿到 400。
|
||||
recipients, badAddr := normalizeRecipients(req.Recipients)
|
||||
if badAddr != "" {
|
||||
Error(w, http.StatusBadRequest, "收件地址无法解析:"+badAddr)
|
||||
return
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
recipients = []string{agentName}
|
||||
}
|
||||
if !rejectHumanRecipients(w, r, recipients) {
|
||||
return
|
||||
}
|
||||
|
||||
// 一次性事件设在过去毫无意义:调度器下一轮就会立刻发出去,
|
||||
// 而模型的意图显然是「未来某时」。这几乎总是时区或年份写错。
|
||||
// 重复事件不拦:一条「每天 9 点」的规则从昨天开始是合理的写法,
|
||||
// AdvanceRecurrence 会把它推到下一个未来时刻。
|
||||
if req.Recurrence == models.RecurNone && req.EventTime.Before(time.Now()) {
|
||||
Error(w, http.StatusBadRequest,
|
||||
"event_time 在过去("+req.EventTime.Format(time.RFC3339)+
|
||||
")。一次性日程会立刻触发 —— 请检查时区与年份是否写对。")
|
||||
return
|
||||
}
|
||||
|
||||
okQuota, charged := guardAgentQuota(w, r, agentName)
|
||||
if !okQuota {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.ReminderText) == "" {
|
||||
req.ReminderText = defaultReminderTemplate
|
||||
}
|
||||
if req.DeliveryMode == "" {
|
||||
req.DeliveryMode = models.DeliverSeparate
|
||||
}
|
||||
|
||||
e := &models.CalendarEvent{
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
Description: req.Description,
|
||||
ReminderText: req.ReminderText,
|
||||
Recipients: recipients,
|
||||
DeliveryMode: req.DeliveryMode,
|
||||
ToAddress: recipients[0],
|
||||
EventTime: req.EventTime,
|
||||
RemindBefore: req.RemindBefore,
|
||||
Recurrence: req.Recurrence,
|
||||
RecurrenceEnd: req.RecurrenceEnd,
|
||||
Status: "active",
|
||||
// created_by 记 Agent 名。它与 users.username 共用命名空间,
|
||||
// 因此不会与人类创建者混淆。
|
||||
CreatedBy: agentName,
|
||||
}
|
||||
out, err := repo.CreateCalendarEvent(r.Context(), e)
|
||||
if err != nil {
|
||||
if charged {
|
||||
// 那次创建实际没有发生,名额还回去
|
||||
repo.ReleaseAgentCalendarEvent(r.Context(), agentName)
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "创建日程失败")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// GET /api/v1/agent/calendar/events
|
||||
//
|
||||
// 只返回本 Agent 建的事件。默认区间是「现在往后 90 天」——
|
||||
// Agent 关心的是「接下来要发生什么」,不是翻历史。
|
||||
func AgentListCalendarEvents(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
from := time.Now().Add(-24 * time.Hour)
|
||||
to := time.Now().AddDate(0, 3, 0)
|
||||
if v := r.URL.Query().Get("from"); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
from = t
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("to"); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
to = t
|
||||
}
|
||||
}
|
||||
status := r.URL.Query().Get("status")
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
if status == "all" {
|
||||
status = "" // repo 里空串 = 不过滤
|
||||
}
|
||||
|
||||
events, err := repo.ListCalendarEventsCreatedBy(r.Context(), agentName, from, to, status)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "读取日程失败")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"events": events,
|
||||
// 把上限一并回传:模型看到 42/50 才知道该清理了,
|
||||
// 只在撞墙时才用报文告知等于让它一直蒙在鼓里。
|
||||
"active_limit": maxActiveEventsPerAgent,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/agent/calendar/events/{id}
|
||||
func AgentGetCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
e := requireOwnEvent(w, r, agentName)
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, e)
|
||||
}
|
||||
|
||||
// PUT /api/v1/agent/calendar/events/{id}
|
||||
//
|
||||
// 部分更新:省略的字段保持原值。
|
||||
//
|
||||
// 与人类端点(整体替换)不同,因为调用方是模型 —— 要求它每次都回传全部
|
||||
// 字段,漏一个就会把提醒正文或收件人清空,而那种破坏没有任何报错。
|
||||
func AgentUpdateCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
cur := requireOwnEvent(w, r, agentName)
|
||||
if cur == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 全部用指针:nil = 没传 = 保持原值。
|
||||
// 用值类型的话「传了空字符串想清空说明」与「没传」无法区分。
|
||||
var req struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
ReminderText *string `json:"reminder_text"`
|
||||
Recipients *[]string `json:"recipients"`
|
||||
DeliveryMode *string `json:"delivery_mode"`
|
||||
EventTime *time.Time `json:"event_time"`
|
||||
RemindBefore *int `json:"remind_before"`
|
||||
Recurrence *string `json:"recurrence"`
|
||||
RecurrenceEnd *time.Time `json:"recurrence_end"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
next := *cur
|
||||
if req.Title != nil {
|
||||
if strings.TrimSpace(*req.Title) == "" {
|
||||
Error(w, http.StatusBadRequest, "title 不能为空")
|
||||
return
|
||||
}
|
||||
next.Title = strings.TrimSpace(*req.Title)
|
||||
}
|
||||
if req.Description != nil {
|
||||
next.Description = *req.Description
|
||||
}
|
||||
if req.ReminderText != nil {
|
||||
next.ReminderText = *req.ReminderText
|
||||
if strings.TrimSpace(next.ReminderText) == "" {
|
||||
next.ReminderText = defaultReminderTemplate
|
||||
}
|
||||
}
|
||||
if req.Recipients != nil {
|
||||
recipients, badAddr := normalizeRecipients(*req.Recipients)
|
||||
if badAddr != "" {
|
||||
Error(w, http.StatusBadRequest, "收件地址无法解析:"+badAddr)
|
||||
return
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
Error(w, http.StatusBadRequest, "recipients 不能改成空 —— 那样日程永远不会提醒任何人")
|
||||
return
|
||||
}
|
||||
if !rejectHumanRecipients(w, r, recipients) {
|
||||
return
|
||||
}
|
||||
next.Recipients = recipients
|
||||
next.ToAddress = recipients[0]
|
||||
}
|
||||
if req.DeliveryMode != nil {
|
||||
next.DeliveryMode = *req.DeliveryMode
|
||||
}
|
||||
if req.EventTime != nil {
|
||||
if req.EventTime.IsZero() {
|
||||
Error(w, http.StatusBadRequest, "event_time 无效")
|
||||
return
|
||||
}
|
||||
next.EventTime = *req.EventTime
|
||||
}
|
||||
if req.RemindBefore != nil {
|
||||
if *req.RemindBefore < 0 {
|
||||
Error(w, http.StatusBadRequest, "remind_before 不能为负")
|
||||
return
|
||||
}
|
||||
next.RemindBefore = *req.RemindBefore
|
||||
}
|
||||
if req.Recurrence != nil {
|
||||
if !validRecurrence(*req.Recurrence) {
|
||||
Error(w, http.StatusBadRequest,
|
||||
"recurrence 必须是 none/daily/weekly/monthly/yearly/lunar_monthly/lunar_yearly 之一")
|
||||
return
|
||||
}
|
||||
next.Recurrence = *req.Recurrence
|
||||
}
|
||||
if req.RecurrenceEnd != nil {
|
||||
next.RecurrenceEnd = req.RecurrenceEnd
|
||||
}
|
||||
if req.Status != nil {
|
||||
switch *req.Status {
|
||||
case "active", "paused", "cancelled":
|
||||
next.Status = *req.Status
|
||||
default:
|
||||
Error(w, http.StatusBadRequest, "status 必须是 active/paused/cancelled")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 改了时间就允许重新触发。
|
||||
//
|
||||
// 不清 fired_for 的后果:把一条已触发的事件时间往后挪,
|
||||
// DueEvents 的判据 `fired_for <> event_time` 恰好又成立了 —— 这是对的;
|
||||
// 但把时间挪成**原值**(比如只改标题时前端回传了同一个时间)不该重发。
|
||||
// 因此这里不主动清,靠 occurrence 相等自然判断即可。
|
||||
if err := repo.UpdateCalendarEvent(r.Context(), next.EventID, &next); err != nil {
|
||||
if errors.Is(err, repo.ErrEventNotFound) {
|
||||
Error(w, http.StatusNotFound, "日程不存在")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "更新日程失败")
|
||||
return
|
||||
}
|
||||
out, err := repo.GetCalendarEvent(r.Context(), next.EventID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "更新成功但读回失败")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/agent/calendar/events/{id}
|
||||
func AgentDeleteCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
e := requireOwnEvent(w, r, agentName)
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
if err := repo.DeleteCalendarEvent(r.Context(), e.EventID); err != nil {
|
||||
if errors.Is(err, repo.ErrEventNotFound) {
|
||||
Error(w, http.StatusNotFound, "日程不存在")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "删除日程失败")
|
||||
return
|
||||
}
|
||||
// 附件随事件一起清:ON DELETE CASCADE 在 SQLite 下需要 foreign_keys=ON,
|
||||
// 而那个 pragma 默认是关的,不显式删会留下孤儿记录。
|
||||
_ = repo.DeleteCalendarAttachments(r.Context(), e.EventID)
|
||||
JSON(w, http.StatusOK, map[string]any{"status": "deleted", "event_id": e.EventID})
|
||||
}
|
||||
330
server/internal/handler/agent_discovery.go
Normal file
330
server/internal/handler/agent_discovery.go
Normal file
@ -0,0 +1,330 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Agent 侧的寻址发现与线索读取。
|
||||
//
|
||||
// # 为什么需要这一组端点
|
||||
//
|
||||
// 在这之前,Agent 能读的只有自己的收件箱。`/agents`、`/contacts`、
|
||||
// `/contacts/suggest`、`/mail/{id}/thread`、`/sessions/{id}` 全部挂在
|
||||
// `middleware.UserAuth` 后面,Agent 密钥一律 401。后果是 `send_mail` 的 `to`
|
||||
// 成了一个**只能靠记忆拼写的自由文本字段**:
|
||||
//
|
||||
// - 想回给抄送方,只能从收件箱渲染出的 `抄送: opencode@/home.new` 里抄一段,
|
||||
// 而 `.new` 是一次性的,抄过去只会再建一条会话;
|
||||
// - 想知道对方接受哪个工作目录,无从查询,只能猜。生产上真实发生过一次:
|
||||
// dsh 猜了 `opencode@/home`,地址解析通过、投递成功,但 `/home` 不是
|
||||
// opencode 的工作目录 —— **猜错比报错更糟,它会静默变成新会话的 workspace**。
|
||||
//
|
||||
// 人类侧从来没有这个问题:`AddressInput` 三段式逐段查 `/contacts/suggest`,
|
||||
// name / path / session 每一段都从活数据里选。这一组端点就是把同一份能力
|
||||
// 给 Agent。
|
||||
//
|
||||
// # 为什么不直接给 Agent 复用人类那几条路由
|
||||
//
|
||||
// 两条理由:
|
||||
//
|
||||
// 1. **作用域不同。** 人类侧 `ListContactsFor(scope=username)` 的 scope 是
|
||||
// 「我参与过的会话」,管理员还能 `?all=true` 看全部。Agent 没有管理员概念,
|
||||
// 也不该看到自己没参与过的线索。把 AgentAuth 加进人类路由组,等于让
|
||||
// `middleware.GetUser` 返回 nil 的请求走进一堆假定 user 非空的 handler。
|
||||
// 2. **审计与演进。** Agent 能读什么是插件契约的一部分(PLUGIN-CONTRACT 的
|
||||
// 能力矩阵),独立成组才能在一处看全。
|
||||
//
|
||||
// # 一律只读
|
||||
//
|
||||
// 这里没有任何写端点。归档、改别名、决策权限都仍然只有人能做 ——
|
||||
// Agent 可以「看见并寻址」,但不能替人整理邮箱。
|
||||
|
||||
// GET /api/v1/agent/contacts
|
||||
//
|
||||
// 本 Agent 参与过的全部会话,每条给出可直接投递的 `address`。
|
||||
// 与人类侧 `/contacts` 同源(`repo.ListContactsFor`),scope 固定为自己。
|
||||
func AgentListContacts(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
archived := r.URL.Query().Get("archived") == "true"
|
||||
contacts, err := repo.ListContactsFor(r.Context(), agentName, archived)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list contacts")
|
||||
return
|
||||
}
|
||||
|
||||
// 联系人条目里的 agent_name 是「会话对面那个人」,但 ListContactsFor 取的是
|
||||
// 会话首封邮件的 to_name(人类侧视角:对面是 Agent)。Agent 自己调用时,
|
||||
// 首封邮件的 to_name 往往就是自己,对面反而是 from_name。
|
||||
// 因此这里补一个 peer 字段明确「该跟谁说话」,不改原字段以免动到前端。
|
||||
out := make([]map[string]any, 0, len(contacts))
|
||||
for _, c := range contacts {
|
||||
peer := c.AgentName
|
||||
if peer == agentName {
|
||||
peer = c.LastFrom
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"session_id": c.SessionID,
|
||||
"session_alias": c.SessionAlias,
|
||||
"subject": c.Subject,
|
||||
"path": c.Path,
|
||||
"status": c.Status,
|
||||
"mail_count": c.MailCount,
|
||||
"unread_count": c.UnreadCount,
|
||||
"last_activity": c.LastActivity,
|
||||
"last_from": c.LastFrom,
|
||||
"max_rounds": c.MaxRounds,
|
||||
"used_rounds": c.UsedRounds,
|
||||
// peer 是这条会话里可与之通信的另一方
|
||||
"peer": peer,
|
||||
// address 是投回这条会话的现成地址。别名为空的老会话给不出可寻址的
|
||||
// 形式,此时置空而不是拼一个 `.new` —— 那会开新线索而不是续谈。
|
||||
"address": addressForSession(peer, c.Path, c.SessionAlias),
|
||||
})
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"contacts": out})
|
||||
}
|
||||
|
||||
// addressForSession 拼「投回这条会话」的地址;无别名时返回空串。
|
||||
//
|
||||
// 刻意不退化成 `name@path`(默认会话):默认会话是「该 name@path 当前最活跃的
|
||||
// 那条」,与调用方想回的那条不一定是同一条。给一个看着能用其实指向别处的地址,
|
||||
// 比给空串危险。
|
||||
func addressForSession(name, path, alias string) string {
|
||||
if alias == "" {
|
||||
return ""
|
||||
}
|
||||
return models.FormatAddress(name, path, alias)
|
||||
}
|
||||
|
||||
// GET /api/v1/agent/contacts/suggest?name=&path=
|
||||
//
|
||||
// 三段式寻址补全,与人类侧 `/contacts/suggest` 同一套语义:
|
||||
//
|
||||
// 不带 name → 候选收件人名(在线 Agent + 活跃用户,去掉自己)
|
||||
// 带 name 不带 path → 该 name 用过的工作目录
|
||||
// name + path 都带 → 该 name@path 下可续谈的会话别名,`new` 永远在最后
|
||||
//
|
||||
// **这是「精准发信」的关键一环**:模型不再拼地址,而是逐段选。
|
||||
func AgentSuggestAddress(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(r.URL.Query().Get("name"))
|
||||
path := strings.TrimSpace(r.URL.Query().Get("path"))
|
||||
|
||||
if name == "" {
|
||||
agents, err := repo.ListAgents(r.Context(), "")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list agents")
|
||||
return
|
||||
}
|
||||
users, _ := repo.ListActiveUsernames(r.Context())
|
||||
|
||||
names := make([]string, 0, len(agents)+len(users))
|
||||
for _, a := range agents {
|
||||
if a.Name == agentName {
|
||||
continue // 不建议给自己发信
|
||||
}
|
||||
names = append(names, a.Name)
|
||||
}
|
||||
names = append(names, users...)
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"kind": "name",
|
||||
"suggestions": emptySlice(names),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
paths, _ := repo.SuggestPaths(r.Context(), name)
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"kind": "path",
|
||||
"suggestions": emptySlice(paths),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 可见性传自己的名字:只提示自己参与过的会话。
|
||||
// 传空会把别人的私下线索也列出来,那是越权。
|
||||
sessions, err := repo.SuggestSessionCandidates(r.Context(), agentName, name, path)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to suggest sessions")
|
||||
return
|
||||
}
|
||||
|
||||
aliases := make([]string, 0, len(sessions)+1)
|
||||
addresses := make([]string, 0, len(sessions)+1)
|
||||
for _, c := range sessions {
|
||||
aliases = append(aliases, c.Alias)
|
||||
addresses = append(addresses, models.FormatAddress(name, path, c.Alias))
|
||||
}
|
||||
// new 总在最后:它不是一条已存在的会话。排在前面会让模型在想续谈时
|
||||
// 顺手开出一条新线索 —— 生产上已经发生过。
|
||||
aliases = append(aliases, "new")
|
||||
addresses = append(addresses, models.FormatAddress(name, path, "new"))
|
||||
sessions = append(sessions, repo.SessionCandidate{
|
||||
Alias: "new", Source: "new", Title: "新建会话",
|
||||
})
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"kind": "session",
|
||||
"suggestions": emptySlice(aliases),
|
||||
// addresses 与 suggestions 同序,可直接塞进 send_mail 的 to
|
||||
"addresses": emptySlice(addresses),
|
||||
"candidates": emptySlice(sessions),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/agent/mail/{id}/thread
|
||||
//
|
||||
// 与人类侧 `/mail/{id}/thread` 同一份实现,可见性判据换成
|
||||
// 「本 Agent 参与过该会话」。抄送协作要靠它回答「谁已经回了、谁还没回」。
|
||||
func AgentGetMailThread(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
serveMailThread(w, r, func(sid uuid.UUID) (bool, error) {
|
||||
return repo.AgentCanAccessSession(r.Context(), agentName, sid)
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/agent/mail/{id}
|
||||
//
|
||||
// 读单封邮件全文(含抄送清单与附件)。收件箱只给摘要,
|
||||
// 而要回给抄送方就必须先看清这封信到底发给了谁。
|
||||
func AgentGetMail(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
mail, err := repo.GetMailByID(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Mail not found")
|
||||
return
|
||||
}
|
||||
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, mail.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该邮件")
|
||||
return
|
||||
}
|
||||
|
||||
fillAttachments(r, mail)
|
||||
|
||||
alias := repo.SessionAliasOf(r.Context(), mail.SessionID)
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"mail": mail,
|
||||
"session_alias": alias,
|
||||
// 回信地址与「我这个身份」都给现成的,省得插件自己拼。
|
||||
// mail.ToWorkspace 是收件方那个地址的 path 位。
|
||||
"reply_address": models.FormatAddress(mail.FromName, "", alias),
|
||||
"self_address": models.FormatAddress(agentName, mail.ToWorkspace, alias),
|
||||
"participants": participantsOf(mail, alias),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/agent/sessions/{id}/participants
|
||||
//
|
||||
// 列出该会话的全部参与方及各自的可投递地址。
|
||||
//
|
||||
// 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、以及**用什么地址找到他**。
|
||||
// 逐封邮件扫收件人与抄送,因为参与方是随往来变化的(一封转发就多一个人)。
|
||||
func AgentSessionParticipants(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
sessionID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该会话")
|
||||
return
|
||||
}
|
||||
|
||||
parts, err := repo.SessionParticipants(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list participants")
|
||||
return
|
||||
}
|
||||
|
||||
alias := repo.SessionAliasOf(r.Context(), sessionID)
|
||||
out := make([]map[string]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
out = append(out, map[string]any{
|
||||
"name": p.Name,
|
||||
"path": p.Path,
|
||||
"roles": p.Roles, // from / to / cc 的并集
|
||||
"is_self": p.Name == agentName,
|
||||
"mail_count": p.MailCount,
|
||||
// address 用**该参与方自己的 path**,不是调用方的:
|
||||
// 抄送给 opencode@/a 与主发给 dsh@/b 是两个工作区,
|
||||
// 用错 path 会让对方在别人的目录里开会话。
|
||||
"address": addressForSession(p.Name, p.Path, alias),
|
||||
})
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"session_id": sessionID,
|
||||
"session_alias": alias,
|
||||
"participants": out,
|
||||
})
|
||||
}
|
||||
|
||||
// participantsOf 从单封邮件里摘出参与方地址,供 AgentGetMail 直接返回。
|
||||
// 与 SessionParticipants 的区别:这里只看这一封(发件人 + 收件人 + 抄送),
|
||||
// 用于「回这封信时该带上谁」;那里看整条会话。
|
||||
func participantsOf(m *models.Mail, alias string) []map[string]any {
|
||||
out := []map[string]any{}
|
||||
add := func(role, name, path string) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"role": role,
|
||||
"name": name,
|
||||
"path": path,
|
||||
"address": addressForSession(name, path, alias),
|
||||
})
|
||||
}
|
||||
// from_workspace 对 Agent 存的是 Agent 名而非路径(历史遗留),
|
||||
// 拿它当 path 会拼出错地址,所以发件人一侧留空 path 走默认。
|
||||
add("from", m.FromName, "")
|
||||
add("to", m.ToName, m.ToWorkspace)
|
||||
for _, c := range m.CCList {
|
||||
add("cc", c.Name, c.Path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
359
server/internal/handler/agents.go
Normal file
359
server/internal/handler/agents.go
Normal file
@ -0,0 +1,359 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// ---------- Agent ----------
|
||||
|
||||
type registerRequest struct {
|
||||
Name string `json:"name"`
|
||||
Secret string `json:"secret"`
|
||||
Workspaces []models.Workspace `json:"workspaces"`
|
||||
Platform string `json:"platform"`
|
||||
}
|
||||
|
||||
// heartbeatRequest 是心跳可选带的上报体。
|
||||
//
|
||||
// 字段全可省:旧插件发空心跳,不能因为新增了上报就把它们报错。
|
||||
type heartbeatRequest struct {
|
||||
// PlatformSessions 是平台侧当前的会话快照(按最近活跃排序)。
|
||||
//
|
||||
// 为什么让插件上报而不是 Gateway 反向拉取:当前架构是单向的
|
||||
// (Agent 持密钥主动连 Gateway,Gateway 从不外呼)。反向拉取需要 Gateway
|
||||
// 保存各平台的地址与凭证,那是另一套信任模型。
|
||||
//
|
||||
// nil 与空数组语义不同:nil = 本次不上报(保留现有镜像),
|
||||
// 空数组 = 平台侧确实一条会话都没有(清空镜像)。
|
||||
// 拿不到会话列表的插件应当省略该字段,而不是传空数组把镜像抹掉。
|
||||
PlatformSessions []repo.PlatformSession `json:"platform_sessions"`
|
||||
|
||||
// Models 是平台当前看得见的模型目录,供配置页勾选。
|
||||
//
|
||||
// 随心跳上报而不是只在注册时上报:模型清单会在运行中变
|
||||
// (换 provider 配置、上游上下线、换了 API key)。只在注册时报一次的话,
|
||||
// 目录会静静变陈,而管理员在配置页上看到的是上次重启时的快照 ——
|
||||
// 选中一个平台已经调不到的模型,失败要到真发邮件时才暴露。
|
||||
//
|
||||
// 与 PlatformSessions 同一约定:nil = 本次不上报(保留现有目录),
|
||||
// 空数组 = 平台确实一个模型都拿不到。拿不到目录时必须省略:
|
||||
// 清空目录会让配置页变成空白,管理员以为该平台没有任何可用模型。
|
||||
Models []repo.CatalogModel `json:"models"`
|
||||
|
||||
// ModeEnforcement 是插件自报的权限档位强制能力:native / advisory。
|
||||
//
|
||||
// 为什么走心跳而不是注册:能力会在运行中变。DSH 的沙箱模式被改成
|
||||
// danger-full-access 时,它就从 native 退化成了 advisory(实测:
|
||||
// approval:"never" 会在 waterfall 之前短路,approval/request 根本不触发)。
|
||||
// 只在注册时报一次的话,发件人看到的是上次重启时的能力快照。
|
||||
//
|
||||
// 与模型目录同一条通道(I-1:平台自己说的才算)。
|
||||
// 省略 = 本次不上报,保留现有值(与 PlatformSessions / Models 同约定)。
|
||||
ModeEnforcement string `json:"mode_enforcement"`
|
||||
}
|
||||
|
||||
// POST /api/v1/agent/register
|
||||
//
|
||||
// 两种认证方式:
|
||||
// 1. Authorization: Bearer <agent_key_token> —— 密钥认证(推荐)。
|
||||
// 密钥未绑定时用本请求的 name 落定;已绑定时 name 必须与之一致,
|
||||
// 否则等于拿别人的密钥冒充新身份。
|
||||
// 2. body 里带 secret —— 旧方式,兼容保留。
|
||||
func RegisterAgent(w http.ResponseWriter, r *http.Request) {
|
||||
var req registerRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing name")
|
||||
return
|
||||
}
|
||||
|
||||
keyToken := middleware.BearerToken(r)
|
||||
if keyToken == "" && req.Secret == "" {
|
||||
Error(w, http.StatusBadRequest, "需要 Authorization: Bearer <密钥> 或 body 里的 secret")
|
||||
return
|
||||
}
|
||||
|
||||
if keyToken != "" {
|
||||
bound, err := repo.VerifyAgentKey(r.Context(), keyToken)
|
||||
if err != nil {
|
||||
writeKeyErr(w, err)
|
||||
return
|
||||
}
|
||||
if bound != "" && bound != req.Name {
|
||||
Error(w, http.StatusForbidden,
|
||||
"该密钥已绑定到 Agent \""+bound+"\",不能用于注册 \""+req.Name+"\"")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.Platform == "" {
|
||||
req.Platform = "pi"
|
||||
}
|
||||
|
||||
// 三维地址的 name 位与人类用户名共用命名空间,不得重名
|
||||
if ok, err := repo.AgentNameAvailable(r.Context(), req.Name); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to validate agent name")
|
||||
return
|
||||
} else if !ok {
|
||||
Error(w, http.StatusConflict, "该名称已被人类用户占用")
|
||||
return
|
||||
}
|
||||
if req.Name == "human" {
|
||||
Error(w, http.StatusBadRequest, "human 是保留别名,不能作为 Agent 名")
|
||||
return
|
||||
}
|
||||
|
||||
// 已退役的名字不可重建 —— 历史邮件的署名由此不会被冒用
|
||||
if retired, err := repo.IsRetiredAgentName(r.Context(), req.Name); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check agent name")
|
||||
return
|
||||
} else if retired {
|
||||
Error(w, http.StatusConflict, "该名字已退役,不可重建(历史邮件署名保护)")
|
||||
return
|
||||
}
|
||||
|
||||
// 密钥认证时不需要 secret,但 agents.secret 非空约束仍在;
|
||||
// 存密钥本身作占位,旧的 name/secret 路径不受影响。
|
||||
secret := req.Secret
|
||||
if secret == "" {
|
||||
secret = keyToken
|
||||
}
|
||||
|
||||
if err := repo.CreateOrUpdateAgent(r.Context(), req.Name, secret, req.Platform, req.Workspaces); err != nil {
|
||||
// 已停用的 Agent 不得靠重新注册复活。回 403 而不是 500:
|
||||
// 这是一个明确的策略拒绝,插件应当停止重试并把原因打出来。
|
||||
if errors.Is(err, repo.ErrAgentDisabled) {
|
||||
Error(w, http.StatusForbidden,
|
||||
"Agent \""+req.Name+"\" 已被管理员停用,无法注册。"+
|
||||
"如需重新启用,请在管理页「默认预算」里恢复它。")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to register agent")
|
||||
return
|
||||
}
|
||||
|
||||
// 待绑定密钥在首次注册成功后落定到该 Agent
|
||||
if keyToken != "" {
|
||||
if err := repo.ClaimAgentKey(r.Context(), keyToken, req.Name); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to bind key")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]string{
|
||||
"status": "registered",
|
||||
"agent_name": req.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/agent/heartbeat
|
||||
func HeartbeatAgent(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
pending, err := repo.HeartbeatAgent(r.Context(), agentName)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to heartbeat")
|
||||
return
|
||||
}
|
||||
|
||||
// 可选的平台会话快照。解不开就当作没带:心跳的主职责是「我还活着」,
|
||||
// 不该因为上报体格式不对就把 Agent 判成离线。
|
||||
//
|
||||
// 但**未知字段必须回报**(resp["unknown_fields"]):这是全站唯一一处宽容
|
||||
// 解码的端点,若还静默忽略,插件把 `models` 拼成 `modles` 就永远没人知道 ——
|
||||
// 而那与 `attachments` vs `attachment_ids` 是同一种事故形状。
|
||||
var req heartbeatRequest
|
||||
var unknownFields []string
|
||||
if r.ContentLength > 0 {
|
||||
unknownFields, _ = DecodeLenient(r, &req)
|
||||
}
|
||||
syncedSessions := -1 // -1 = 本次未上报
|
||||
if req.PlatformSessions != nil {
|
||||
if err := repo.ReplacePlatformSessions(r.Context(), agentName, req.PlatformSessions); err != nil {
|
||||
// 镜像写失败只影响候选补全,不影响投递,因此不报错
|
||||
syncedSessions = -1
|
||||
} else {
|
||||
syncedSessions = len(req.PlatformSessions)
|
||||
}
|
||||
}
|
||||
|
||||
// 模型目录同理:写失败只让配置页看到的目录陈一轮,下一次心跳会补上。
|
||||
syncedModels := -1
|
||||
if req.Models != nil {
|
||||
if err := repo.ReplaceModelCatalog(r.Context(), agentName, req.Models); err == nil {
|
||||
syncedModels = len(req.Models)
|
||||
}
|
||||
}
|
||||
|
||||
// 档位强制能力:省略时不动(保留现有值)。
|
||||
// 写失败不影响心跳本身 —— 心跳的主职责是「我还活着」。
|
||||
if req.ModeEnforcement != "" {
|
||||
_ = repo.SetAgentModeEnforcement(r.Context(), agentName, req.ModeEnforcement)
|
||||
}
|
||||
|
||||
// 心跳回传该 Agent 的累计统计与新任务默认预算。
|
||||
//
|
||||
// 不再回传「剩余额度」:额度属于具体任务(会话)而不属于 Agent,
|
||||
// 剩余往返随每次发信响应(budget_remaining)回传,在那里才有意义。
|
||||
stats, sErr := repo.GetAgentStats(r.Context(), agentName)
|
||||
if sErr != nil {
|
||||
// 统计读不到不影响心跳本身
|
||||
stats = repo.AgentStats{AgentName: agentName}
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"status": "ok",
|
||||
"pending_mails": pending,
|
||||
"stats": stats,
|
||||
}
|
||||
if syncedSessions >= 0 {
|
||||
resp["platform_sessions_synced"] = syncedSessions
|
||||
}
|
||||
if syncedModels >= 0 {
|
||||
resp["models_synced"] = syncedModels
|
||||
}
|
||||
// 回传当前生效的模型范围,插件无需另起一个请求去读。
|
||||
//
|
||||
// 随心跳回传而不是让插件自己轮询:管理员在配置页改了范围后,
|
||||
// 插件最多一个心跳周期(30 秒)就能看到新值,不需要重启。
|
||||
if allowed, aErr := repo.ListAllowedModels(r.Context(), agentName); aErr == nil {
|
||||
resp["allowed_models"] = allowed
|
||||
resp["models_unrestricted"] = len(allowed) == 0
|
||||
}
|
||||
// 未知字段回报:只有真的出现时才带这一项,正常心跳的响应不多一个空数组。
|
||||
// 插件看到它就知道自己上报的某个字段服务端根本没收。
|
||||
if len(unknownFields) > 0 {
|
||||
resp["unknown_fields"] = unknownFields
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GET /api/v1/agents
|
||||
func ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||
statusFilter := r.URL.Query().Get("status")
|
||||
|
||||
agents, err := repo.ListAgents(r.Context(), statusFilter)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list agents")
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"agents": emptySlice(agents),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 停用 / 恢复 ----------
|
||||
|
||||
type setAgentStatusRequest struct {
|
||||
// Disabled true = 停用,false = 恢复
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
// PUT /api/v1/admin/agents/{name}/status —— 停用或恢复一个 Agent
|
||||
//
|
||||
// 停用是可逆的「归档」,比 DELETE 轻一档:
|
||||
// - 邮件、会话、权限记录、转发幂等键全部保留(往来里有一半是人自己写的)
|
||||
// - 从地址补全、GET /agents、可授权范围里消失
|
||||
// - 全部密钥被撤销,插件拿不到新任务也发不出信
|
||||
// - 重新注册会被拒(否则插件下次启动就把它复活了)
|
||||
// - 别人发信给它得到 409(见 repo.RecipientDeliverable)
|
||||
//
|
||||
// 与 DELETE 的分工:停用留着运行态随时可恢复,删除清掉运行态且名字退役。
|
||||
func AdminSetAgentStatus(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimSpace(chi.URLParam(r, "name"))
|
||||
if name == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing agent name")
|
||||
return
|
||||
}
|
||||
|
||||
var req setAgentStatusRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
revoked, err := repo.SetAgentDisabled(r.Context(), name, req.Disabled)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
Error(w, http.StatusNotFound, "Agent 不存在: "+name)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to update agent status")
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"agent_name": name,
|
||||
"disabled": req.Disabled,
|
||||
}
|
||||
if req.Disabled {
|
||||
resp["keys_revoked"] = revoked
|
||||
resp["detail"] = "已停用。邮件与会话保留;该 Agent 的密钥已全部撤销," +
|
||||
"恢复后需要重新签发。停用期间别人发信给它会收到 409。"
|
||||
} else {
|
||||
resp["needs_new_key"] = true
|
||||
resp["detail"] = "已恢复为离线状态。停用时撤销的密钥不会自动回来 —— " +
|
||||
"必须在「密钥」面板重新签发一把并写进该插件的配置," +
|
||||
"否则它会一直拿旧密钥重试并被拒(401)。"
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/admin/agents/{name}
|
||||
//
|
||||
// 删除 Agent 的全部运行态,保留邮件历史。
|
||||
//
|
||||
// 取舍(见 PLUGIN-CONTRACT.md):
|
||||
// - 邮件与会话不删(是审计凭据,且往来里有一半是人自己写的)
|
||||
// - 它建的日历事件置 cancelled(留着会由调度器一直触发,发信人却已不存在)
|
||||
// - 名字立即不可重建(Agent 名与人类用户名共用命名空间,
|
||||
// 否则下一个同名注册者会看起来像是历史邮件的发信人)
|
||||
func AdminDeleteAgent(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimSpace(chi.URLParam(r, "name"))
|
||||
if name == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing agent name")
|
||||
return
|
||||
}
|
||||
// 人类账号不走这个端点。硬编码某个用户名会在换管理员时失效,
|
||||
// 所以按「是不是人类用户」判定。
|
||||
if isHuman, hErr := repo.IsHumanUser(r.Context(), name); hErr != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check recipient")
|
||||
return
|
||||
} else if isHuman {
|
||||
Error(w, http.StatusForbidden,
|
||||
"\""+name+"\" 是人类用户,不能用这个端点删除(请去用户管理)")
|
||||
return
|
||||
}
|
||||
|
||||
keysRevoked, err := repo.DeleteAgent(r.Context(), name)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
Error(w, http.StatusNotFound, "Agent 不存在: "+name)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to delete agent")
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"agent_name": name,
|
||||
"keys_revoked": keysRevoked,
|
||||
"detail": "已删除。邮件与会话保留(审计凭据);" +
|
||||
"密钥、平台会话镜像、模型范围已清除;它建的日历事件已置为取消。" +
|
||||
"此名字今后不可再注册(历史邮件的署名由此不会被冒用)。",
|
||||
})
|
||||
}
|
||||
69
server/internal/handler/alias_test.go
Normal file
69
server/internal/handler/alias_test.go
Normal file
@ -0,0 +1,69 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
// 平台侧 slug/标题不受本侧寻址约束,normalizeAlias 必须把它改写成
|
||||
// 能安全出现在 name@path.<alias> 末段的形式。
|
||||
func TestNormalizeAlias(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
// opencode 风格 slug 原样通过
|
||||
{"jolly-cactus", "jolly-cactus"},
|
||||
{"fix-memory-leak", "fix-memory-leak"},
|
||||
|
||||
// 非法字符统一换 -,连续的压缩成一个
|
||||
{"fix.memory.leak", "fix-memory-leak"},
|
||||
{"修复 登录态 丢失", "修复-登录态-丢失"},
|
||||
{"a//b..c", "a-b-c"},
|
||||
{"user@host", "user-host"},
|
||||
|
||||
// 首尾的分隔符要去掉
|
||||
{".leading", "leading"},
|
||||
{"trailing.", "trailing"},
|
||||
{" spaced ", "spaced"},
|
||||
|
||||
// 保留字必须避开,否则会被寻址当成「新建会话」
|
||||
{"new", "session-new"},
|
||||
|
||||
// 全是非法字符 → 空串,交由调用方报错
|
||||
{"...", ""},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
if got := normalizeAlias(c.in); got != c.want {
|
||||
t.Errorf("normalizeAlias(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 规范化后的别名必须能通过寻址校验,否则同步会写进一个自己都拒绝的别名。
|
||||
func TestNormalizeAliasPassesValidation(t *testing.T) {
|
||||
for _, in := range []string{
|
||||
"jolly-cactus", "fix.memory.leak", "修复 登录态 丢失", "new", "user@host/path",
|
||||
} {
|
||||
norm := normalizeAlias(in)
|
||||
if norm == "" {
|
||||
continue
|
||||
}
|
||||
if err := validateSessionAlias(norm); err != nil {
|
||||
t.Errorf("normalizeAlias(%q) = %q,但未通过 validateSessionAlias: %v", in, norm, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 截断长别名时不能切坏多字节字符(session_alias 是 VARCHAR(128))。
|
||||
func TestNormalizeAliasTruncatesOnValidUTF8(t *testing.T) {
|
||||
long := ""
|
||||
for i := 0; i < 100; i++ {
|
||||
long += "修" // 每个 3 字节,共 300 字节
|
||||
}
|
||||
got := normalizeAlias(long)
|
||||
if len(got) > 128 {
|
||||
t.Errorf("normalizeAlias 截断后 %d 字节,超过 128", len(got))
|
||||
}
|
||||
for _, r := range got {
|
||||
if r == '\uFFFD' {
|
||||
t.Fatalf("normalizeAlias 截断产生了非法 UTF-8: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
419
server/internal/handler/attachments.go
Normal file
419
server/internal/handler/attachments.go
Normal file
@ -0,0 +1,419 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/agentmail/gateway/internal/blob"
|
||||
"github.com/agentmail/gateway/internal/config"
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 附件 ----------
|
||||
//
|
||||
// 上传与发信是两步:
|
||||
// 1. POST /attachments (multipart)→ 拿到 attachment_id
|
||||
// 2. 发信时把 id 放进 attachment_ids
|
||||
// 之所以不合成一步:Agent 侧的工具接口是 JSON,没法带 multipart;
|
||||
// 而人类侧若只支持一步,就无法在写信过程中先传文件再改正文。
|
||||
//
|
||||
// 未挂载的附件是合法中间态,超时由 GC 清理(repo.SweepOrphanAttachments)。
|
||||
|
||||
// Blobs 是附件内容存储,由 main 在启动时注入。
|
||||
var Blobs *blob.Store
|
||||
|
||||
// sanitizeFilename 清理用户提供的文件名。
|
||||
//
|
||||
// 文件名只用于展示与下载时的 Content-Disposition,磁盘路径完全由 sha256 派生,
|
||||
// 因此这里的目的不是防路径穿越(那已由内容寻址杜绝),而是:
|
||||
// - 去掉目录成分,避免下载时浏览器按 "a/b/c.txt" 解释
|
||||
// - 去掉控制字符与换行,避免污染 HTTP 响应头
|
||||
// - 限长,避免超出数据库列宽
|
||||
func sanitizeFilename(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
// 同时处理 / 与 \:上传方可能是 Windows 客户端
|
||||
if i := strings.LastIndexAny(name, `/\`); i >= 0 {
|
||||
name = name[i+1:]
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
continue // 控制字符一律丢弃
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
name = strings.TrimSpace(b.String())
|
||||
|
||||
// "." 与 ".." 作为文件名毫无意义,且容易在各层被特殊解释
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return "unnamed"
|
||||
}
|
||||
|
||||
const maxBytes = 255
|
||||
if len(name) > maxBytes {
|
||||
cut := name[:maxBytes]
|
||||
for len(cut) > 0 && !utf8.ValidString(cut) {
|
||||
cut = cut[:len(cut)-1]
|
||||
}
|
||||
name = cut
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// detectContentType 优先用客户端声明的类型,缺失时按扩展名猜,兜底 octet-stream。
|
||||
// 无论如何都不回显未经处理的客户端值到响应头(下载时统一用 octet-stream,见 DownloadAttachment)。
|
||||
func detectContentType(declared, filename string) string {
|
||||
if ct := strings.TrimSpace(declared); ct != "" && ct != "application/octet-stream" {
|
||||
if parsed, _, err := mime.ParseMediaType(ct); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
if ext := filepath.Ext(filename); ext != "" {
|
||||
if byExt := mime.TypeByExtension(ext); byExt != "" {
|
||||
if parsed, _, err := mime.ParseMediaType(byExt); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// uploadAttachment 是 Agent 与人类两条上传路径的公共实现。
|
||||
func uploadAttachment(w http.ResponseWriter, r *http.Request, uploader string) {
|
||||
if Blobs == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "附件存储未初始化")
|
||||
return
|
||||
}
|
||||
|
||||
max := config.C.MaxAttachmentBytes
|
||||
|
||||
// 双层限制:MaxBytesReader 卡整个请求体(含 multipart 边界与其他字段),
|
||||
// blob.Put 的 max 卡单个文件内容。少了外层,攻击者可以用超大 multipart 头拖死内存。
|
||||
r.Body = http.MaxBytesReader(w, r.Body, max+1<<20)
|
||||
|
||||
// 32MB 内存缓冲上限,超出部分 multipart 会自动落临时文件
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
Error(w, http.StatusBadRequest, "解析 multipart 失败(是否超过大小上限?)")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if r.MultipartForm != nil {
|
||||
r.MultipartForm.RemoveAll()
|
||||
}
|
||||
}()
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "缺少 file 字段")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
name := sanitizeFilename(header.Filename)
|
||||
ctype := detectContentType(header.Header.Get("Content-Type"), name)
|
||||
|
||||
// 先落盘再入库:反过来会出现「库里有记录、磁盘没文件」的下载 500
|
||||
sum, size, err := Blobs.Put(file, max)
|
||||
if errors.Is(err, blob.ErrTooLarge) {
|
||||
Error(w, http.StatusRequestEntityTooLarge,
|
||||
fmt.Sprintf("附件超过上限 %.1f MB", float64(max)/(1<<20)))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "保存附件失败")
|
||||
return
|
||||
}
|
||||
|
||||
a, err := repo.CreateAttachment(r.Context(), uploader, name, ctype, size, sum)
|
||||
if err != nil {
|
||||
// 落盘成功但入库失败:留下的孤立文件由 GC 回收,不影响正确性
|
||||
Error(w, http.StatusInternalServerError, "登记附件失败")
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{"attachment": a})
|
||||
}
|
||||
|
||||
// POST /api/v1/attachments —— Agent 侧上传
|
||||
func UploadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
uploadAttachment(w, r, agentName)
|
||||
}
|
||||
|
||||
// POST /api/v1/me/attachments —— 人类侧上传
|
||||
func MeUploadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
uploadAttachment(w, r, user.Username)
|
||||
}
|
||||
|
||||
// downloadAttachment 是 Agent 与人类两条下载路径的公共实现。
|
||||
func downloadAttachment(w http.ResponseWriter, r *http.Request, viewer string) {
|
||||
if Blobs == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "附件存储未初始化")
|
||||
return
|
||||
}
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
a, err := repo.GetAttachment(r.Context(), id)
|
||||
if errors.Is(err, repo.ErrAttachmentNotFound) {
|
||||
Error(w, http.StatusNotFound, "附件不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "读取附件失败")
|
||||
return
|
||||
}
|
||||
|
||||
allowed, err := repo.AttachmentAccessible(r.Context(), a, viewer)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "校验权限失败")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该附件")
|
||||
return
|
||||
}
|
||||
|
||||
f, err := Blobs.Open(a.SHA256)
|
||||
if err != nil {
|
||||
// 元数据在库但文件不在盘:说明存储被外部改动过,这是运维问题而非用户输入问题
|
||||
Error(w, http.StatusInternalServerError, "附件内容缺失")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// 一律 octet-stream + attachment:绝不按声明的 MIME 内联渲染。
|
||||
// 否则一个上传的 .html/.svg 就能在本站域下执行脚本,等于自带 XSS。
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", a.SizeBytes))
|
||||
w.Header().Set("Content-Disposition", contentDisposition(a.Filename))
|
||||
|
||||
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
|
||||
}
|
||||
|
||||
// contentDisposition 构造下载头。
|
||||
// filename* 用 RFC 5987 编码承载非 ASCII 名字,filename= 给只认 ASCII 的老客户端兜底;
|
||||
// 兜底值里的引号与反斜杠必须去掉,否则能截断响应头。
|
||||
func contentDisposition(name string) string {
|
||||
var ascii strings.Builder
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r == '"' || r == '\\':
|
||||
ascii.WriteByte('_')
|
||||
case r < 0x20 || r > 0x7e:
|
||||
ascii.WriteByte('_')
|
||||
default:
|
||||
ascii.WriteRune(r)
|
||||
}
|
||||
}
|
||||
fallback := ascii.String()
|
||||
if fallback == "" {
|
||||
fallback = "attachment"
|
||||
}
|
||||
return fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`,
|
||||
fallback, urlEncodeRFC5987(name))
|
||||
}
|
||||
|
||||
// urlEncodeRFC5987 按 RFC 5987 的 attr-char 集合做百分号编码。
|
||||
func urlEncodeRFC5987(s string) string {
|
||||
const safe = "!#$&+-.^_`|~" // attr-char 中除字母数字外允许的字符
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
isAlnum := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
|
||||
if isAlnum || strings.IndexByte(safe, c) >= 0 {
|
||||
b.WriteByte(c)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%%%02X", c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// GET /api/v1/attachments/{id} —— Agent 侧下载
|
||||
func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
downloadAttachment(w, r, agentName)
|
||||
}
|
||||
|
||||
// GET /api/v1/me/attachments/{id} —— 人类侧下载
|
||||
func MeDownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
downloadAttachment(w, r, user.Username)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/me/attachments/{id} —— 删除自己上传且尚未挂载的附件
|
||||
//
|
||||
// 已挂载的不允许删:邮件是不可篡改的历史记录,附件是它的一部分。
|
||||
func MeDeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
a, err := repo.GetAttachment(r.Context(), id)
|
||||
if errors.Is(err, repo.ErrAttachmentNotFound) {
|
||||
Error(w, http.StatusNotFound, "附件不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "读取附件失败")
|
||||
return
|
||||
}
|
||||
if a.Uploader != user.Username {
|
||||
Error(w, http.StatusForbidden, "只能删除自己上传的附件")
|
||||
return
|
||||
}
|
||||
if a.MailID != nil {
|
||||
Error(w, http.StatusConflict, "附件已随邮件发出,不能删除")
|
||||
return
|
||||
}
|
||||
|
||||
sum, orphaned, err := repo.DeleteAttachment(r.Context(), id)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "删除附件失败")
|
||||
return
|
||||
}
|
||||
// 内容寻址下多条记录可能共享同一文件,只有最后一条引用消失才删磁盘
|
||||
if orphaned && Blobs != nil {
|
||||
_ = Blobs.Remove(sum)
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// parseAttachmentIDs 把请求里的附件 id 列表解析为 UUID。
|
||||
func parseAttachmentIDs(raw []string) ([]uuid.UUID, error) {
|
||||
out := make([]uuid.UUID, 0, len(raw))
|
||||
for _, s := range raw {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
id, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("非法的 attachment_id %q", s)
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// attachAll 把附件挂到刚创建的邮件上,并把错误翻译成 HTTP 响应。
|
||||
// 返回 false 表示已写出错误响应,调用方应立即返回。
|
||||
func attachAll(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, ids []uuid.UUID, uploader string) bool {
|
||||
if len(ids) == 0 {
|
||||
return true
|
||||
}
|
||||
return writeAttachErr(w, repo.AttachToMail(r.Context(), mailID, ids, uploader))
|
||||
}
|
||||
|
||||
// checkAttachable 在**产生任何副作用之前**校验附件可不可挂。
|
||||
//
|
||||
// 返回 false 表示已写出错误响应,调用方应立即返回。
|
||||
//
|
||||
// # 为什么不能只靠 attachAll
|
||||
//
|
||||
// attachAll 在 CreateMail **之后**调用,于是附件不合法时请求返回 403/409,
|
||||
// 但那封邮件**已经入库、已经通知了收件人、已经扣掉了会话预算**。
|
||||
// 生产实测:两封探针邮件(一封 403「只能附加自己上传的附件」、一封 409
|
||||
// 「附件已随其他邮件发出」)都躺在 mails 表里,used_rounds 也涨了。
|
||||
// 发件方看到 4xx 会重试,收件方于是收到两封。
|
||||
//
|
||||
// 纯输入校验必须在副作用之前做完 —— 与「400 之后会话已建好」是同一个教训。
|
||||
//
|
||||
// 它**不取代** attachAll:两次调用之间仍有竞态窗口(另一个请求把同一个附件
|
||||
// 挂走了),那一次由 attachAll 的原子 UPDATE 拦下、并由调用方回滚。
|
||||
// 双层分工:这里挡住绝大多数(拼错 id、拿别人的附件、重复挂),
|
||||
// attachAll 挡住真正的并发。
|
||||
func checkAttachable(w http.ResponseWriter, r *http.Request, ids []uuid.UUID, uploader string) bool {
|
||||
if len(ids) == 0 {
|
||||
return true
|
||||
}
|
||||
return writeAttachErr(w, repo.EnsureAttachable(r.Context(), ids, uploader))
|
||||
}
|
||||
|
||||
// writeAttachErr 把 repo 层的附件错误映射成 HTTP 响应。
|
||||
//
|
||||
// checkAttachable 与 attachAll 共用一份:同一种错误在两条路径上必须给出同一个
|
||||
// 状态码与同一句话 —— 分开写早晚会分叉,而调用方无法区分自己碰上的是哪一层。
|
||||
func writeAttachErr(w http.ResponseWriter, err error) bool {
|
||||
switch {
|
||||
case err == nil:
|
||||
return true
|
||||
case errors.Is(err, repo.ErrAttachmentNotFound):
|
||||
Error(w, http.StatusNotFound, "附件不存在")
|
||||
case errors.Is(err, repo.ErrAttachmentNotOwned):
|
||||
Error(w, http.StatusForbidden, "只能附加自己上传的附件")
|
||||
case errors.Is(err, repo.ErrAttachmentAlreadyAttached):
|
||||
Error(w, http.StatusConflict, "附件已随其他邮件发出")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "附加附件失败")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fillAttachments 给邮件填充附件列表(读取单封/线程时用)。
|
||||
// 读附件失败不该让整封邮件打不开,因此吞错只留空列表。
|
||||
//
|
||||
// 一批邮件走一次查询:逐封调 ListAttachmentsFor 是 N+1,
|
||||
// 一个 200 封的会话打开一次要打 200 次库。
|
||||
func fillAttachments(r *http.Request, mails ...*models.Mail) {
|
||||
ids := make([]uuid.UUID, 0, len(mails))
|
||||
for _, m := range mails {
|
||||
if m != nil {
|
||||
ids = append(ids, m.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
byMail, err := repo.ListAttachmentsForMails(r.Context(), ids)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, m := range mails {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
// 没有附件的邮件保持 nil:Attachments 带 omitempty,
|
||||
// 填空切片只会给每封邮件的 JSON 加一个 "attachments":[]
|
||||
if as := byMail[m.ID]; len(as) > 0 {
|
||||
m.Attachments = as
|
||||
}
|
||||
}
|
||||
}
|
||||
138
server/internal/handler/attachments_test.go
Normal file
138
server/internal/handler/attachments_test.go
Normal file
@ -0,0 +1,138 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 文件名只用于展示与下载头;磁盘路径由 sha256 派生,
|
||||
// 因此这里守的是「不污染 HTTP 头、不被当成目录」而非路径穿越。
|
||||
func TestSanitizeFilename(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"report.pdf", "report.pdf"},
|
||||
{"中文 文件名.txt", "中文 文件名.txt"},
|
||||
|
||||
// 目录成分必须剥掉(含 Windows 风格)
|
||||
{"../../etc/passwd", "passwd"},
|
||||
{"/abs/path/x.log", "x.log"},
|
||||
{`C:\Users\me\a.txt`, "a.txt"},
|
||||
{"a/b/c.txt", "c.txt"},
|
||||
|
||||
// 控制字符会污染 Content-Disposition
|
||||
{"bad\r\nname.txt", "badname.txt"},
|
||||
{"tab\there.txt", "tabhere.txt"},
|
||||
|
||||
// 无意义的名字兜底
|
||||
{"", "unnamed"},
|
||||
{" ", "unnamed"},
|
||||
{".", "unnamed"},
|
||||
{"..", "unnamed"},
|
||||
{"/", "unnamed"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := sanitizeFilename(c.in); got != c.want {
|
||||
t.Errorf("sanitizeFilename(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 超长名字按 UTF-8 边界截断,不产生非法序列。
|
||||
func TestSanitizeFilenameTruncates(t *testing.T) {
|
||||
long := strings.Repeat("中", 200) + ".txt" // 每字 3 字节,共 600+
|
||||
got := sanitizeFilename(long)
|
||||
if len(got) > 255 {
|
||||
t.Errorf("截断后 %d 字节,超过 255", len(got))
|
||||
}
|
||||
for _, r := range got {
|
||||
if r == '\uFFFD' {
|
||||
t.Fatalf("截断产生非法 UTF-8: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectContentType(t *testing.T) {
|
||||
cases := []struct{ declared, filename, want string }{
|
||||
{"application/pdf", "x.pdf", "application/pdf"},
|
||||
// 客户端没给类型时按扩展名猜
|
||||
{"", "notes.txt", "text/plain"},
|
||||
{"application/octet-stream", "data.json", "application/json"},
|
||||
// 带参数的声明要剥掉参数
|
||||
{"text/plain; charset=utf-8", "a.txt", "text/plain"},
|
||||
// 认不出就兜底
|
||||
{"", "blob.unknownext", "application/octet-stream"},
|
||||
{"garbage//not-a-type", "blob.unknownext", "application/octet-stream"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := detectContentType(c.declared, c.filename)
|
||||
// mime.TypeByExtension 在不同系统上可能返回带参数的值,只比主类型
|
||||
if !strings.HasPrefix(got, c.want) {
|
||||
t.Errorf("detectContentType(%q, %q) = %q, want prefix %q",
|
||||
c.declared, c.filename, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content-Disposition 必须双写:filename* 承载 UTF-8,filename= 给老客户端兜底。
|
||||
// 兜底值里的引号/反斜杠/非 ASCII 一律换成下划线,否则能截断响应头。
|
||||
func TestContentDisposition(t *testing.T) {
|
||||
got := contentDisposition("报告 v2.pdf")
|
||||
if !strings.HasPrefix(got, "attachment; ") {
|
||||
t.Errorf("必须以 attachment 开头: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "filename*=UTF-8''") {
|
||||
t.Errorf("缺少 RFC 5987 编码: %s", got)
|
||||
}
|
||||
// 非 ASCII 不能出现在 filename= 的兜底值里
|
||||
ascii := got[:strings.Index(got, "filename*=")]
|
||||
for _, r := range ascii {
|
||||
if r > 0x7e {
|
||||
t.Errorf("兜底 filename 含非 ASCII 字符 %q: %s", r, ascii)
|
||||
}
|
||||
}
|
||||
|
||||
// 引号注入不能逃出引号
|
||||
evil := contentDisposition(`a"; x="y`)
|
||||
if strings.Contains(evil[:strings.Index(evil, "filename*=")], `"; x=`) {
|
||||
t.Errorf("引号未转义,可截断响应头: %s", evil)
|
||||
}
|
||||
|
||||
// 控制字符(若绕过 sanitize 直达此处)也不能出现
|
||||
ctl := contentDisposition("a\r\nb.txt")
|
||||
if strings.ContainsAny(ctl, "\r\n") {
|
||||
t.Errorf("响应头含换行: %q", ctl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLEncodeRFC5987(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"abc.txt", "abc.txt"},
|
||||
{"a b", "a%20b"},
|
||||
{"中", "%E4%B8%AD"},
|
||||
{`a"b`, "a%22b"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := urlEncodeRFC5987(c.in); got != c.want {
|
||||
t.Errorf("urlEncodeRFC5987(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAttachmentIDs(t *testing.T) {
|
||||
valid := "3f2504e0-4f89-11d3-9a0c-0305e82c3301"
|
||||
|
||||
got, err := parseAttachmentIDs([]string{valid, " ", ""})
|
||||
if err != nil {
|
||||
t.Fatalf("合法输入报错: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Errorf("空白项应被忽略,得到 %d 个", len(got))
|
||||
}
|
||||
|
||||
if _, err := parseAttachmentIDs([]string{"not-a-uuid"}); err == nil {
|
||||
t.Error("非法 UUID 应报错")
|
||||
}
|
||||
|
||||
if got, err := parseAttachmentIDs(nil); err != nil || len(got) != 0 {
|
||||
t.Errorf("nil 应返回空列表,得到 %v, %v", got, err)
|
||||
}
|
||||
}
|
||||
408
server/internal/handler/auth.go
Normal file
408
server/internal/handler/auth.go
Normal file
@ -0,0 +1,408 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 登录 / 登出 / 自身信息 ----------
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type userOut struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
AllowedAgents []string `json:"allowed_agents"`
|
||||
AllowedPaths []string `json:"allowed_paths"`
|
||||
LastLogin string `json:"last_login,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
func toUserOut(u *models.User) userOut {
|
||||
o := userOut{
|
||||
UserID: u.ID.String(),
|
||||
Username: u.Username,
|
||||
DisplayName: u.DisplayName,
|
||||
Role: u.Role,
|
||||
Status: u.Status,
|
||||
AllowedAgents: emptySlice(u.AllowedAgents),
|
||||
AllowedPaths: emptySlice(u.AllowedPaths),
|
||||
CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
if u.LastLogin != nil {
|
||||
o.LastLogin = u.LastLogin.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// ---------- 首次初始化 ----------
|
||||
|
||||
// GET /api/v1/setup/status —— 公开:前端据此判断是否展示初始化向导
|
||||
func SetupStatus(w http.ResponseWriter, r *http.Request) {
|
||||
needs, err := repo.NeedsSetup(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check setup status")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]bool{"needs_setup": needs})
|
||||
}
|
||||
|
||||
type setupRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// POST /api/v1/setup/admin —— 公开,但仅在系统无任何用户时可用
|
||||
func SetupAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
var req setupRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
Error(w, http.StatusBadRequest, "密码自少 8 位")
|
||||
return
|
||||
}
|
||||
|
||||
u, err := repo.SetupFirstAdmin(r.Context(), req.Username, req.Password, req.DisplayName)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, repo.ErrAlreadySetup):
|
||||
Error(w, http.StatusConflict, "系统已初始化,请直接登录")
|
||||
case errors.Is(err, repo.ErrInvalidUsername):
|
||||
Error(w, http.StatusBadRequest, "用户名只能是 2-64 位的小写字母、数字、点、下划线、连字符,且不能为 human")
|
||||
case errors.Is(err, repo.ErrNameTaken):
|
||||
Error(w, http.StatusConflict, "该名称已被 Agent 占用")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "初始化失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化后直接登录
|
||||
token, expires, err := repo.CreateUserSession(r.Context(), u.ID, r.UserAgent())
|
||||
if err == nil {
|
||||
maxAge := int(time.Until(expires).Seconds())
|
||||
if maxAge < 0 {
|
||||
maxAge = 0
|
||||
}
|
||||
middleware.SetSessionCookie(w, token, maxAge)
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/login
|
||||
func Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(req.Username))
|
||||
if name == "" || req.Password == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing username or password")
|
||||
return
|
||||
}
|
||||
|
||||
if locked, remain := limiter.Locked(r.Context(), name); locked {
|
||||
JSON(w, http.StatusTooManyRequests, map[string]interface{}{
|
||||
"error": "尝试过于频繁,请稍后再试",
|
||||
"retry_after": remain,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
u, err := repo.Authenticate(r.Context(), name, req.Password)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, repo.ErrBadCredentials):
|
||||
limiter.Fail(r.Context(), name)
|
||||
Error(w, http.StatusUnauthorized, "用户名或密码错误")
|
||||
case errors.Is(err, repo.ErrUserDisabled):
|
||||
Error(w, http.StatusForbidden, "账号已被禁用")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "登录失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
limiter.Reset(r.Context(), name)
|
||||
|
||||
token, expires, err := repo.CreateUserSession(r.Context(), u.ID, r.UserAgent())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "无法创建会话")
|
||||
return
|
||||
}
|
||||
maxAge := int(time.Until(expires).Seconds())
|
||||
if maxAge < 0 {
|
||||
maxAge = 0
|
||||
}
|
||||
middleware.SetSessionCookie(w, token, maxAge)
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"user": toUserOut(u),
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/logout
|
||||
func Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if token := middleware.SessionToken(r); token != "" {
|
||||
_ = repo.DeleteUserSession(r.Context(), token)
|
||||
}
|
||||
middleware.ClearSessionCookie(w)
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
|
||||
}
|
||||
|
||||
// GET /api/v1/auth/me
|
||||
func Me(w http.ResponseWriter, r *http.Request) {
|
||||
u := middleware.GetUser(r)
|
||||
if u == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
|
||||
}
|
||||
|
||||
type changePasswordRequest struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/password
|
||||
func ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
u := middleware.GetUser(r)
|
||||
if u == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req changePasswordRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < 8 {
|
||||
Error(w, http.StatusBadRequest, "新密码至少 8 位")
|
||||
return
|
||||
}
|
||||
if _, err := repo.Authenticate(r.Context(), u.Username, req.OldPassword); err != nil {
|
||||
Error(w, http.StatusUnauthorized, "原密码错误")
|
||||
return
|
||||
}
|
||||
if err := repo.SetPassword(r.Context(), u.ID, req.NewPassword); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "修改密码失败")
|
||||
return
|
||||
}
|
||||
middleware.ClearSessionCookie(w)
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "password_changed"})
|
||||
}
|
||||
|
||||
// ---------- 管理员:用户管理 ----------
|
||||
|
||||
// GET /api/v1/admin/users
|
||||
func AdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := repo.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list users")
|
||||
return
|
||||
}
|
||||
out := make([]userOut, 0, len(users))
|
||||
for i := range users {
|
||||
out = append(out, toUserOut(&users[i]))
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"users": out})
|
||||
}
|
||||
|
||||
type createUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
AllowedAgents []string `json:"allowed_agents"`
|
||||
AllowedPaths []string `json:"allowed_paths"`
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/users
|
||||
func AdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req createUserRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
Error(w, http.StatusBadRequest, "密码至少 8 位")
|
||||
return
|
||||
}
|
||||
|
||||
u, err := repo.CreateUser(r.Context(), req.Username, req.Password, req.DisplayName, req.Role,
|
||||
req.AllowedAgents, req.AllowedPaths)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, repo.ErrNameTaken):
|
||||
Error(w, http.StatusConflict, "该名称已被用户或 Agent 占用")
|
||||
case errors.Is(err, repo.ErrInvalidUsername):
|
||||
Error(w, http.StatusBadRequest, "用户名只能是 2-64 位的小写字母、数字、点、下划线、连字符,且不能为 human")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "创建用户失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
|
||||
}
|
||||
|
||||
type updateUserRequest struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
Role *string `json:"role"`
|
||||
Status *string `json:"status"`
|
||||
AllowedAgents *[]string `json:"allowed_agents"`
|
||||
AllowedPaths *[]string `json:"allowed_paths"`
|
||||
}
|
||||
|
||||
// PUT /api/v1/admin/users/{id}
|
||||
func AdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req updateUserRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
// 不允许把最后一个管理员降级或禁用
|
||||
if err := guardLastAdmin(r, id, req.Role, req.Status); err != nil {
|
||||
Error(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
u, err := repo.UpdateUser(r.Context(), id, repo.UserUpdate{
|
||||
DisplayName: req.DisplayName,
|
||||
Role: req.Role,
|
||||
Status: req.Status,
|
||||
AllowedAgents: req.AllowedAgents,
|
||||
AllowedPaths: req.AllowedPaths,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, repo.ErrUserNotFound) {
|
||||
Error(w, http.StatusNotFound, "用户不存在")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "更新用户失败")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/scopes —— 可授权的 Agent 与目录候选
|
||||
func AdminListScopes(w http.ResponseWriter, r *http.Request) {
|
||||
agents, err := repo.ListAgents(r.Context(), "")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list agents")
|
||||
return
|
||||
}
|
||||
names := make([]string, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
names = append(names, a.Name)
|
||||
}
|
||||
paths, _ := repo.AllWorkspaceNames(r.Context())
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"agents": emptySlice(names),
|
||||
"paths": emptySlice(paths),
|
||||
})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/admin/users/{id} —— 禁用而非物理删除,保留邮件历史
|
||||
func AdminDisableUser(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
disabled := "disabled"
|
||||
if err := guardLastAdmin(r, id, nil, &disabled); err != nil {
|
||||
Error(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
if err := repo.DisableUser(r.Context(), id); err != nil {
|
||||
if errors.Is(err, repo.ErrUserNotFound) {
|
||||
Error(w, http.StatusNotFound, "用户不存在")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "禁用用户失败")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "disabled"})
|
||||
}
|
||||
|
||||
type resetPasswordRequest struct {
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/users/{id}/reset
|
||||
func AdminResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req resetPasswordRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < 8 {
|
||||
Error(w, http.StatusBadRequest, "密码至少 8 位")
|
||||
return
|
||||
}
|
||||
if err := repo.SetPassword(r.Context(), id, req.NewPassword); err != nil {
|
||||
if errors.Is(err, repo.ErrUserNotFound) {
|
||||
Error(w, http.StatusNotFound, "用户不存在")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "重置密码失败")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "password_reset"})
|
||||
}
|
||||
|
||||
// ---------- 辅助 ----------
|
||||
|
||||
func pathUUID(w http.ResponseWriter, r *http.Request, key string) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(chi.URLParam(r, key))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid "+key)
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// guardLastAdmin 阻止把系统里最后一个可用管理员降级或禁用
|
||||
func guardLastAdmin(r *http.Request, id uuid.UUID, role, status *string) error {
|
||||
demoting := role != nil && *role != "admin"
|
||||
disabling := status != nil && *status != "active"
|
||||
if !demoting && !disabling {
|
||||
return nil
|
||||
}
|
||||
|
||||
target, err := repo.GetUserByID(r.Context(), id)
|
||||
if err != nil || !target.IsAdmin() || target.Status != "active" {
|
||||
return nil
|
||||
}
|
||||
n, err := repo.CountAdmins(r.Context())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if n <= 1 {
|
||||
return errors.New("系统至少需要保留一个可用管理员")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
806
server/internal/handler/calendar.go
Normal file
806
server/internal/handler/calendar.go
Normal file
@ -0,0 +1,806 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/agentmail/gateway/internal/blob"
|
||||
"github.com/agentmail/gateway/internal/config"
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
// defaultReminderTemplate 是提醒正文的默认模板。
|
||||
//
|
||||
// 存变量而非字面值:{title}/{time}/{description} 在触发时由
|
||||
// scheduler.RenderReminder 替换。与前端 CalendarEventEditor 的
|
||||
// DEFAULT_TEMPLATE 必须逐字一致 —— 前端用它作 placeholder 与预览,
|
||||
// 两边不同会让人看到的预览与 Agent 实收的正文不是一回事。
|
||||
const defaultReminderTemplate = "日程提醒:{title}\n时间:{time}\n{description}"
|
||||
|
||||
// ─── Calendar Events ───
|
||||
|
||||
// POST /api/v1/calendar/events
|
||||
func CreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ReminderText string `json:"reminder_text"`
|
||||
AgentName string `json:"agent_name"`
|
||||
ToAddress string `json:"to_address"`
|
||||
Recipients []string `json:"recipients"`
|
||||
DeliveryMode string `json:"delivery_mode"`
|
||||
EventTime time.Time `json:"event_time"`
|
||||
RemindBefore int `json:"remind_before"`
|
||||
Recurrence string `json:"recurrence"`
|
||||
RecurrenceEnd *time.Time `json:"recurrence_end"`
|
||||
// Status 在创建时存在只为与更新端点同形:前端的 CalendarEventInput 是
|
||||
// **一份**类型,新建与编辑发的是同一个对象。不接这个字段的后果在
|
||||
// 严格解码下是新建日程直接 400。
|
||||
//
|
||||
// 新建时它只能是 active(新建一个已取消的提醒没有意义),
|
||||
// 但传 paused/cancelled 也不报错 —— 照字面履行比推回去更有用。
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Title == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing title")
|
||||
return
|
||||
}
|
||||
if req.Status == "" {
|
||||
req.Status = models.EventActive
|
||||
}
|
||||
if !validEventStatus(req.Status) {
|
||||
Error(w, http.StatusBadRequest,
|
||||
"status 必须是 active/paused/cancelled 之一")
|
||||
return
|
||||
}
|
||||
if req.EventTime.IsZero() {
|
||||
Error(w, http.StatusBadRequest, "Missing event_time")
|
||||
return
|
||||
}
|
||||
if req.ReminderText == "" {
|
||||
// 默认模板必须存**变量形式**而不是把值烤进去。
|
||||
//
|
||||
// 原来这里 Sprintf 出一份含字面时间的正文。对重复事件是错的:
|
||||
// AdvanceRecurrence 只推进 event_time,reminder_text 保持不动 ——
|
||||
// 于是「每天 9 点」的提醒从第二天起永远写着第一天的日期,
|
||||
// Agent 收到的信里时间与实际触发时刻越差越远。
|
||||
//
|
||||
// 变量形式由 scheduler.RenderReminder 在**触发时**替换,
|
||||
// 每一次触发都拿当时的 event_time。前端的 DEFAULT_TEMPLATE
|
||||
// 也是这一份(client/electron/src/components/CalendarEventEditor.tsx),
|
||||
// 两处必须一致,否则预览与实发不符。
|
||||
req.ReminderText = defaultReminderTemplate
|
||||
}
|
||||
if req.Recurrence == "" {
|
||||
req.Recurrence = models.RecurNone
|
||||
}
|
||||
if !validRecurrence(req.Recurrence) {
|
||||
Error(w, http.StatusBadRequest,
|
||||
"recurrence 必须是 none/daily/weekly/monthly/lunar_monthly/lunar_yearly 之一")
|
||||
return
|
||||
}
|
||||
|
||||
recipients, badAddr := normalizeRecipients(req.Recipients)
|
||||
if badAddr != "" {
|
||||
// 地址在这里就校验而不是等到触发时:建事件时报错人能立刻改,
|
||||
// 而触发时报错只会进 journalctl —— 人以为提醒设好了,实际永远发不出去。
|
||||
Error(w, http.StatusBadRequest, "收件地址无法解析:"+badAddr)
|
||||
return
|
||||
}
|
||||
// 收件人一个都没有时事件永远发不出去,这不该静默通过
|
||||
if len(recipients) == 0 && strings.TrimSpace(req.ToAddress) == "" &&
|
||||
strings.TrimSpace(req.AgentName) == "" {
|
||||
Error(w, http.StatusBadRequest, "至少要有一个收件人(recipients / to_address / agent_name)")
|
||||
return
|
||||
}
|
||||
if req.DeliveryMode == "" {
|
||||
req.DeliveryMode = models.DeliverSeparate
|
||||
}
|
||||
|
||||
e := &models.CalendarEvent{
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
ReminderText: req.ReminderText,
|
||||
AgentName: req.AgentName,
|
||||
ToAddress: req.ToAddress,
|
||||
Recipients: recipients,
|
||||
DeliveryMode: req.DeliveryMode,
|
||||
EventTime: req.EventTime,
|
||||
RemindBefore: req.RemindBefore,
|
||||
Recurrence: req.Recurrence,
|
||||
RecurrenceEnd: req.RecurrenceEnd,
|
||||
Status: req.Status,
|
||||
CreatedBy: user.Username,
|
||||
}
|
||||
|
||||
if _, err := repo.CreateCalendarEvent(r.Context(), e); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create event")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, e)
|
||||
}
|
||||
|
||||
// GET /api/v1/calendar/events?from=...&to=...
|
||||
func ListCalendarEvents(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
fromStr := r.URL.Query().Get("from")
|
||||
toStr := r.URL.Query().Get("to")
|
||||
status := r.URL.Query().Get("status")
|
||||
|
||||
var from, to time.Time
|
||||
if fromStr != "" {
|
||||
from, _ = time.Parse(time.RFC3339, fromStr)
|
||||
}
|
||||
if toStr != "" {
|
||||
to, _ = time.Parse(time.RFC3339, toStr)
|
||||
}
|
||||
if to.IsZero() {
|
||||
to = time.Now().AddDate(0, 1, 0) // 默认往后一个月
|
||||
}
|
||||
if from.IsZero() {
|
||||
from = time.Now().AddDate(0, -1, 0) // 默认往前一个月
|
||||
}
|
||||
|
||||
events, err := repo.ListCalendarEvents(r.Context(), from, to, status)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list events")
|
||||
return
|
||||
}
|
||||
if events == nil {
|
||||
events = []models.CalendarEvent{}
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"events": events,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/calendar/events/{id}
|
||||
func GetCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := pathUUID(w, r, "id"); !ok {
|
||||
return
|
||||
}
|
||||
eventID := chi.URLParam(r, "id")
|
||||
e, err := repo.GetCalendarEvent(r.Context(), eventID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repo.ErrEventNotFound) {
|
||||
Error(w, http.StatusNotFound, "Event not found")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to get event")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, e)
|
||||
}
|
||||
|
||||
// PUT /api/v1/calendar/events/{id}
|
||||
func UpdateCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
eventID := chi.URLParam(r, "id")
|
||||
if _, ok := pathUUID(w, r, "id"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ReminderText string `json:"reminder_text"`
|
||||
AgentName string `json:"agent_name"`
|
||||
ToAddress string `json:"to_address"`
|
||||
Recipients []string `json:"recipients"`
|
||||
DeliveryMode string `json:"delivery_mode"`
|
||||
EventTime time.Time `json:"event_time"`
|
||||
RemindBefore int `json:"remind_before"`
|
||||
Recurrence string `json:"recurrence"`
|
||||
RecurrenceEnd *time.Time `json:"recurrence_end"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Recurrence != "" && !validRecurrence(req.Recurrence) {
|
||||
Error(w, http.StatusBadRequest,
|
||||
"recurrence 必须是 none/daily/weekly/monthly/lunar_monthly/lunar_yearly 之一")
|
||||
return
|
||||
}
|
||||
// status 直接写进库,所以必须先校验:一个拼错的值(比如 "pause")会变成
|
||||
// 调度器不认识的状态 —— DueEvents 只查 active,那条提醒于是静默失效,
|
||||
// 而界面下拉框里没有这个选项,人再也改不回来。
|
||||
if req.Status == "" {
|
||||
req.Status = models.EventActive
|
||||
}
|
||||
if !validEventStatus(req.Status) {
|
||||
Error(w, http.StatusBadRequest, "status 必须是 active/paused/cancelled 之一")
|
||||
return
|
||||
}
|
||||
recipients, badAddr := normalizeRecipients(req.Recipients)
|
||||
if badAddr != "" {
|
||||
Error(w, http.StatusBadRequest, "收件地址无法解析:"+badAddr)
|
||||
return
|
||||
}
|
||||
|
||||
e := &models.CalendarEvent{
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
ReminderText: req.ReminderText,
|
||||
AgentName: req.AgentName,
|
||||
ToAddress: req.ToAddress,
|
||||
Recipients: recipients,
|
||||
DeliveryMode: req.DeliveryMode,
|
||||
EventTime: req.EventTime,
|
||||
RemindBefore: req.RemindBefore,
|
||||
Recurrence: req.Recurrence,
|
||||
RecurrenceEnd: req.RecurrenceEnd,
|
||||
Status: req.Status,
|
||||
}
|
||||
if err := repo.UpdateCalendarEvent(r.Context(), eventID, e); err != nil {
|
||||
if errors.Is(err, repo.ErrEventNotFound) {
|
||||
Error(w, http.StatusNotFound, "Event not found")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to update event")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, e)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/calendar/events/{id}
|
||||
func DeleteCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
eventID := chi.URLParam(r, "id")
|
||||
if err := repo.DeleteCalendarEvent(r.Context(), eventID); err != nil {
|
||||
if errors.Is(err, repo.ErrEventNotFound) {
|
||||
Error(w, http.StatusNotFound, "Event not found")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to delete event")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// ─── Calendar Attachments ───
|
||||
|
||||
// POST /api/v1/calendar/events/{id}/attachments
|
||||
func UploadCalendarAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
if Blobs == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "附件存储未初始化")
|
||||
return
|
||||
}
|
||||
eventID := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if eventID == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing event id")
|
||||
return
|
||||
}
|
||||
// 事件必须存在:否则会攒下一堆孤儿附件记录,而 ON DELETE CASCADE
|
||||
// 永远清不掉它们(没有对应的父行可删)。
|
||||
if _, err := repo.GetCalendarEvent(r.Context(), eventID); err != nil {
|
||||
Error(w, http.StatusNotFound, "事件不存在")
|
||||
return
|
||||
}
|
||||
|
||||
max := config.C.MaxAttachmentBytes
|
||||
// 与邮件附件同一套双层限制:外层卡整个请求体(含 multipart 边界),
|
||||
// blob.Put 的 max 卡单个文件内容。少了外层,超大 multipart 头能拖死内存。
|
||||
r.Body = http.MaxBytesReader(w, r.Body, max+1<<20)
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
Error(w, http.StatusBadRequest, "解析 multipart 失败(是否超过大小上限?)")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if r.MultipartForm != nil {
|
||||
r.MultipartForm.RemoveAll()
|
||||
}
|
||||
}()
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "缺少 file 字段")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 原来这里是 `data := make([]byte, header.Size); file.Read(data)` ——
|
||||
// 两处错:单次 Read 不保证填满缓冲(大文件必然短读,sha256 因此算的是
|
||||
// 半截内容),而且**文件内容从未落盘**,只往库里写了一条元数据。
|
||||
// 结果是附件"上传成功"、清单里看得见、发提醒时取不到任何字节。
|
||||
sum, size, err := Blobs.Put(file, max)
|
||||
if errors.Is(err, blob.ErrTooLarge) {
|
||||
Error(w, http.StatusRequestEntityTooLarge,
|
||||
fmt.Sprintf("附件超过上限 %.1f MB", float64(max)/(1<<20)))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "保存附件失败")
|
||||
return
|
||||
}
|
||||
|
||||
att := &models.CalendarAttachment{
|
||||
EventID: eventID,
|
||||
Filename: sanitizeFilename(header.Filename),
|
||||
SizeBytes: size,
|
||||
SHA256: sum,
|
||||
}
|
||||
if err := repo.AddCalendarAttachment(r.Context(), att); err != nil {
|
||||
// 落盘成功但入库失败:孤立文件由 GC 回收,不影响正确性
|
||||
Error(w, http.StatusInternalServerError, "登记附件失败")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, att)
|
||||
}
|
||||
|
||||
// GET /api/v1/calendar/events/{id}/attachments
|
||||
func ListCalendarAttachments(w http.ResponseWriter, r *http.Request) {
|
||||
eventID := chi.URLParam(r, "id")
|
||||
atts, err := repo.ListCalendarAttachments(r.Context(), eventID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list attachments")
|
||||
return
|
||||
}
|
||||
if atts == nil {
|
||||
atts = []models.CalendarAttachment{}
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"attachments": atts})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/calendar/events/{id}/attachments/{aid}
|
||||
func DeleteCalendarAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
attID := strings.TrimSpace(chi.URLParam(r, "attachmentID"))
|
||||
if attID == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing attachment id")
|
||||
return
|
||||
}
|
||||
// 原来这里返回 501 并让人「删整个事件来清附件」—— 那要求人为了撤掉
|
||||
// 一个错传的文件把整条日程连提醒配置一起重建。
|
||||
//
|
||||
// 磁盘上的 blob 不在这里删:内容寻址下同一个 sha256 可能被别的附件
|
||||
// (甚至别的邮件)引用着,删文件会让那些引用一起坏掉。孤立 blob 归 GC。
|
||||
ok, err := repo.DeleteCalendarAttachment(r.Context(), attID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "删除附件失败")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
Error(w, http.StatusNotFound, "附件不存在")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"status": "deleted", "attachment_id": attID})
|
||||
}
|
||||
|
||||
// validRecurrence 白名单校验重复规则。
|
||||
//
|
||||
// 必须白名单而不是「未知值当 none」:把 `lunar_montly`(拼错)静默当成
|
||||
// 不重复,用户设的每月提醒只会响一次,而没有任何地方报错。
|
||||
// validEventStatus 校验日历事件状态(包装 models.ValidEventStatus,与
|
||||
// validRecurrence 保持同一种调用形状)。
|
||||
func validEventStatus(s string) bool {
|
||||
return models.ValidEventStatus(s)
|
||||
}
|
||||
|
||||
func validRecurrence(r string) bool {
|
||||
switch r {
|
||||
case models.RecurNone, models.RecurDaily, models.RecurWeekly, models.RecurMonthly,
|
||||
models.RecurYearly, models.RecurLunarMonthly, models.RecurLunarYearly:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeRecipients 清洗收件人列表:去空白、去重、校验地址可解析。
|
||||
//
|
||||
// 返回第二个值非空表示有地址解析失败(值即那个地址),调用方回 400。
|
||||
// 在建事件时校验而不是等触发:建事件时报错人能立刻改,
|
||||
// 触发时报错只会进 journalctl —— 人以为设好了,实际永远发不出去。
|
||||
//
|
||||
// 去重是必要的:together 模式下同一个 Agent 既是主收件人又在抄送里,
|
||||
// 会让它收到两条一模一样的 SSE,插件可能因此起两轮。
|
||||
func normalizeRecipients(in []string) ([]string, string) {
|
||||
seen := make(map[string]bool, len(in))
|
||||
out := make([]string, 0, len(in))
|
||||
for _, raw := range in {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := models.ParseAddress(raw); err != nil {
|
||||
return nil, raw
|
||||
}
|
||||
if seen[raw] {
|
||||
continue
|
||||
}
|
||||
seen[raw] = true
|
||||
out = append(out, raw)
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
|
||||
// ─── iCal 导入导出 ───
|
||||
|
||||
// GET /api/v1/calendar/export.ics
|
||||
func ExportCalendarICS(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
// 区间取自查询参数:前端导出的是「当前正在看的那段」,
|
||||
// 写死 ±1 年会让人点导出后得到一堆与屏幕上不符的事件。
|
||||
from := time.Now().AddDate(-1, 0, 0)
|
||||
to := time.Now().AddDate(1, 0, 0)
|
||||
if v := r.URL.Query().Get("from"); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
from = t
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("to"); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
to = t
|
||||
}
|
||||
}
|
||||
events, err := repo.ListCalendarEvents(r.Context(), from, to, "active")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list events")
|
||||
return
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("BEGIN:VCALENDAR\r\n")
|
||||
sb.WriteString("VERSION:2.0\r\n")
|
||||
sb.WriteString("PRODID:-//AgentMail//Calendar//EN\r\n")
|
||||
|
||||
for _, e := range events {
|
||||
sb.WriteString("BEGIN:VEVENT\r\n")
|
||||
fmt.Fprintf(&sb, "UID:%s@agentmail\r\n", e.EventID)
|
||||
fmt.Fprintf(&sb, "DTSTAMP:%s\r\n", e.EventTime.UTC().Format("20060102T150405Z"))
|
||||
fmt.Fprintf(&sb, "DTSTART:%s\r\n", e.EventTime.UTC().Format("20060102T150405Z"))
|
||||
// 默认 1 小时持续时间
|
||||
fmt.Fprintf(&sb, "DTEND:%s\r\n", e.EventTime.Add(time.Hour).UTC().Format("20060102T150405Z"))
|
||||
// 转义换行
|
||||
summary := strings.ReplaceAll(e.Title, "\n", "\\n")
|
||||
fmt.Fprintf(&sb, "SUMMARY:%s\r\n", summary)
|
||||
if e.Description != "" {
|
||||
desc := strings.ReplaceAll(e.Description, "\n", "\\n")
|
||||
fmt.Fprintf(&sb, "DESCRIPTION:%s\r\n", desc)
|
||||
}
|
||||
if e.Recurrence != "none" {
|
||||
var freq string
|
||||
switch e.Recurrence {
|
||||
case "daily":
|
||||
freq = "DAILY"
|
||||
case "weekly":
|
||||
freq = "WEEKLY"
|
||||
case "monthly":
|
||||
freq = "MONTHLY"
|
||||
case "yearly":
|
||||
freq = "YEARLY"
|
||||
}
|
||||
if freq != "" {
|
||||
fmt.Fprintf(&sb, "RRULE:FREQ=%s\r\n", freq)
|
||||
}
|
||||
// 农历规则 RFC 5545 表达不了(RRULE 只有公历频率)。
|
||||
//
|
||||
// 折中:用 X- 扩展属性记下真实规则,并把它降级成最接近的公历
|
||||
// 近似(lunar_monthly → MONTHLY、lunar_yearly → YEARLY)。
|
||||
// 别的客户端至少能看到一个大致对的重复;导回本系统时
|
||||
// X- 属性会把精确规则还原。
|
||||
//
|
||||
// 不写近似 RRULE 的后果更糟:外部客户端会把它当一次性事件,
|
||||
// 用户以为导出的日历里有「每年农历生日」,实际只有一条。
|
||||
if models.IsLunarRecurrence(e.Recurrence) {
|
||||
fmt.Fprintf(&sb, "X-AGENTMAIL-RECURRENCE:%s\r\n", e.Recurrence)
|
||||
if e.Recurrence == models.RecurLunarMonthly {
|
||||
sb.WriteString("RRULE:FREQ=MONTHLY\r\n")
|
||||
} else {
|
||||
sb.WriteString("RRULE:FREQ=YEARLY\r\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
// VALARM 的 TRIGGER 必须写成 `-PT<n>M`。
|
||||
//
|
||||
// 两个坑:iCal 的 duration 里 `M` **在 T 之前是月、在 T 之后才是分钟** ——
|
||||
// 原来写的 `-P15M` 在任何合规日历客户端里都是「提前 15 个月」。
|
||||
// 而且原来用 maxInt(RemindBefore, 15) 兜底,把用户明确设的
|
||||
// 「到点提醒」(0) 悄悄改成提前 15 分钟;导出不该修改语义。
|
||||
// 收件人与投递模式同样没有标准字段可放。
|
||||
// 不导出的后果:往返一圈后事件变成「没有收件人」,永远不会提醒。
|
||||
if rs := e.EffectiveRecipients(); len(rs) > 0 {
|
||||
fmt.Fprintf(&sb, "X-AGENTMAIL-RECIPIENTS:%s\r\n", strings.Join(rs, ","))
|
||||
fmt.Fprintf(&sb, "X-AGENTMAIL-DELIVERY:%s\r\n", e.EffectiveDeliveryMode())
|
||||
}
|
||||
fmt.Fprintf(&sb, "BEGIN:VALARM\r\n")
|
||||
fmt.Fprintf(&sb, "TRIGGER:-PT%dM\r\n", e.RemindBefore)
|
||||
fmt.Fprintf(&sb, "ACTION:DISPLAY\r\n")
|
||||
fmt.Fprintf(&sb, "DESCRIPTION:%s\r\n", summary)
|
||||
fmt.Fprintf(&sb, "END:VALARM\r\n")
|
||||
|
||||
sb.WriteString("END:VEVENT\r\n")
|
||||
}
|
||||
|
||||
sb.WriteString("END:VCALENDAR\r\n")
|
||||
|
||||
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="agentmail-calendar.ics"`)
|
||||
w.Write([]byte(sb.String()))
|
||||
}
|
||||
|
||||
// POST /api/v1/calendar/import.ics
|
||||
func ImportCalendarICS(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
// 两种上传形态都接受。
|
||||
//
|
||||
// multipart 是浏览器 <input type=file> 的天然形态;raw text/calendar 是
|
||||
// 脚本与 Agent 的天然形态(curl --data-binary @x.ics)。只支持前者会让
|
||||
// 命令行调用者收到含糊的「Missing file field」,只支持后者则要求前端
|
||||
// 先把文件读成字符串再发 —— 两边各让一步不如两边都收。
|
||||
var body []byte
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(ct, "multipart/") {
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Failed to parse multipart: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Missing file field")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
body, err = io.ReadAll(file)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Failed to read file")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
body, err = io.ReadAll(http.MaxBytesReader(w, r.Body, 10<<20))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Failed to read body")
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(body) == 0 {
|
||||
Error(w, http.StatusBadRequest, "Empty .ics payload")
|
||||
return
|
||||
}
|
||||
|
||||
events := parseICS(body)
|
||||
imported := 0
|
||||
for _, e := range events {
|
||||
e.CreatedBy = user.Username
|
||||
e.Status = "active"
|
||||
if _, err := repo.CreateCalendarEvent(r.Context(), &e); err == nil {
|
||||
imported++
|
||||
}
|
||||
}
|
||||
|
||||
// skipped 单独给出而不是让前端自己减:insert 失败(撞名、约束冲突)
|
||||
// 与「解析出来但没入库」是同一回事,前端只关心「有几个没进来」。
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"imported": imported,
|
||||
"skipped": len(events) - imported,
|
||||
"total": len(events),
|
||||
})
|
||||
}
|
||||
|
||||
// ─── iCal 解析 ───
|
||||
|
||||
func parseICS(data []byte) []models.CalendarEvent {
|
||||
var events []models.CalendarEvent
|
||||
var current *models.CalendarEvent
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, raw := range lines {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理折叠行(iCal 的续行以空格开头)
|
||||
if strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t") {
|
||||
if current != nil && len(events) > 0 {
|
||||
// 简单续行处理:追加到最后一个字段
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
colon := strings.Index(line, ":")
|
||||
if colon < 0 {
|
||||
continue
|
||||
}
|
||||
key := line[:colon]
|
||||
value := line[colon+1:]
|
||||
|
||||
// 去掉参数部分(如 DTSTART;TZID=...:value)
|
||||
if semi := strings.Index(key, ";"); semi >= 0 {
|
||||
key = key[:semi]
|
||||
}
|
||||
// 键名大小写不敏感(RFC 5545 §3.1)。X- 扩展属性尤其容易被
|
||||
// 其他客户端改写大小写,不归一化会让往返丢掉农历规则。
|
||||
key = strings.ToUpper(key)
|
||||
|
||||
switch key {
|
||||
case "BEGIN":
|
||||
if value == "VEVENT" {
|
||||
current = &models.CalendarEvent{Recurrence: "none"}
|
||||
}
|
||||
case "END":
|
||||
if value == "VEVENT" && current != nil {
|
||||
if !current.EventTime.IsZero() {
|
||||
events = append(events, *current)
|
||||
}
|
||||
current = nil
|
||||
}
|
||||
case "SUMMARY":
|
||||
if current != nil {
|
||||
current.Title = strings.ReplaceAll(value, "\\n", "\n")
|
||||
}
|
||||
case "DESCRIPTION":
|
||||
if current != nil {
|
||||
current.Description = strings.ReplaceAll(value, "\\n", "\n")
|
||||
}
|
||||
case "DTSTART":
|
||||
if current != nil {
|
||||
if t, err := time.Parse("20060102T150405Z", value); err == nil {
|
||||
current.EventTime = t
|
||||
} else if t, err := time.ParseInLocation("20060102T150405", value, time.Local); err == nil {
|
||||
current.EventTime = t
|
||||
} else if t, err := time.Parse("20060102", value); err == nil {
|
||||
current.EventTime = t
|
||||
}
|
||||
}
|
||||
case "RRULE":
|
||||
// **不覆盖已经从 X-AGENTMAIL-RECURRENCE 读到的农历规则。**
|
||||
//
|
||||
// 导出时农历事件同时写了 X- 精确值与一条公历近似 RRULE
|
||||
// (给别的客户端看)。X- 出现在 RRULE 之前时,
|
||||
// 若这里无条件赋值就会把精确的 lunar_monthly 打回 monthly
|
||||
// —— 往返一圈农历规则悄悄退化成公历,用户要过一个月才发现
|
||||
// 提醒日子不对。
|
||||
if current != nil && !models.IsLunarRecurrence(current.Recurrence) {
|
||||
v := strings.ToUpper(value)
|
||||
switch {
|
||||
case strings.Contains(v, "FREQ=DAILY"):
|
||||
current.Recurrence = models.RecurDaily
|
||||
case strings.Contains(v, "FREQ=WEEKLY"):
|
||||
current.Recurrence = models.RecurWeekly
|
||||
case strings.Contains(v, "FREQ=MONTHLY"):
|
||||
current.Recurrence = models.RecurMonthly
|
||||
case strings.Contains(v, "FREQ=YEARLY"):
|
||||
current.Recurrence = models.RecurYearly
|
||||
}
|
||||
}
|
||||
case "TRIGGER":
|
||||
if current != nil {
|
||||
if mins, ok := parseTriggerMinutes(value); ok {
|
||||
current.RemindBefore = mins
|
||||
}
|
||||
}
|
||||
case "X-AGENTMAIL-RECURRENCE":
|
||||
// 精确规则覆盖上面从 RRULE 猜出来的近似值。
|
||||
// 顺序无关:X- 属性只在值合法时才生效。
|
||||
if current != nil && validRecurrence(value) {
|
||||
current.Recurrence = value
|
||||
}
|
||||
case "X-AGENTMAIL-RECIPIENTS":
|
||||
if current != nil {
|
||||
list, bad := normalizeRecipients(strings.Split(value, ","))
|
||||
// 单个地址坏掉不该让整份导入失败:其余收件人仍有效。
|
||||
// 全坏时 list 为空,事件会在 Create 时被收件人校验拦下。
|
||||
if bad == "" {
|
||||
current.Recipients = list
|
||||
}
|
||||
}
|
||||
case "X-AGENTMAIL-DELIVERY":
|
||||
if current != nil && (value == models.DeliverSeparate || value == models.DeliverTogether) {
|
||||
current.DeliveryMode = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
// ─── 辅助 ───
|
||||
|
||||
func blobSha256(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return fmt.Sprintf("%x", sum[:])
|
||||
}
|
||||
|
||||
// parseTriggerMinutes 把 VALARM 的 TRIGGER duration 解析成「提前多少分钟」。
|
||||
//
|
||||
// 接受 `-PT30M` / `-PT1H` / `-PT1H30M` / `-P1D` / `-P1DT2H` 这些形态。
|
||||
// 关键规则:`M` 出现在 `T` **之后**才是分钟,之前是月 —— 按月的 trigger
|
||||
// 无法映射到 remind_before(那是个分钟数),直接忽略比乱换算好。
|
||||
//
|
||||
// 正号(事件之后提醒)也忽略:remind_before 语义上只能提前。
|
||||
// 返回 ok=false 表示「这条 TRIGGER 用不上」,调用方保持原值不动。
|
||||
func parseTriggerMinutes(v string) (int, bool) {
|
||||
v = strings.TrimSpace(strings.ToUpper(v))
|
||||
if !strings.HasPrefix(v, "-P") {
|
||||
return 0, false
|
||||
}
|
||||
rest := v[2:]
|
||||
|
||||
// 切成 T 前后两段:前面是日期部分(Y/M/W/D),后面是时间部分(H/M/S)
|
||||
datePart, timePart := rest, ""
|
||||
if i := strings.Index(rest, "T"); i >= 0 {
|
||||
datePart, timePart = rest[:i], rest[i+1:]
|
||||
}
|
||||
|
||||
total := 0
|
||||
// 日期部分只认 W/D。Y/M 是可变长度(月有 28~31 天),换算成分钟只能靠猜。
|
||||
if n, ok := durationField(datePart, 'W'); ok {
|
||||
total += n * 7 * 24 * 60
|
||||
}
|
||||
if n, ok := durationField(datePart, 'D'); ok {
|
||||
total += n * 24 * 60
|
||||
}
|
||||
if n, ok := durationField(timePart, 'H'); ok {
|
||||
total += n * 60
|
||||
}
|
||||
if n, ok := durationField(timePart, 'M'); ok {
|
||||
total += n
|
||||
}
|
||||
// 秒不进 remind_before:它的粒度是分钟,30 秒会被截成 0 而看不出区别
|
||||
if total <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return total, true
|
||||
}
|
||||
|
||||
// durationField 从 `1H30M` 这样的串里取出紧接在 unit 之前的整数。
|
||||
func durationField(s string, unit byte) (int, bool) {
|
||||
idx := strings.IndexByte(s, unit)
|
||||
if idx < 0 {
|
||||
return 0, false
|
||||
}
|
||||
start := idx
|
||||
for start > 0 && s[start-1] >= '0' && s[start-1] <= '9' {
|
||||
start--
|
||||
}
|
||||
if start == idx {
|
||||
return 0, false
|
||||
}
|
||||
n := 0
|
||||
for i := start; i < idx; i++ {
|
||||
n = n*10 + int(s[i]-'0')
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
221
server/internal/handler/contacts.go
Normal file
221
server/internal/handler/contacts.go
Normal file
@ -0,0 +1,221 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/agentmail/gateway/internal/sse"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- Contacts(左侧联系人界面,按登录用户隔离) ----------
|
||||
|
||||
// GET /api/v1/contacts?archived=false
|
||||
func ListContacts(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
archived := r.URL.Query().Get("archived") == "true"
|
||||
|
||||
// 管理员可用 ?all=true 查看全部
|
||||
scope := user.Username
|
||||
if user.IsAdmin() && r.URL.Query().Get("all") == "true" {
|
||||
scope = ""
|
||||
}
|
||||
|
||||
contacts, err := repo.ListContactsFor(r.Context(), scope, archived)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list contacts")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"contacts": emptySlice(contacts),
|
||||
})
|
||||
}
|
||||
|
||||
type archiveRequest struct {
|
||||
Address string `json:"address"`
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
// POST /api/v1/contacts/archive
|
||||
// 归档指定 name@path.session:Agent 侧会话归档 + 邮箱界面移除
|
||||
func ArchiveContact(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req archiveRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
var sessionID uuid.UUID
|
||||
switch {
|
||||
case req.SessionID != "":
|
||||
id, err := uuid.Parse(req.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid session_id")
|
||||
return
|
||||
}
|
||||
sessionID = id
|
||||
|
||||
case req.Address != "":
|
||||
addr, err := models.ParseAddress(req.Address)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid address: "+err.Error())
|
||||
return
|
||||
}
|
||||
id, err := repo.FindSessionByAddress(r.Context(), addr.Name, addr.Path, addr.Session)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "No session matches "+req.Address)
|
||||
return
|
||||
}
|
||||
sessionID = id
|
||||
|
||||
default:
|
||||
Error(w, http.StatusBadRequest, "Provide address or session_id")
|
||||
return
|
||||
}
|
||||
|
||||
// 鉴权:只能归档自己参与的会话(管理员不限)
|
||||
allowed, err := repo.UserCanAccessSession(r.Context(), user, sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权归档他人的会话")
|
||||
return
|
||||
}
|
||||
|
||||
session, err := repo.GetSessionByID(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Session not found")
|
||||
return
|
||||
}
|
||||
mails, _ := repo.GetSessionMails(r.Context(), sessionID)
|
||||
|
||||
if err := repo.ArchiveSession(r.Context(), sessionID); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to archive session")
|
||||
return
|
||||
}
|
||||
|
||||
alias := ""
|
||||
if session.Alias != nil {
|
||||
alias = *session.Alias
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"session_id": sessionID.String(),
|
||||
"session_alias": alias,
|
||||
"archived_by": user.Username,
|
||||
}
|
||||
|
||||
// 通知会话内所有参与方(Agent 与人类),各自归档/移除
|
||||
notified := map[string]bool{}
|
||||
for _, m := range mails {
|
||||
names := append([]string{m.FromName, m.ToName}, ccNames(m.CCList)...)
|
||||
for _, name := range names {
|
||||
if name == "" || notified[name] {
|
||||
continue
|
||||
}
|
||||
notified[name] = true
|
||||
sse.Default.SendToRecipient(name, "session_archived", payload)
|
||||
}
|
||||
}
|
||||
if !notified[user.Username] {
|
||||
sse.Default.SendToUser(user.Username, "session_archived", payload)
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "archived",
|
||||
"session_id": sessionID.String(),
|
||||
"session_alias": alias,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/contacts/suggest?name=xxx&path=yyy
|
||||
// 三段式补全:无 name 给 Agent+人类用户名;有 name 给工作区;两者都有给会话别名(含 new)
|
||||
func SuggestAddress(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
name := r.URL.Query().Get("name")
|
||||
path := r.URL.Query().Get("path")
|
||||
|
||||
if name == "" {
|
||||
agents, err := repo.ListAgents(r.Context(), "")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list agents")
|
||||
return
|
||||
}
|
||||
users, _ := repo.ListActiveUsernames(r.Context())
|
||||
|
||||
names := make([]string, 0, len(agents)+len(users))
|
||||
for _, a := range agents {
|
||||
names = append(names, a.Name)
|
||||
}
|
||||
for _, u := range users {
|
||||
if u == user.Username {
|
||||
continue // 不建议给自己发信
|
||||
}
|
||||
names = append(names, u)
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"kind": "name",
|
||||
"suggestions": emptySlice(names),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
// 人类用户没有工作区,直接给空列表(前端会继续走 session 段)
|
||||
paths, _ := repo.SuggestPaths(r.Context(), name)
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"kind": "path",
|
||||
"suggestions": emptySlice(paths),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := repo.SuggestSessionCandidates(r.Context(), user.Username, name, path)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to suggest sessions")
|
||||
return
|
||||
}
|
||||
// suggestions 保留纯字符串形式:已部署的前端与第三方客户端只认这一个字段。
|
||||
// 带标题与来源的完整形式另放 candidates,两个字段同序。
|
||||
aliases := make([]string, 0, len(sessions)+1)
|
||||
for _, c := range sessions {
|
||||
aliases = append(aliases, c.Alias)
|
||||
}
|
||||
// new 总是可选且永远在最后:它不是一条已存在的会话,
|
||||
// 排在前面会让人在想续谈时随手回车开出一条新线索。
|
||||
aliases = append(aliases, "new")
|
||||
sessions = append(sessions, repo.SessionCandidate{Alias: "new", Source: "new", Title: "新建会话"})
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"kind": "session",
|
||||
"suggestions": aliases,
|
||||
"candidates": sessions,
|
||||
})
|
||||
}
|
||||
|
||||
func ccNames(list []models.Address) []string {
|
||||
out := make([]string, 0, len(list))
|
||||
for _, a := range list {
|
||||
if a.Name != "" {
|
||||
out = append(out, a.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
138
server/internal/handler/decode_test.go
Normal file
138
server/internal/handler/decode_test.go
Normal file
@ -0,0 +1,138 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
)
|
||||
|
||||
// 400 的信息必须指向具体字段。
|
||||
//
|
||||
// 起因:写跨主机验证脚本时把 workspaces 传成了字符串数组,
|
||||
// 服务端回的是一句固定的 "Invalid JSON" —— 只能靠翻服务端结构体才发现是哪个字段。
|
||||
// 第三方客户端没有这个条件。
|
||||
func TestDecodeBodyErrorNamesTheField(t *testing.T) {
|
||||
type body struct {
|
||||
Name string `json:"name"`
|
||||
Workspaces []models.Workspace `json:"workspaces"`
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
payload string
|
||||
wantHas []string
|
||||
wantMiss []string
|
||||
}{
|
||||
{
|
||||
name: "字段类型不对要说出字段名与期望类型",
|
||||
payload: `{"name":"bot","workspaces":["/tmp/ws"]}`,
|
||||
// 期望能看出:是 workspaces,要的是 object 数组,给的是 string
|
||||
wantHas: []string{"workspaces", "object", "string"},
|
||||
// 不该把 Go 类型名漏出去
|
||||
wantMiss: []string{"models.Workspace", "[]models"},
|
||||
},
|
||||
{
|
||||
name: "整个体的类型不对",
|
||||
payload: `["not","an","object"]`,
|
||||
wantHas: []string{"object"},
|
||||
},
|
||||
{
|
||||
// 截断的 JSON 走的是 io.ErrUnexpectedEOF,不是 json.SyntaxError
|
||||
name: "被截断的体要说明是截断",
|
||||
payload: `{"name":`,
|
||||
wantHas: []string{"语法", "结束"},
|
||||
},
|
||||
{
|
||||
name: "非法字符要给出位置",
|
||||
payload: `{"name":1x}`,
|
||||
wantHas: []string{"语法", "字节"},
|
||||
},
|
||||
{
|
||||
name: "空体单独说明",
|
||||
payload: ``,
|
||||
wantHas: []string{"为空"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/x", strings.NewReader(c.payload))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var v body
|
||||
if DecodeBody(w, r, &v) {
|
||||
t.Fatal("这个体应当解析失败")
|
||||
}
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("状态码应为 400,实际 %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("响应不是 JSON: %v", err)
|
||||
}
|
||||
msg := resp["error"]
|
||||
if msg == "" {
|
||||
t.Fatal("error 字段为空")
|
||||
}
|
||||
for _, want := range c.wantHas {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("信息里应含 %q,实际 %q", want, msg)
|
||||
}
|
||||
}
|
||||
for _, miss := range c.wantMiss {
|
||||
if strings.Contains(msg, miss) {
|
||||
t.Errorf("信息里不该含 Go 类型名 %q:%q", miss, msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 合法请求体不该被拦,也不该写任何响应 ——
|
||||
// 写了的话调用方接着写自己的响应就成了两次 WriteHeader。
|
||||
func TestDecodeBodyPassesValidPayload(t *testing.T) {
|
||||
type body struct {
|
||||
Name string `json:"name"`
|
||||
Workspaces []models.Workspace `json:"workspaces"`
|
||||
}
|
||||
|
||||
payload := `{"name":"bot","workspaces":[{"name":"demo","path":"/tmp/ws"}]}`
|
||||
r := httptest.NewRequest(http.MethodPost, "/x", strings.NewReader(payload))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var v body
|
||||
if !DecodeBody(w, r, &v) {
|
||||
t.Fatalf("合法体被拒:%s", w.Body.String())
|
||||
}
|
||||
if w.Body.Len() != 0 {
|
||||
t.Errorf("成功时不该写响应体,实际写了 %q", w.Body.String())
|
||||
}
|
||||
if v.Name != "bot" || len(v.Workspaces) != 1 || v.Workspaces[0].Path != "/tmp/ws" {
|
||||
t.Errorf("解析结果不对:%+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// 空数组是合法的 —— 两个正式插件注册时都传 workspaces: []。
|
||||
func TestDecodeBodyAcceptsEmptyWorkspaces(t *testing.T) {
|
||||
type body struct {
|
||||
Name string `json:"name"`
|
||||
Workspaces []models.Workspace `json:"workspaces"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/x",
|
||||
strings.NewReader(`{"name":"opencode","workspaces":[]}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var v body
|
||||
if !DecodeBody(w, r, &v) {
|
||||
t.Fatalf("空 workspaces 被拒:%s", w.Body.String())
|
||||
}
|
||||
if len(v.Workspaces) != 0 {
|
||||
t.Errorf("应为空数组,实际 %+v", v.Workspaces)
|
||||
}
|
||||
}
|
||||
91
server/internal/handler/events.go
Normal file
91
server/internal/handler/events.go
Normal file
@ -0,0 +1,91 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/agentmail/gateway/internal/sse"
|
||||
)
|
||||
|
||||
// GET /api/v1/events/stream
|
||||
//
|
||||
// 四种凭证,都必须真正验证过身份才能订阅:
|
||||
// Authorization: Bearer <agent_key_token> → Agent 通道(密钥认证)
|
||||
// X-Agent-Name + X-Agent-Secret → Agent 通道(旧方式,兼容)
|
||||
// 登录 Cookie 或 Bearer <user_key_token> → 人类用户通道
|
||||
// ?access_token=<token> → 浏览器 EventSource 专用回退
|
||||
//
|
||||
// 注意不能只凭 X-Agent-Name 就分流:那等于任何人报个名字就能读走别人的新邮件通知。
|
||||
// query 令牌仅本端点接受(EventSource 无法带自定义头),其余接口一律要求请求头,
|
||||
// 因为 URL 里的令牌会进访问日志与 Referer。
|
||||
func SSEStream(w http.ResponseWriter, r *http.Request) {
|
||||
agentName, ok := resolveStreamAgent(r)
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "凭证无效")
|
||||
return
|
||||
}
|
||||
|
||||
userName := ""
|
||||
if agentName == "" {
|
||||
u := middleware.OptionalUserWithQuery(r)
|
||||
if u == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
userName = u.Username
|
||||
}
|
||||
|
||||
client := sse.Default.AddClient(w, r, agentName, userName)
|
||||
if client == nil {
|
||||
Error(w, http.StatusInternalServerError, "SSE not supported")
|
||||
return
|
||||
}
|
||||
|
||||
<-r.Context().Done()
|
||||
sse.Default.RemoveClient(client.ID)
|
||||
}
|
||||
|
||||
// resolveStreamAgent 校验 Agent 侧凭证。
|
||||
// 返回 ("", true) 表示这不是 Agent 请求,交给人类用户分支;
|
||||
// 返回 ("", false) 表示带了 Agent 凭证但验证失败。
|
||||
func resolveStreamAgent(r *http.Request) (string, bool) {
|
||||
// 密钥认证:Bearer 令牌可能是 Agent 密钥,也可能是用户密钥。
|
||||
// 先按 Agent 密钥试,失败就落到人类分支(那里会再按用户密钥试)。
|
||||
token := middleware.BearerToken(r)
|
||||
if token == "" {
|
||||
token = middleware.QueryToken(r) // EventSource 回退
|
||||
}
|
||||
if token != "" {
|
||||
name, err := repo.VerifyAgentKey(r.Context(), token)
|
||||
if err == nil && name != "" {
|
||||
return name, true
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
name := r.Header.Get("X-Agent-Name")
|
||||
if name == "" {
|
||||
name = r.URL.Query().Get("agent_name")
|
||||
}
|
||||
if name == "" {
|
||||
return "", true // 非 Agent 请求
|
||||
}
|
||||
|
||||
secret := r.Header.Get("X-Agent-Secret")
|
||||
if secret == "" {
|
||||
return "", false // 报了名字却没给凭证
|
||||
}
|
||||
agent, err := repo.VerifyAgent(r.Context(), name, secret)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return agent.Name, true
|
||||
}
|
||||
|
||||
// GET /api/v1/events/status
|
||||
func SSEStatus(w http.ResponseWriter, r *http.Request) {
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"connected_clients": sse.Default.ClientCount(),
|
||||
})
|
||||
}
|
||||
292
server/internal/handler/forward.go
Normal file
292
server/internal/handler/forward.go
Normal file
@ -0,0 +1,292 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 转发 ----------
|
||||
//
|
||||
// 转发 = 引用原文 + 新收件人。与「回复」的区别:
|
||||
// 回复(reply_to)落回原会话,收件人是原发件人;
|
||||
// 转发按目标地址的 session 位另行定位会话,收件人是新指定的人。
|
||||
// 因此转发不复用 reply_to,而是走完整的三维寻址。
|
||||
|
||||
type forwardRequest struct {
|
||||
// To 新收件人,完整三维地址
|
||||
To string `json:"to"`
|
||||
// CC 可选抄送
|
||||
CC string `json:"cc"`
|
||||
// Comment 转发者附加的说明,置于引用原文之前
|
||||
Comment string `json:"comment"`
|
||||
// Subject 可选;留空时自动加 "Fwd: " 前缀
|
||||
Subject string `json:"subject"`
|
||||
// SessionAlias 仅在目标地址以 .new 结尾时生效
|
||||
SessionAlias string `json:"session_alias"`
|
||||
}
|
||||
|
||||
// quoteBody 把原文渲染为 Markdown 引用块。
|
||||
// 逐行加 "> " 而不是整段包裹:原文本身可能含代码块与列表,
|
||||
// 只有逐行前缀才能在任何 Markdown 渲染器里保持引用语义。
|
||||
func quoteBody(m *models.Mail) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("---\n\n")
|
||||
b.WriteString(fmt.Sprintf("> **转发自** %s", m.FromName))
|
||||
if m.FromWorkspace != "" {
|
||||
b.WriteString("@" + m.FromWorkspace)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf("> **主题** %s\n", m.Subject))
|
||||
b.WriteString(fmt.Sprintf("> **时间** %s\n", m.CreatedAt.Format("2006-01-02 15:04:05")))
|
||||
if len(m.CCList) > 0 {
|
||||
names := make([]string, 0, len(m.CCList))
|
||||
for _, c := range m.CCList {
|
||||
names = append(names, c.Raw)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("> **抄送** %s\n", strings.Join(names, ", ")))
|
||||
}
|
||||
b.WriteString(">\n")
|
||||
for _, line := range strings.Split(m.Body, "\n") {
|
||||
b.WriteString("> " + line + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// forwardSubject 生成转发主题,避免 "Fwd: Fwd: Fwd:" 无限叠加。
|
||||
func forwardSubject(custom, original string) string {
|
||||
if s := strings.TrimSpace(custom); s != "" {
|
||||
return s
|
||||
}
|
||||
if strings.HasPrefix(original, "Fwd: ") {
|
||||
return original
|
||||
}
|
||||
return "Fwd: " + original
|
||||
}
|
||||
|
||||
// doForward 是 Agent 与人类两条转发路径的公共实现。
|
||||
// actor 是转发者名(Agent 名或用户名),fromWorkspace 仅 Agent 有。
|
||||
func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor, fromWorkspace string, isAgent bool) {
|
||||
var req forwardRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.To) == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing to")
|
||||
return
|
||||
}
|
||||
|
||||
src, err := repo.LoadForwardSource(r.Context(), mailID, actor)
|
||||
switch {
|
||||
case errors.Is(err, repo.ErrMailNotFound):
|
||||
Error(w, http.StatusNotFound, "待转发的邮件不存在")
|
||||
return
|
||||
case errors.Is(err, repo.ErrForwardNotAllowed):
|
||||
Error(w, http.StatusForbidden, "只能转发自己参与过的邮件")
|
||||
return
|
||||
case err != nil:
|
||||
Error(w, http.StatusInternalServerError, "Failed to load mail")
|
||||
return
|
||||
}
|
||||
|
||||
to, err := models.ParseAddress(req.To)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid to address: "+err.Error())
|
||||
return
|
||||
}
|
||||
ccList, err := models.ParseAddressList(req.CC)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid cc address: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
if !isAgent && user != nil {
|
||||
to = resolveHumanAlias(to, user.Username)
|
||||
for i := range ccList {
|
||||
ccList[i] = resolveHumanAlias(ccList[i], user.Username)
|
||||
}
|
||||
if msg := checkScope(r, user, append([]models.Address{to}, ccList...)); msg != "" {
|
||||
Error(w, http.StatusForbidden, msg)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 可达性:转发目标必须存在且未停用。人与 Agent 两条转发路径共用这道检查。
|
||||
if !checkDeliverable(w, r, append([]models.Address{to}, ccList...)) {
|
||||
return
|
||||
}
|
||||
|
||||
subject := forwardSubject(req.Subject, src.Subject)
|
||||
|
||||
// 转发按目标地址寻址,不带 reply_to:它是一条新线索,不该并进原会话
|
||||
sessionID, _, created, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias, agentLimiterKey(isAgent, actor))
|
||||
if err != nil {
|
||||
writeErr(w, err, "Failed to resolve session")
|
||||
return
|
||||
}
|
||||
|
||||
// 权限档位继承自源会话(plan 档派不出 full 档子任务,约束沿链条传递)。
|
||||
// 只在【新建】目标会话时设:复用既有会话时不改写对方正在遵守的规则。
|
||||
if created {
|
||||
mode := repo.InheritedMode(r.Context(), &src.SessionID, models.DefaultPermissionMode)
|
||||
if _, err := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to set permission mode")
|
||||
return
|
||||
}
|
||||
_ = repo.SetSessionEnforcement(r.Context(), sessionID,
|
||||
repo.AgentModeEnforcement(r.Context(), to.Name))
|
||||
}
|
||||
|
||||
if isAgent {
|
||||
// 转发也是一次主动发信,扣【目标会话】的往返预算。
|
||||
// 扣目标而不是源:转发开启的是一条新线索,消耗的是新线索的额度。
|
||||
budget, bErr := repo.ConsumeSessionBudget(r.Context(), sessionID)
|
||||
if errors.Is(bErr, repo.ErrSessionBudgetExhausted) {
|
||||
Error(w, http.StatusForbidden, fmt.Sprintf(
|
||||
"目标会话的往返预算已用尽(%d/%d)。请让人在对话页调高该会话的预算。",
|
||||
budget.Used, budget.Max))
|
||||
return
|
||||
}
|
||||
if bErr != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check session budget")
|
||||
return
|
||||
}
|
||||
repo.BumpSentCount(r.Context(), actor)
|
||||
} else if user != nil {
|
||||
_ = repo.SetSessionOwner(r.Context(), sessionID, user.ID)
|
||||
}
|
||||
|
||||
body := quoteBody(src)
|
||||
if c := strings.TrimSpace(req.Comment); c != "" {
|
||||
body = c + "\n\n" + body
|
||||
}
|
||||
attachedCount := 0
|
||||
|
||||
// parent_mail_id 指向原邮件:即便落在新会话里,也能回溯这封转发从何而来
|
||||
newID, err := repo.CreateMail(r.Context(), sessionID, &src.ID,
|
||||
actor, fromWorkspace, to.Name, to.Path, subject, body, ccList)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create mail")
|
||||
return
|
||||
}
|
||||
|
||||
// 附件随转发一同带过去——只引用正文而丢掉附件,收件人拿到的是一封残缺的邮件。
|
||||
// 内容寻址下这只是新增元数据,不拷磁盘文件。
|
||||
if n, err := repo.CopyAttachmentsTo(r.Context(), src.ID, newID, actor); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "复制附件失败")
|
||||
return
|
||||
} else {
|
||||
attachedCount = n
|
||||
}
|
||||
|
||||
// 转发在数据上 parent 指向原邮件(用于回溯来源),但对**收件方**而言这是一封
|
||||
// 全新的信:那封原邮件不是它写的,也不在它的线索里。
|
||||
// 因此 in_reply_to 传空串 —— 提示词该说「有人转了一封信给你」而不是
|
||||
// 「你上封信的回复到了」。
|
||||
notifyRecipients(r.Context(), to, ccList, sessionID, newID, actor, subject, "")
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"mail_id": newID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
"session_alias": repo.SessionAliasOf(r.Context(), sessionID),
|
||||
"forwarded_from": src.ID.String(),
|
||||
"attachments": attachedCount,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/mail/{id}/forward —— Agent 侧转发
|
||||
func ForwardMail(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
doForward(w, r, mailID, agentName, agentName, true)
|
||||
}
|
||||
|
||||
// POST /api/v1/me/mail/{id}/forward —— 人类侧转发
|
||||
func MeForwardMail(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
doForward(w, r, mailID, user.Username, "", false)
|
||||
}
|
||||
|
||||
// ---------- Agent 默认预算与统计(管理员) ----------
|
||||
|
||||
// GET /api/v1/admin/quotas
|
||||
//
|
||||
// 路径沿用 quotas(兼容已部署的前端),但语义已变:
|
||||
// 返回的是【新任务默认预算 + 累计统计】,而不是会拦请求的终身额度。
|
||||
// 真正的额度在每条会话上(GET /sessions/{id}/budget)。
|
||||
func AdminListQuotas(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := repo.ListAgentStats(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list agent stats")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"quotas": stats})
|
||||
}
|
||||
|
||||
type setQuotaRequest struct {
|
||||
// DefaultRounds 派给该 Agent 的新任务默认多少个来回(0 = 不限)
|
||||
DefaultRounds *int `json:"default_rounds"`
|
||||
// MaxRounds 是 DefaultRounds 的旧字段名,保留兼容:
|
||||
// 已部署的前端与脚本不应该因为改名就难以察觉地失效。
|
||||
MaxRounds *int `json:"max_rounds"`
|
||||
}
|
||||
|
||||
// PUT /api/v1/admin/quotas/{name}
|
||||
//
|
||||
// 只能改【新任务默认预算】。不再接受 reset:
|
||||
// 累计发信数是观测数据,不拦任何请求,归零它只会销毁历史。
|
||||
// 要给某个卡住的任务加额度,去那条会话的对话页改预算。
|
||||
func AdminSetQuota(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimSpace(chi.URLParam(r, "name"))
|
||||
if name == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing agent name")
|
||||
return
|
||||
}
|
||||
|
||||
var req setQuotaRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
n := req.DefaultRounds
|
||||
if n == nil {
|
||||
n = req.MaxRounds // 兼容旧字段名
|
||||
}
|
||||
if n == nil {
|
||||
Error(w, http.StatusBadRequest, "需要 default_rounds")
|
||||
return
|
||||
}
|
||||
if *n < 0 {
|
||||
Error(w, http.StatusBadRequest, "default_rounds 不能为负")
|
||||
return
|
||||
}
|
||||
|
||||
st, err := repo.SetDefaultRounds(r.Context(), name, *n)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"quota": st})
|
||||
}
|
||||
77
server/internal/handler/forward_test.go
Normal file
77
server/internal/handler/forward_test.go
Normal file
@ -0,0 +1,77 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 转发主题不能无限叠加 Fwd: 前缀,否则转发几轮后主题栏全是前缀。
|
||||
func TestForwardSubject(t *testing.T) {
|
||||
cases := []struct{ custom, original, want string }{
|
||||
{"", "修复登录态", "Fwd: 修复登录态"},
|
||||
{"", "Fwd: 修复登录态", "Fwd: 修复登录态"}, // 已有前缀不再叠加
|
||||
{"自定义主题", "修复登录态", "自定义主题"},
|
||||
{" ", "修复登录态", "Fwd: 修复登录态"}, // 全空白视为未指定
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := forwardSubject(c.custom, c.original); got != c.want {
|
||||
t.Errorf("forwardSubject(%q, %q) = %q, want %q", c.custom, c.original, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 引用块必须逐行加 "> ":原文含代码块或列表时,
|
||||
// 只有逐行前缀才能在任何 Markdown 渲染器里保持引用语义。
|
||||
func TestQuoteBodyPrefixesEveryLine(t *testing.T) {
|
||||
m := &models.Mail{
|
||||
ID: uuid.New(),
|
||||
FromName: "opencode",
|
||||
FromWorkspace: "/root",
|
||||
Subject: "巡检结果",
|
||||
Body: "第一行\n\n```go\nfmt.Println(1)\n```\n- 列表项",
|
||||
CreatedAt: time.Date(2026, 9, 2, 10, 30, 0, 0, time.UTC),
|
||||
CCList: []models.Address{
|
||||
{Name: "pi", Path: "root", Raw: "pi@root.new"},
|
||||
},
|
||||
}
|
||||
|
||||
out := quoteBody(m)
|
||||
|
||||
for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
|
||||
if line == "---" || line == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, ">") {
|
||||
t.Errorf("引用块出现未加前缀的行: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
// 元信息必须齐全,否则收件人不知道这封转发的来路
|
||||
for _, want := range []string{"opencode@/root", "巡检结果", "2026-09-02 10:30:00", "pi@root.new"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("引用块缺少 %q\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
// 原文正文本身要在引用里
|
||||
if !strings.Contains(out, "> fmt.Println(1)") {
|
||||
t.Errorf("原文代码行未被引用:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// 无抄送时不该渲染出空的「抄送」行。
|
||||
func TestQuoteBodyOmitsEmptyCC(t *testing.T) {
|
||||
m := &models.Mail{
|
||||
FromName: "admin",
|
||||
Subject: "x",
|
||||
Body: "y",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if strings.Contains(quoteBody(m), "抄送") {
|
||||
t.Error("无抄送时不应出现「抄送」行")
|
||||
}
|
||||
}
|
||||
410
server/internal/handler/helpers.go
Normal file
410
server/internal/handler/helpers.go
Normal file
@ -0,0 +1,410 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"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。**拒绝未知字段。**
|
||||
//
|
||||
// # 为什么必须严格
|
||||
//
|
||||
// 宽容解码把「字段名写错」变成一种**静默成功**:请求返回 200,服务端却什么都
|
||||
// 没收到。生产实测过最坏的一种形状 —— homeagent 插件的 send_mail 传的是
|
||||
// `attachments: [{"attachment_id": …}]`,而服务端要的是 `attachment_ids: ["…"]`:
|
||||
//
|
||||
// $ curl -X POST /mail/send -d '{…,"attachments":[{"attachment_id":"598f100e…"}]}'
|
||||
// HTTP 200 {"mail_id":"2a64fdc8…", …}
|
||||
// $ sqlite3 "SELECT COUNT(*) FROM attachments WHERE mail_id='2a64fdc8…'"
|
||||
// 0
|
||||
//
|
||||
// 邮件发出去了、附件一个都没带、没有任何一层报错。那个 bug 在库里活了很久 ——
|
||||
// **正因为没人会去核对一个返回 200 的请求**。
|
||||
//
|
||||
// 严格解码把它变成一个当场可见的 400。这是 `I-5`(失败必须当场可见)在
|
||||
// 请求解析层的落点:宁可让调用方收到一句「字段 X 不认识」,
|
||||
// 也不要让它以为自己传的东西生效了。
|
||||
//
|
||||
// 需要宽容的地方只有一处(心跳,见 DecodeLenient),且必须显式说明理由。
|
||||
func Decode(r *http.Request, v interface{}) error {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(v)
|
||||
}
|
||||
|
||||
// DecodeLenient 解析请求体但**容忍未知字段**,同时把认不出的字段名报回来。
|
||||
//
|
||||
// 只给心跳用,理由是那条路径的职责是「我还活着」:插件比服务端新、多带了一个
|
||||
// 服务端还不认识的字段时,代价不该是整个心跳体(含会话快照与模型目录)被丢掉。
|
||||
//
|
||||
// 但**容忍不等于咽下去**。返回的 unknown 列表必须被调用方回报给插件
|
||||
// (心跳响应里的 `unknown_fields`),否则又变成一次静默忽略 —— 那正是
|
||||
// `attachments` vs `attachment_ids` 能拖那么久的原因。
|
||||
//
|
||||
// 实现上要解两遍(宽容一遍取值、严格一遍找未知字段),所以先把 body 读进内存。
|
||||
func DecodeLenient(r *http.Request, v interface{}) (unknown []string, err error) {
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, maxLenientBodyBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 取值这一遍必须宽容:未知字段不能让整个心跳体作废。
|
||||
if uErr := json.Unmarshal(raw, v); uErr != nil {
|
||||
return nil, uErr
|
||||
}
|
||||
|
||||
// 再严格解一遍**只为找出未知字段**。json 每遇到一个未知字段就立即返回,
|
||||
// 所以要循环剥:不循环的话「多带了三个字段」只会报出第一个。
|
||||
probeType := reflect.TypeOf(v)
|
||||
for probeType != nil && probeType.Kind() == reflect.Ptr {
|
||||
probeType = probeType.Elem()
|
||||
}
|
||||
if probeType == nil {
|
||||
return nil, nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < maxUnknownFieldsReported; i++ {
|
||||
probe := reflect.New(probeType).Interface()
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
dErr := dec.Decode(probe)
|
||||
if dErr == nil {
|
||||
break
|
||||
}
|
||||
name := unknownFieldName(dErr)
|
||||
// 不是未知字段错误(宽容那遍已经成功,所以这里本不应出现其他错),
|
||||
// 或者同一个名字又出现一次 —— 都说明剥不下去了,停。
|
||||
if name == "" || seen[name] {
|
||||
break
|
||||
}
|
||||
seen[name] = true
|
||||
unknown = append(unknown, name)
|
||||
stripped, sErr := stripTopLevelKey(raw, name)
|
||||
if sErr != nil {
|
||||
break
|
||||
}
|
||||
raw = stripped
|
||||
}
|
||||
return unknown, nil
|
||||
}
|
||||
|
||||
const (
|
||||
// maxLenientBodyBytes 是心跳体的读取上限。会话快照 200 条 + 模型目录 300 条,
|
||||
// 每条百来字节,2MB 有充足余量;超出的部分被截断后 json 解析会报错,
|
||||
// 那正是我们想要的(一个畸形巨大的心跳体不该被当成有效上报)。
|
||||
maxLenientBodyBytes = 2 << 20
|
||||
// maxUnknownFieldsReported 是回报的未知字段数上限。
|
||||
// 报头几个足够定位问题,无上限循环会让一个塞满垃圾键的请求变成 CPU 消耗。
|
||||
maxUnknownFieldsReported = 8
|
||||
)
|
||||
|
||||
// stripTopLevelKey 从一个 JSON 对象里删掉一个顶层键。
|
||||
//
|
||||
// 只动顶层:未知字段错误报的就是顶层键名。嵌套结构里的未知字段报的名字
|
||||
// 在顶层找不到,这里返回错误,循环随即停下 —— 那个字段仍会被报出来。
|
||||
func stripTopLevelKey(raw []byte, key string) ([]byte, error) {
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := m[key]; !ok {
|
||||
return nil, errors.New("key not at top level")
|
||||
}
|
||||
delete(m, key)
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
// unknownFieldName 从 encoding/json 的未知字段错误里取出那个字段名。
|
||||
//
|
||||
// json 包没有为这种错误定义类型(返回的是 *errors.errorString),
|
||||
// 只能按文本匹配 `json: unknown field "xxx"`。
|
||||
// 匹配不上时返回空串,调用方回落到笼统文案。
|
||||
func unknownFieldName(err error) string {
|
||||
const prefix = `json: unknown field "`
|
||||
msg := err.Error()
|
||||
i := strings.Index(msg, prefix)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := msg[i+len(prefix):]
|
||||
j := strings.IndexByte(rest, '"')
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
return rest[:j]
|
||||
}
|
||||
|
||||
// jsonFieldNames 反射列出一个请求结构体接受的 JSON 键。
|
||||
//
|
||||
// 用途是把「字段 X 不认识」补成「应为 a / b / c 之一」——
|
||||
// 少了这半句,调用方只知道自己错了,仍要去翻服务端源码才知道对的是什么。
|
||||
// 那正是 `attachments` vs `attachment_ids` 当初拖了那么久的原因。
|
||||
func jsonFieldNames(v interface{}) []string {
|
||||
t := reflect.TypeOf(v)
|
||||
for t != nil && t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t == nil || t.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, t.NumField())
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
f := t.Field(i)
|
||||
if f.PkgPath != "" {
|
||||
continue // 非导出字段不参与 JSON
|
||||
}
|
||||
name := f.Tag.Get("json")
|
||||
if idx := strings.IndexByte(name, ','); idx >= 0 {
|
||||
name = name[:idx]
|
||||
}
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = f.Name
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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, v))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// decodeErrMsg 把 json 解码错误翻成一句能照着改的话。
|
||||
//
|
||||
// 刻意不回显 json 包的原文:它带 Go 的类型名(如 models.Workspace),
|
||||
// 那是本侧的实现细节,对调用方没有意义,也不该出现在公开 API 的响应里。
|
||||
func decodeErrMsg(err error, target interface{}) string {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return "请求体为空"
|
||||
}
|
||||
|
||||
// 未知字段:把认识的键一并列出来。只说「不认识 x」的话,调用方还得去翻
|
||||
// 服务端源码才知道对的拼法 —— 而拼错字段名恰恰是最容易犯、最难自查的错
|
||||
//(宽容解码时它连报错都没有,见 Decode 的注释)。
|
||||
if bad := unknownFieldName(err); bad != "" {
|
||||
msg := "不认识的字段 \"" + bad + "\""
|
||||
if names := jsonFieldNames(target); len(names) > 0 {
|
||||
msg += ";本端点接受:" + strings.Join(names, " / ")
|
||||
}
|
||||
return msg
|
||||
}
|
||||
// 截断的 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 ""
|
||||
}
|
||||
|
||||
// validPermissionModeInput 校验人显式指定的权限档位。
|
||||
//
|
||||
// 与 repo 层的 Normalize 分工不同:**人显式传了一个认不出的档位时必须报错**,
|
||||
// 不能静默用默认档。他以为自己给了 plan,实际拿到 workspace —— 那是比报错
|
||||
// 更坏的结果(他会以为自己收紧了)。
|
||||
//
|
||||
// 而 repo 层的 Normalize 面向的是「库里的历史脏数据」与「省略该字段」,
|
||||
// 那两种情形下静默回落到默认档才是对的。
|
||||
func validPermissionModeInput(w http.ResponseWriter, mode string) bool {
|
||||
if mode == "" || models.ValidPermissionMode(mode) {
|
||||
return true
|
||||
}
|
||||
Error(w, http.StatusBadRequest,
|
||||
"permission_mode 非法:"+mode+"(应为 plan / workspace / full)")
|
||||
return false
|
||||
}
|
||||
423
server/internal/handler/ics_test.go
Normal file
423
server/internal/handler/ics_test.go
Normal file
@ -0,0 +1,423 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
)
|
||||
|
||||
// parseICS 是导入的唯一入口,解析错了不会报错 —— 事件只是安静地不出现,
|
||||
// 或者出现在错误的时间。这些测试锁住 iCal 的形态约定。
|
||||
|
||||
func TestParseICSBasicEvent(t *testing.T) {
|
||||
ics := "BEGIN:VCALENDAR\r\n" +
|
||||
"VERSION:2.0\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"SUMMARY:发布评审\r\n" +
|
||||
"DESCRIPTION:看 llmsproxy 的部署脚本\r\n" +
|
||||
"DTSTART:20260903T063000Z\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("期望 1 个事件,得到 %d", len(events))
|
||||
}
|
||||
e := events[0]
|
||||
if e.Title != "发布评审" {
|
||||
t.Errorf("Title = %q,期望 发布评审", e.Title)
|
||||
}
|
||||
if e.Description != "看 llmsproxy 的部署脚本" {
|
||||
t.Errorf("Description = %q", e.Description)
|
||||
}
|
||||
if !e.EventTime.Equal(time.Date(2026, 9, 3, 6, 30, 0, 0, time.UTC)) {
|
||||
t.Errorf("EventTime = %v,期望 2026-09-03T06:30:00Z", e.EventTime)
|
||||
}
|
||||
if e.Recurrence != "none" {
|
||||
t.Errorf("Recurrence = %q,期望 none", e.Recurrence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICSMultipleEvents(t *testing.T) {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("BEGIN:VCALENDAR\r\n")
|
||||
for i, title := range []string{"甲", "乙", "丙"} {
|
||||
sb.WriteString("BEGIN:VEVENT\r\n")
|
||||
sb.WriteString("SUMMARY:" + title + "\r\n")
|
||||
sb.WriteString("DTSTART:2026090" + string(rune('1'+i)) + "T020000Z\r\n")
|
||||
sb.WriteString("END:VEVENT\r\n")
|
||||
}
|
||||
sb.WriteString("END:VCALENDAR\r\n")
|
||||
|
||||
events := parseICS([]byte(sb.String()))
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("期望 3 个事件,得到 %d", len(events))
|
||||
}
|
||||
for i, want := range []string{"甲", "乙", "丙"} {
|
||||
if events[i].Title != want {
|
||||
t.Errorf("第 %d 个 Title = %q,期望 %q", i, events[i].Title, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 没有 DTSTART 的 VEVENT 必须被丢弃:让它进库会得到一个 zero time 事件,
|
||||
// 调度器认为它「早就该触发了」,于是立刻发一封莫名其妙的提醒。
|
||||
func TestParseICSDropsEventWithoutStart(t *testing.T) {
|
||||
ics := "BEGIN:VCALENDAR\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"SUMMARY:没有时间\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
|
||||
if events := parseICS([]byte(ics)); len(events) != 0 {
|
||||
t.Fatalf("无 DTSTART 的事件应被丢弃,却得到 %d 个", len(events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICSRecurrence(t *testing.T) {
|
||||
cases := []struct {
|
||||
rrule string
|
||||
want string
|
||||
}{
|
||||
{"FREQ=DAILY", "daily"},
|
||||
{"FREQ=WEEKLY;BYDAY=MO", "weekly"},
|
||||
{"FREQ=MONTHLY;BYMONTHDAY=1", "monthly"},
|
||||
{"FREQ=YEARLY", "yearly"},
|
||||
// 不支持的频率退回 none 而不是乱猜:把 HOURLY 当 daily
|
||||
// 会让提醒少发 23 次且没有任何报错。
|
||||
{"FREQ=HOURLY", "none"},
|
||||
{"FREQ=SECONDLY", "none"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" +
|
||||
"RRULE:" + c.rrule + "\r\nEND:VEVENT\r\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("%s: 期望 1 个事件", c.rrule)
|
||||
}
|
||||
if events[0].Recurrence != c.want {
|
||||
t.Errorf("%s: Recurrence = %q,期望 %q", c.rrule, events[0].Recurrence, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICSTriggerToRemindBefore(t *testing.T) {
|
||||
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" +
|
||||
"TRIGGER:-PT30M\r\nEND:VEVENT\r\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Fatal("期望 1 个事件")
|
||||
}
|
||||
if events[0].RemindBefore != 30 {
|
||||
t.Errorf("RemindBefore = %d,期望 30", events[0].RemindBefore)
|
||||
}
|
||||
}
|
||||
|
||||
// DTSTART 有三种合法形态,都得认。只认 UTC 那种会让本地时间的 .ics
|
||||
// 整份导入失败(每个事件都缺 DTSTART → 全被丢弃 → 「导入 0 个」且无提示)。
|
||||
func TestParseICSDateFormats(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"UTC", "20260903T063000Z"},
|
||||
{"本地时间", "20260903T143000"},
|
||||
{"仅日期", "20260903"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:" + c.value + "\r\nEND:VEVENT\r\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Errorf("%s (%s): 期望 1 个事件,得到 %d", c.name, c.value, len(events))
|
||||
continue
|
||||
}
|
||||
if events[0].EventTime.IsZero() {
|
||||
t.Errorf("%s (%s): EventTime 为零值", c.name, c.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DTSTART;TZID=Asia/Shanghai:... 这种带参数的键必须归一化到 DTSTART,
|
||||
// 否则 switch 落空 → 无 EventTime → 事件被丢。
|
||||
func TestParseICSStripsKeyParameters(t *testing.T) {
|
||||
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\n" +
|
||||
"DTSTART;TZID=Asia/Shanghai:20260903T143000\r\nEND:VEVENT\r\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("带 TZID 参数的 DTSTART 应被识别,得到 %d 个事件", len(events))
|
||||
}
|
||||
if events[0].EventTime.IsZero() {
|
||||
t.Error("EventTime 为零值")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICSEscapedNewlines(t *testing.T) {
|
||||
ics := "BEGIN:VEVENT\r\nSUMMARY:第一行\\n第二行\r\n" +
|
||||
"DTSTART:20260903T020000Z\r\nEND:VEVENT\r\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Fatal("期望 1 个事件")
|
||||
}
|
||||
if !strings.Contains(events[0].Title, "\n") {
|
||||
t.Errorf("转义的 \\n 应还原成真换行,得到 %q", events[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICSEmptyAndGarbage(t *testing.T) {
|
||||
for _, in := range []string{"", "不是 ics", "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"} {
|
||||
if events := parseICS([]byte(in)); len(events) != 0 {
|
||||
t.Errorf("输入 %q 应给 0 个事件,得到 %d", in, len(events))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LF 换行(非 CRLF)的 .ics 也要能解析:很多工具导出的是 LF。
|
||||
func TestParseICSAcceptsLFLineEndings(t *testing.T) {
|
||||
ics := "BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:LF 换行\n" +
|
||||
"DTSTART:20260903T020000Z\nEND:VEVENT\nEND:VCALENDAR\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("LF 换行应能解析,得到 %d 个事件", len(events))
|
||||
}
|
||||
if events[0].Title != "LF 换行" {
|
||||
t.Errorf("Title = %q", events[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
// TRIGGER duration 解析。
|
||||
//
|
||||
// 原实现是 fmt.Sscanf(value, "-PT%dM", &mins),只认一种形态;导出端又写的是
|
||||
// `-P15M`(T 之前的 M 在 iCal 里是**月**)—— 于是自己导出的文件自己都读不回来。
|
||||
func TestParseTriggerMinutes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{"-PT30M", 30, true},
|
||||
{"-PT1H", 60, true},
|
||||
{"-PT1H30M", 90, true},
|
||||
{"-P1D", 1440, true},
|
||||
{"-P1DT2H", 1560, true},
|
||||
{"-P1W", 10080, true},
|
||||
{"-pt45m", 45, true}, // 大小写不敏感
|
||||
|
||||
// T 之前的 M 是月,映射不到分钟数,忽略比乱换算好
|
||||
{"-P3M", 0, false},
|
||||
// 正号 = 事件之后提醒,remind_before 表达不了
|
||||
{"PT30M", 0, false},
|
||||
// 零时长与垃圾输入
|
||||
{"-PT0M", 0, false},
|
||||
{"", 0, false},
|
||||
{"垃圾", 0, false},
|
||||
{"-P", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := parseTriggerMinutes(c.in)
|
||||
if ok != c.ok || got != c.want {
|
||||
t.Errorf("parseTriggerMinutes(%q) = (%d, %v),期望 (%d, %v)",
|
||||
c.in, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出写的 TRIGGER 必须能被自己的导入解析回同一个分钟数。
|
||||
// 这条往返曾经是断的:导出 -P15M、导入找 -PT%dM。
|
||||
func TestTriggerRoundtrip(t *testing.T) {
|
||||
for _, mins := range []int{5, 15, 30, 60, 120, 1440} {
|
||||
// 导出端的写法(与 ExportCalendarICS 里那行一致)
|
||||
trigger := fmt.Sprintf("-PT%dM", mins)
|
||||
got, ok := parseTriggerMinutes(trigger)
|
||||
if !ok {
|
||||
t.Errorf("%d 分钟导出成 %q 后无法解析", mins, trigger)
|
||||
continue
|
||||
}
|
||||
if got != mins {
|
||||
t.Errorf("%d 分钟往返后变成 %d(trigger=%q)", mins, got, trigger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 整份 .ics 的往返:TRIGGER 经过 parseICS 后落到 RemindBefore 上。
|
||||
func TestParseICSTriggerVariants(t *testing.T) {
|
||||
cases := []struct {
|
||||
trigger string
|
||||
want int
|
||||
}{
|
||||
{"-PT15M", 15},
|
||||
{"-PT2H", 120},
|
||||
{"-P1D", 1440},
|
||||
}
|
||||
for _, c := range cases {
|
||||
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" +
|
||||
"TRIGGER:" + c.trigger + "\r\nEND:VEVENT\r\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Errorf("%s: 期望 1 个事件", c.trigger)
|
||||
continue
|
||||
}
|
||||
if events[0].RemindBefore != c.want {
|
||||
t.Errorf("%s: RemindBefore = %d,期望 %d", c.trigger, events[0].RemindBefore, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默认提醒模板必须是**变量形式**,不能把当时的时间烤成字面值。
|
||||
//
|
||||
// 重复事件上这个区别是致命的:AdvanceRecurrence 只推进 event_time,
|
||||
// reminder_text 保持不动 —— 字面值会让「每天 9 点」的提醒从第二天起
|
||||
// 永远写着第一天的日期,且不报任何错。
|
||||
//
|
||||
// 同时锁住「与前端 DEFAULT_TEMPLATE 逐字一致」:
|
||||
// client/electron/src/components/CalendarEventEditor.tsx 用它作预览,
|
||||
// 两边不同会让人看到的预览与 Agent 实收的正文不是一回事。
|
||||
func TestDefaultReminderTemplateUsesVariables(t *testing.T) {
|
||||
for _, v := range []string{"{title}", "{time}", "{description}"} {
|
||||
if !strings.Contains(defaultReminderTemplate, v) {
|
||||
t.Errorf("默认模板缺变量 %s:%q", v, defaultReminderTemplate)
|
||||
}
|
||||
}
|
||||
// 前端那份的字面内容(保持同步)
|
||||
const frontend = "日程提醒:{title}\n时间:{time}\n{description}"
|
||||
if defaultReminderTemplate != frontend {
|
||||
t.Errorf("后端默认模板与前端 DEFAULT_TEMPLATE 不一致:\n后端 %q\n前端 %q",
|
||||
defaultReminderTemplate, frontend)
|
||||
}
|
||||
// 不该含任何形如年份的字面数字 —— 那是「把值烤进模板」的迹象
|
||||
for _, digit := range []string{"2026", "20:", ":00"} {
|
||||
if strings.Contains(defaultReminderTemplate, digit) {
|
||||
t.Errorf("默认模板含字面时间片段 %q:%q", digit, defaultReminderTemplate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 农历与多收件人的 iCal 往返 ───
|
||||
|
||||
// 农历规则 RFC 5545 表达不了。折中方案:X- 扩展属性记精确规则 +
|
||||
// 降级成最接近的公历 RRULE。别的客户端至少能看到一个大致对的重复,
|
||||
// 导回本系统时 X- 属性还原精确规则。
|
||||
func TestParseICSLunarRecurrenceExtension(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"X- 属性覆盖 RRULE 近似值",
|
||||
"RRULE:FREQ=MONTHLY\r\nX-AGENTMAIL-RECURRENCE:lunar_monthly\r\n",
|
||||
"lunar_monthly",
|
||||
},
|
||||
{
|
||||
"农历年",
|
||||
"RRULE:FREQ=YEARLY\r\nX-AGENTMAIL-RECURRENCE:lunar_yearly\r\n",
|
||||
"lunar_yearly",
|
||||
},
|
||||
{
|
||||
"X- 在 RRULE 之前也生效(顺序无关)",
|
||||
"X-AGENTMAIL-RECURRENCE:lunar_monthly\r\nRRULE:FREQ=MONTHLY\r\n",
|
||||
"lunar_monthly",
|
||||
},
|
||||
{
|
||||
"非法 X- 值被忽略,保留 RRULE 的近似值",
|
||||
"RRULE:FREQ=MONTHLY\r\nX-AGENTMAIL-RECURRENCE:lunar_montly\r\n",
|
||||
"monthly",
|
||||
},
|
||||
{
|
||||
"键名小写也认(RFC 5545 §3.1 大小写不敏感)",
|
||||
"x-agentmail-recurrence:lunar_yearly\r\n",
|
||||
"lunar_yearly",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" +
|
||||
c.body + "END:VEVENT\r\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Errorf("%s: 期望 1 个事件,得到 %d", c.name, len(events))
|
||||
continue
|
||||
}
|
||||
if events[0].Recurrence != c.want {
|
||||
t.Errorf("%s: Recurrence = %q,期望 %q", c.name, events[0].Recurrence, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICSRecipientsExtension(t *testing.T) {
|
||||
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" +
|
||||
"X-AGENTMAIL-RECIPIENTS:pi@/home/x,dsh,opencode@/tmp.alias\r\n" +
|
||||
"X-AGENTMAIL-DELIVERY:together\r\nEND:VEVENT\r\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("期望 1 个事件,得到 %d", len(events))
|
||||
}
|
||||
e := events[0]
|
||||
if len(e.Recipients) != 3 {
|
||||
t.Fatalf("收件人应有 3 个,得到 %d:%v", len(e.Recipients), e.Recipients)
|
||||
}
|
||||
if e.Recipients[0] != "pi@/home/x" || e.Recipients[2] != "opencode@/tmp.alias" {
|
||||
t.Errorf("收件人内容或顺序不符:%v", e.Recipients)
|
||||
}
|
||||
if e.DeliveryMode != "together" {
|
||||
t.Errorf("DeliveryMode = %q,期望 together", e.DeliveryMode)
|
||||
}
|
||||
}
|
||||
|
||||
// 未知投递模式必须被忽略(留空 → EffectiveDeliveryMode 给 separate),
|
||||
// 而不是原样写进库里。
|
||||
func TestParseICSRejectsBadDeliveryMode(t *testing.T) {
|
||||
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" +
|
||||
"X-AGENTMAIL-DELIVERY:随便写的\r\nEND:VEVENT\r\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Fatal("期望 1 个事件")
|
||||
}
|
||||
if events[0].DeliveryMode != "" {
|
||||
t.Errorf("非法投递模式应被忽略,得到 %q", events[0].DeliveryMode)
|
||||
}
|
||||
if events[0].EffectiveDeliveryMode() != models.DeliverSeparate {
|
||||
t.Error("兜底应是 separate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidRecurrence(t *testing.T) {
|
||||
for _, ok := range []string{"none", "daily", "weekly", "monthly", "yearly", "lunar_monthly", "lunar_yearly"} {
|
||||
if !validRecurrence(ok) {
|
||||
t.Errorf("%q 应合法", ok)
|
||||
}
|
||||
}
|
||||
// 拼错必须被拒而不是静默当 none —— 后者会让每月提醒只响一次且无报错
|
||||
for _, bad := range []string{"", "lunar_montly", "LUNAR_MONTHLY", "每月", "lunar_weekly"} {
|
||||
if validRecurrence(bad) {
|
||||
t.Errorf("%q 应非法", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRecipients(t *testing.T) {
|
||||
got, bad := normalizeRecipients([]string{" pi ", "", "dsh", "pi", " "})
|
||||
if bad != "" {
|
||||
t.Fatalf("不该报错,得到 %q", bad)
|
||||
}
|
||||
// 去空白 + 去重,保留首次出现的顺序
|
||||
if len(got) != 2 || got[0] != "pi" || got[1] != "dsh" {
|
||||
t.Errorf("清洗结果 %v,期望 [pi dsh]", got)
|
||||
}
|
||||
|
||||
// 去重是必要的:together 模式下同一 Agent 既主收又抄送会收到两条 SSE
|
||||
if dup, _ := normalizeRecipients([]string{"pi@/x", "pi@/x"}); len(dup) != 1 {
|
||||
t.Errorf("重复地址应去重,得到 %v", dup)
|
||||
}
|
||||
|
||||
// 非法地址回报具体是哪一个
|
||||
if _, bad := normalizeRecipients([]string{"pi", "@@@bad@@@"}); bad == "" {
|
||||
t.Error("非法地址应被报出")
|
||||
}
|
||||
|
||||
// nil / 空输入给空数组而不是 nil(避免序列化成 null)
|
||||
if out, _ := normalizeRecipients(nil); out == nil {
|
||||
t.Error("nil 输入应给空数组")
|
||||
}
|
||||
}
|
||||
176
server/internal/handler/keys.go
Normal file
176
server/internal/handler/keys.go
Normal file
@ -0,0 +1,176 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
// ---------- 密钥管理 ----------
|
||||
//
|
||||
// 两套接口,权限边界不同:
|
||||
// /admin/agent-keys —— 管理员签发 Agent 接入密钥
|
||||
// /me/keys —— 用户自助签发客户端连接密钥(不能注册 Agent)
|
||||
//
|
||||
// 密钥全文只在创建响应里出现一次,列表接口只给前 8 位 hint。
|
||||
|
||||
type createKeyRequest struct {
|
||||
// AgentName 仅 Agent 密钥使用;留空表示「待绑定」,首次注册时按注册请求的 name 落定
|
||||
AgentName string `json:"agent_name"`
|
||||
// Label 人类可读备注(如「我的笔记本」「CI 机器」)
|
||||
Label string `json:"label"`
|
||||
// KeyType permanent / one_time / timed
|
||||
KeyType string `json:"key_type"`
|
||||
// ExpiresHours 仅 timed 使用,必须为正
|
||||
ExpiresHours int `json:"expires_hours"`
|
||||
// KeyToken 仅 Agent 密钥使用:登记一把客户端已在本地生成的密钥。
|
||||
// 插件首次安装时自己生成密钥并打印出来,管理员把它填到这里完成登记,
|
||||
// 密钥全文因此不需要从服务器往客户端传。留空则由服务器生成。
|
||||
KeyToken string `json:"key_token"`
|
||||
}
|
||||
|
||||
// normalizeKeyType 默认给 permanent,避免调用方漏填时落到非法值
|
||||
func normalizeKeyType(t string) string {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
return models.KeyPermanent
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/agent-keys
|
||||
func CreateAgentKey(w http.ResponseWriter, r *http.Request) {
|
||||
admin := middleware.GetUser(r)
|
||||
if admin == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req createKeyRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
key, err := repo.CreateAgentKey(r.Context(),
|
||||
strings.TrimSpace(req.AgentName), normalizeKeyType(req.KeyType),
|
||||
strings.TrimSpace(req.Label), req.ExpiresHours, admin.ID,
|
||||
strings.TrimSpace(req.KeyToken))
|
||||
if err != nil {
|
||||
writeKeyErr(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 唯一一次回传全文
|
||||
JSON(w, http.StatusOK, map[string]any{"key": key})
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/agent-keys?agent_name=xxx
|
||||
func ListAgentKeys(w http.ResponseWriter, r *http.Request) {
|
||||
keys, err := repo.ListAgentKeys(r.Context(), r.URL.Query().Get("agent_name"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list keys")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"keys": keys})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/admin/agent-keys/{id}
|
||||
func DeleteAgentKey(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := repo.DeleteAgentKey(r.Context(), id); err != nil {
|
||||
writeKeyErr(w, err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
type bindKeyRequest struct {
|
||||
AgentName string `json:"agent_name"`
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/agent-keys/{id}/bind
|
||||
func BindAgentKey(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req bindKeyRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.AgentName)
|
||||
if name == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing agent_name")
|
||||
return
|
||||
}
|
||||
if err := repo.BindAgentKey(r.Context(), id, name); err != nil {
|
||||
writeKeyErr(w, err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "bound", "agent_name": name})
|
||||
}
|
||||
|
||||
// ---------- 用户连接密钥 ----------
|
||||
|
||||
// POST /api/v1/me/keys
|
||||
func CreateMyKey(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req createKeyRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
key, err := repo.CreateUserKey(r.Context(), user.ID,
|
||||
strings.TrimSpace(req.Label), normalizeKeyType(req.KeyType), req.ExpiresHours)
|
||||
if err != nil {
|
||||
writeKeyErr(w, err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"key": key})
|
||||
}
|
||||
|
||||
// GET /api/v1/me/keys
|
||||
func ListMyKeys(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
keys, err := repo.ListUserKeys(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list keys")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"keys": keys})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/me/keys/{id}
|
||||
func DeleteMyKey(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// repo 层带 user_id 条件,删不到就是不属于自己或不存在,统一 404
|
||||
if err := repo.DeleteUserKey(r.Context(), user.ID, id); err != nil {
|
||||
writeKeyErr(w, err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
692
server/internal/handler/mail.go
Normal file
692
server/internal/handler/mail.go
Normal file
@ -0,0 +1,692 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/notify"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- Mail ----------
|
||||
|
||||
type sendMailRequest struct {
|
||||
To string `json:"to"` // name@path.session(省略 session=默认会话,new=新建,别名=必须已存在)
|
||||
CC string `json:"cc"` // 逗号/分号/空格分隔的多个 name@path.session
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
ReplyTo string `json:"reply_to"`
|
||||
// SessionAlias 仅在本次投递【新建】会话时生效,为新会话命名,
|
||||
// 之后即可用 name@path.<alias> 续谈。命中已有会话时该字段被忽略。
|
||||
SessionAlias string `json:"session_alias"`
|
||||
// AttachmentIDs 先用 POST /attachments 上传拿到的 id;只能附加自己上传且未挂载的
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
|
||||
// Relay 标识本次发信是【插件代劳转发】而不是模型自主发信。
|
||||
//
|
||||
// 基本原则:**配额约束的是模型的自主发信,不是 harness 的转发**。
|
||||
// 平台原生的权限询问与本轮的最终总结都是插件搬运的,不计配额。
|
||||
//
|
||||
// RelayKey 必須是上游那条消息的稳定标识(permission id / assistant message id):
|
||||
// 它由平台生成,模型伪造不出,而唯一约束保证同一条上游消息只能免费转一次。
|
||||
Relay string `json:"relay"` // "" | "permission" | "summary"
|
||||
RelayKey string `json:"relay_key"` // 上游消息 id;relay 非空时必填
|
||||
|
||||
// FromSessionID 是发信时模型所处的邮件会话 id(即「这活是谁派给我的」)。
|
||||
//
|
||||
// **只用于权限档位继承**:Agent 新开一条会话时,新会话不得比它所在
|
||||
// 的那条会话更宽松。注意这里**没有** permission_mode 字段 —— 那是有意的:
|
||||
// 让 Agent 自己指定档位等于发一封 mode=full 的信就能提权。
|
||||
//
|
||||
// 省略时回落到默认档(不是 full)。插件担不担得起传这个值不影响安全下限:
|
||||
// 没传 = 拿默认档,不会因此拿到更大的权限。
|
||||
FromSessionID string `json:"from_session_id"`
|
||||
}
|
||||
|
||||
// resolveTarget 根据三维地址 name@path.session 决定投递的会话。
|
||||
//
|
||||
// session 位三态语义(设计文档):
|
||||
// - 省略(pi@root) → 投递到 name@path 的默认会话;从未通信则建立
|
||||
// - new(pi@root.new) → 强制新建一个会话
|
||||
// - 具体别名(pi@root.fix-leak)→ 必须已存在且该收件人参与过,否则 404 无法送达
|
||||
//
|
||||
// alias 为新建会话命名(仅新建时生效),使其之后可被 name@path.<alias> 寻址。
|
||||
// reply_to 优先于地址:显式回复某封邮件时沿用该邮件的会话。
|
||||
//
|
||||
// byAgent 非空时表示这是 Agent 发起的投递,新建会话要过速率限制:
|
||||
// 往返预算按会话计,Agent 用 .new 开一串会话就等于绕过预算。
|
||||
// 人类不受此限(手工点「新建邮件」的频率天然受限,加限制只会在批量派活时误伤)。
|
||||
// resolveTarget 依据地址的 session 位定位(或新建)会话。
|
||||
//
|
||||
// 返回值:会话 id / 父邮件 id(仅 reply_to 路径非 nil)/ **created** / 错误。
|
||||
//
|
||||
// created 为真**仅**表示这次调用真的新建了一条会话。它存在的理由是:
|
||||
// `parentMailID == nil` 曾被当作「新建会话」的判据,而那是错的 ——
|
||||
// 省略 session 位复用默认会话时 parentMailID 也是 nil。实测后果:
|
||||
// 第一封信 `max_rounds=7`,第二封信省略该字段,会话预算被静默改成 20。
|
||||
// 「只在新建时生效」的字段(往返预算、权限档位)必须靠这个返回值判断,
|
||||
// 否则每封新信都在改写对方正在遵守的规则。
|
||||
func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, subject, alias string, byAgent string) (uuid.UUID, *uuid.UUID, bool, error) {
|
||||
if replyTo != "" {
|
||||
replyID, err := uuid.Parse(replyTo)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, false, errBadRequest("Invalid reply_to UUID")
|
||||
}
|
||||
mail, err := repo.GetMailByID(r.Context(), replyID)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, false, errNotFound("Parent mail not found")
|
||||
}
|
||||
repo.TouchSession(r.Context(), mail.SessionID)
|
||||
return mail.SessionID, &replyID, false, nil
|
||||
}
|
||||
|
||||
switch addr.Mode() {
|
||||
case models.SessionNew:
|
||||
// 新建会话:若调用方给了别名,当场命名,之后即可用 name@path.<alias> 续谈。
|
||||
// 别名全局唯一(负责寻址),已被占用时报 409 而不是静默吐出重名会话。
|
||||
var aliasPtr *string
|
||||
if a := strings.TrimSpace(alias); a != "" {
|
||||
if err := validateSessionAlias(a); err != nil {
|
||||
return uuid.Nil, nil, false, err
|
||||
}
|
||||
if _, err := repo.FindSessionByAlias(r.Context(), a); err == nil {
|
||||
return uuid.Nil, nil, false, errConflict(fmt.Sprintf(
|
||||
"会话别名 %q 已被占用;若要接着该会话谈请用 %s@%s.%s", a, addr.Name, addr.Path, a))
|
||||
}
|
||||
aliasPtr = &a
|
||||
}
|
||||
// Agent 主动开新线索要过速率限制
|
||||
if ok, retry := repo.AllowNewSession(r.Context(), byAgent); !ok {
|
||||
return uuid.Nil, nil, false, errRateLimited(fmt.Sprintf(
|
||||
"新建会话过于频繁(1 小时内已开 %d 条)。请在已有会话里继续,或 %d 秒后再试。",
|
||||
repo.SessionRateLimit(), retry))
|
||||
}
|
||||
// 带上 addr.Path:会话属于哪个工作区是会话自己的属性,
|
||||
// 不存下来的话「这个工作区下有哪些会话」就只能从 mails 反推。
|
||||
id, err := repo.CreateSession(r.Context(), aliasPtr, fromAgent, subject, addr.Path)
|
||||
if err != nil {
|
||||
// 建失败要把名额还回去:那次新建实际上没有发生
|
||||
repo.ReleaseNewSession(r.Context(), byAgent)
|
||||
return id, nil, false, err
|
||||
}
|
||||
// `.new` 是一次性动作:它建完会话就用完了,之后要再投进这条会话只能靠
|
||||
// `name@path.<别名>`。未命名会话既查不到(FindNamedSessionFor 的
|
||||
// `session_alias = $1` 对 NULL 不成立)也补全不出来,收件方与抄送方
|
||||
// 除了回复那一封之外再也无法寻址到它 —— 再发一次 `.new` 只会建第三条会话。
|
||||
// 因此这里立刻给一个别名,平台随后仍可用 SyncSessionAlias 改写它。
|
||||
if aliasPtr == nil {
|
||||
// 命名失败不该让发信失败:邮件本身能送达,代价只是这条会话暂时
|
||||
// 只能用 reply_to 续谈,比整封退回轻。
|
||||
_, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject))
|
||||
}
|
||||
return id, nil, true, nil
|
||||
|
||||
case models.SessionDefault:
|
||||
// 默认会话「从未通信则建立」也会产生新会话,但一个 name@path 只有一条,
|
||||
// 不构成暴开的手段,因此不计入速率限制。
|
||||
//
|
||||
// created 必须区分「这次建了」与「复用了既有的那条」:两者在这里都返回
|
||||
// parentMailID == nil,靠它判断会把续谈误当新建(预算与档位被静默改写)。
|
||||
id, created, err := repo.FindOrCreateDefaultSessionCreated(r.Context(), addr.Name, addr.Path, fromAgent, subject)
|
||||
if err != nil {
|
||||
return id, nil, false, err
|
||||
}
|
||||
// 默认会话同样需要可寻址的别名:省略 session 位能投进来,但要**指名**
|
||||
// 投进这一条(而不是「该 name@path 当前的默认会话」)仍然只能靠别名。
|
||||
// 已有别名时 EnsureSessionAlias 直接返回,复用旧会话不会被改名。
|
||||
_, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject))
|
||||
return id, nil, created, nil
|
||||
|
||||
default: // models.SessionNamed
|
||||
id, err := repo.FindNamedSessionFor(r.Context(), addr.Name, addr.Path, addr.Session)
|
||||
if err == nil {
|
||||
repo.TouchSession(r.Context(), id)
|
||||
return id, nil, false, nil
|
||||
}
|
||||
if !errors.Is(err, repo.ErrSessionNotFound) {
|
||||
return uuid.Nil, nil, false, err
|
||||
}
|
||||
|
||||
// 本侧没有这条别名 —— 再看平台会话镜像。
|
||||
//
|
||||
// TUI 与邮箱是同一个 Agent 的两个入口,人在平台界面上开的会话
|
||||
// 早就被补全列为候选(agent_platform_sessions),此前投递侧却没有
|
||||
// 这一跳,选中后只能得到 404 —— 候选列表在承诺一件做不到的事。
|
||||
//
|
||||
// 命中就**接管**它:本侧建一条会话并绑定 platform_id,插件收到投递
|
||||
// 事件时据此 resume 那条平台会话而不是新建。
|
||||
// 接管**是**新建本侧会话(绑定了 platform_id 的那条),
|
||||
// 所以 created 为真:它此前没有档位与预算,需要按这次投递定下来。
|
||||
if adopted, aErr := adoptFromPlatform(r, addr, fromAgent, subject, byAgent); aErr == nil {
|
||||
return adopted, nil, true, nil
|
||||
} else if !errors.Is(aErr, repo.ErrSessionNotFound) {
|
||||
return uuid.Nil, nil, false, aErr
|
||||
}
|
||||
|
||||
return uuid.Nil, nil, false, errNotFound(fmt.Sprintf(
|
||||
"无法送达:会话 %q 不存在于 %s@%s。若要新建会话请用 %s@%s.new,投递默认会话请省略 session 位",
|
||||
addr.Session, addr.Name, addr.Path, addr.Name, addr.Path))
|
||||
}
|
||||
}
|
||||
|
||||
// adoptFromPlatform 把地址里的 session 位当作**平台会话的 slug** 来解析,
|
||||
// 命中则接管那条会话。
|
||||
//
|
||||
// 返回 repo.ErrSessionNotFound 表示镜像里也没有,调用方据此回 404。
|
||||
//
|
||||
// # 为什么接管而不是直接投
|
||||
//
|
||||
// 平台会话在本侧没有身份:没有 session_id、没有预算、没法归档,
|
||||
// 也无处记录「谁往里投过什么」。接管一次之后它就是一条正常的本侧会话,
|
||||
// 只是多带一个 platform_id 告诉插件「别新建,去 resume 那条」。
|
||||
//
|
||||
// # 为什么一条平台会话只能被接管一次
|
||||
//
|
||||
// 第二次投递必须复用第一次建的本侧会话。否则同一条 TUI 对话会在邮箱里
|
||||
// 裂成多条互不相干的线索 —— 人看到三个同名会话,而回信只落在其中一条上。
|
||||
func adoptFromPlatform(r *http.Request, addr models.Address, fromAgent, subject, byAgent string) (uuid.UUID, error) {
|
||||
platformID, realWorkspace, title, err := repo.FindPlatformSession(
|
||||
r.Context(), addr.Name, addr.Session, addr.Path)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
// 已被接管过 → 复用,不再建新的
|
||||
if existing, fErr := repo.FindSessionByPlatformID(r.Context(), addr.Name, platformID); fErr == nil {
|
||||
repo.TouchSession(r.Context(), existing)
|
||||
return existing, nil
|
||||
} else if !errors.Is(fErr, repo.ErrSessionNotFound) {
|
||||
return uuid.Nil, fErr
|
||||
}
|
||||
|
||||
// 接管等于新开一条本侧线索,计入速率限制 —— 否则它成了绕过
|
||||
// AllowNewSession 的后门(镜像里有几百条 slug 可选)。
|
||||
if ok, retry := repo.AllowNewSession(r.Context(), byAgent); !ok {
|
||||
return uuid.Nil, errRateLimited(fmt.Sprintf(
|
||||
"新建会话过于频繁(1 小时内已开 %d 条)。请在已有会话里继续,或 %d 秒后再试。",
|
||||
repo.SessionRateLimit(), retry))
|
||||
}
|
||||
|
||||
// 主题优先用平台侧标题:它是那条对话在谈什么,比这封邮件的主题更能
|
||||
// 代表整条会话。人在补全里看到的也是这个标题。
|
||||
sub := strings.TrimSpace(title)
|
||||
if sub == "" {
|
||||
sub = subject
|
||||
}
|
||||
id, err := repo.AdoptPlatformSession(
|
||||
r.Context(), addr.Name, platformID, addr.Session, realWorkspace, sub)
|
||||
if err != nil {
|
||||
repo.ReleaseNewSession(r.Context(), byAgent)
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// POST /api/v1/mail/send
|
||||
func SendMail(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req sendMailRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.To == "" || req.Subject == "" || req.Body == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing to, subject, or body")
|
||||
return
|
||||
}
|
||||
|
||||
to, err := models.ParseAddress(req.To)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid to address: "+err.Error())
|
||||
return
|
||||
}
|
||||
ccList, err := models.ParseAddressList(req.CC)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid cc address: "+err.Error())
|
||||
return
|
||||
}
|
||||
attachIDs, err := parseAttachmentIDs(req.AttachmentIDs)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 附件可挂性必须在**建邮件之前**校验。
|
||||
//
|
||||
// 原先只在 CreateMail 之后调 attachAll,于是附件不合法时返回 403/409,
|
||||
// 但那封邮件已入库、已通知收件人、已扣预算(生产实测两封探针邮件均如此)。
|
||||
// 发件方看到 4xx 会重试,收件方于是收到两封。
|
||||
if !checkAttachable(w, r, attachIDs, agentName) {
|
||||
return
|
||||
}
|
||||
|
||||
// 可达性:收件人必须存在且未停用。Agent 侧同样要查 ——
|
||||
// 模型拿到 200 就会当作「话已传到」并停手等对方,而那封信永远不会有人读。
|
||||
if !checkDeliverable(w, r, append([]models.Address{to}, ccList...)) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, parentMailID, created, err := resolveTarget(r, to, req.ReplyTo, agentName, req.Subject, req.SessionAlias, agentName)
|
||||
if err != nil {
|
||||
writeErr(w, err, "Failed to resolve session")
|
||||
return
|
||||
}
|
||||
|
||||
// Agent 新开的会话继承权限档位,**不得自行抬档**。
|
||||
//
|
||||
// req 里根本没有 permission_mode 字段 —— 这是有意的:Agent 能指定档位
|
||||
// 就等于发一封 mode=full 的信给自己提权。档位由发信方当前所处的会话
|
||||
// (也就是「这活是谁派给我的」)推导,且只能同档或更严。
|
||||
//
|
||||
// 这保证 plan 档的任务派不出 full 档的子任务 —— 与 hop_limit 防自激同形:
|
||||
// 约束必须沿着链条传递下去,否则一跳之后就失效了。
|
||||
// 判据是 `created`:省略 session 位复用默认会话时 parentMailID 也是 nil,
|
||||
// 用后者会让每一封续谈的信重新“继承”一次 —— 而那条会话的档位可能已经
|
||||
// 被人在对话页里改过,重继承等于把人的修改静默回滚。
|
||||
if created {
|
||||
// 发信方自己那条会话的档位是上限。插件没传 from_session_id 时
|
||||
// 回落到默认档 —— 不会因为没传而拿到更大的权限。
|
||||
var parent *uuid.UUID
|
||||
if req.FromSessionID != "" {
|
||||
if pid, pErr := uuid.Parse(req.FromSessionID); pErr == nil {
|
||||
parent = &pid
|
||||
}
|
||||
}
|
||||
mode := repo.InheritedMode(r.Context(), parent, models.DefaultPermissionMode)
|
||||
if _, sErr := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); sErr != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to set permission mode")
|
||||
return
|
||||
}
|
||||
_ = repo.SetSessionEnforcement(r.Context(), sessionID,
|
||||
repo.AgentModeEnforcement(r.Context(), to.Name))
|
||||
}
|
||||
|
||||
// 配额在建邮件之前扣:否则邮件已入库再报 403,收件方会看到一封发件方以为发失败的邮件。
|
||||
// 只限制主动发信,不限制收信(卡住收信只会让邮件凭空消失)。
|
||||
//
|
||||
// 插件代劳转发(relay)走免配额通道:配额约束的是模型的自主发信,
|
||||
// 不是 harness 把平台原生的权限询问与最终总结搬到邮件里。
|
||||
relay, relayKey, err := parseRelay(req.Relay, req.RelayKey)
|
||||
if err != nil {
|
||||
writeErr(w, err, "Invalid relay")
|
||||
return
|
||||
}
|
||||
|
||||
var budget repo.SessionBudget
|
||||
// relayFree 表示本次 relay 走免配额通道。
|
||||
//
|
||||
// **免配额只给发往人类的 relay。**
|
||||
//
|
||||
// 豁免的理由是「harness 把平台原生的权限询问与最终总结搬进邮件,
|
||||
// 不该算模型的自主发信」—— 而那是**假定收件方是人**写的。
|
||||
// 收件方是另一个同样会自动转发的 Agent 时,双方都不在做决定,
|
||||
// 整个回路里没有任何一处在计数 —— 生产上跑出过 41 封且间隔从
|
||||
// 15 分钟缩到 5 秒的无穷循环(会话 f3d824ce)。
|
||||
//
|
||||
// 因此 Agent→Agent 的 relay 照样扣会话预算,max_rounds 就能截断它。
|
||||
relayFree := false
|
||||
if relay != "" {
|
||||
human, hErr := repo.IsHumanUser(r.Context(), to.Name)
|
||||
if hErr != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to resolve recipient")
|
||||
return
|
||||
}
|
||||
relayFree = human
|
||||
}
|
||||
|
||||
if relay != "" {
|
||||
// 硬上限:一条会话里**连续**的 relay 邮件不得超过上限。
|
||||
//
|
||||
// 与预算无关的第二道防线:预算给得大(比如 200)时,两个 Agent 仍能
|
||||
// 烧掉 200 个来回;而故障报告这类**必须**走 relay 的邮件也需要受约束。
|
||||
//
|
||||
// 「连续」是关键:中间只要有一封自主发信或人类插话,计数就归零。
|
||||
hops, hopErr := repo.CountTrailingRelayHops(r.Context(), sessionID)
|
||||
if hopErr == nil && hops >= repo.MaxRelayHops() {
|
||||
Error(w, http.StatusForbidden, fmt.Sprintf(
|
||||
"本会话已连续 %d 封自动转发(上限 %d)。这通常意味着两个 Agent 在互相"+
|
||||
"唤醒而无人决策。若确实需要继续,请由模型主动调 send_mail(不带 relay),"+
|
||||
"或由人类在会话里插一句话。",
|
||||
hops, repo.MaxRelayHops()))
|
||||
return
|
||||
}
|
||||
|
||||
// 先占幂等键。重复则说明这条上游消息已经转过,
|
||||
// 这是插件重试 / SSE 重放的正常结果,不是故障 —— 幂等地返回成功。
|
||||
if cErr := repo.ClaimRelay(r.Context(), agentName, relayKey, relay); cErr != nil {
|
||||
if errors.Is(cErr, repo.ErrRelayDuplicate) {
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "duplicate_relay",
|
||||
"relay": relay,
|
||||
"relay_key": relayKey,
|
||||
"detail": "该上游消息已转发过,本次调用未产生新邮件",
|
||||
})
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to claim relay")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if relayFree {
|
||||
// 只读快照用于回传,不扣预算
|
||||
budget, _ = repo.GetSessionBudget(r.Context(), sessionID)
|
||||
} else {
|
||||
// 额度只看【本任务】的往返预算。
|
||||
//
|
||||
// 不再叠一层 Agent 终身额度:那种额度跑满后要管理员手工重置才能再干活,
|
||||
// 而 Agent 是长期在线的。防止 Agent 用 .new 开一串新会话绕过预算,
|
||||
// 靠的是新建会话速率限制(resolveTarget 里)。
|
||||
budget, err = repo.ConsumeSessionBudget(r.Context(), sessionID)
|
||||
if errors.Is(err, repo.ErrSessionBudgetExhausted) {
|
||||
// 预算耗尽时要把幂等键还回去:否则那条上游消息永远转不出来了,
|
||||
// 之后管理员加了额度也无法重发。
|
||||
if relay != "" {
|
||||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||||
}
|
||||
Error(w, http.StatusForbidden, fmt.Sprintf(
|
||||
"本任务的往返预算已用尽(%d/%d)。自动转发的总结与权限询问不占预算;"+
|
||||
"若需继续主动发信,请让人在对话页调高本任务的预算。",
|
||||
budget.Used, budget.Max))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check session budget")
|
||||
return
|
||||
}
|
||||
// 纯统计,不拦请求;写失败也不该让邮件发不出去
|
||||
repo.BumpSentCount(r.Context(), agentName)
|
||||
}
|
||||
|
||||
// Agent 可以在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
|
||||
// 标记从入库正文里剥掉:它是给系统看的元数据,不该出现在人读的正文里
|
||||
// (react-markdown 会把 HTML 注释转义成可见文本,不会自动吞掉)。
|
||||
//
|
||||
// 提议只是提议 —— 别名是人的寻址入口,Agent 干到一半自己改掉会让人
|
||||
// 上一秒记住的地址下一秒失效。真正改名要等用户在前端点「接受」。
|
||||
proposal, body := extractRenameProposal(req.Body)
|
||||
|
||||
mailID, err := repo.CreateMail(r.Context(), sessionID, parentMailID,
|
||||
agentName, agentName, to.Name, to.Path, req.Subject, body, ccList)
|
||||
if err != nil {
|
||||
// 建邮件失败时必须把幂等键还回去,否则这条上游消息永远转不出来了
|
||||
if relay != "" {
|
||||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to create mail")
|
||||
return
|
||||
}
|
||||
if relay != "" {
|
||||
// 关联失败不影响功能,只是少一条审计记录
|
||||
_ = repo.BindRelayMail(r.Context(), agentName, relayKey, mailID)
|
||||
}
|
||||
if proposal != nil {
|
||||
// 记不上提议不该让发信失败:邮件本身已经入库,提议是旁支信息
|
||||
_ = repo.SetMailRenameProposal(r.Context(), mailID, proposal.Alias, proposal.Reason)
|
||||
}
|
||||
|
||||
if !attachAll(w, r, mailID, attachIDs, agentName) {
|
||||
// 走到这里说明碰上了 checkAttachable 之后的竞态窗口(另一个请求把同一个
|
||||
// 附件挂走了)。必须回滚已产生的副作用,否则收件方会拿到一封没有附件的
|
||||
// 邮件,而发件方以为整次请求失败了。
|
||||
//
|
||||
// 三件事都要退:邮件本身、本次往返预算、relay 幂等键。
|
||||
// 错误均忽略:响应已由 attachAll 写出,回滚失败只能记日志。
|
||||
_ = repo.DeleteMailByID(r.Context(), mailID)
|
||||
if !relayFree {
|
||||
repo.RefundSessionBudget(r.Context(), sessionID)
|
||||
}
|
||||
if relay != "" {
|
||||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
notifyRecipients(r.Context(), to, ccList, sessionID, mailID, agentName, req.Subject, parentIDString(parentMailID))
|
||||
|
||||
// 回传会话别名与本任务剩余往返,让发件方知道后续用什么地址续谈、还能发几封
|
||||
resp := map[string]any{
|
||||
"mail_id": mailID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
"session_alias": repo.SessionAliasOf(r.Context(), sessionID),
|
||||
}
|
||||
// 预算属于【本任务】,不限时不回传 —— 多给一个 -1 只会让插件去判断哪个值是哨兵
|
||||
if !budget.Unlimited {
|
||||
resp["budget_remaining"] = budget.Remaining
|
||||
resp["budget_used"] = budget.Used
|
||||
resp["budget_max"] = budget.Max
|
||||
}
|
||||
if relay != "" {
|
||||
// 告知本次未扣预算,否则插件看到 budget_remaining 没变会以为数据错了
|
||||
resp["relay"] = relay
|
||||
resp["budget_charged"] = false
|
||||
}
|
||||
if proposal != nil {
|
||||
// 回传规范化后的别名:Agent 提的名字可能含非法字符被改写过,
|
||||
// 让它知道最终会拿什么去问用户
|
||||
resp["rename_proposed"] = proposal.Alias
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// notifyRecipients 是 notify.Recipients 的薄封装,保留旧签名减少调用点改动。
|
||||
//
|
||||
// 实现只有一份,在 internal/notify 里 —— 此前 handler 与 scheduler 各写一份,
|
||||
// 加字段时漏改一处直接造成生产事故(详见那个包的注释)。
|
||||
//
|
||||
// parentMailID 为空字串表示这不是回信。
|
||||
func notifyRecipients(ctx context.Context, to models.Address, cc []models.Address, sessionID, mailID uuid.UUID, from, subject, parentMailID string) {
|
||||
notify.Recipients(ctx, notify.Mail{
|
||||
SessionID: sessionID,
|
||||
MailID: mailID,
|
||||
From: from,
|
||||
To: to,
|
||||
CC: cc,
|
||||
Subject: subject,
|
||||
ParentMailID: parentMailID,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/mail/inbox
|
||||
func GetInbox(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
status := r.URL.Query().Get("status")
|
||||
if status == "" {
|
||||
status = "unread"
|
||||
}
|
||||
limit := 10
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if n, err := parseInt(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
mails, err := repo.ListInbox(r.Context(), agentName, status, limit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list inbox")
|
||||
return
|
||||
}
|
||||
// Agent 靠收件箱列表得知有哪些附件可下载,否则它不知道该调 attachment_id
|
||||
ptrs := make([]*models.Mail, len(mails))
|
||||
for i := range mails {
|
||||
ptrs[i] = &mails[i]
|
||||
}
|
||||
fillAttachments(r, ptrs...)
|
||||
total, _ := repo.CountUnread(r.Context(), agentName)
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"mails": emptySlice(mails),
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/mail/{id} —— 需登录,且需对所属会话有权限
|
||||
func GetMail(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
mail, err := repo.GetMailByID(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Mail not found")
|
||||
return
|
||||
}
|
||||
|
||||
allowed, err := repo.UserCanAccessSession(r.Context(), user, mail.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该邮件")
|
||||
return
|
||||
}
|
||||
fillAttachments(r, mail)
|
||||
JSON(w, http.StatusOK, mail)
|
||||
}
|
||||
|
||||
// POST /api/v1/mail/{id}/read —— 需登录,只能标记自己可见的邮件
|
||||
func MarkMailRead(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
mail, err := repo.GetMailByID(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Mail not found")
|
||||
return
|
||||
}
|
||||
allowed, err := repo.UserCanAccessSession(r.Context(), user, mail.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权操作该邮件")
|
||||
return
|
||||
}
|
||||
|
||||
if err := repo.MarkMailRead(r.Context(), mailID); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to mark read")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "read"})
|
||||
}
|
||||
|
||||
func parseInt(s string) (int, error) {
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, nil
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type markReadRequest struct {
|
||||
// MailIDs 要标记为已读的邮件;省略/为空 = 把收件箱里全部未读标掉。
|
||||
MailIDs []string `json:"mail_ids"`
|
||||
}
|
||||
|
||||
// POST /api/v1/mail/read —— Agent 侧批量标记已读
|
||||
//
|
||||
// 为什么需要它:Agent 读完 read_inbox 后没有任何办法把邮件标掉,
|
||||
// 于是每次拉收件箱都把同一批旧邮件重新捞出来 —— 处理过的信和新来的信混在一起,
|
||||
// 模型分不清哪封该回。心跳里的未读数也永远只增不减。
|
||||
//
|
||||
// 鉴权写进 UPDATE 的 WHERE 而不是先查后改:不是发给自己的邮件根本改不动,
|
||||
// 既省一次查询,也没有「查完到改之间邮件被转走」的时间窗。
|
||||
func MarkInboxRead(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req markReadRequest
|
||||
// 允许空 body:`POST /mail/read` 不带任何内容 = 全部标掉
|
||||
if r.ContentLength > 0 {
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 不给 id 就把收件箱里全部未读标掉。
|
||||
// 这是 Agent 最常见的用法:一轮处理完,剩下的都不必再看。
|
||||
if len(req.MailIDs) == 0 {
|
||||
n, err := repo.MarkAllInboxReadFor(r.Context(), agentName)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to mark read")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"status": "read", "marked": n, "scope": "all"})
|
||||
return
|
||||
}
|
||||
|
||||
const maxBatch = 200
|
||||
if len(req.MailIDs) > maxBatch {
|
||||
Error(w, http.StatusBadRequest, fmt.Sprintf("一次最多标记 %d 封", maxBatch))
|
||||
return
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(req.MailIDs))
|
||||
for _, s := range req.MailIDs {
|
||||
id, err := uuid.Parse(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "非法的 mail_id: "+s)
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
n, err := repo.MarkMailsReadFor(r.Context(), agentName, ids)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to mark read")
|
||||
return
|
||||
}
|
||||
// 不因为「有些 id 不是发给你的」而报错:那些 id 只是没被标掉。
|
||||
// 报错会让整批失败,而 Agent 通常是把上一轮列出的 id 原样传回来,
|
||||
// 其中可能混着已读的(幂等)——那不该是错误。
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "read",
|
||||
"marked": n,
|
||||
"requested": len(ids),
|
||||
})
|
||||
}
|
||||
|
||||
// parentIDString 把可空的父邮件 id 转成字符串(nil → 空串)。
|
||||
//
|
||||
// 空串在 SSE payload 里的语义是「这不是回信」—— 插件据此选提示词。
|
||||
func parentIDString(id *uuid.UUID) string {
|
||||
if id == nil {
|
||||
return ""
|
||||
}
|
||||
return id.String()
|
||||
}
|
||||
392
server/internal/handler/me.go
Normal file
392
server/internal/handler/me.go
Normal file
@ -0,0 +1,392 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- /me:当前登录人类用户的邮箱(全部路由需 UserAuth) ----------
|
||||
|
||||
type meSendMailRequest struct {
|
||||
To string `json:"to"` // name@path.session
|
||||
CC string `json:"cc"` // 多个 name@path.session
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
ReplyTo string `json:"reply_to"`
|
||||
// SessionAlias 仅在本次投递【新建】会话时生效,为新会话命名
|
||||
SessionAlias string `json:"session_alias"`
|
||||
// AttachmentIDs 先用 POST /me/attachments 上传拿到的 id
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
// MaxRounds 是本次任务的往返预算(0/省略 = 不限)。
|
||||
//
|
||||
// 配额的真实语义是「这件事值得多少个来回」——那是任务的属性,
|
||||
// 所以在派活的这一刻给,而不是事后到管理员页面去调某个 Agent 的全局配额。
|
||||
// 仅在本次投递【新建】会话时生效;续谈已有会话请用
|
||||
// PUT /sessions/{id}/budget(对话页里可随时改)。
|
||||
MaxRounds *int `json:"max_rounds"`
|
||||
|
||||
// PermissionMode 声明本任务允许 Agent 动手到什么程度:plan / workspace / full。
|
||||
//
|
||||
// 与 MaxRounds 同理,**仅在本次投递【新建】会话时生效**:续谈已有会话若也接受
|
||||
// 这个字段,每封新信都会悄悄改掉对方正在遵守的规则 —— 而 plan 档的会话里
|
||||
// 模型已经被告知「只许看」,第二封信把它改成 full 是在一段已有上下文里换规则。
|
||||
// 续谈请用 PUT /sessions/{id}/permission(对话页里可随时改)。
|
||||
//
|
||||
// 省略时用 models.DefaultPermissionMode(workspace)。
|
||||
PermissionMode string `json:"permission_mode"`
|
||||
}
|
||||
|
||||
// POST /api/v1/me/mail/send
|
||||
func MeSendMail(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req meSendMailRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.To == "" || req.Subject == "" || req.Body == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing to, subject, or body")
|
||||
return
|
||||
}
|
||||
|
||||
to, err := models.ParseAddress(req.To)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid to address: "+err.Error())
|
||||
return
|
||||
}
|
||||
ccList, err := models.ParseAddressList(req.CC)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid cc address: "+err.Error())
|
||||
return
|
||||
}
|
||||
attachIDs, err := parseAttachmentIDs(req.AttachmentIDs)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 附件可挂性必须在**建邮件之前**校验(与 Agent 侧同理,见 mail.go)。
|
||||
if !checkAttachable(w, r, attachIDs, user.Username) {
|
||||
return
|
||||
}
|
||||
|
||||
// human@ 是兼容别名,人类发信时解析为自己
|
||||
to = resolveHumanAlias(to, user.Username)
|
||||
for i := range ccList {
|
||||
ccList[i] = resolveHumanAlias(ccList[i], user.Username)
|
||||
}
|
||||
|
||||
// 权限边界:校验可调用的 Agent 与可访问的目录
|
||||
if msg := checkScope(r, user, append([]models.Address{to}, ccList...)); msg != "" {
|
||||
Error(w, http.StatusForbidden, msg)
|
||||
return
|
||||
}
|
||||
|
||||
// 可达性:收件人必须存在且未停用,否则邮件进黑洞
|
||||
if !checkDeliverable(w, r, append([]models.Address{to}, ccList...)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 纯输入校验必须在建会话【之前】做完。
|
||||
//
|
||||
// 原来两项校验都在 resolveTarget 之后:请求返回 400,但 `.new` 已经建好了
|
||||
// 会话、占掉了新建速率名额、并留下一条谁也不会再用的空线索。实测发 5 封
|
||||
// 非法请求就攒下 5 条垃圾会话。校验不依赖会话,本来就该先做。
|
||||
rounds := -1
|
||||
if req.MaxRounds != nil {
|
||||
if *req.MaxRounds < 0 {
|
||||
Error(w, http.StatusBadRequest, "max_rounds 不能为负")
|
||||
return
|
||||
}
|
||||
rounds = *req.MaxRounds
|
||||
}
|
||||
if !validPermissionModeInput(w, req.PermissionMode) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, parentMailID, created, err := resolveTarget(r, to, req.ReplyTo, user.Username, req.Subject, req.SessionAlias, "")
|
||||
if err != nil {
|
||||
writeErr(w, err, "Failed to resolve session")
|
||||
return
|
||||
}
|
||||
// 人类发起的会话归属于该用户
|
||||
_ = repo.SetSessionOwner(r.Context(), sessionID, user.ID)
|
||||
|
||||
// 新建会话时定往返预算。只在新建时设:续谈已有会话若也接受这个字段,
|
||||
// 每封新信都会悄悄改掉对方正在遵守的预算,人却不一定意识到自己改了。
|
||||
//
|
||||
// 没显式给就用【收件 Agent 的默认值】。默认值挂在 Agent 上而不是全站一个数:
|
||||
// 跑测试的小工具与重构整个模块的 Agent,合理来回数差一个量级。
|
||||
// 判据是 `created` 而不是 `parentMailID == nil`:后者在「省略 session 位复用
|
||||
// 默认会话」时也成立,于是第二封信会把对方正在遵守的预算改写成默认值
|
||||
//(实测:max_rounds=7 的会话被第二封省略该字段的信改成 20)。
|
||||
if created {
|
||||
if rounds < 0 {
|
||||
rounds = repo.DefaultRoundsFor(r.Context(), to.Name)
|
||||
}
|
||||
if _, err := repo.SetSessionBudget(r.Context(), sessionID, rounds); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to set session budget")
|
||||
return
|
||||
}
|
||||
|
||||
// 权限档位同样只在新建时定。人可以直接指定(不继承)—— 人就是权限的源头,
|
||||
// 而 Agent 侧的 SendMail 走 InheritedMode 不得自行抬档。
|
||||
mode := models.NormalizePermissionMode(req.PermissionMode)
|
||||
if _, err := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to set permission mode")
|
||||
return
|
||||
}
|
||||
// 强制力是事实快照:按收件 Agent 当下自报的能力定死。
|
||||
// 收件方是人类时也走这里 —— AgentModeEnforcement 查不到就返回 advisory,
|
||||
// 而人的收件箱本来不执行任何档位,这个值对他无意义也无害。
|
||||
_ = repo.SetSessionEnforcement(r.Context(), sessionID,
|
||||
repo.AgentModeEnforcement(r.Context(), to.Name))
|
||||
}
|
||||
|
||||
// 续谈已有会话时,人也可以显式改档位。人是权限的源头,
|
||||
// 可以任改三档——与 Agent 不同,人没有「只能同档或更严」的约束。
|
||||
if !created && req.PermissionMode != "" {
|
||||
mode := models.NormalizePermissionMode(req.PermissionMode)
|
||||
if _, err := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to update permission mode")
|
||||
return
|
||||
}
|
||||
_ = repo.SetSessionEnforcement(r.Context(), sessionID,
|
||||
repo.AgentModeEnforcement(r.Context(), to.Name))
|
||||
}
|
||||
|
||||
// 人类侧不产生改名提议(人直接有改名按钮,用不着向自己提议),
|
||||
// 但仍然剥掉标记:粘贴进正文时它会被渲染成一行可见的转义文本。
|
||||
_, body := extractRenameProposal(req.Body)
|
||||
|
||||
mailID, err := repo.CreateMail(r.Context(), sessionID, parentMailID,
|
||||
user.Username, "", to.Name, to.Path, req.Subject, body, ccList)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create mail")
|
||||
return
|
||||
}
|
||||
|
||||
if !attachAll(w, r, mailID, attachIDs, user.Username) {
|
||||
// 竞态窗口(见 mail.go 同位置):回滚那封已入库的邮件。
|
||||
// 人类发信不扣会话预算、也不走 relay,所以只需退邮件本身。
|
||||
_ = repo.DeleteMailByID(r.Context(), mailID)
|
||||
return
|
||||
}
|
||||
|
||||
notifyRecipients(r.Context(), to, ccList, sessionID, mailID, user.Username, req.Subject, parentIDString(parentMailID))
|
||||
|
||||
resp := map[string]any{
|
||||
"mail_id": mailID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
"session_alias": repo.SessionAliasOf(r.Context(), sessionID),
|
||||
}
|
||||
// 回传预算,让前端不必再单独查一次就能显示「本任务还剩几个来回」
|
||||
if b, err := repo.GetSessionBudget(r.Context(), sessionID); err == nil && !b.Unlimited {
|
||||
resp["budget_max"] = b.Max
|
||||
resp["budget_used"] = b.Used
|
||||
resp["budget_remaining"] = b.Remaining
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GET /api/v1/me/mail/inbox
|
||||
func MeGetInbox(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
status := r.URL.Query().Get("status")
|
||||
if status == "" {
|
||||
status = "all"
|
||||
}
|
||||
limit := 50
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if n, err := parseInt(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
mails, err := repo.ListInbox(r.Context(), user.Username, status, limit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list inbox")
|
||||
return
|
||||
}
|
||||
// 列表页要显示附件图标与下载入口
|
||||
ptrs := make([]*models.Mail, len(mails))
|
||||
for i := range mails {
|
||||
ptrs[i] = &mails[i]
|
||||
}
|
||||
fillAttachments(r, ptrs...)
|
||||
total, _ := repo.CountUnread(r.Context(), user.Username)
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"mails": emptySlice(mails),
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/me/mail/sent
|
||||
func MeGetSent(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
limit := 50
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if n, err := parseInt(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
mails, err := repo.ListSentBy(r.Context(), user.Username, limit)
|
||||
if err == nil {
|
||||
ptrs := make([]*models.Mail, len(mails))
|
||||
for i := range mails {
|
||||
ptrs[i] = &mails[i]
|
||||
}
|
||||
fillAttachments(r, ptrs...)
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list sent")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"mails": emptySlice(mails),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/me/sessions
|
||||
func MeGetSessions(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
scope := user.Username
|
||||
if user.IsAdmin() && r.URL.Query().Get("all") == "true" {
|
||||
scope = ""
|
||||
}
|
||||
|
||||
sessions, err := repo.ListSessionsFor(r.Context(), scope, 50)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list sessions")
|
||||
return
|
||||
}
|
||||
|
||||
type SessionOut struct {
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
SessionAlias *string `json:"session_alias"`
|
||||
FromAgent string `json:"from_agent"`
|
||||
Subject string `json:"subject"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
MailCount int `json:"mail_count"`
|
||||
UnreadCount int `json:"unread_count"`
|
||||
// 往返预算随列表一并返回:预算是【任务】的属性,
|
||||
// 工作列表上就应当看得见哪些任务快跑满了,
|
||||
// 而不是点进去一个一个查。
|
||||
MaxRounds int `json:"max_rounds"`
|
||||
UsedRounds int `json:"used_rounds"`
|
||||
}
|
||||
|
||||
result := make([]SessionOut, 0, len(sessions))
|
||||
for _, s := range sessions {
|
||||
unread, _ := repo.CountUnreadInSession(r.Context(), user.Username, s.ID)
|
||||
result = append(result, SessionOut{
|
||||
SessionID: s.ID,
|
||||
SessionAlias: s.Alias,
|
||||
FromAgent: s.FromAgent,
|
||||
Subject: s.Subject,
|
||||
Status: s.Status,
|
||||
CreatedAt: s.CreatedAt,
|
||||
UpdatedAt: s.UpdatedAt,
|
||||
MailCount: s.MailCount,
|
||||
UnreadCount: unread,
|
||||
MaxRounds: s.MaxRounds,
|
||||
UsedRounds: s.UsedRounds,
|
||||
})
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"sessions": result,
|
||||
})
|
||||
}
|
||||
|
||||
// resolveHumanAlias 把兼容别名 human 解析为具体用户名
|
||||
func resolveHumanAlias(a models.Address, username string) models.Address {
|
||||
if a.Name != "human" {
|
||||
return a
|
||||
}
|
||||
a.Name = username
|
||||
a.Raw = username + "@" + a.Path
|
||||
if a.Session != "" {
|
||||
a.Raw += "." + a.Session
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// checkScope 校验用户的 Agent 白名单与目录白名单;返回空串表示通过。
|
||||
// 收件方是人类用户时不受 Agent 白名单约束(人与人通信始终允许)。
|
||||
func checkScope(r *http.Request, user *models.User, addrs []models.Address) string {
|
||||
if user.IsAdmin() {
|
||||
return ""
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if a.Name == "" || a.Name == user.Username {
|
||||
continue
|
||||
}
|
||||
isHuman, err := repo.IsHumanUser(r.Context(), a.Name)
|
||||
if err != nil {
|
||||
return "无法校验收件人权限"
|
||||
}
|
||||
if !isHuman && !user.CanUseAgent(a.Name) {
|
||||
return "无权调用 Agent: " + a.Name
|
||||
}
|
||||
if !user.CanUsePath(a.Path) {
|
||||
return "无权访问目录: " + a.Path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// checkDeliverable 校验每个收件人(含拄送)当前能不能收信,写好响应并返回 false 表示已拒绝。
|
||||
//
|
||||
// 拄送位同样要查:不查的话 cc 就成了绕过口 —— 把已删除的 Agent 放到 cc 位
|
||||
// 依旧能把邮件送进黑洞,而且因为不是主收件人更不容易被发现。
|
||||
func checkDeliverable(w http.ResponseWriter, r *http.Request, addrs []models.Address) bool {
|
||||
for _, a := range addrs {
|
||||
err := repo.RecipientDeliverable(r.Context(), a.Name)
|
||||
switch {
|
||||
case err == nil:
|
||||
continue
|
||||
case errors.Is(err, repo.ErrRecipientUnknown):
|
||||
Error(w, http.StatusNotFound,
|
||||
"收件人不存在:"+a.Name+"。它既不是人类用户也不是已注册的 Agent(可能已被删除)。")
|
||||
return false
|
||||
case errors.Is(err, repo.ErrRecipientDisabled):
|
||||
Error(w, http.StatusConflict,
|
||||
"Agent \""+a.Name+"\" 已被管理员停用,现在不接收新任务。请先在管理页恢复它。")
|
||||
return false
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "无法校验收件人状态")
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
116
server/internal/handler/models_scope.go
Normal file
116
server/internal/handler/models_scope.go
Normal file
@ -0,0 +1,116 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// ---------- 邮件场景下的可用模型 ----------
|
||||
//
|
||||
// GET /agent/models/allowed 读取被允许的模型(Agent 凭证)
|
||||
// GET /admin/agents/{name}/models 管理员读目录 + 已选
|
||||
// PUT /admin/agents/{name}/models 管理员保存选择与优先级
|
||||
//
|
||||
// **目录上报走心跳**(见 agents.go 的 heartbeatRequest.Models),不另设端点:
|
||||
// 模型清单会在运行中变(换 provider 配置、上游上下线、换 API key),
|
||||
// 心跳本来就是 30 秒一次的现成通道。另设一个 POST 等于给「目录是谁写的」
|
||||
// 这个问题留两个答案,排查时要同时看两处。
|
||||
//
|
||||
// 生效的模型范围同样随心跳响应回传(allowed_models),因此插件通常不需要调
|
||||
// 下面这个 GET —— 它是给非插件的第三方客户端(没有心跳循环)与排查用的。
|
||||
|
||||
// GET /api/v1/agent/models/allowed —— 插件读取被允许的模型
|
||||
//
|
||||
// 返回按优先级排序的列表。空列表表示**不限定**,插件应回退到平台自己的默认模型
|
||||
// —— 与「一个都不许用」不同,后者等于让 Agent 彻底哑掉,不该是一次误配的后果。
|
||||
func GetAllowedModels(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
models, err := repo.ListAllowedModels(r.Context(), agentName)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list allowed models")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"models": models,
|
||||
// unrestricted 明确表达「没配 = 不限」,省得插件自己去判断空数组的含义
|
||||
"unrestricted": len(models) == 0,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/agents/{name}/models —— 管理员读目录(带已选标记)
|
||||
func AdminListAgentModels(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimSpace(chi.URLParam(r, "name"))
|
||||
if name == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing agent name")
|
||||
return
|
||||
}
|
||||
catalog, err := repo.ListModelCatalog(r.Context(), name)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list model catalog")
|
||||
return
|
||||
}
|
||||
// 已选但已不在目录里的模型要单独给出来:平台可能临时下线了某个模型,
|
||||
// 界面上不显示的话管理员会以为自己没选过它,而它其实还在被插件尝试。
|
||||
stale, err := repo.ListStaleAllowedModels(r.Context(), name)
|
||||
if err != nil {
|
||||
stale = []repo.ModelRef{}
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"agent_name": name,
|
||||
"catalog": catalog,
|
||||
"stale": stale,
|
||||
})
|
||||
}
|
||||
|
||||
// PUT /api/v1/admin/agents/{name}/models —— 管理员保存选择
|
||||
//
|
||||
// 入参顺序即优先级(rank)。插件按这个顺序逐个尝试,全部失败才回一封失败邮件。
|
||||
func AdminSetAgentModels(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimSpace(chi.URLParam(r, "name"))
|
||||
if name == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing agent name")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Models []repo.ModelRef `json:"models"`
|
||||
}
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if len(req.Models) > maxAllowedModels {
|
||||
Error(w, http.StatusBadRequest,
|
||||
"选定的模型过多(上限 "+strconv.Itoa(maxAllowedModels)+" 个)")
|
||||
return
|
||||
}
|
||||
|
||||
if err := repo.SetAllowedModels(r.Context(), name, req.Models); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to save allowed models")
|
||||
return
|
||||
}
|
||||
// 回传保存后的实际结果而不是回显入参:repo 层会跳过重复项与空字段,
|
||||
// 回显入参会让前端以为那些也存下来了。
|
||||
saved, err := repo.ListAllowedModels(r.Context(), name)
|
||||
if err != nil {
|
||||
saved = []repo.ModelRef{}
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "saved",
|
||||
"models": saved,
|
||||
})
|
||||
}
|
||||
|
||||
// maxAllowedModels 限制管理员能选多少个模型。
|
||||
//
|
||||
// 降级尝试是串行的:选 50 个意味着最坏情况下一封邮件要等 50 次模型调用超时。
|
||||
// 十个已经足够表达「主力 + 几个备选」。
|
||||
const maxAllowedModels = 10
|
||||
383
server/internal/handler/permission.go
Normal file
383
server/internal/handler/permission.go
Normal file
@ -0,0 +1,383 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/agentmail/gateway/internal/sse"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- Permission ----------
|
||||
|
||||
type permissionRequestRequest struct {
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options"`
|
||||
Context string `json:"context"`
|
||||
SessionID *string `json:"session_id"`
|
||||
// 可选:显式指定决策人(人类用户名)。省略时由会话 owner 决定。
|
||||
To string `json:"to"`
|
||||
// RelayKey 是上游那条权限询问的稳定 id(opencode 的 permission.id)。
|
||||
//
|
||||
// 权限请求本来就不扣配额(人不点头 Agent 就动不了,收费等于收「求人费」),
|
||||
// 这里要的只是**幂等**:permission.updated 事件会重复触发,插件也会重连重放,
|
||||
// 没有幂等键就会给同一次询问生成好几封邮件。
|
||||
RelayKey string `json:"relay_key"`
|
||||
// Kind 区分待办类型:"permission"(危险工具审批,默认)或 "question"
|
||||
// (Agent 主动询问)。主动询问不套权限档位判定 —— plan/full 档也可能需要
|
||||
// 补充信息,审批档不能拦它。
|
||||
Kind string `json:"kind"`
|
||||
// MultiSelect 仅 question 使用:ask_user_question 的多选语义。
|
||||
MultiSelect bool `json:"multi_select"`
|
||||
}
|
||||
|
||||
type permissionDecideRequest struct {
|
||||
MailID string `json:"mail_id"`
|
||||
Decision string `json:"decision"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// POST /api/v1/permission/request
|
||||
func RequestPermission(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req permissionRequestRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Question == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing question")
|
||||
return
|
||||
}
|
||||
|
||||
// 校验请求类型。空串按 permission 处理(历史客户端不传也不会被拒)。
|
||||
kind := strings.TrimSpace(req.Kind)
|
||||
if kind == "" {
|
||||
kind = "permission"
|
||||
}
|
||||
if kind != "permission" && kind != "question" {
|
||||
Error(w, http.StatusBadRequest, `kind 只能是 ""、"permission" 或 "question"`)
|
||||
return
|
||||
}
|
||||
|
||||
options := req.Options
|
||||
if len(options) == 0 {
|
||||
options = []string{"同意", "拒绝"}
|
||||
}
|
||||
|
||||
// 幂等:同一条上游询问只生成一封邮件。
|
||||
// 重复不是故障(插件重试/事件重放的正常结果),因此幂等地返回已存在的结论而非报错。
|
||||
relayKey := strings.TrimSpace(req.RelayKey)
|
||||
if relayKey != "" {
|
||||
if len(relayKey) > 160 {
|
||||
Error(w, http.StatusBadRequest, "relay_key 过长(上限 160 字节)")
|
||||
return
|
||||
}
|
||||
if err := repo.ClaimRelay(r.Context(), agentName, relayKey, "permission"); err != nil {
|
||||
if errors.Is(err, repo.ErrRelayDuplicate) {
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "duplicate_relay",
|
||||
"relay_key": relayKey,
|
||||
"detail": "该权限询问已转发过,本次调用未产生新邮件",
|
||||
})
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to claim relay")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 确定 session
|
||||
var sessionID uuid.UUID
|
||||
if req.SessionID != nil && *req.SessionID != "" {
|
||||
id, err := uuid.Parse(*req.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid session_id")
|
||||
return
|
||||
}
|
||||
sessionID = id
|
||||
repo.TouchSession(r.Context(), sessionID)
|
||||
} else {
|
||||
// workspace 空串:权限询问不经三维寻址,没有 path 位可归属。
|
||||
id, err := repo.CreateSession(r.Context(), nil, agentName, "权限请求: "+req.Question, "")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create session")
|
||||
return
|
||||
}
|
||||
sessionID = id
|
||||
}
|
||||
|
||||
// 权限档位决定审批型询问该不该存在。**主动询问(question)不受此约束**:
|
||||
// 无论 plan/full,模型都可能需要向人补充信息,拦住就是阻塞整个任务。
|
||||
//
|
||||
// 审批型只有 workspace 档需要人:
|
||||
// - plan 档 → 409。该档的语义就是「这轮不动手」,没什么可问人的,
|
||||
// 模型该做的是把方案写在回信里。
|
||||
// - full 档 → 409。已经声明全权,再问一遍只是噪音;插件本不该发这封信,
|
||||
// 发了说明它没按档位翻译,报错比静默接受好。
|
||||
//
|
||||
// 这也是为什么下面不再有「退回第一个管理员」的兜底:
|
||||
// 既然只有一档需要人,那一档里找不到人就是 409,没有中间形态。
|
||||
mode := repo.SessionPermissionMode(r.Context(), sessionID)
|
||||
needHuman := kind != "question" && !models.ModeNeedsHuman(mode)
|
||||
if needHuman {
|
||||
if relayKey != "" {
|
||||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||||
}
|
||||
detail := "本会话的权限档位是 " + mode + ",不产生权限询问。"
|
||||
suggestion := ""
|
||||
if mode == models.ModePlan {
|
||||
suggestion = "plan 档只允许读与查。请不要尝试写入或执行命令," +
|
||||
"把方案、需要人工执行的步骤写在回信里。如需动手,请请发件人把档位改成 workspace。"
|
||||
} else {
|
||||
suggestion = "full 档下工具调用无需审批,插件不应该转发权限询问。" +
|
||||
"这通常意味着插件没按会话档位配置平台的审批策略。"
|
||||
}
|
||||
JSON(w, http.StatusConflict, map[string]interface{}{
|
||||
"error": "本会话不接受权限询问(档位 " + mode + ")",
|
||||
"detail": detail,
|
||||
"suggestion": suggestion,
|
||||
"permission_mode": mode,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 决策人:显式指定优先,否则取会话 owner,再否则沿线索找最近的人类。
|
||||
//
|
||||
// **不再退回第一个管理员**。那段兜底让下面的 409 分支永远不可达:
|
||||
// decider 空 → 填上管理员 → IsHumanUser 通过 → NearestHumanInThread 根本不会被调用。
|
||||
// 实测:pi 给自己新开会话派活跑 bash,权限邮件 to_name=jianf,而那条链上
|
||||
// 没有任何人类参与过。而且那段 409 自己的注释就在论证兜底是错的:
|
||||
// 「管理员对这条 Agent 链的上下文一无所知」。两条策略互相矛盾,
|
||||
// 先执行的那条把后写的那条变成了死代码。
|
||||
decider := req.To
|
||||
if decider == "" || decider == "human" {
|
||||
owner, err := repo.SessionOwnerUsername(r.Context(), sessionID)
|
||||
if err == nil && owner != "" {
|
||||
decider = owner
|
||||
}
|
||||
}
|
||||
|
||||
// 关键防线:decider 必须是人类用户。
|
||||
//
|
||||
// Agent 无法通过 Web UI 决策权限 —— SendToUser 投递到不存在的用户通道,
|
||||
// 而桥的 await Promise 永不 resolve,会话永久阻塞。这在 Agent 给自己发信时
|
||||
// 必然发生:pi 分配任务给自己的另一个会话 → 该会话触发权限询问 → 邮件发给 pi
|
||||
// → pi 不是人类用户 → 整条会话卡死。
|
||||
//
|
||||
// 修复:沿会话树上溯找最近的人类节点 —— 权限应追溯到最初分配任务的人。
|
||||
if isHuman, _ := repo.IsHumanUser(r.Context(), decider); !isHuman {
|
||||
human, err := repo.NearestHumanInThread(r.Context(), sessionID, decider)
|
||||
if err == nil && human != "" {
|
||||
decider = human
|
||||
} else {
|
||||
// 整条任务链上没有人类:Agent → Agent → Agent,中间没有任何人介入。
|
||||
//
|
||||
// 这条分支曾经**永远不可达**:上游有一段「退回第一个管理员」的兜底,
|
||||
// 把 decider 填成 admin,IsHumanUser 于是通过,这里根本不会被调用。
|
||||
// 实测:pi 给自己新开会话派活跑 bash → 权限邮件 to_name=jianf。
|
||||
// 那段兜底已删(参见上面的档位判定)。
|
||||
//
|
||||
// 为什么不该转给管理员:管理员对这条 Agent 链的上下文一无所知,
|
||||
// 既不知道这个 bash 命令在做什么,也不知道拒绝后 Agent 该怎么绕过去。
|
||||
//
|
||||
// 正确做法:直接拒绝,让 Agent 收到明确的错误信息,由它自己决定下一步:
|
||||
// 换用不需要权限的方式(subprocess、文件操作等),或在邮件里说明情况让上游转给人类。
|
||||
if relayKey != "" {
|
||||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||||
}
|
||||
JSON(w, http.StatusConflict, map[string]interface{}{
|
||||
"error": "权限询问无法送达:该任务链上没有人类用户",
|
||||
"detail": "整条任务都是 Agent 之间的邮件往来,没有人类参与决策。请换用不需要权限的方式完成此操作,或在回复中说明情况让上游转达给人类。",
|
||||
"suggestion": "考虑用 subprocess/file 工具替代需要权限的工具,或通过邮件向上游请求人类协助。",
|
||||
"decider_was": decider,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
body := req.Context
|
||||
if body == "" {
|
||||
body = req.Question
|
||||
}
|
||||
mailID, err := repo.CreatePermissionMail(r.Context(), sessionID, agentName, decider, req.Question, body, options, kind, req.MultiSelect)
|
||||
if err != nil {
|
||||
// 归还幂等键,否则这次询问永远转不出来了
|
||||
if relayKey != "" {
|
||||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to create permission mail")
|
||||
return
|
||||
}
|
||||
if relayKey != "" {
|
||||
_ = repo.BindRelayMail(r.Context(), agentName, relayKey, mailID)
|
||||
}
|
||||
if err := repo.CreatePermissionRequest(r.Context(), mailID, sessionID, agentName, req.Question, options, req.Context, kind, req.MultiSelect); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create permission request")
|
||||
return
|
||||
}
|
||||
|
||||
// 只推给该决策人。
|
||||
//
|
||||
// 这一处不走 notify.Recipients:那个函数推给「三维地址解析出的参与方」,
|
||||
// 而权限询问的投递对象是逐会话树找出来的人类决策人(NearestHumanInThread),
|
||||
// 不是一个地址 —— 抄送也不应当收到它(权限是待办,不是广播)。
|
||||
//
|
||||
// 但 payload 必须带足字段:前端的授权页靠 session_alias + 会话 workspace
|
||||
// 拼出「哪个 Agent、在哪个目录、哪条线索」。只给 from_name 的话人
|
||||
// 看到的只是一个光秃的 Agent 名,无法判断该不该批。
|
||||
alias := repo.SessionAliasOf(r.Context(), sessionID)
|
||||
sse.Default.SendToUser(decider, "new_mail", map[string]interface{}{
|
||||
"mail_id": mailID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
"from_name": agentName,
|
||||
"subject": "权限请求: " + req.Question,
|
||||
"mail_type": "permission_request",
|
||||
"role": "to",
|
||||
"session_alias": alias,
|
||||
})
|
||||
|
||||
JSON(w, http.StatusOK, map[string]string{
|
||||
"mail_id": mailID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
"permission_mail_id": mailID.String(),
|
||||
"decider": decider,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/permission/decide —— 需登录;只有该权限请求的收件人或管理员可决策
|
||||
func DecidePermission(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req permissionDecideRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.MailID == "" || req.Decision == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing mail_id or decision")
|
||||
return
|
||||
}
|
||||
|
||||
mailID, err := uuid.Parse(req.MailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid mail_id UUID")
|
||||
return
|
||||
}
|
||||
|
||||
perm, err := repo.GetPermissionByMailID(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Permission request not found")
|
||||
return
|
||||
}
|
||||
if perm.Result != nil && *perm.Result != "" {
|
||||
Error(w, http.StatusConflict, "该请求已被处理")
|
||||
return
|
||||
}
|
||||
|
||||
// 鉴权:必须是这封权限邮件的收件人,或管理员
|
||||
mail, err := repo.GetMailByID(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Mail not found")
|
||||
return
|
||||
}
|
||||
if !user.IsAdmin() && mail.ToName != user.Username {
|
||||
Error(w, http.StatusForbidden, "无权决策他人的权限请求")
|
||||
return
|
||||
}
|
||||
|
||||
// 决策选项必须在候选内 —— 仅限审批型。主动询问允许自由文本回答,
|
||||
// 多选时 decision 是多个原始标签(前端用换行分隔),同样不套暂时选项表。
|
||||
if perm.Kind != "question" && !contains(perm.Options, req.Decision) {
|
||||
Error(w, http.StatusBadRequest, "决策必须是候选项之一")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := repo.DecidePermission(r.Context(), mailID, req.Decision); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to decide permission")
|
||||
return
|
||||
}
|
||||
|
||||
decisionMailID, err := repo.CreateDecisionMail(
|
||||
r.Context(), perm.SessionID, mailID, user.Username, perm.AgentName, req.Decision, req.Note)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create decision mail")
|
||||
return
|
||||
}
|
||||
|
||||
// 通知发起 Agent 恢复执行
|
||||
// 带上上游 permission id:插件要拿它回复 opencode 的原生权限询问。
|
||||
// 两边 id 空间不同,光给 AgentMail 的 mail_id 插件对不上;
|
||||
// 而插件重启后内存映射会丢,所以这个映射由服务端持久化并在此回传。
|
||||
payload := map[string]interface{}{
|
||||
"mail_id": mailID.String(),
|
||||
"decision_mail_id": decisionMailID.String(),
|
||||
"decision": req.Decision,
|
||||
"note": req.Note,
|
||||
"decided_by": user.Username,
|
||||
"kind": perm.Kind,
|
||||
"multi_select": perm.MultiSelect,
|
||||
// 会话 id:插件重启丢了待决映射时,会退化成「把决策当一封通知投进会话」,
|
||||
// 那条路径要靠这个字段找到原会话,否则会凭空另开一个。
|
||||
"session_id": perm.SessionID.String(),
|
||||
}
|
||||
if key, kind := repo.RelayKeyForMail(r.Context(), mailID); key != "" {
|
||||
payload["relay_key"] = key
|
||||
payload["relay_kind"] = kind
|
||||
}
|
||||
sse.Default.SendToAgent(perm.AgentName, "permission_decision", payload)
|
||||
// 只刷新决策人自己的界面
|
||||
sse.Default.SendToUser(user.Username, "session_update", map[string]interface{}{
|
||||
"session_id": perm.SessionID.String(),
|
||||
"status": "active",
|
||||
})
|
||||
|
||||
JSON(w, http.StatusOK, map[string]string{
|
||||
"status": "decided",
|
||||
"decision_mail_id": decisionMailID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/permission/pending —— 需登录;普通用户只看发给自己的
|
||||
func ListPendingPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
forUser := user.Username
|
||||
if user.IsAdmin() && r.URL.Query().Get("all") == "true" {
|
||||
forUser = ""
|
||||
}
|
||||
|
||||
reqs, err := repo.ListPendingPermissionsFor(r.Context(), forUser)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list pending permissions")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"requests": emptySlice(reqs),
|
||||
})
|
||||
}
|
||||
|
||||
func contains(list []string, v string) bool {
|
||||
for _, s := range list {
|
||||
if s == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
75
server/internal/handler/ratelimit.go
Normal file
75
server/internal/handler/ratelimit.go
Normal file
@ -0,0 +1,75 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
)
|
||||
|
||||
// 登录失败限速:同一用户名连续 N 次失败后锁定一段时间。
|
||||
// 用 DB 而非进程内内存计数器,多实例部署时各实例共享同一份计数。
|
||||
const (
|
||||
maxLoginFailures = 5
|
||||
lockoutDuration = 5 * time.Minute
|
||||
failureWindow = 15 * time.Minute
|
||||
)
|
||||
|
||||
// LoginLimiter 通过 DB 实现的登录失败限速器。
|
||||
type LoginLimiter struct{}
|
||||
|
||||
var limiter = &LoginLimiter{}
|
||||
|
||||
// Locked 返回该用户名是否处于锁定期,以及剩余秒数。
|
||||
// 不记账,只读。
|
||||
func (l *LoginLimiter) Locked(ctx context.Context, name string) (bool, int) {
|
||||
bucket := "login:" + name
|
||||
cutoff := time.Now().Add(-failureWindow)
|
||||
|
||||
var count int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM rate_limits WHERE bucket = $1 AND ts >= $2`,
|
||||
bucket, cutoff).Scan(&count)
|
||||
if err != nil || count < maxLoginFailures {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// 找到最早那条记录 + lockoutDuration = 解锁时间
|
||||
var earliest time.Time
|
||||
err = db.DB.QueryRowContext(ctx,
|
||||
`SELECT MIN(ts) FROM rate_limits WHERE bucket = $1 AND ts >= $2`,
|
||||
bucket, cutoff).Scan(&earliest)
|
||||
if err != nil || earliest.IsZero() {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
unlockAt := earliest.Add(lockoutDuration)
|
||||
now := time.Now()
|
||||
if now.Before(unlockAt) {
|
||||
remain := int(unlockAt.Sub(now).Seconds()) + 1
|
||||
if remain < 1 {
|
||||
remain = 1
|
||||
}
|
||||
return true, remain
|
||||
}
|
||||
|
||||
// 锁定期已过,清理旧记录
|
||||
db.DB.ExecContext(ctx, `DELETE FROM rate_limits WHERE bucket = $1 AND ts < $2`,
|
||||
bucket, unlockAt)
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// Fail 记录一次登录失败。达到阈值时不额外标记 ——
|
||||
// Locked() 用 COUNT >= maxLoginFailures 自然判定锁定。
|
||||
func (l *LoginLimiter) Fail(ctx context.Context, name string) {
|
||||
bucket := "login:" + name
|
||||
db.DB.ExecContext(ctx,
|
||||
`INSERT INTO rate_limits (bucket, ts) VALUES ($1, $2)`,
|
||||
bucket, time.Now())
|
||||
}
|
||||
|
||||
// Reset 登录成功后清除失败计数。
|
||||
func (l *LoginLimiter) Reset(ctx context.Context, name string) {
|
||||
bucket := "login:" + name
|
||||
db.DB.ExecContext(ctx, `DELETE FROM rate_limits WHERE bucket = $1`, bucket)
|
||||
}
|
||||
85
server/internal/handler/relay_test.go
Normal file
85
server/internal/handler/relay_test.go
Normal file
@ -0,0 +1,85 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// parseRelay 是免配额通道的入口校验。白名单 + 强制幂等键这两条必须守住:
|
||||
// 前者防止 relay 变成任意字符串的后门,后者是「同一条上游消息只转一次」的基础。
|
||||
func TestParseRelay(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
kind, key string
|
||||
wantKind string
|
||||
wantKey string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "都为空 = 普通自主发信,正常扣配额", kind: "", key: "", wantKind: "", wantKey: ""},
|
||||
{name: "总结转发", kind: "summary", key: "msg_1", wantKind: "summary", wantKey: "msg_1"},
|
||||
{name: "权限转发", kind: "permission", key: "per_1", wantKind: "permission", wantKey: "per_1"},
|
||||
{name: "两端空白被裁掉", kind: " summary ", key: " msg_2 ", wantKind: "summary", wantKey: "msg_2"},
|
||||
|
||||
// 白名单外的类型必须拒:否则 relay:"anything" 就绕过了配额
|
||||
{name: "未知类型", kind: "whatever", key: "k", wantErr: true},
|
||||
// 没有幂等键就无法阻止同一条上游消息反复转发
|
||||
{name: "缺幂等键", kind: "summary", key: "", wantErr: true},
|
||||
{name: "只给了键没给类型", kind: "", key: "k", wantErr: true},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
kind, key, err := parseRelay(c.kind, c.key)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("期望报错,实际通过:kind=%q key=%q", kind, key)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("意外报错: %v", err)
|
||||
}
|
||||
if kind != c.wantKind || key != c.wantKey {
|
||||
t.Fatalf("得到 (%q, %q),期望 (%q, %q)", kind, key, c.wantKind, c.wantKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRelayRejectsOverlongKey(t *testing.T) {
|
||||
long := make([]byte, 161)
|
||||
for i := range long {
|
||||
long[i] = 'k'
|
||||
}
|
||||
if _, _, err := parseRelay("summary", string(long)); err == nil {
|
||||
t.Fatal("超长 relay_key 应被拒绝(列宽 160)")
|
||||
}
|
||||
}
|
||||
|
||||
// 免配额类型是白名单,不是黑名单。新增一种转发时必须同时更新这里,
|
||||
// 免得悄悄多出一条不受审视的免费通道。
|
||||
func TestRelayKindsIsExactlyTwo(t *testing.T) {
|
||||
want := map[string]bool{"permission": true, "summary": true}
|
||||
if len(relayKinds) != len(want) {
|
||||
t.Fatalf("免配额类型数量变了:%v。新增前请确认它确实是 harness 代劳而非模型自主发信", relayKinds)
|
||||
}
|
||||
for k := range want {
|
||||
if !relayKinds[k] {
|
||||
t.Fatalf("缺少免配额类型 %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 报错必须是 400 而不是 500:这些都是调用方参数问题
|
||||
func TestParseRelayErrorsAreBadRequest(t *testing.T) {
|
||||
for _, c := range [][2]string{{"whatever", "k"}, {"summary", ""}, {"", "k"}} {
|
||||
_, _, err := parseRelay(c[0], c[1])
|
||||
if err == nil {
|
||||
t.Fatalf("(%q,%q) 应报错", c[0], c[1])
|
||||
}
|
||||
var he httpError
|
||||
if !errors.As(err, &he) || he.status != 400 {
|
||||
t.Fatalf("(%q,%q) 的错误不是 400: %#v", c[0], c[1], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
141
server/internal/handler/rename_proposal.go
Normal file
141
server/internal/handler/rename_proposal.go
Normal file
@ -0,0 +1,141 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ---------- Agent 在正文里提议改会话别名 ----------
|
||||
//
|
||||
// 与「平台命名自动同步」(POST /sessions/:id/sync)互补:
|
||||
// 自动同步 = 平台起的名字,后台静默生效,不打扰人
|
||||
// 正文提议 = Agent 干完活后觉得该换个更贴切的名字,需要人点头
|
||||
//
|
||||
// 为什么走正文而不是让 Agent 直接调 PUT alias:
|
||||
// 别名是**人**的寻址入口。Agent 干到一半自己改掉,人上一秒记住的地址下一秒失效。
|
||||
// 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
|
||||
//
|
||||
// 载体选 HTML 注释:
|
||||
// - react-markdown 默认不解析 raw HTML,注释在页面上不可见(实测渲染为转义文本节点,
|
||||
// 不是节点丢失 —— 所以必须从原始正文里剥掉,不能指望渲染器吞掉它)
|
||||
// - 纯文本邮件客户端里它是一行不碍事的注释,不像自造标记那样显眼
|
||||
// - 不与 Markdown 语法冲突,不会被格式化工具改写
|
||||
|
||||
// renameProposalRe 匹配 Agent 提议改名的标记。
|
||||
//
|
||||
// 形如:<!-- agentmail:rename-session alias="fix-login-leak" reason="定位到是登录态泄漏" -->
|
||||
// reason 可选。alias 用双引号包裹,因此别名本身不能含双引号 —— 但合法别名连
|
||||
// 空白和 . / @ 都不许有,双引号自然也在禁止之列,不构成限制。
|
||||
//
|
||||
// 用正则而不是完整 HTML 解析:这是一个格式固定的单行标记,正则足够且不引依赖。
|
||||
var renameProposalRe = regexp.MustCompile(
|
||||
`(?s)<!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->`)
|
||||
|
||||
// RenameProposal 是从正文里解析出的一条改名提议。
|
||||
type RenameProposal struct {
|
||||
// Alias 已经过 normalizeAlias 规范化,可直接用于 PUT /sessions/:id/alias
|
||||
Alias string `json:"alias"`
|
||||
// Reason 是 Agent 给出的理由,可为空
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// extractRenameProposal 从正文里取出改名提议,并返回剥掉标记后的正文。
|
||||
//
|
||||
// 只认**最后一条**:Agent 在长回复里可能反复修正措辞,最后写下的才是它的结论。
|
||||
// 标记一律从正文里剥掉 —— 它是给系统看的元数据,不该出现在人读的正文里
|
||||
// (react-markdown 会把 HTML 注释转义成可见文本)。
|
||||
//
|
||||
// 非法别名(规范化后为空或不合法)视为无提议,但标记仍然剥掉:
|
||||
// 与其在正文里留一行乱码,不如当它没提。
|
||||
func extractRenameProposal(body string) (*RenameProposal, string) {
|
||||
matches := renameProposalRe.FindAllStringSubmatch(body, -1)
|
||||
cleaned := stripProposalMarkers(body)
|
||||
if len(matches) == 0 {
|
||||
return nil, cleaned
|
||||
}
|
||||
|
||||
last := matches[len(matches)-1]
|
||||
alias := normalizeAlias(strings.TrimSpace(last[1]))
|
||||
if alias == "" {
|
||||
return nil, cleaned
|
||||
}
|
||||
if err := validateSessionAlias(alias); err != nil {
|
||||
return nil, cleaned
|
||||
}
|
||||
reason := ""
|
||||
if len(last) > 2 {
|
||||
reason = strings.TrimSpace(last[2])
|
||||
}
|
||||
// 理由是展示给人看的一句话,过长会把提示条撑破
|
||||
const maxReason = 200
|
||||
if len(reason) > maxReason {
|
||||
reason = preview(reason, maxReason)
|
||||
}
|
||||
return &RenameProposal{Alias: alias, Reason: reason}, cleaned
|
||||
}
|
||||
|
||||
// stripProposalMarkers 移除全部提议标记,并把因此产生的多余空行压回一个。
|
||||
func stripProposalMarkers(body string) string {
|
||||
out := renameProposalRe.ReplaceAllString(body, "")
|
||||
// 标记独占一行时会留下连续空行,压成一个空行(Markdown 的段落分隔)
|
||||
for strings.Contains(out, "\n\n\n") {
|
||||
out = strings.ReplaceAll(out, "\n\n\n", "\n\n")
|
||||
}
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
|
||||
// preview 按 UTF-8 边界截断。与 repo.preview 同逻辑,这里为避免 handler → repo
|
||||
// 的反向依赖而复制一份(两处都是 5 行,抽公共包不值当)。
|
||||
func preview(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
cut := max
|
||||
for cut > 0 && s[cut]&0xC0 == 0x80 {
|
||||
cut--
|
||||
}
|
||||
return s[:cut] + "..."
|
||||
}
|
||||
|
||||
// ---------- 插件代劳转发(免配额通道) ----------
|
||||
|
||||
// relayKinds 是允许免配额的转发类型。
|
||||
//
|
||||
// 白名单而不是任意字符串:免配额通道必须有明确边界,
|
||||
// 否则 `relay: "whatever"` 就成了绕过配额的后门。
|
||||
//
|
||||
// permission —— 平台原生的权限询问(opencode 的 permission.updated)。
|
||||
// 不转给人,人就看不到,Agent 卡在那里等一个永远不会来的回答。
|
||||
// summary —— 本轮的最终总结(session.idle 时最后一条 assistant 消息)。
|
||||
// 模型已经把话说完了,插件只是搬运;对它收费会导致配额用尽时
|
||||
// Agent 连交代都做不了。
|
||||
var relayKinds = map[string]bool{
|
||||
"permission": true,
|
||||
"summary": true,
|
||||
}
|
||||
|
||||
// parseRelay 校验免配额转发参数,返回规范化后的 (kind, key)。
|
||||
// 两者都为空表示这是普通的自主发信,正常扣配额。
|
||||
func parseRelay(kind, key string) (string, string, error) {
|
||||
kind = strings.TrimSpace(kind)
|
||||
key = strings.TrimSpace(key)
|
||||
|
||||
if kind == "" {
|
||||
if key != "" {
|
||||
return "", "", errBadRequest("给了 relay_key 却没给 relay 类型")
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
if !relayKinds[kind] {
|
||||
return "", "", errBadRequest(`relay 只能是 "permission" 或 "summary"`)
|
||||
}
|
||||
// 幂等键是免配额通道的唯一约束基础,不能省:
|
||||
// 没有它就无法阻止同一条上游消息被反复转发。
|
||||
if key == "" {
|
||||
return "", "", errBadRequest("relay 转发必须带 relay_key(上游消息的稳定 id)")
|
||||
}
|
||||
if len(key) > 160 {
|
||||
return "", "", errBadRequest("relay_key 过长(上限 160 字节)")
|
||||
}
|
||||
return kind, key, nil
|
||||
}
|
||||
143
server/internal/handler/rename_proposal_test.go
Normal file
143
server/internal/handler/rename_proposal_test.go
Normal file
@ -0,0 +1,143 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractRenameProposal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantAlias string
|
||||
wantReason string
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "无标记时原样返回",
|
||||
body: "普通正文。",
|
||||
wantAlias: "",
|
||||
wantBody: "普通正文。",
|
||||
},
|
||||
{
|
||||
name: "带理由",
|
||||
body: "已定位问题。\n\n<!-- agentmail:rename-session alias=\"fix-login-leak\" reason=\"是登录态泄漏\" -->",
|
||||
wantAlias: "fix-login-leak",
|
||||
wantReason: "是登录态泄漏",
|
||||
wantBody: "已定位问题。",
|
||||
},
|
||||
{
|
||||
name: "无理由",
|
||||
body: "<!-- agentmail:rename-session alias=\"cache-eval\" -->\n\n正文在后。",
|
||||
wantAlias: "cache-eval",
|
||||
wantBody: "正文在后。",
|
||||
},
|
||||
{
|
||||
// Agent 在长回复里反复修正措辞,最后写下的才是它的结论
|
||||
name: "多条只取最后一条",
|
||||
body: "<!-- agentmail:rename-session alias=\"first\" -->\n中间\n<!-- agentmail:rename-session alias=\"second\" -->",
|
||||
wantAlias: "second",
|
||||
wantBody: "中间",
|
||||
},
|
||||
{
|
||||
// 别名含 . / @ 会让三维地址切分歧义,normalizeAlias 改写为 -
|
||||
name: "非法字符被规范化",
|
||||
body: "<!-- agentmail:rename-session alias=\"fix login.leak/now\" -->",
|
||||
wantAlias: "fix-login-leak-now",
|
||||
wantBody: "",
|
||||
},
|
||||
{
|
||||
// "new" 是寻址保留字
|
||||
name: "保留字被改写",
|
||||
body: "<!-- agentmail:rename-session alias=\"new\" -->",
|
||||
wantAlias: "session-new",
|
||||
wantBody: "",
|
||||
},
|
||||
{
|
||||
// 规范化后为空 → 视为无提议,但标记仍要剥掉
|
||||
name: "空别名视为无提议且剥掉标记",
|
||||
body: "正文\n<!-- agentmail:rename-session alias=\"\" -->",
|
||||
wantAlias: "",
|
||||
wantBody: "正文",
|
||||
},
|
||||
{
|
||||
name: "多余空格容错",
|
||||
body: "<!-- agentmail:rename-session alias=\"ok-name\" -->",
|
||||
wantAlias: "ok-name",
|
||||
wantBody: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
p, body := extractRenameProposal(c.body)
|
||||
gotAlias := ""
|
||||
gotReason := ""
|
||||
if p != nil {
|
||||
gotAlias, gotReason = p.Alias, p.Reason
|
||||
}
|
||||
if gotAlias != c.wantAlias {
|
||||
t.Errorf("alias = %q,期望 %q", gotAlias, c.wantAlias)
|
||||
}
|
||||
if gotReason != c.wantReason {
|
||||
t.Errorf("reason = %q,期望 %q", gotReason, c.wantReason)
|
||||
}
|
||||
if body != c.wantBody {
|
||||
t.Errorf("剥标记后正文 = %q,期望 %q", body, c.wantBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 标记必须从入库正文里彻底消失:react-markdown 不解析 raw HTML,
|
||||
// 留着会被转义成一行可见的乱码文本,而不是被渲染器吞掉。
|
||||
func TestProposalMarkerNeverSurvivesInBody(t *testing.T) {
|
||||
bodies := []string{
|
||||
"<!-- agentmail:rename-session alias=\"a\" -->",
|
||||
"前\n<!-- agentmail:rename-session alias=\"a\" reason=\"r\" -->\n后",
|
||||
"<!-- agentmail:rename-session alias=\"\" -->", // 无效提议也要剥
|
||||
}
|
||||
for _, b := range bodies {
|
||||
_, out := extractRenameProposal(b)
|
||||
if renameProposalRe.MatchString(out) {
|
||||
t.Errorf("正文里仍残留标记:%q", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提议出来的别名必须能直接通过 PUT alias 的校验,
|
||||
// 否则前端点「接受」时会拿到 400 —— 系统自己造出了自己拒绝的值。
|
||||
func TestProposedAliasPassesValidation(t *testing.T) {
|
||||
inputs := []string{
|
||||
"fix login.leak",
|
||||
"new",
|
||||
"a@b/c",
|
||||
" spaced name ",
|
||||
"正常中文别名",
|
||||
}
|
||||
for _, in := range inputs {
|
||||
p, _ := extractRenameProposal("<!-- agentmail:rename-session alias=\"" + in + "\" -->")
|
||||
if p == nil {
|
||||
continue // 规范化后为空,已按无提议处理
|
||||
}
|
||||
if err := validateSessionAlias(p.Alias); err != nil {
|
||||
t.Errorf("提议 %q → %q 未通过 validateSessionAlias: %v", in, p.Alias, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasonTruncatedOnUTF8Boundary(t *testing.T) {
|
||||
long := ""
|
||||
for i := 0; i < 100; i++ {
|
||||
long += "很长的理由"
|
||||
}
|
||||
p, _ := extractRenameProposal("<!-- agentmail:rename-session alias=\"x\" reason=\"" + long + "\" -->")
|
||||
if p == nil {
|
||||
t.Fatal("应当解析出提议")
|
||||
}
|
||||
if len(p.Reason) > 210 { // 200 + "..."
|
||||
t.Errorf("理由未截断:%d 字节", len(p.Reason))
|
||||
}
|
||||
for _, r := range p.Reason {
|
||||
if r == 0xFFFD {
|
||||
t.Fatal("截断产生了替换符,说明切在多字节字符中间")
|
||||
}
|
||||
}
|
||||
}
|
||||
397
server/internal/handler/sessions.go
Normal file
397
server/internal/handler/sessions.go
Normal file
@ -0,0 +1,397 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/agentmail/gateway/internal/sse"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- Session(均需登录,且做会话级鉴权) ----------
|
||||
|
||||
// requireSessionAccess 解析路径中的会话 ID 并校验当前用户有权访问
|
||||
func requireSessionAccess(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
sessionID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
allowed, err := repo.UserCanAccessSession(r.Context(), user, sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该会话")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return sessionID, true
|
||||
}
|
||||
|
||||
// GET /api/v1/sessions/{id}
|
||||
func GetSession(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
session, err := repo.GetSessionByID(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Session not found")
|
||||
return
|
||||
}
|
||||
mails, err := repo.GetSessionMails(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to get session mails")
|
||||
return
|
||||
}
|
||||
// 会话线程要展示附件,逐封填充。
|
||||
//
|
||||
// 这里漏掉过:前端的会话视图走的是本端点(GET /sessions/{id}),
|
||||
// 而不是下面那个 /sessions/{id}/mails —— 后者填了附件但没人调用,
|
||||
// 于是 Agent 回信里的附件在 UI 上完全不存在。
|
||||
ptrs := make([]*models.Mail, len(mails))
|
||||
for i := range mails {
|
||||
ptrs[i] = &mails[i]
|
||||
}
|
||||
fillAttachments(r, ptrs...)
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"session": session,
|
||||
"mails": emptySlice(mails),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/sessions/{id}/mails
|
||||
func GetSessionMails(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
mails, err := repo.GetSessionMails(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to get mails")
|
||||
return
|
||||
}
|
||||
// 会话线程要展示附件,逐封填充
|
||||
ptrs := make([]*models.Mail, len(mails))
|
||||
for i := range mails {
|
||||
ptrs[i] = &mails[i]
|
||||
}
|
||||
fillAttachments(r, ptrs...)
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"mails": emptySlice(mails),
|
||||
})
|
||||
}
|
||||
|
||||
type updateAliasRequest struct {
|
||||
Alias string `json:"alias"`
|
||||
}
|
||||
|
||||
// PUT /api/v1/sessions/{id}/alias
|
||||
//
|
||||
// 会话别名负责三维寻址(name@path.<alias>),因此必须全局唯一,
|
||||
// 且不能叫 "new"(那是寻址保留字)。
|
||||
func UpdateSessionAlias(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req updateAliasRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
alias := strings.TrimSpace(req.Alias)
|
||||
if alias == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing alias")
|
||||
return
|
||||
}
|
||||
if err := validateSessionAlias(alias); err != nil {
|
||||
writeErr(w, err, "Invalid alias")
|
||||
return
|
||||
}
|
||||
|
||||
// 被其他会话占用时报 409,而不是默默造出两个同名可寻址会话
|
||||
if s, err := repo.FindSessionByAlias(r.Context(), alias); err == nil && s.ID != sessionID {
|
||||
Error(w, http.StatusConflict, "会话别名 \""+alias+"\" 已被其他会话占用")
|
||||
return
|
||||
}
|
||||
|
||||
if err := repo.UpdateSessionAlias(r.Context(), sessionID, alias); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to update alias")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{
|
||||
"status": "updated",
|
||||
"alias": alias,
|
||||
})
|
||||
}
|
||||
|
||||
// syncSessionRequest 是 Agent 平台回传自己那侧的会话标识。
|
||||
//
|
||||
// 各 Agent 平台(opencode / Claude Code / DSH…)都会由模型为会话生成一个摘要标题,
|
||||
// 并配一个短 slug。不在本侧另造一套命名:平台那边叫什么,本侧就叫什么。
|
||||
type syncSessionRequest struct {
|
||||
// Alias 是平台侧的短标识(如 opencode 的 slug "jolly-cactus"),写入本侧 session_alias 供寻址。
|
||||
Alias string `json:"alias"`
|
||||
// Title 是平台侧模型生成的摘要标题(如「修复登录态丢失」),写入本侧 subject 供展示。
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// POST /api/v1/sessions/{id}/sync
|
||||
//
|
||||
// Agent 侧端点:把平台生成的会话标题与 slug 同步到本侧。
|
||||
// alias 撞名时自动追加 -2/-3 后缀(本侧别名负责寻址必须唯一,而平台 slug 不保证全局唯一),
|
||||
// 因此本接口不会因撞名失败,响应里回传最终落库的别名。
|
||||
func SyncSession(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
sessionID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Agent 只能同步自己参与过的会话
|
||||
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权修改未参与的会话")
|
||||
return
|
||||
}
|
||||
|
||||
var req syncSessionRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]string{"status": "synced"}
|
||||
|
||||
if title := strings.TrimSpace(req.Title); title != "" {
|
||||
if err := repo.SyncSessionTitle(r.Context(), sessionID, title); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to sync title")
|
||||
return
|
||||
}
|
||||
resp["title"] = title
|
||||
}
|
||||
|
||||
if alias := strings.TrimSpace(req.Alias); alias != "" {
|
||||
// 平台 slug 可能带非法字符,落库前按本侧寻址规则规范化
|
||||
norm := normalizeAlias(alias)
|
||||
if norm == "" {
|
||||
Error(w, http.StatusBadRequest, "alias 规范化后为空,无法作为寻址别名")
|
||||
return
|
||||
}
|
||||
final, err := repo.SyncSessionAlias(r.Context(), sessionID, norm)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to sync alias")
|
||||
return
|
||||
}
|
||||
resp["alias"] = final
|
||||
}
|
||||
|
||||
// 让参与方前端立即看到新标题/别名
|
||||
sse.Default.Broadcast("session_update", map[string]interface{}{
|
||||
"session_id": sessionID.String(),
|
||||
"alias": resp["alias"],
|
||||
"title": resp["title"],
|
||||
})
|
||||
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GET /api/v1/sessions/{id}/rename-proposal
|
||||
//
|
||||
// 返回该会话里最新一条尚未处理的改名提议(Agent 在正文里提的)。
|
||||
// 「尚未处理」= 既不是当前别名(已接受),也不在驳回记录里。
|
||||
// 无提议时返回 {"proposal": null},前端据此决定要不要显示提示条。
|
||||
func GetRenameProposal(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
alias, reason, err := repo.PendingRenameProposal(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to load proposal")
|
||||
return
|
||||
}
|
||||
if alias == "" {
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"proposal": nil})
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"proposal": map[string]string{"alias": alias, "reason": reason},
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/sessions/{id}/rename-proposal/dismiss
|
||||
//
|
||||
// 用户驳回当前提议。记下被驳回的别名,好让提示条不再反复弹同一个建议 ——
|
||||
// 否则每次打开会话都要重新点一次「忽略」。
|
||||
//
|
||||
// 接受提议走已有的 PUT /sessions/{id}/alias,不另开端点:
|
||||
// 那条路径已经有唯一性校验与 409 处理,复制一遍只会多一个出错的地方。
|
||||
func DismissRenameProposal(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
alias, _, err := repo.PendingRenameProposal(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to load proposal")
|
||||
return
|
||||
}
|
||||
if alias == "" {
|
||||
// 已经没有待处理提议(可能是另一个标签页刚处理过),当作成功
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "no_pending"})
|
||||
return
|
||||
}
|
||||
if err := repo.DismissRenameProposal(r.Context(), sessionID, alias); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to dismiss proposal")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{
|
||||
"status": "dismissed",
|
||||
"dismissed": alias,
|
||||
})
|
||||
}
|
||||
|
||||
type sessionBudgetRequest struct {
|
||||
// MaxRounds 是本会话的往返预算上限(0 = 不限)。
|
||||
MaxRounds *int `json:"max_rounds"`
|
||||
// Reset 把已用次数归零(上限不变)。可与 MaxRounds 同时给:
|
||||
// 「加到 20 并从头算」是一次很自然的操作,拆成两个请求只会让前端多一次往返。
|
||||
Reset bool `json:"reset"`
|
||||
}
|
||||
|
||||
type updatePermissionRequest struct {
|
||||
// PermissionMode 三档 plan / workspace / full。
|
||||
//
|
||||
// 对话页里人可随时改,改了即时生效(不继承、不限「只能同档或更严」——
|
||||
// 那是 Agent 主动派子任务时的约束;人改档是对一条已有会话的明示意愿,
|
||||
// 可以从 plan 直接调到 full)。脏值 fail-closed 到默认档而不是 full。
|
||||
PermissionMode string `json:"permission_mode"`
|
||||
}
|
||||
|
||||
// PUT /api/v1/sessions/{id}/permission
|
||||
//
|
||||
// 对话页里随时调档位。与 budget 同位置编辑:两者都是任务的属性,
|
||||
// 人看着往来内容才知道「这件事现在该收紧还是放开」。
|
||||
//
|
||||
// 人类可以任改三档(包括从 plan 提到 full —— 人是权限的源头);
|
||||
// Agent 不经此端点(Agent 改档须走发信继承路径,不得自行提权)。
|
||||
func UpdateSessionPermission(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req updatePermissionRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
mode := models.NormalizePermissionMode(req.PermissionMode)
|
||||
if mode == "" {
|
||||
mode = models.DefaultPermissionMode
|
||||
}
|
||||
perm, err := repo.SetSessionPermissionMode(r.Context(), sessionID, mode)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to update permission mode")
|
||||
return
|
||||
}
|
||||
|
||||
// 强制力不在此刷新:它是「平台能力」的事实快照,在会话建立时定死
|
||||
// (见 repo.SetSessionEnforcement 的注释)。人改档位不改变平台的能力,
|
||||
// 插件升级才改变 —— 那要等新投递/新会话才会反映。
|
||||
|
||||
sse.Default.Broadcast("session_update", map[string]interface{}{
|
||||
"session_id": sessionID.String(),
|
||||
"permission_mode": perm.Mode,
|
||||
"permission_enforcement": perm.Enforcement,
|
||||
})
|
||||
JSON(w, http.StatusOK, perm)
|
||||
}
|
||||
|
||||
// GET /api/v1/sessions/{id}/budget
|
||||
//
|
||||
// 本会话的往返预算。与 Agent 全局配额是两层,都要过:
|
||||
// 会话预算管「这件事值得多少个来回」,全局配额管「这个 Agent 总共能发多少」。
|
||||
func GetSessionBudgetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
b, err := repo.GetSessionBudget(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Session not found")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, b)
|
||||
}
|
||||
|
||||
// PUT /api/v1/sessions/{id}/budget
|
||||
//
|
||||
// 在对话页里随时调本任务的预算 —— 这是配额最该被编辑的地方:
|
||||
// 人看着往来内容才知道这件事还值不值得再来几个回合。
|
||||
func UpdateSessionBudget(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req sessionBudgetRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.MaxRounds == nil && !req.Reset {
|
||||
Error(w, http.StatusBadRequest, "需要给出 max_rounds 或 reset")
|
||||
return
|
||||
}
|
||||
|
||||
b, err := repo.GetSessionBudget(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Session not found")
|
||||
return
|
||||
}
|
||||
if req.MaxRounds != nil {
|
||||
if *req.MaxRounds < 0 {
|
||||
Error(w, http.StatusBadRequest, "max_rounds 不能为负")
|
||||
return
|
||||
}
|
||||
// 允许调到低于已用次数:那表示「就到这里为止」,是人的合法意图。
|
||||
// 此时剩余为 0,Agent 下次发信即被拦。
|
||||
b, err = repo.SetSessionBudget(r.Context(), sessionID, *req.MaxRounds)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to set budget")
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Reset {
|
||||
b, err = repo.ResetSessionBudget(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to reset budget")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 让会话的其他参与方(含 Agent 侧界面)立刻看到新预算
|
||||
sse.Default.Broadcast("session_update", map[string]interface{}{
|
||||
"session_id": sessionID.String(),
|
||||
"budget_max": b.Max,
|
||||
"budget_used": b.Used,
|
||||
"budget_remaining": b.Remaining,
|
||||
})
|
||||
JSON(w, http.StatusOK, b)
|
||||
}
|
||||
247
server/internal/handler/strictdecode_test.go
Normal file
247
server/internal/handler/strictdecode_test.go
Normal file
@ -0,0 +1,247 @@
|
||||
package handler
|
||||
|
||||
// 严格解码的回归测试。
|
||||
//
|
||||
// 事故背景(生产实测):homeagent 插件的 send_mail 传的是
|
||||
// `attachments: [{"attachment_id": …}]`,而服务端要的是 `attachment_ids: ["…"]`。
|
||||
// 宽容解码让这变成一种**静默成功**:
|
||||
//
|
||||
// POST /mail/send {…,"attachments":[{"attachment_id":"598f100e…"}]}
|
||||
// → HTTP 200 {"mail_id":"2a64fdc8…"}
|
||||
// → SELECT COUNT(*) FROM attachments WHERE mail_id='2a64fdc8…' → 0
|
||||
//
|
||||
// 邮件发出去了、附件一个都没带、没有任何一层报错。那个 bug 活了很久,
|
||||
// 正因为没人会去核对一个返回 200 的请求。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 这就是那次事故的最小复现:把 attachment_ids 写成 attachments。
|
||||
func TestStrictDecodeRejectsMisspelledField(t *testing.T) {
|
||||
type sendReq struct {
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/mail/send", strings.NewReader(
|
||||
`{"to":"jianf","subject":"x","attachments":[{"attachment_id":"598f100e"}]}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var req sendReq
|
||||
if DecodeBody(w, r, &req) {
|
||||
t.Fatal("拼错的字段名必须被拒绝 —— 否则又是一次静默成功")
|
||||
}
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("状态码应为 400,实际 %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("响应不是 JSON: %v", err)
|
||||
}
|
||||
msg := resp["error"]
|
||||
|
||||
// 报出错的字段名
|
||||
if !strings.Contains(msg, "attachments") {
|
||||
t.Errorf("信息里应指出 attachments,实际 %q", msg)
|
||||
}
|
||||
// **并且**列出对的拼法 —— 少了这半句,调用方仍要去翻服务端源码,
|
||||
// 而拼错字段名恰恰是最容易犯、最难自查的错
|
||||
if !strings.Contains(msg, "attachment_ids") {
|
||||
t.Errorf("信息里应列出正确字段 attachment_ids,实际 %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 负向对照:合法字段必须原样通过,不能被严格解码误伤。
|
||||
func TestStrictDecodeAcceptsCorrectField(t *testing.T) {
|
||||
type sendReq struct {
|
||||
To string `json:"to"`
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/mail/send", strings.NewReader(
|
||||
`{"to":"jianf","attachment_ids":["a","b"]}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var req sendReq
|
||||
if !DecodeBody(w, r, &req) {
|
||||
t.Fatalf("合法请求体被拒了:%s", w.Body.String())
|
||||
}
|
||||
if w.Code != http.StatusOK { // recorder 默认 200,即「没写过响应」
|
||||
t.Fatalf("不该写任何响应,实际状态码 %d", w.Code)
|
||||
}
|
||||
if len(req.AttachmentIDs) != 2 {
|
||||
t.Fatalf("附件 id 应解出 2 个,实际 %#v", req.AttachmentIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// 省略可选字段仍然合法 —— 严格针对的是「多」而不是「少」。
|
||||
func TestStrictDecodeAllowsOmittedFields(t *testing.T) {
|
||||
type sendReq struct {
|
||||
To string `json:"to"`
|
||||
CC string `json:"cc"`
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/mail/send", strings.NewReader(`{"to":"jianf"}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var req sendReq
|
||||
if !DecodeBody(w, r, &req) {
|
||||
t.Fatalf("省略可选字段被拒了:%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DecodeLenient(心跳唯一的例外)───
|
||||
|
||||
func TestDecodeLenientKeepsKnownFieldsAndReportsUnknown(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
ModeEnforcement string `json:"mode_enforcement"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(
|
||||
`{"models":["a"],"mode_enforcement":"native","futureField":1}`))
|
||||
|
||||
var req hb
|
||||
unknown, err := DecodeLenient(r, &req)
|
||||
if err != nil {
|
||||
t.Fatalf("心跳体不该整体作废: %v", err)
|
||||
}
|
||||
// 已知字段必须照常取到 —— 这正是心跳要宽容的理由:
|
||||
// 插件比服务端新时,代价不该是会话快照与模型目录一起丢掉
|
||||
if len(req.Models) != 1 || req.ModeEnforcement != "native" {
|
||||
t.Fatalf("已知字段应正常解析,实际 %#v", req)
|
||||
}
|
||||
// 但**必须报出来**,否则又是一次静默忽略
|
||||
if len(unknown) != 1 || unknown[0] != "futureField" {
|
||||
t.Fatalf("未知字段应报 [futureField],实际 %#v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// 多个未知字段要全报出来。
|
||||
//
|
||||
// json 每遇到一个未知字段就立刻返回,所以实现里必须循环剥 ——
|
||||
// 不循环的话「多带了三个字段」只会报出第一个,而人改完那一个又撞上下一个。
|
||||
func TestDecodeLenientReportsAllUnknownFields(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(
|
||||
`{"models":[],"aaa":1,"bbb":2,"ccc":3}`))
|
||||
|
||||
var req hb
|
||||
unknown, err := DecodeLenient(r, &req)
|
||||
if err != nil {
|
||||
t.Fatalf("不该报错: %v", err)
|
||||
}
|
||||
if len(unknown) != 3 {
|
||||
t.Fatalf("应报出 3 个未知字段,实际 %#v", unknown)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, u := range unknown {
|
||||
got[u] = true
|
||||
}
|
||||
for _, want := range []string{"aaa", "bbb", "ccc"} {
|
||||
if !got[want] {
|
||||
t.Errorf("未报出 %q(实际 %#v)", want, unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeLenientCleanBodyReportsNothing(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(`{"models":["a"]}`))
|
||||
var req hb
|
||||
unknown, err := DecodeLenient(r, &req)
|
||||
if err != nil {
|
||||
t.Fatalf("不该报错: %v", err)
|
||||
}
|
||||
// 正常心跳的响应里不该多一个空数组 —— 调用方据此决定是否带 unknown_fields
|
||||
if len(unknown) != 0 {
|
||||
t.Fatalf("干净的体不该报未知字段,实际 %#v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeLenientEmptyBody(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(``))
|
||||
var req hb
|
||||
unknown, err := DecodeLenient(r, &req)
|
||||
if err != nil {
|
||||
t.Fatalf("空体应静默通过(心跳可以不带 body): %v", err)
|
||||
}
|
||||
if len(unknown) != 0 {
|
||||
t.Fatalf("空体不该报未知字段,实际 %#v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// 语法错误仍然要报 —— 宽容的是「多字段」,不是「烂 JSON」。
|
||||
func TestDecodeLenientStillRejectsMalformedJSON(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(`{"models":`))
|
||||
var req hb
|
||||
if _, err := DecodeLenient(r, &req); err == nil {
|
||||
t.Fatal("截断的 JSON 必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
// 类型不对也要报:`models` 要的是数组,给字符串说明插件写错了结构,
|
||||
// 那不是「服务端还不认识的新字段」。
|
||||
func TestDecodeLenientStillRejectsWrongType(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(`{"models":"oops"}`))
|
||||
var req hb
|
||||
if _, err := DecodeLenient(r, &req); err == nil {
|
||||
t.Fatal("类型不匹配必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── jsonFieldNames ───
|
||||
|
||||
func TestJSONFieldNamesListsAcceptedKeys(t *testing.T) {
|
||||
type req struct {
|
||||
To string `json:"to"`
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
Skipped string `json:"-"`
|
||||
NoTag string
|
||||
unexported string //nolint:unused // 刻意保留:验证非导出字段不进清单
|
||||
}
|
||||
|
||||
names := jsonFieldNames(&req{})
|
||||
joined := strings.Join(names, ",")
|
||||
|
||||
for _, want := range []string{"to", "attachment_ids"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("应含 %q,实际 %q", want, joined)
|
||||
}
|
||||
}
|
||||
// json:"-" 的字段不该出现在「本端点接受」的清单里 —— 它确实不接受
|
||||
if strings.Contains(joined, "Skipped") || strings.Contains(joined, "-") {
|
||||
t.Errorf("json:\"-\" 的字段不该列出,实际 %q", joined)
|
||||
}
|
||||
// 无 tag 时用字段名(json 包也是这么匹配的)
|
||||
if !strings.Contains(joined, "NoTag") {
|
||||
t.Errorf("无 tag 字段应按字段名列出,实际 %q", joined)
|
||||
}
|
||||
// 非导出字段 json 根本不看
|
||||
if strings.Contains(joined, "unexported") {
|
||||
t.Errorf("非导出字段不该列出,实际 %q", joined)
|
||||
}
|
||||
}
|
||||
212
server/internal/handler/thread.go
Normal file
212
server/internal/handler/thread.go
Normal file
@ -0,0 +1,212 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 对话树(从线索根整树展开,分块加载) ----------
|
||||
|
||||
// 分页参数。上限存在的意义是防止 ?limit=100000 一次把整条线索拉走 ——
|
||||
// 那就等于绕过了分块加载。
|
||||
const (
|
||||
threadDefaultLimit = 60
|
||||
threadMaxLimit = 200
|
||||
// anchorPathBudget 是「补齐根到锚点这条路径」时最多回填的层数。
|
||||
// 只在锚点没落进 BFS 首页时才用得上(几百封的巨型线索)。
|
||||
anchorPathBudget = 60
|
||||
)
|
||||
|
||||
// threadNode 是返回给前端的树节点。
|
||||
//
|
||||
// Detached 表示「这封的父邮件当前不在返回集里」,两种原因:
|
||||
// - 父邮件不可见(转发把线索引到别处,下游往来不回流给上游参与者)
|
||||
// - 父邮件还没加载(分块加载的边界,往下翻会补上)
|
||||
//
|
||||
// 前端据此画出断点,而不是因为找不到父节点就把它悄悄丢掉。
|
||||
// 两种原因用 ParentHidden 区分:不可见是永久的,未加载是暂时的。
|
||||
type threadNode struct {
|
||||
repo.TreeMail
|
||||
Detached bool `json:"detached,omitempty"`
|
||||
// ParentHidden 为真表示父邮件确实存在但无权查看(不是尚未加载)
|
||||
ParentHidden bool `json:"parent_hidden,omitempty"`
|
||||
}
|
||||
|
||||
// GET /api/v1/mail/{id}/thread
|
||||
//
|
||||
// 以给定邮件所在**线索的根**为起点,BFS 展开整棵树:
|
||||
//
|
||||
// ?offset=0(默认) 从根开始的第一块
|
||||
// ?offset=N 继续往后取(下滑加载)
|
||||
//
|
||||
// 曾经的实现是「锚点的祖先链 + 锚点的子树」两个方向各自分页,问题是
|
||||
// **兄弟节点整条分支都在盲区里**:一封抄送给两个 Agent 的邮件会收到两个回复,
|
||||
// 它们互为兄弟;从其中一个回复看树,另一个回复既不是它的祖先也不是它的子孙,
|
||||
// 于是永远不显示。挂在原件上的转发分支同理。改成从根整树 BFS 后,
|
||||
// 兄弟、抄送产生的平行回复、转发分支都是根的子孙,一次覆盖。
|
||||
//
|
||||
// 树可跨会话(转发是新线索但仍指向原件),因此**逐个会话鉴权**,
|
||||
// 只返回当前用户有权访问的节点。被过滤掉的计入 hidden。
|
||||
func GetMailThread(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
serveMailThread(w, r, func(sid uuid.UUID) (bool, error) {
|
||||
return repo.UserCanAccessSession(r.Context(), user, sid)
|
||||
})
|
||||
}
|
||||
|
||||
// serveMailThread 是人类与 Agent 两条对话树路径的公共实现。
|
||||
//
|
||||
// 差别只在**会话可见性判据**:人类走 UserCanAccessSession(管理员全可见、
|
||||
// 其余看参与过的会话),Agent 走 AgentCanAccessSession(只看自己参与过的)。
|
||||
// 其余全部逻辑——上溯线索根、BFS 分页、锚点路径回填、detached 标记——两侧必须
|
||||
// 完全一致:让 Agent 看到一棵与人类不同形状的树,只会让双方对「谁回了谁」
|
||||
// 产生分歧,而这正是抄送协作要靠对话树解决的问题。
|
||||
func serveMailThread(w http.ResponseWriter, r *http.Request, canAccess func(uuid.UUID) (bool, error)) {
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// 先确认调用者确实看得到作为锚点的这封邮件,否则等于给了一个
|
||||
// 「随便报 mail_id 就能探测线索存在性」的接口
|
||||
mail, err := repo.GetMailByID(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Mail not found")
|
||||
return
|
||||
}
|
||||
allowed, err := canAccess(mail.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该邮件")
|
||||
return
|
||||
}
|
||||
|
||||
limit := intQuery(r, "limit", threadDefaultLimit, 1, threadMaxLimit)
|
||||
offset := intQuery(r, "offset", 0, 0, 1<<20)
|
||||
|
||||
// 上溯到线索根:整棵树都是它的子孙。
|
||||
rootID, anchorDepth, err := repo.ThreadRootOf(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to locate thread root")
|
||||
return
|
||||
}
|
||||
|
||||
raw, hasMore, err := repo.DescendantsRaw(r.Context(), rootID, offset, limit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to load thread")
|
||||
return
|
||||
}
|
||||
|
||||
// 锚点必须可见 —— 用户点开的就是它。巨型线索里 BFS 首页可能还没到锚点那一层,
|
||||
// 此时单独把「根 → 锚点」这条路径补进来,否则用户点开一封邮件却在树里找不到它。
|
||||
if offset == 0 && anchorDepth > 0 && !containsMail(raw, mailID) {
|
||||
path, _, pErr := repo.AncestorsRaw(r.Context(), mailID, 0, anchorPathBudget)
|
||||
if pErr == nil {
|
||||
// AncestorsRaw 给的是相对锚点的负 depth,换算成距根的绝对深度
|
||||
for i := range path {
|
||||
path[i].Depth += anchorDepth
|
||||
}
|
||||
raw = append(raw, path...)
|
||||
}
|
||||
// 锚点自己(AncestorsRaw 从父开始,不含锚点)
|
||||
if anchor, aErr := repo.TreeMailByID(r.Context(), mailID, anchorDepth); aErr == nil {
|
||||
raw = append(raw, *anchor)
|
||||
}
|
||||
}
|
||||
|
||||
// 会话鉴权结果按会话缓存:一条线索里同一会话通常有多封,逐封查是浪费
|
||||
seen := map[uuid.UUID]bool{}
|
||||
canSee := func(sid uuid.UUID) bool {
|
||||
if v, ok := seen[sid]; ok {
|
||||
return v
|
||||
}
|
||||
v, err := canAccess(sid)
|
||||
if err != nil {
|
||||
v = false // 查不出来就当看不到:宁可少给,不可多给
|
||||
}
|
||||
seen[sid] = v
|
||||
return v
|
||||
}
|
||||
|
||||
// 可见性过滤。父节点是否在**本次返回集**里决定 detached;
|
||||
// 父存在却不在集里,再判断是「无权看」还是「没加载」。
|
||||
visible := map[uuid.UUID]bool{}
|
||||
present := map[uuid.UUID]bool{}
|
||||
for _, m := range raw {
|
||||
present[m.ID] = true
|
||||
if canSee(m.SessionID) {
|
||||
visible[m.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
nodes := []threadNode{}
|
||||
emitted := map[uuid.UUID]bool{}
|
||||
for _, m := range raw {
|
||||
if !visible[m.ID] || emitted[m.ID] {
|
||||
// 补齐锚点路径时可能与 BFS 结果重叠,去重
|
||||
continue
|
||||
}
|
||||
emitted[m.ID] = true
|
||||
n := threadNode{TreeMail: m}
|
||||
if m.ParentMailID != nil && !visible[*m.ParentMailID] {
|
||||
n.Detached = true
|
||||
// 父邮件在本次结果里出现过但被过滤掉 = 确实无权查看;
|
||||
// 完全没出现过 = 只是还没加载到,往下翻会补上
|
||||
n.ParentHidden = present[*m.ParentMailID]
|
||||
}
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"anchor_mail_id": mailID,
|
||||
"root_mail_id": rootID,
|
||||
"anchor_depth": anchorDepth,
|
||||
"nodes": nodes,
|
||||
"total": len(nodes),
|
||||
"hidden": len(raw) - len(nodes),
|
||||
"has_more": hasMore,
|
||||
// 下一页的 offset。前端把它原样回传即可,不必自己算已加载数量。
|
||||
"next_offset": offset + limit,
|
||||
})
|
||||
}
|
||||
|
||||
// containsMail 判断某封邮件是否已在结果集里。
|
||||
func containsMail(list []repo.TreeMail, id uuid.UUID) bool {
|
||||
for i := range list {
|
||||
if list[i].ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// intQuery 读取整数 query 参数并夹到 [min, max]。
|
||||
// 非法值一律回落到默认值 —— 分页参数不该因为一个笔误就让整个请求失败。
|
||||
func intQuery(r *http.Request, key string, def, min, max int) int {
|
||||
s := r.URL.Query().Get(key)
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
if v < min {
|
||||
return min
|
||||
}
|
||||
if v > max {
|
||||
return max
|
||||
}
|
||||
return v
|
||||
}
|
||||
26
server/internal/handler/thread_test.go
Normal file
26
server/internal/handler/thread_test.go
Normal file
@ -0,0 +1,26 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIntQueryClampsAndFallsBack(t *testing.T) {
|
||||
cases := []struct {
|
||||
q string
|
||||
want int
|
||||
}{
|
||||
{"", 40}, // 缺省
|
||||
{"limit=10", 10}, // 正常
|
||||
{"limit=0", 1}, // 低于下限 → 夹到下限
|
||||
{"limit=999", 200}, // 高于上限 → 夹到上限
|
||||
{"limit=abc", 40}, // 非法 → 回落默认值,而不是让整个请求 400
|
||||
{"limit=-5", 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := httptest.NewRequest("GET", "/x?"+c.q, nil)
|
||||
if got := intQuery(r, "limit", 40, 1, 200); got != c.want {
|
||||
t.Fatalf("intQuery(%q) = %d,期望 %d", c.q, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
205
server/internal/lunar/lunar.go
Normal file
205
server/internal/lunar/lunar.go
Normal file
@ -0,0 +1,205 @@
|
||||
// Package lunar 把公历与农历互转,并提供「按农历推进」的重复规则计算。
|
||||
//
|
||||
// 为什么要单独一层而不直接用 lunar-go:
|
||||
//
|
||||
// 1. **lunar-go 在非法日期上 panic 而不是返回 error**。
|
||||
// `NewLunarFromYmd(2027, 9, 30)` 直接 panic("only 29 days in lunar
|
||||
// year 2027 month 9") —— 农历月是 29 或 30 天不定,「每月农历三十」
|
||||
// 这条规则必然会撞上 29 天的月份。调度器里一次 panic 就让那一轮所有
|
||||
// 提醒全部落空(虽然有 recover 兜底,但结果是那一条提醒永久卡住)。
|
||||
//
|
||||
// 2. **闰月用负数月份表示**(-6 = 闰六月),这个约定藏在库内部。
|
||||
// 2025 有闰六月、2028 有闰五月,而 2026/2027 没有 —— 「每年农历某月
|
||||
// 某日」跨过闰月年份时必须决定落在哪个月,这个决策不该散落在 repo 里。
|
||||
//
|
||||
// 3. **按农历推进不能靠加固定天数**。农历月 29~30 天、农历年 353~385 天
|
||||
// (闰年多一个月)。用 AddDate 近似会越推越偏,一年下来能差半个月。
|
||||
package lunar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/6tail/lunar-go/calendar"
|
||||
)
|
||||
|
||||
// Date 是一个农历日期。
|
||||
//
|
||||
// Month 为负数表示闰月(-6 = 闰六月),与 lunar-go 的约定一致 ——
|
||||
// 刻意沿用而不另造一个 IsLeap bool:两种表示混用时转换处极易写反,
|
||||
// 而负数在数值比较里天然排在正数前面(闰六月在六月之后,需要注意这一点,
|
||||
// 见 monthsInYear 的排序)。
|
||||
type Date struct {
|
||||
Year int
|
||||
Month int // 负数 = 闰月
|
||||
Day int
|
||||
}
|
||||
|
||||
// FromSolar 把公历时刻转成农历日期。
|
||||
//
|
||||
// 只取年月日,时分秒由调用方保留 —— 农历只定义到「日」,
|
||||
// 「农历七月十五早上九点」的「九点」是公历时钟的概念。
|
||||
func FromSolar(t time.Time) Date {
|
||||
s := calendar.NewSolarFromYmd(t.Year(), int(t.Month()), t.Day())
|
||||
l := s.GetLunar()
|
||||
return Date{Year: l.GetYear(), Month: l.GetMonth(), Day: l.GetDay()}
|
||||
}
|
||||
|
||||
// ToSolar 把农历日期转回公历,并带上给定的时分秒。
|
||||
//
|
||||
// **日期会被夹到该农历月的实际天数内**:请求农历三十而该月只有 29 天时
|
||||
// 返回廿九,而不是 panic 也不是滚到下个月的初一。
|
||||
//
|
||||
// 夹而不滚的理由:「每月农历三十」的语义是「月末那天」,滚到下月初一会让
|
||||
// 提醒出现在完全错误的日子(且与前一次提醒只隔一天)。
|
||||
//
|
||||
// 返回的 clamped 说明是否发生了夹取 —— 调用方据此决定是否要在 UI 上提示。
|
||||
func (d Date) ToSolar(loc *time.Location, hour, min, sec, nsec int) (t time.Time, clamped bool, err error) {
|
||||
days, err := DaysInMonth(d.Year, d.Month)
|
||||
if err != nil {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
day := d.Day
|
||||
if day > days {
|
||||
day = days
|
||||
clamped = true
|
||||
}
|
||||
if day < 1 {
|
||||
return time.Time{}, false, fmt.Errorf("农历日 %d 非法", d.Day)
|
||||
}
|
||||
|
||||
// lunar-go 在非法输入上 panic,这里兜住转成 error:
|
||||
// 上面已经夹过日期,理论上不会触发,但闰月不存在之类的组合仍可能进来。
|
||||
var solar *calendar.Solar
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("农历 %d-%d-%d 无法转公历: %v", d.Year, d.Month, day, r)
|
||||
}
|
||||
}()
|
||||
solar = calendar.NewLunarFromYmd(d.Year, d.Month, day).GetSolar()
|
||||
}()
|
||||
if err != nil {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
if solar == nil {
|
||||
return time.Time{}, false, fmt.Errorf("农历 %d-%d-%d 转公历得到空值", d.Year, d.Month, day)
|
||||
}
|
||||
|
||||
return time.Date(solar.GetYear(), time.Month(solar.GetMonth()), solar.GetDay(),
|
||||
hour, min, sec, nsec, loc), clamped, nil
|
||||
}
|
||||
|
||||
// DaysInMonth 返回某个农历月有多少天(29 或 30)。
|
||||
//
|
||||
// Month 为负数时查闰月。该年没有这个闰月则返回错误 ——
|
||||
// 这不是异常情况:「每年农历闰六月十五」这条规则在没有闰六月的年份
|
||||
// 本来就无法落地,调用方需要据此跳过而不是猜一个日子。
|
||||
func DaysInMonth(year, month int) (int, error) {
|
||||
var days int
|
||||
var found bool
|
||||
// LunarYear.GetMonths() 是 *list.List,元素为 *LunarMonth。
|
||||
// 一个 LunarYear 对象里会带上跨年边界的月份,因此必须同时比对 year。
|
||||
ly := calendar.NewLunarYear(year)
|
||||
for e := ly.GetMonths().Front(); e != nil; e = e.Next() {
|
||||
lm, ok := e.Value.(*calendar.LunarMonth)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if lm.GetYear() == year && lm.GetMonth() == month {
|
||||
days = lm.GetDayCount()
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if month < 0 {
|
||||
return 0, fmt.Errorf("农历 %d 年没有闰 %d 月", year, -month)
|
||||
}
|
||||
return 0, fmt.Errorf("农历 %d 年没有 %d 月", year, month)
|
||||
}
|
||||
return days, nil
|
||||
}
|
||||
|
||||
// LeapMonth 返回某农历年的闰月(0 = 无闰月)。
|
||||
func LeapMonth(year int) int {
|
||||
return calendar.NewLunarYear(year).GetLeapMonth()
|
||||
}
|
||||
|
||||
// AddMonths 在农历上推进若干个月。
|
||||
//
|
||||
// 逐月走而不是「月份数 + n 再取模」:中间可能夹着闰月,
|
||||
// 而闰月是否存在取决于年份,没有闭式公式。
|
||||
//
|
||||
// 闰月的处理:**推进时跳过闰月**。从六月推一个月得七月,不是闰六月。
|
||||
// 理由是「每月十五」这类规则的用户期望是一年 12 次,
|
||||
// 把闰月算进去会让闰年多出一次提醒 —— 那是农历年的性质,不是提醒的性质。
|
||||
// 想要闰月本身的提醒应该用 lunar_yearly 指定 -6 月。
|
||||
func (d Date) AddMonths(n int) Date {
|
||||
y, m := d.Year, d.Month
|
||||
// 从闰月出发时先归到对应的正月份:闰六月 +1 → 七月
|
||||
if m < 0 {
|
||||
m = -m
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
m++
|
||||
if m > 12 {
|
||||
m = 1
|
||||
y++
|
||||
}
|
||||
}
|
||||
return Date{Year: y, Month: m, Day: d.Day}
|
||||
}
|
||||
|
||||
// AddYears 在农历上推进若干年,月份与日期保持不变。
|
||||
//
|
||||
// 从闰月出发时(Month < 0)目标年没有同一个闰月,退回对应的正月份 ——
|
||||
// 「去年闰六月十五」在今年最接近的对应日就是六月十五。
|
||||
// 直接放弃(不再提醒)更糟:那是静默地让重复事件消失。
|
||||
func (d Date) AddYears(n int) Date {
|
||||
y := d.Year + n
|
||||
m := d.Month
|
||||
if m < 0 && LeapMonth(y) != -m {
|
||||
m = -m
|
||||
}
|
||||
return Date{Year: y, Month: m, Day: d.Day}
|
||||
}
|
||||
|
||||
// String 给出「二〇二六年七月廿二」这样的中文农历表示。
|
||||
//
|
||||
// UI 上必须显示它:农历事件的公历日期每年都在变,
|
||||
// 只显示公历会让人无法确认「这条规则是不是我想的那个农历日子」。
|
||||
func (d Date) String() string {
|
||||
days, err := DaysInMonth(d.Year, d.Month)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("农历 %d 年%d月%d日(无效)", d.Year, d.Month, d.Day)
|
||||
}
|
||||
day := d.Day
|
||||
if day > days {
|
||||
day = days
|
||||
}
|
||||
var out string
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
out = fmt.Sprintf("农历 %d-%d-%d", d.Year, d.Month, d.Day)
|
||||
}
|
||||
}()
|
||||
out = calendar.NewLunarFromYmd(d.Year, d.Month, day).String()
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
// FormatSolar 把公历时刻渲染成「2026-09-03(农历七月廿二)」。
|
||||
//
|
||||
// 给提醒正文与 UI 用:两种历都写出来,人才能确认规则没被理解错。
|
||||
func FormatSolar(t time.Time) string {
|
||||
d := FromSolar(t)
|
||||
full := d.String()
|
||||
// 去掉年份部分(「二〇二六年」共 4 个中文字符 + 「年」),只留月日 ——
|
||||
// 公历年份已经在前面写了,重复一遍反而更难读。
|
||||
if r := []rune(full); len(r) > 5 && r[4] == '年' {
|
||||
full = string(r[5:])
|
||||
}
|
||||
return fmt.Sprintf("%s(农历%s)", t.Format("2006-01-02"), full)
|
||||
}
|
||||
216
server/internal/lunar/lunar_test.go
Normal file
216
server/internal/lunar/lunar_test.go
Normal file
@ -0,0 +1,216 @@
|
||||
package lunar
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 已知对照点。农历换算错了不会报错,只会让提醒发在错误的日子,
|
||||
// 因此必须钉住几个可人工核对的锚点。
|
||||
func TestFromSolarKnownDates(t *testing.T) {
|
||||
cases := []struct {
|
||||
solar string
|
||||
year int
|
||||
month int
|
||||
day int
|
||||
}{
|
||||
{"2026-09-03", 2026, 7, 22},
|
||||
{"2026-01-01", 2025, 11, 13},
|
||||
// 2025 有闰六月:闰月里的日子 Month 应为负
|
||||
{"2025-07-25", 2025, -6, 1},
|
||||
{"2025-06-25", 2025, 6, 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
st, err := time.Parse("2006-01-02", c.solar)
|
||||
if err != nil {
|
||||
t.Fatalf("解析 %s: %v", c.solar, err)
|
||||
}
|
||||
got := FromSolar(st)
|
||||
if got.Year != c.year || got.Month != c.month || got.Day != c.day {
|
||||
t.Errorf("%s → 农历 %d-%d-%d,期望 %d-%d-%d",
|
||||
c.solar, got.Year, got.Month, got.Day, c.year, c.month, c.day)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToSolarRoundtrip(t *testing.T) {
|
||||
loc := time.Local
|
||||
for _, solar := range []string{"2026-09-03", "2027-02-14", "2028-06-30", "2025-07-25"} {
|
||||
st, _ := time.Parse("2006-01-02", solar)
|
||||
d := FromSolar(st)
|
||||
back, clamped, err := d.ToSolar(loc, 9, 30, 0, 0)
|
||||
if err != nil {
|
||||
t.Errorf("%s 往返失败: %v", solar, err)
|
||||
continue
|
||||
}
|
||||
if clamped {
|
||||
t.Errorf("%s 往返不该发生夹取", solar)
|
||||
}
|
||||
if back.Format("2006-01-02") != solar {
|
||||
t.Errorf("%s 往返得到 %s", solar, back.Format("2006-01-02"))
|
||||
}
|
||||
if back.Hour() != 9 || back.Minute() != 30 {
|
||||
t.Errorf("%s 往返丢了时分:%v", solar, back)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 农历月是 29 或 30 天不定,「每月农历三十」必然撞上 29 天的月份。
|
||||
// lunar-go 在这种输入上**panic** 而不是返回 error —— 必须被夹住。
|
||||
func TestToSolarClampsShortMonth(t *testing.T) {
|
||||
// 2027 农历九月只有 29 天
|
||||
days, err := DaysInMonth(2027, 9)
|
||||
if err != nil {
|
||||
t.Fatalf("查天数: %v", err)
|
||||
}
|
||||
if days != 29 {
|
||||
t.Fatalf("前提变了:2027 农历九月现在是 %d 天", days)
|
||||
}
|
||||
|
||||
got, clamped, err := Date{Year: 2027, Month: 9, Day: 30}.ToSolar(time.Local, 9, 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("三十日应被夹到廿九而不是报错,得到 %v", err)
|
||||
}
|
||||
if !clamped {
|
||||
t.Error("应报告发生了夹取")
|
||||
}
|
||||
// 夹取后应等于该月廿九
|
||||
want, _, _ := Date{Year: 2027, Month: 9, Day: 29}.ToSolar(time.Local, 9, 0, 0, 0)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("夹取后 %v,期望与廿九相同 %v", got, want)
|
||||
}
|
||||
// 且必须仍在同一个农历月内 —— 滚到下月初一是错的
|
||||
if FromSolar(got).Month != 9 {
|
||||
t.Errorf("夹取后跑出了农历九月:%s", FromSolar(got).String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestToSolarRejectsNonexistentLeapMonth(t *testing.T) {
|
||||
// 2026 无闰月
|
||||
if LeapMonth(2026) != 0 {
|
||||
t.Fatalf("前提变了:2026 闰月 = %d", LeapMonth(2026))
|
||||
}
|
||||
_, _, err := Date{Year: 2026, Month: -6, Day: 1}.ToSolar(time.Local, 9, 0, 0, 0)
|
||||
if err == nil {
|
||||
t.Error("不存在的闰月应返回错误而不是猜一个日子")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeapMonth(t *testing.T) {
|
||||
cases := map[int]int{2025: 6, 2026: 0, 2027: 0, 2028: 5}
|
||||
for y, want := range cases {
|
||||
if got := LeapMonth(y); got != want {
|
||||
t.Errorf("LeapMonth(%d) = %d,期望 %d", y, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaysInMonth(t *testing.T) {
|
||||
if d, err := DaysInMonth(2026, 1); err != nil || d != 30 {
|
||||
t.Errorf("2026 正月应 30 天,得到 %d err=%v", d, err)
|
||||
}
|
||||
if d, err := DaysInMonth(2027, 9); err != nil || d != 29 {
|
||||
t.Errorf("2027 九月应 29 天,得到 %d err=%v", d, err)
|
||||
}
|
||||
if _, err := DaysInMonth(2026, -6); err == nil {
|
||||
t.Error("2026 没有闰六月,应返回错误")
|
||||
}
|
||||
if _, err := DaysInMonth(2026, 13); err == nil {
|
||||
t.Error("13 月应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
// 按农历推进不能靠加固定天数:农历月 29~30 天,闰年 13 个月。
|
||||
func TestAddMonths(t *testing.T) {
|
||||
d := Date{Year: 2026, Month: 7, Day: 22}
|
||||
if got := d.AddMonths(1); got.Month != 8 || got.Year != 2026 {
|
||||
t.Errorf("+1 月 = %d-%d,期望 2026-8", got.Year, got.Month)
|
||||
}
|
||||
// 跨年
|
||||
if got := (Date{Year: 2026, Month: 12, Day: 5}).AddMonths(1); got.Year != 2027 || got.Month != 1 {
|
||||
t.Errorf("腊月 +1 = %d-%d,期望 2027-1", got.Year, got.Month)
|
||||
}
|
||||
// 推 12 次回到同月次年
|
||||
if got := d.AddMonths(12); got.Year != 2027 || got.Month != 7 {
|
||||
t.Errorf("+12 月 = %d-%d,期望 2027-7", got.Year, got.Month)
|
||||
}
|
||||
// 从闰月出发先归正:闰六月 +1 → 七月(一年 12 次,不因闰年多一次)
|
||||
if got := (Date{Year: 2025, Month: -6, Day: 15}).AddMonths(1); got.Month != 7 {
|
||||
t.Errorf("闰六月 +1 = %d 月,期望 7 月", got.Month)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddYears(t *testing.T) {
|
||||
if got := (Date{Year: 2026, Month: 7, Day: 22}).AddYears(1); got.Year != 2027 || got.Month != 7 {
|
||||
t.Errorf("+1 年 = %d-%d", got.Year, got.Month)
|
||||
}
|
||||
// 从闰月出发、目标年没有同一闰月 → 退回正月份而不是静默消失
|
||||
got := (Date{Year: 2025, Month: -6, Day: 15}).AddYears(1)
|
||||
if got.Year != 2026 || got.Month != 6 {
|
||||
t.Errorf("闰六月 +1 年 = %d-%d,期望 2026-6(退回正六月)", got.Year, got.Month)
|
||||
}
|
||||
// 目标年恰好也有同一闰月 → 保持闰月
|
||||
// 2025 闰六月 → 2028 闰五月,所以这里构造 2028 的闰五月 +0 年
|
||||
if LeapMonth(2028) == 5 {
|
||||
keep := (Date{Year: 2028, Month: -5, Day: 1}).AddYears(0)
|
||||
if keep.Month != -5 {
|
||||
t.Errorf("目标年有同一闰月时应保持,得到 %d", keep.Month)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 「每年农历某月某日」的公历日期每年都在漂移 —— 这正是需要农历重复的理由。
|
||||
// 如果用公历 yearly,日子会固定,与用户的期望(过农历生日/祭日)不符。
|
||||
func TestYearlyLunarDriftsInSolar(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
d := Date{Year: 2026, Month: 7, Day: 22}
|
||||
for i := 0; i < 6; i++ {
|
||||
st, _, err := d.AddYears(i).ToSolar(time.Local, 9, 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 年转换失败: %v", i, err)
|
||||
}
|
||||
seen[st.Format("01-02")] = true
|
||||
}
|
||||
if len(seen) < 4 {
|
||||
t.Errorf("六年里公历月日只有 %d 种,农历重复应当漂移", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringChinese(t *testing.T) {
|
||||
got := (Date{Year: 2026, Month: 7, Day: 22}).String()
|
||||
if got != "二〇二六年七月廿二" {
|
||||
t.Errorf("String() = %q,期望 二〇二六年七月廿二", got)
|
||||
}
|
||||
// 闰月要带「闰」字,否则人分不清是哪个月
|
||||
leap := (Date{Year: 2025, Month: -6, Day: 1}).String()
|
||||
if leap != "二〇二五年闰六月初一" {
|
||||
t.Errorf("闰月 String() = %q", leap)
|
||||
}
|
||||
// 非法日期不 panic
|
||||
bad := (Date{Year: 2026, Month: 13, Day: 1}).String()
|
||||
if bad == "" {
|
||||
t.Error("非法日期应返回可读文本而不是空串")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSolar(t *testing.T) {
|
||||
st, _ := time.Parse("2006-01-02", "2026-09-03")
|
||||
got := FormatSolar(st)
|
||||
if got != "2026-09-03(农历七月廿二)" {
|
||||
t.Errorf("FormatSolar = %q,期望 2026-09-03(农历七月廿二)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 时区:农历只定义到「日」,时分秒是公历时钟的概念,必须原样带过去。
|
||||
func TestToSolarKeepsClockTime(t *testing.T) {
|
||||
got, _, err := (Date{Year: 2026, Month: 7, Day: 22}).ToSolar(time.Local, 14, 45, 30, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("转换: %v", err)
|
||||
}
|
||||
if got.Hour() != 14 || got.Minute() != 45 || got.Second() != 30 {
|
||||
t.Errorf("时钟被改动:%v", got)
|
||||
}
|
||||
if got.Location() != time.Local {
|
||||
t.Errorf("时区被改动:%v", got.Location())
|
||||
}
|
||||
}
|
||||
98
server/internal/middleware/auth.go
Normal file
98
server/internal/middleware/auth.go
Normal file
@ -0,0 +1,98 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const AgentNameKey contextKey = "agent_name"
|
||||
|
||||
// bearerToken 从 Authorization: Bearer <token> 取出令牌,缺失时返回空串。
|
||||
func bearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
const p = "Bearer "
|
||||
if len(h) > len(p) && strings.EqualFold(h[:len(p)], p) {
|
||||
return strings.TrimSpace(h[len(p):])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// BearerToken 导出给 handler 层用(注册接口不过中间件,需要自己取密钥)。
|
||||
func BearerToken(r *http.Request) string { return bearerToken(r) }
|
||||
|
||||
// keyAuthError 把密钥校验错误翻译成对外文案。
|
||||
// 「不存在」与「已使用/已过期」区分开:前两者是拿错了密钥,后者是密钥生命周期到了,
|
||||
// 运维需要据此判断该重新签发还是该检查配置。
|
||||
func keyAuthError(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, repo.ErrKeyUsed):
|
||||
return `{"error":"密钥已使用(一次性密钥只能用一次)"}`
|
||||
case errors.Is(err, repo.ErrKeyExpired):
|
||||
return `{"error":"密钥已过期"}`
|
||||
default:
|
||||
return `{"error":"密钥无效"}`
|
||||
}
|
||||
}
|
||||
|
||||
// AgentAuth 验证 Agent 身份,支持两种凭证:
|
||||
//
|
||||
// Authorization: Bearer <agent_key_token> —— 密钥认证(推荐)
|
||||
// X-Agent-Name + X-Agent-Secret —— 旧的 name/secret 方式(兼容保留)
|
||||
//
|
||||
// 用户密钥(user_keys)不接受:两类密钥共享 token 命名空间但走各自的验证表,
|
||||
// 因此用用户密钥调 Agent 接口只会得到「密钥无效」。
|
||||
func AgentAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if token := bearerToken(r); token != "" {
|
||||
agentName, err := repo.VerifyAgentKey(r.Context(), token)
|
||||
if err != nil {
|
||||
http.Error(w, keyAuthError(err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if agentName == "" {
|
||||
// 密钥有效但尚未绑定 Agent:注册接口会用请求里的 name 落定它,
|
||||
// 其余接口无法确定调用者身份,只能拒。
|
||||
http.Error(w, `{"error":"密钥尚未绑定 Agent,请先调用 /agent/register 完成注册"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
repo.HeartbeatAgent(r.Context(), agentName)
|
||||
ctx := context.WithValue(r.Context(), AgentNameKey, agentName)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
agentName := r.Header.Get("X-Agent-Name")
|
||||
agentSecret := r.Header.Get("X-Agent-Secret")
|
||||
if agentName == "" || agentSecret == "" {
|
||||
http.Error(w, `{"error":"Missing Authorization: Bearer <key> or X-Agent-Name/X-Agent-Secret header"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := repo.VerifyAgent(r.Context(), agentName, agentSecret)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"Invalid credentials"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
repo.HeartbeatAgent(r.Context(), agent.Name)
|
||||
ctx := context.WithValue(r.Context(), AgentNameKey, agent.Name)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// GetAgentName 从 context 中获取 agent_name
|
||||
func GetAgentName(r *http.Request) string {
|
||||
if v := r.Context().Value(AgentNameKey); v != nil {
|
||||
return v.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
161
server/internal/middleware/user.go
Normal file
161
server/internal/middleware/user.go
Normal file
@ -0,0 +1,161 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/config"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
const UserKey contextKey = "auth_user"
|
||||
|
||||
// SetSessionCookie 写入登录 Cookie
|
||||
func SetSessionCookie(w http.ResponseWriter, token string, maxAge int) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: config.C.CookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: config.C.SecureCookie,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: maxAge,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearSessionCookie 清除登录 Cookie
|
||||
func ClearSessionCookie(w http.ResponseWriter) {
|
||||
SetSessionCookie(w, "", -1)
|
||||
}
|
||||
|
||||
// SessionToken 从请求中取出登录令牌
|
||||
func SessionToken(r *http.Request) string {
|
||||
c, err := r.Cookie(config.C.CookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
// QueryToken 从 ?access_token= 取出令牌。
|
||||
//
|
||||
// 仅为浏览器 EventSource 存在:它不支持自定义请求头,因此订阅 SSE 时
|
||||
// 除了 Cookie 就只剩 query 一条路。代价是令牌会进访问日志,
|
||||
// 所以只在 SSE 端点启用,其余接口一律要求 Authorization 头。
|
||||
func QueryToken(r *http.Request) string {
|
||||
return strings.TrimSpace(r.URL.Query().Get("access_token"))
|
||||
}
|
||||
|
||||
// UserAuth 校验人类用户登录态,把 *models.User 注入 context。
|
||||
// 支持两种凭证:浏览器 Cookie,或 Authorization: Bearer <user_key_token>(第三方客户端)。
|
||||
func UserAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, err := resolve(r)
|
||||
if err != nil {
|
||||
// 只有 Cookie 路径才清 Cookie;密钥认证失败不应频带浏览器会话
|
||||
if bearerToken(r) == "" {
|
||||
ClearSessionCookie(w)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"not authenticated"}`))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), UserKey, u)))
|
||||
})
|
||||
}
|
||||
|
||||
// AdminOnly 叠在 UserAuth 之后,要求 role = admin
|
||||
func AdminOnly(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u := GetUser(r)
|
||||
if u == nil || !u.IsAdmin() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"error":"admin only"}`))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// UserAuthAllowQueryToken 与 UserAuth 相同,但额外接受 ?access_token=。
|
||||
//
|
||||
// 只给那些【由浏览器直接发起、无法设置请求头】的端点用(附件下载的 <a download>)。
|
||||
// URL 里的令牌会进访问日志与 Referer,所以不能全局开启。
|
||||
func UserAuthAllowQueryToken(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, err := resolve(r)
|
||||
if err != nil {
|
||||
if token := QueryToken(r); token != "" {
|
||||
if ku, kErr := repo.VerifyUserKey(r.Context(), token); kErr == nil {
|
||||
u, err = ku, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"not authenticated"}`))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), UserKey, u)))
|
||||
})
|
||||
}
|
||||
|
||||
// GetUser 从 context 取登录用户;未登录返回 nil
|
||||
func GetUser(r *http.Request) *models.User {
|
||||
if v := r.Context().Value(UserKey); v != nil {
|
||||
if u, ok := v.(*models.User); ok {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserName 便捷取登录用户名
|
||||
func GetUserName(r *http.Request) string {
|
||||
if u := GetUser(r); u != nil {
|
||||
return u.Username
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// OptionalUser 解析登录态但不拦截(SSE 等需要区分匿名/登录的场景)
|
||||
func OptionalUser(r *http.Request) *models.User {
|
||||
u, err := resolve(r)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// OptionalUserWithQuery 在 resolve 的三种凭证之外额外接受 ?access_token=。
|
||||
// 仅 SSE 用:浏览器 EventSource 无法带自定义头。
|
||||
func OptionalUserWithQuery(r *http.Request) *models.User {
|
||||
if u := OptionalUser(r); u != nil {
|
||||
return u
|
||||
}
|
||||
if token := QueryToken(r); token != "" {
|
||||
if u, err := repo.VerifyUserKey(r.Context(), token); err == nil {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolve 解析调用者身份:Cookie 优先,其次 Bearer 用户密钥。
|
||||
//
|
||||
// 用户密钥只能走到这里(/me/* 与会话级接口),Agent 密钥只能走 AgentAuth,
|
||||
// 两者各自查自己的表,因此拿 Agent 密钥读人类邮箱会得到 not authenticated。
|
||||
func resolve(r *http.Request) (*models.User, error) {
|
||||
if token := SessionToken(r); token != "" {
|
||||
return repo.ResolveUserSession(r.Context(), token)
|
||||
}
|
||||
if token := bearerToken(r); token != "" {
|
||||
return repo.VerifyUserKey(r.Context(), token)
|
||||
}
|
||||
return nil, repo.ErrSessionInvalid
|
||||
}
|
||||
168
server/internal/models/address.go
Normal file
168
server/internal/models/address.go
Normal file
@ -0,0 +1,168 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Address 是三维寻址 name@path.session 的解析结果
|
||||
type Address struct {
|
||||
Name string `json:"name"` // Agent 实例名(或人类用户名)
|
||||
Path string `json:"path"` // 工作区路径(可含 /,可为空)
|
||||
Session string `json:"session"` // 会话别名;"new" = 新建;"" = 默认会话
|
||||
Raw string `json:"raw"` // 原始字符串
|
||||
}
|
||||
|
||||
// SessionMode 是 session 位的三种语义
|
||||
type SessionMode int
|
||||
|
||||
const (
|
||||
// SessionDefault:session 位省略 → 投递到 name@path 的默认会话(不存在则建立)
|
||||
SessionDefault SessionMode = iota
|
||||
// SessionNew:session 位为 new → 强制新建一个会话
|
||||
SessionNew
|
||||
// SessionNamed:session 位为具体别名 → 必须已存在,否则无法送达
|
||||
SessionNamed
|
||||
)
|
||||
|
||||
// Mode 返回该地址 session 位的语义
|
||||
func (a Address) Mode() SessionMode {
|
||||
switch a.Session {
|
||||
case "":
|
||||
return SessionDefault
|
||||
case "new":
|
||||
return SessionNew
|
||||
default:
|
||||
return SessionNamed
|
||||
}
|
||||
}
|
||||
|
||||
// IsNewSession 表示该地址要求新建会话(仅 session == "new")。
|
||||
// 注意:session 位省略不等于 new,那是「默认会话」,见 Mode()。
|
||||
func (a Address) IsNewSession() bool {
|
||||
return a.Mode() == SessionNew
|
||||
}
|
||||
|
||||
// IsDefaultSession 表示该地址省略了 session 位,走默认会话
|
||||
func (a Address) IsDefaultSession() bool {
|
||||
return a.Mode() == SessionDefault
|
||||
}
|
||||
|
||||
func (a Address) String() string {
|
||||
return a.Raw
|
||||
}
|
||||
|
||||
// ParseAddress 解析 name@path.session 三维地址。
|
||||
//
|
||||
// 支持形态:
|
||||
//
|
||||
// deepseekharness@/program.updatefeature → name=deepseekharness path=/program session=updatefeature
|
||||
// pi@root.new → name=pi path=root session=new(新建)
|
||||
// builder@ModelRouter.fix-leak → name=builder path=ModelRouter session=fix-leak
|
||||
// human@.new → name=human path="" session=new
|
||||
// human → name=human path="" session=""(默认会话)
|
||||
//
|
||||
// 规则:
|
||||
// - 第一个 @ 之前是 name(必填)
|
||||
// - @ 之后按【最后一个 .】切成 path 与 session,因此 path 内可以包含 . 与 /
|
||||
// - 没有 . 时,整段视为 path,session 为空(默认会话)
|
||||
//
|
||||
// session 位三态语义见 Address.Mode():省略=默认会话,new=新建,其他=必须已存在。
|
||||
func ParseAddress(s string) (Address, error) {
|
||||
raw := strings.TrimSpace(s)
|
||||
if raw == "" {
|
||||
return Address{}, fmt.Errorf("empty address")
|
||||
}
|
||||
|
||||
// 只有形如 "@name@path.session" 时才剥掉前导 @;
|
||||
// "@ModelRouter.new" 缺少 name,应当报错而不是被当成名字。
|
||||
trimmed := raw
|
||||
if strings.HasPrefix(raw, "@") && strings.Contains(raw[1:], "@") {
|
||||
trimmed = raw[1:]
|
||||
}
|
||||
|
||||
at := strings.Index(trimmed, "@")
|
||||
if at < 0 {
|
||||
// 只有名字:human / builder
|
||||
name := strings.TrimSpace(trimmed)
|
||||
if name == "" {
|
||||
return Address{}, fmt.Errorf("missing agent name in %q", raw)
|
||||
}
|
||||
return Address{Name: name, Raw: raw}, nil
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(trimmed[:at])
|
||||
if name == "" {
|
||||
return Address{}, fmt.Errorf("missing agent name in %q", raw)
|
||||
}
|
||||
|
||||
rest := trimmed[at+1:]
|
||||
|
||||
// 按最后一个 . 切 path / session;path 内允许 / 与 .
|
||||
var path, session string
|
||||
if dot := strings.LastIndex(rest, "."); dot >= 0 {
|
||||
path = rest[:dot]
|
||||
session = rest[dot+1:]
|
||||
} else {
|
||||
path = rest
|
||||
}
|
||||
|
||||
return Address{
|
||||
Name: name,
|
||||
Path: strings.TrimSpace(path),
|
||||
Session: strings.TrimSpace(session),
|
||||
Raw: raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FormatAddress 把三段拼回可寻址的 name@path.session。
|
||||
//
|
||||
// **必须走这个函数而不是自己拼字符串**:path 为空时(人类用户没有工作区)
|
||||
// 朴素拼接得到 "admin.silent-harbor",而它没有 @,ParseAddress 会把整串当成
|
||||
// 名字,session 位丢失,地址静默失效。空 path 也必须留下那个 @ 与 . ——
|
||||
// "admin@.silent-harbor" 才解析成 name=admin path="" session=silent-harbor。
|
||||
//
|
||||
// session 传空则省略该位(默认会话语义)。
|
||||
func FormatAddress(name, path, session string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
path = strings.TrimSpace(path)
|
||||
session = strings.TrimSpace(session)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
if session == "" {
|
||||
if path == "" {
|
||||
return name
|
||||
}
|
||||
return name + "@" + path
|
||||
}
|
||||
return name + "@" + path + "." + session
|
||||
}
|
||||
|
||||
// WithSession 返回同一收件方在指定会话下的地址。
|
||||
// 用于把 .new 换成刚建出来的会话别名 —— 参与方拿到的地址必须是能再次投递的那个。
|
||||
func (a Address) WithSession(session string) string {
|
||||
return FormatAddress(a.Name, a.Path, session)
|
||||
}
|
||||
|
||||
// ParseAddressList 解析逗号/分号/空白分隔的多个地址(用于 CC)
|
||||
func ParseAddressList(s string) ([]Address, error) {
|
||||
raw := strings.TrimSpace(s)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == '\n' || r == '\t' || r == ' '
|
||||
})
|
||||
|
||||
out := make([]Address, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
addr, err := ParseAddress(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, addr)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
94
server/internal/models/address_test.go
Normal file
94
server/internal/models/address_test.go
Normal file
@ -0,0 +1,94 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseAddress(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
name string
|
||||
path string
|
||||
session string
|
||||
mode SessionMode
|
||||
}{
|
||||
// 用户给出的两个例子
|
||||
{"deepseekharness@/program.upadtefeature", "deepseekharness", "/program", "upadtefeature", SessionNamed},
|
||||
{"pi@root.new", "pi", "root", "new", SessionNew},
|
||||
|
||||
// 常规形态
|
||||
{"builder@ModelRouter.fix-memory-leak", "builder", "ModelRouter", "fix-memory-leak", SessionNamed},
|
||||
{"@builder@ModelRouter.new", "builder", "ModelRouter", "new", SessionNew},
|
||||
{"human@.new", "human", "", "new", SessionNew},
|
||||
|
||||
// 省略 session 位 = 默认会话(不等于 new)
|
||||
{"human", "human", "", "", SessionDefault},
|
||||
{"ops@prod", "ops", "prod", "", SessionDefault},
|
||||
|
||||
// path 内含 . 与 /(按最后一个 . 切)
|
||||
{"agent@/home/a.b/c.deploy", "agent", "/home/a.b/c", "deploy", SessionNamed},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got, err := ParseAddress(c.in)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAddress(%q) unexpected error: %v", c.in, err)
|
||||
}
|
||||
if got.Name != c.name || got.Path != c.path || got.Session != c.session {
|
||||
t.Errorf("ParseAddress(%q) = {name:%q path:%q session:%q}, want {name:%q path:%q session:%q}",
|
||||
c.in, got.Name, got.Path, got.Session, c.name, c.path, c.session)
|
||||
}
|
||||
if got.Mode() != c.mode {
|
||||
t.Errorf("ParseAddress(%q).Mode() = %v, want %v", c.in, got.Mode(), c.mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// session 位省略与 new 必须是两种不同语义:
|
||||
// 省略 → 默认会话;new → 强制新建;其他 → 必须已存在。
|
||||
func TestSessionModeSemantics(t *testing.T) {
|
||||
def, _ := ParseAddress("pi@root")
|
||||
if def.Mode() != SessionDefault || def.IsNewSession() || !def.IsDefaultSession() {
|
||||
t.Errorf("pi@root 应为默认会话,得到 mode=%v isNew=%v", def.Mode(), def.IsNewSession())
|
||||
}
|
||||
|
||||
new_, _ := ParseAddress("pi@root.new")
|
||||
if new_.Mode() != SessionNew || !new_.IsNewSession() || new_.IsDefaultSession() {
|
||||
t.Errorf("pi@root.new 应为新建,得到 mode=%v", new_.Mode())
|
||||
}
|
||||
|
||||
named, _ := ParseAddress("pi@root.fix-leak")
|
||||
if named.Mode() != SessionNamed || named.IsNewSession() || named.IsDefaultSession() {
|
||||
t.Errorf("pi@root.fix-leak 应为具名会话,得到 mode=%v", named.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAddressErrors(t *testing.T) {
|
||||
for _, in := range []string{"", " ", "@", "@ModelRouter.new"} {
|
||||
if _, err := ParseAddress(in); err == nil {
|
||||
t.Errorf("ParseAddress(%q) expected error, got nil", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAddressList(t *testing.T) {
|
||||
list, err := ParseAddressList("pi@root.new, deepseekharness@/program.upadtefeature;ops@prod")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(list) != 3 {
|
||||
t.Fatalf("got %d addresses, want 3", len(list))
|
||||
}
|
||||
if list[0].Name != "pi" || !list[0].IsNewSession() {
|
||||
t.Errorf("addr[0] = %+v", list[0])
|
||||
}
|
||||
if list[1].Path != "/program" || list[1].Session != "upadtefeature" {
|
||||
t.Errorf("addr[1] = %+v", list[1])
|
||||
}
|
||||
if list[2].Name != "ops" || list[2].Path != "prod" || list[2].Mode() != SessionDefault {
|
||||
t.Errorf("addr[2] = %+v", list[2])
|
||||
}
|
||||
|
||||
empty, err := ParseAddressList(" ")
|
||||
if err != nil || empty != nil {
|
||||
t.Errorf("empty list = %v, %v; want nil, nil", empty, err)
|
||||
}
|
||||
}
|
||||
176
server/internal/models/calendar.go
Normal file
176
server/internal/models/calendar.go
Normal file
@ -0,0 +1,176 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CalendarEvent 日历事件。
|
||||
//
|
||||
// 设计参照 Outlook:事件有时间、提醒、收件人,触发时产生一封邮件。
|
||||
// 事件本身是日历实体,提醒是触发器,邮件是投递通道 —— 三者分离。
|
||||
type CalendarEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ReminderText string `json:"reminder_text"`
|
||||
|
||||
// AgentName / ToAddress 是**单收件人时代的字段**,保留作兼容与兜底:
|
||||
// Recipients 为空时用它们。新代码一律读 EffectiveRecipients()。
|
||||
AgentName string `json:"agent_name"`
|
||||
ToAddress string `json:"to_address"`
|
||||
|
||||
// Recipients 是完整的收件人列表(每项是完整三维地址串)。
|
||||
//
|
||||
// 为什么不用 []Address 而用 []string:地址的三段语义(尤其 session 位的
|
||||
// new/别名三态)在**触发那一刻**才该被解析 —— 存结构化的话,
|
||||
// 「.new」这种一次性语义在建事件时就被固化,而重复事件每次触发都该
|
||||
// 重新决定落到哪条会话。存原始串让 ParseAddress 在投递时做这个决定。
|
||||
Recipients []string `json:"recipients"`
|
||||
|
||||
// DeliveryMode 决定多收件人怎么投:
|
||||
// "separate"(默认)—— 每人各发一封,落在各自的会话里,互相看不到
|
||||
// "together" —— 第一个是主收件人,其余进 cc_list,共享同一条线索
|
||||
//
|
||||
// 两种语义都需要而不是二选一:「让三个 Agent 各自独立汇报」与
|
||||
// 「让 pi 主办、dsh 知情」是完全不同的任务形态,用错会让协作失败 ——
|
||||
// 前者用 together 会让三个 Agent 互相看到对方的回复而趋同,
|
||||
// 后者用 separate 会让 dsh 完全不知道 pi 在做什么。
|
||||
DeliveryMode string `json:"delivery_mode"`
|
||||
|
||||
EventTime time.Time `json:"event_time"`
|
||||
RemindBefore int `json:"remind_before"` // 提前多少分钟
|
||||
|
||||
// Recurrence:公历 none/daily/weekly/monthly/yearly + 农历两种
|
||||
// lunar_monthly —— 每农历月同一日(如每月十五)
|
||||
// lunar_yearly —— 每农历年同月同日(过农历生日/祭日)
|
||||
//
|
||||
// lunar_daily 不存在:农历的「日」与公历同长,那就是 daily。
|
||||
// lunar_weekly 也不存在:农历没有「周」这个单位。
|
||||
Recurrence string `json:"recurrence"`
|
||||
RecurrenceEnd *time.Time `json:"recurrence_end,omitempty"`
|
||||
|
||||
Status string `json:"status"` // active/paused/cancelled
|
||||
LastFiredAt *time.Time `json:"last_fired_at,omitempty"`
|
||||
|
||||
// PermissionMode 是事件触发时新建会话应采用的档位(plan / workspace / full)。
|
||||
// 空 = workspace(默认)。
|
||||
// 复用已有会话时不能直接搬用:要 ModeAtMost(会话现档, 事件档) ——
|
||||
// 事件档表示「这件事允许到什么程度」,而会话现档可能更严(plan 档派出的
|
||||
// 任务不该因为日程触发就偷偷升到 workspace)。
|
||||
PermissionMode string `json:"permission_mode"`
|
||||
|
||||
// FiredFor 是已触发的那个 occurrence(值 = 当时的 EventTime)。
|
||||
// 去重靠它与 EventTime 相等判断,不是拿 LastFiredAt 比大小 ——
|
||||
// DueEvents 有 60 秒 lookahead,后者在窗口内恒为真会导致每 tick 重发。
|
||||
FiredFor *time.Time `json:"fired_for,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
}
|
||||
|
||||
// 重复规则常量。农历规则单独一组:它们的推进要经过 internal/lunar,
|
||||
// 不能像公历那样 AddDate 固定天数(农历月 29~30 天、闰年 13 个月)。
|
||||
const (
|
||||
RecurNone = "none"
|
||||
RecurDaily = "daily"
|
||||
RecurWeekly = "weekly"
|
||||
RecurMonthly = "monthly"
|
||||
RecurYearly = "yearly"
|
||||
RecurLunarMonthly = "lunar_monthly"
|
||||
RecurLunarYearly = "lunar_yearly"
|
||||
)
|
||||
|
||||
// IsLunarRecurrence 判断一条重复规则是否按农历推进。
|
||||
func IsLunarRecurrence(r string) bool {
|
||||
return r == RecurLunarMonthly || r == RecurLunarYearly
|
||||
}
|
||||
|
||||
// 事件状态常量。
|
||||
//
|
||||
// 此前这三个值只以裸字符串形式散落在 handler、scheduler 与前端里,而更新端点
|
||||
// 把 `status` 原样写进库 —— 于是一个拼错的值(比如 "pause")会变成一个
|
||||
// **调度器不认识的状态**:DueEvents 只查 status='active',那条提醒于是静默失效。
|
||||
// 人以为自己只是暂停了它,实际上再也恢复不了(界面的下拉框里没有这个选项)。
|
||||
//
|
||||
// 提成常量后,handler.validEventStatus 能对着这一份清单校验。
|
||||
const (
|
||||
// EventActive 生效中:到点会触发提醒。
|
||||
EventActive = "active"
|
||||
// EventPaused 暂停:保留事件与重复规则,但不触发。
|
||||
EventPaused = "paused"
|
||||
// EventCancelled 已取消:保留历史记录,不再触发也不再推进重复。
|
||||
EventCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// ValidEventStatus 判断状态取值是否合法。
|
||||
func ValidEventStatus(s string) bool {
|
||||
switch s {
|
||||
case EventActive, EventPaused, EventCancelled:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 投递模式常量。
|
||||
const (
|
||||
DeliverSeparate = "separate"
|
||||
DeliverTogether = "together"
|
||||
)
|
||||
|
||||
// EffectiveRecipients 返回真正要投的收件人列表。
|
||||
//
|
||||
// Recipients 优先;为空时退回 ToAddress,再退回 AgentName。
|
||||
// 这个兜底链让旧数据(只有 agent_name 的事件)继续工作 ——
|
||||
// 历史事件不迁移,读的时候归一化。
|
||||
func (e *CalendarEvent) EffectiveRecipients() []string {
|
||||
if len(e.Recipients) > 0 {
|
||||
out := make([]string, 0, len(e.Recipients))
|
||||
for _, r := range e.Recipients {
|
||||
if r = strings.TrimSpace(r); r != "" {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
if a := strings.TrimSpace(e.ToAddress); a != "" {
|
||||
return []string{a}
|
||||
}
|
||||
if a := strings.TrimSpace(e.AgentName); a != "" {
|
||||
return []string{a}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EffectiveDeliveryMode 归一化投递模式,未知值按 separate 处理。
|
||||
//
|
||||
// 默认 separate 而不是 together:separate 的失败是「Agent 各干各的」,
|
||||
// together 的失败是「本该独立的 Agent 互相污染了上下文」——
|
||||
// 后者更难发现也更难挽回。
|
||||
func (e *CalendarEvent) EffectiveDeliveryMode() string {
|
||||
if e.DeliveryMode == DeliverTogether {
|
||||
return DeliverTogether
|
||||
}
|
||||
return DeliverSeparate
|
||||
}
|
||||
|
||||
// CalendarAttachment 事件附件。
|
||||
type CalendarAttachment struct {
|
||||
AttachmentID string `json:"attachment_id"`
|
||||
EventID string `json:"event_id"`
|
||||
Filename string `json:"filename"`
|
||||
SHA256 string `json:"sha256"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CalendarView 日历视图(月/周/日)。
|
||||
type CalendarView struct {
|
||||
Events []CalendarEvent `json:"events"`
|
||||
// 当前时间线上的事件数(用于统计徽章)
|
||||
UpcomingCount int `json:"upcoming_count"`
|
||||
TodayCount int `json:"today_count"`
|
||||
}
|
||||
309
server/internal/models/models.go
Normal file
309
server/internal/models/models.go
Normal file
@ -0,0 +1,309 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Agent 代表一个已注册的 Agent 实例
|
||||
type Agent struct {
|
||||
ID uuid.UUID `json:"agent_id"`
|
||||
Name string `json:"agent_name"`
|
||||
Secret string `json:"-"`
|
||||
HostURL string `json:"host_url"`
|
||||
Workspaces []Workspace `json:"workspaces"`
|
||||
Platform string `json:"platform"`
|
||||
Status string `json:"status"`
|
||||
// DefaultRounds 是派给该 Agent 的新任务默认多少个来回(0 = 不限)。
|
||||
// 真正的额度在每条会话上(sessions.max_rounds),这里只是默认值。
|
||||
DefaultRounds int `json:"default_rounds"`
|
||||
// UsedRounds 是累计发信数,纯统计,不拦请求。
|
||||
// 它原本是「终身额度」——那种额度跑满要人工重置才能再干活,
|
||||
// 而 Agent 是长期在线的,所以已降级为观测数据。
|
||||
UsedRounds int `json:"used_rounds"`
|
||||
LastSeen *time.Time `json:"last_seen"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// ModeEnforcement 是该平台插件自报的权限档位强制能力(native / advisory),
|
||||
// 随心跳上报(与模型目录同一条通道,见 I-1:平台自己说的才算)。
|
||||
//
|
||||
// 为什么要存:发件人在派活前得知道 plan 档在对方那儿到底算不算。
|
||||
// homeagent 的核心没有工具调用拦截点,档位只能写进提示词 ——
|
||||
// 把这个事实藏起来比做不到本身更危险。
|
||||
ModeEnforcement string `json:"mode_enforcement"`
|
||||
}
|
||||
|
||||
// Workspace 是 Agent 管理的项目工作区
|
||||
type Workspace struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// Session 是有明确边界的任务会话
|
||||
type Session struct {
|
||||
ID uuid.UUID `json:"session_id"`
|
||||
Alias *string `json:"session_alias"`
|
||||
FromAgent string `json:"from_agent"`
|
||||
Subject string `json:"subject"`
|
||||
Status string `json:"status"`
|
||||
OwnerUserID *uuid.UUID `json:"owner_user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
MailCount int `json:"mail_count,omitempty"`
|
||||
|
||||
// RenameDismissed 是用户驳回过的改名提议。
|
||||
// 记下来才能让提示条不再反复弹同一个建议。
|
||||
RenameDismissed string `json:"rename_dismissed,omitempty"`
|
||||
|
||||
// AliasSource 记录别名是谁定的:
|
||||
// platform = Agent 平台自动同步来的,后续同步可以覆盖
|
||||
// manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖
|
||||
// 没有这个区分,平台下一次 session.updated 会把人刚定的名字冲掉。
|
||||
AliasSource string `json:"alias_source,omitempty"`
|
||||
|
||||
// MaxRounds/UsedRounds 是本任务的往返预算(0 = 本会话不限)。
|
||||
// 配额的语义是「这件事值得多少个来回」,那是任务的属性而非 Agent 的属性,
|
||||
// 所以在写信时给、在对话页里随时调。
|
||||
MaxRounds int `json:"max_rounds"`
|
||||
UsedRounds int `json:"used_rounds"`
|
||||
|
||||
// PermissionMode 声明本任务允许 Agent 动手到什么程度:
|
||||
// plan / workspace / full。空值按 DefaultPermissionMode 处理。
|
||||
PermissionMode string `json:"permission_mode"`
|
||||
|
||||
// PermissionEnforcement 记录接收平台是否真正强制了权限档位:
|
||||
// native / advisory。它描述执行事实,不与 PermissionMode 混为一谈。
|
||||
PermissionEnforcement string `json:"permission_enforcement"`
|
||||
}
|
||||
|
||||
// User 是人类用户(多用户账号体系)
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
PasswordHash string `json:"-"`
|
||||
Role string `json:"role"` // admin / user
|
||||
Status string `json:"status"` // active / disabled
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastLogin *time.Time `json:"last_login"`
|
||||
|
||||
// 权限边界:空切片 = 不限
|
||||
AllowedAgents []string `json:"allowed_agents"`
|
||||
AllowedPaths []string `json:"allowed_paths"`
|
||||
}
|
||||
|
||||
// IsAdmin 判断是否管理员
|
||||
func (u User) IsAdmin() bool { return u.Role == "admin" }
|
||||
|
||||
// CanUseAgent 判断用户是否可向指定 Agent 发信
|
||||
// 空白名单(或为空) = 不限;管理员不受限;收件方是人类用户时不走此限制
|
||||
func (u User) CanUseAgent(agentName string) bool {
|
||||
if u.IsAdmin() || len(u.AllowedAgents) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, a := range u.AllowedAgents {
|
||||
if a == agentName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CanUsePath 判断用户是否可访问指定工作区。
|
||||
// 空白名单 = 不限;管理员不受限;空 path(人类地址)总是允许。
|
||||
// 匹配规则:完全相等,或白名单项作为目录前缀(/program 允许 /program/sub)。
|
||||
func (u User) CanUsePath(path string) bool {
|
||||
if u.IsAdmin() || len(u.AllowedPaths) == 0 || path == "" {
|
||||
return true
|
||||
}
|
||||
for _, p := range u.AllowedPaths {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if path == p {
|
||||
return true
|
||||
}
|
||||
prefix := p
|
||||
if !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Mail 是会话中的一封邮件
|
||||
type Mail struct {
|
||||
ID uuid.UUID `json:"mail_id"`
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
ParentMailID *uuid.UUID `json:"parent_mail_id"`
|
||||
FromName string `json:"from_name"`
|
||||
FromWorkspace string `json:"from_workspace"`
|
||||
ToName string `json:"to_name"`
|
||||
ToWorkspace string `json:"to_workspace"`
|
||||
CCList []Address `json:"cc_list"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
MailType string `json:"mail_type"`
|
||||
PermOptions []string `json:"permission_options,omitempty"`
|
||||
PermResult string `json:"permission_result,omitempty"`
|
||||
PermissionKind string `json:"permission_kind,omitempty"`
|
||||
PermissionMulti bool `json:"permission_multi_select,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
HopLimit int `json:"hop_limit"`
|
||||
|
||||
SessionAlias string `json:"session_alias,omitempty"`
|
||||
|
||||
// SessionWorkspace 是**这条会话**的工作目录(sessions.workspace)。
|
||||
//
|
||||
// 为什么不能用 FromWorkspace / ToWorkspace 代替:
|
||||
// - 人 → Agent:to_workspace 是真路径,from_workspace 为空(人没有工作目录)
|
||||
// - Agent → 人:to_workspace 为空,而 **from_workspace 存的是 Agent 名
|
||||
// 而不是路径**(历史遗留,见 db/migrate.go 的 sessions.workspace 注释)
|
||||
//
|
||||
// 于是「Agent 发来的这封信,那个 Agent 在哪个目录干活」只能从会话上取 ——
|
||||
// 前端要靠它拼出 `name@path.alias` 这个可投递地址(界面上曾显示成
|
||||
// `dsh@dsh`,就是拿 from_workspace 当路径拼出来的)。
|
||||
SessionWorkspace string `json:"session_workspace,omitempty"`
|
||||
|
||||
BodyPreview string `json:"body_preview,omitempty"`
|
||||
|
||||
// Attachments 仅在读取单封邮件/会话线程时填充;列表接口为省带宽留空
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
|
||||
// RenameAlias / RenameReason 是 Agent 在本封正文里提议的新会话别名。
|
||||
// 存在邮件上而非会话上:邮件是不可篡改的历史记录,
|
||||
// 「谁在哪一封里提了什么」应当留痕。
|
||||
RenameAlias string `json:"rename_alias,omitempty"`
|
||||
RenameReason string `json:"rename_reason,omitempty"`
|
||||
|
||||
// FromHuman 表示发件方是人类用户而不是 Agent。
|
||||
//
|
||||
// 插件靠它判「要不要自动转发本轮结论」:Agent 之间不自动回,
|
||||
// 否则两边都以为对方的插件会代它开口,持续互相唤醒(生产实测 6 轮)。
|
||||
//
|
||||
// **补拉路径必须有它**:SSE 事件里叫 `from_human`,而插件重启后走
|
||||
// `GET /mail/inbox` 补投 —— 那条路径上没有这个字段的话,补投的邮件会被
|
||||
// 保守当成 Agent 来信,于是人发的那封失去自动回信。
|
||||
FromHuman bool `json:"from_human"`
|
||||
|
||||
// ToHuman 表示收件方是人类用户而不是 Agent(判据与 FromHuman 同源:
|
||||
// to_name 是否存在于 users 表)。
|
||||
//
|
||||
// 前端拼地址时靠它决定「要不要带 path 与会话位」:人只写名字,
|
||||
// Agent 才拼 `name@path.session`。此前靠 `to_workspace` 是否为空的启发式 ——
|
||||
// 但对 Agent 而言 to_workspace 存的是 Agent 名而不是路径(历史遗留),
|
||||
// 那条启发式在「Agent 名恰好为空」时会猜错。显式布尔胜过猜。
|
||||
ToHuman bool `json:"to_human"`
|
||||
|
||||
// PermissionMode / PermissionEnforcement 是所属会话的权限档位与实际强制力。
|
||||
//
|
||||
// **补拉路径必须有它们**(与 FromHuman 同一个理由):SSE 事件里叫
|
||||
// `permission_mode` / `permission_enforcement`,而插件重启后走
|
||||
// `GET /mail/inbox` 补投 —— 那条路径上没有这两个字段的话,补投的邮件
|
||||
// 会拿不到档位,插件只能回落默认档 —— 于是一条 plan 档的任务在重启后
|
||||
// 惄惄变成了 workspace 档。
|
||||
PermissionMode string `json:"permission_mode"`
|
||||
PermissionEnforcement string `json:"permission_enforcement"`
|
||||
}
|
||||
|
||||
// PermissionRequest 是 Agent 向人类发起的权限请求
|
||||
type PermissionRequest struct {
|
||||
ID uuid.UUID `json:"request_id"`
|
||||
MailID uuid.UUID `json:"mail_id"`
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
AgentName string `json:"agent_name"`
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options"`
|
||||
Context string `json:"context"`
|
||||
Kind string `json:"kind"` // permission | question
|
||||
MultiSelect bool `json:"multi_select"` // 仅 question 使用
|
||||
Result *string `json:"result"`
|
||||
DecidedAt *time.Time `json:"decided_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SSE 事件类型
|
||||
const (
|
||||
EventNewMail = "new_mail"
|
||||
EventPermissionDecision = "permission_decision"
|
||||
EventSessionUpdate = "session_update"
|
||||
EventAgentOnline = "agent_online"
|
||||
)
|
||||
|
||||
// ---------- 密钥认证 ----------
|
||||
|
||||
// 密钥类型:签发时决定其生命周期
|
||||
const (
|
||||
// KeyPermanent 永不过期,可重复使用(正式部署的 Agent 用这个)
|
||||
KeyPermanent = "permanent"
|
||||
// KeyOneTime 首次验证后即失效(用于把 Agent 首次接入的窗口压到最小)
|
||||
KeyOneTime = "one_time"
|
||||
// KeyTimed 到 ExpiresAt 之后失效
|
||||
KeyTimed = "timed"
|
||||
)
|
||||
|
||||
// ValidKeyType 判断密钥类型是否受支持
|
||||
func ValidKeyType(t string) bool {
|
||||
return t == KeyPermanent || t == KeyOneTime || t == KeyTimed
|
||||
}
|
||||
|
||||
// AgentKey 是管理员签发的 Agent 接入密钥。
|
||||
// AgentName 为空表示「待绑定」——密钥有效但还没指定属于哪个 Agent,
|
||||
// 首次注册时由注册请求里的 name 落定。
|
||||
type AgentKey struct {
|
||||
ID uuid.UUID `json:"key_id"`
|
||||
Token string `json:"key_token,omitempty"` // 仅创建时回显一次
|
||||
TokenHint string `json:"token_hint"` // 前 8 位 + 省略号,用于列表展示
|
||||
AgentName *string `json:"agent_name"`
|
||||
KeyType string `json:"key_type"`
|
||||
Label string `json:"label"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
UsedAt *time.Time `json:"used_at"`
|
||||
CreatedBy *uuid.UUID `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// UserKey 是用户自助签发的客户端连接密钥,只能用于 /me/* 人类邮箱接口。
|
||||
type UserKey struct {
|
||||
ID uuid.UUID `json:"key_id"`
|
||||
Token string `json:"key_token,omitempty"` // 仅创建时回显一次
|
||||
TokenHint string `json:"token_hint"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Label string `json:"label"`
|
||||
KeyType string `json:"key_type"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
UsedAt *time.Time `json:"used_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TokenHint 返回密钥的展示形式:只露前 8 位。
|
||||
// 密钥全文仅在创建响应里出现一次,之后任何列表接口都只给 hint。
|
||||
func TokenHint(token string) string {
|
||||
if len(token) <= 8 {
|
||||
return token
|
||||
}
|
||||
return token[:8] + "…"
|
||||
}
|
||||
|
||||
// ---------- 附件 ----------
|
||||
|
||||
// Attachment 是一封邮件的附件元数据。文件内容存磁盘,按 sha256 内容寻址。
|
||||
//
|
||||
// MailID 为空表示「已上传、尚未挂到邮件上」:上传与发信是两步操作
|
||||
// (Agent 侧工具走 JSON,无法在发信请求里带 multipart),中间态必须允许存在。
|
||||
type Attachment struct {
|
||||
ID uuid.UUID `json:"attachment_id"`
|
||||
MailID *uuid.UUID `json:"mail_id"`
|
||||
Uploader string `json:"uploader"`
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
142
server/internal/models/permission_mode.go
Normal file
142
server/internal/models/permission_mode.go
Normal file
@ -0,0 +1,142 @@
|
||||
package models
|
||||
|
||||
// ─── 权限档位 ───
|
||||
//
|
||||
// 三档描述「这条任务允许 Agent 动手到什么程度」。**AgentMail 声明,平台执行,
|
||||
// 插件只做翻译** —— 不能让插件按工具名自己猜着拦,那会同时违反 I-1(平台原生
|
||||
// 信号是唯一真相来源)与 I-4(插件只搬运不决策),而且四个插件对「workspace
|
||||
// 到底管什么」必然各猜一套。
|
||||
//
|
||||
// 档位与 DSH 原生的三档沙箱一一对应(read-only / workspace-write /
|
||||
// danger-full-access,见 @deepseek-ai/dsh-sandbox-policy)—— 那不是巧合,
|
||||
// 是同一个问题的同一个答案。
|
||||
const (
|
||||
// ModePlan 只读:查资料、读代码、出方案,一个字都不许写。
|
||||
//
|
||||
// 危险操作**直接拒绝**,不产生权限邮件 —— plan 档的语义就是「这轮不动手」,
|
||||
// 没什么可问人的。模型该做的是把方案写在回信里。
|
||||
ModePlan = "plan"
|
||||
|
||||
// ModeWorkspace 本目录内可动手,越界要问人。默认档。
|
||||
//
|
||||
// 「本目录」= 会话的 workspace(三维地址的 path 位)。越界的定义是
|
||||
// 写到那个目录之外,或跑一条无法判定影响范围的命令。
|
||||
ModeWorkspace = "workspace"
|
||||
|
||||
// ModeFull 自动放行,不问人。
|
||||
//
|
||||
// 不产生权限邮件:既然已经声明了全权,再问一遍只是噪音。
|
||||
ModeFull = "full"
|
||||
)
|
||||
|
||||
// DefaultPermissionMode 是没有显式指定时的档位。
|
||||
//
|
||||
// 选 workspace 而不是 full:默认值应当是「多数任务够用且出错代价可控」的那一档。
|
||||
// 一个默认全权的系统里,「我忘了收紧」与「我确实需要全权」在数据上无法区分。
|
||||
const DefaultPermissionMode = ModeWorkspace
|
||||
|
||||
// PermissionModes 是全部合法档位,按宽松程度递增排列。
|
||||
//
|
||||
// 顺序有意义:ModeAtMost 靠它做「向更严取整」。
|
||||
var PermissionModes = []string{ModePlan, ModeWorkspace, ModeFull}
|
||||
|
||||
// ValidPermissionMode 判断是不是合法档位。
|
||||
func ValidPermissionMode(m string) bool {
|
||||
for _, v := range PermissionModes {
|
||||
if v == m {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NormalizePermissionMode 把外部输入收敛成合法档位。
|
||||
//
|
||||
// 空串 → 默认档;非法值 → 默认档(**不是** ModeFull)。
|
||||
// 拼错一个档位名不该换来比预期更大的权限。
|
||||
func NormalizePermissionMode(m string) string {
|
||||
if ValidPermissionMode(m) {
|
||||
return m
|
||||
}
|
||||
return DefaultPermissionMode
|
||||
}
|
||||
|
||||
// modeRank 是档位的宽松程度序号,越大越宽松。
|
||||
//
|
||||
// 只接已经归一化过的档位 —— 调用方负责先跑 NormalizePermissionMode。
|
||||
// 让它自己处理非法值会造出两套语义:曾经这里把未知值当 rank 0(plan),
|
||||
// 而 NormalizePermissionMode 把它归到 workspace,于是同一个脏值在不同函数里
|
||||
// 含义不同,ModeAtMost 也因此不可交换(单元测试当场抓到)。
|
||||
func modeRank(m string) int {
|
||||
for i, v := range PermissionModes {
|
||||
if v == m {
|
||||
return i
|
||||
}
|
||||
}
|
||||
// 归一化后不可能走到这里;防御性地返回默认档的序号。
|
||||
return modeRank(DefaultPermissionMode)
|
||||
}
|
||||
|
||||
// ModeAtMost 返回 a 与 b 里更严的那一档。
|
||||
//
|
||||
// 两个用途:
|
||||
// - 子会话继承:Agent 派活时子会话不得比父会话宽松(plan 档派不出 full 档子任务)
|
||||
// - 平台取整:平台表达不出精确档位时向更严的方向取整
|
||||
//
|
||||
// 为什么必须是同一个函数:这两处若各写一遍,早晚有一处会写成「取更宽松」。
|
||||
//
|
||||
// **先归一化再比较**:两个脏值都变成默认档,于是结果与参数顺序无关(可交换),
|
||||
// 也与 NormalizePermissionMode / ModeNeedsHuman 对同一个脏值的理解一致。
|
||||
func ModeAtMost(a, b string) string {
|
||||
na := NormalizePermissionMode(a)
|
||||
nb := NormalizePermissionMode(b)
|
||||
if modeRank(na) <= modeRank(nb) {
|
||||
return na
|
||||
}
|
||||
return nb
|
||||
}
|
||||
|
||||
// ModeNeedsHuman 这一档会不会产生权限邮件(即需不需要人来点头)。
|
||||
//
|
||||
// 只有 workspace 档需要人。这一点直接决定了「找不到人类时怎么办」:
|
||||
// plan 档当场拒绝、full 档自动放行,两者都不问人,所以**只有 workspace 档
|
||||
// 会走到「这条链上有没有人类」这个问题**,找不到就是 409。
|
||||
//
|
||||
// 这也是为什么 permission.go 里那段「退回第一个管理员」的兜底必须删掉:
|
||||
// 它让 409 分支永远不可达(实测:pi 给自己派活跑 bash,权限邮件发给了 jianf),
|
||||
// 而那段 409 的注释本身就在论证兜底是错的 —— 管理员对这条 Agent 链一无所知。
|
||||
func ModeNeedsHuman(m string) bool {
|
||||
return NormalizePermissionMode(m) == ModeWorkspace
|
||||
}
|
||||
|
||||
// ─── 强制力 ───
|
||||
//
|
||||
// 档位是「要求什么」,强制力是「平台实际做到了什么」。两者必须分开记录并且
|
||||
// 都对人可见(I-5:失败必须可见)—— 否则发件人以为 plan 档管住了 homeagent,
|
||||
// 而 homeagent 的核心根本没有工具调用拦截点。
|
||||
const (
|
||||
// EnforcementNative 平台有原生拦截点,档位被真正执行。
|
||||
EnforcementNative = "native"
|
||||
|
||||
// EnforcementAdvisory 平台没有拦截点,档位只写进提示词。
|
||||
//
|
||||
// 模型至少知道「这活只让你看不让你动」,但没有任何机制阻止它动手。
|
||||
// 这不是缺陷掩饰 —— 是把「做不到」如实标出来,让发件人自己决定要不要派。
|
||||
EnforcementAdvisory = "advisory"
|
||||
)
|
||||
|
||||
// ValidEnforcement 判断强制力取值是否合法。
|
||||
func ValidEnforcement(e string) bool {
|
||||
return e == EnforcementNative || e == EnforcementAdvisory
|
||||
}
|
||||
|
||||
// NormalizeEnforcement 收敛强制力取值。
|
||||
//
|
||||
// 空串或非法值 → advisory。**保守方向是 advisory 而不是 native**:
|
||||
// 没自报过的插件,我们不能替它宣称「档位在这里是被强制的」。
|
||||
func NormalizeEnforcement(e string) string {
|
||||
if ValidEnforcement(e) {
|
||||
return e
|
||||
}
|
||||
return EnforcementAdvisory
|
||||
}
|
||||
160
server/internal/models/permission_mode_test.go
Normal file
160
server/internal/models/permission_mode_test.go
Normal file
@ -0,0 +1,160 @@
|
||||
package models
|
||||
|
||||
// 权限档位的判据测试。
|
||||
//
|
||||
// 为什么值得单独一组测试:`ModeAtMost` 被两处调用(子会话继承 / 平台向更严取整),
|
||||
// 两处若各写一遍必有一处写成「取更宽松」。而 `NormalizePermissionMode` 的保守
|
||||
// 取向(非法值 → workspace 而非 full)是安全属性,拼错一个档位名不该换来更大权限。
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidPermissionMode(t *testing.T) {
|
||||
for _, m := range []string{ModePlan, ModeWorkspace, ModeFull} {
|
||||
if !ValidPermissionMode(m) {
|
||||
t.Fatalf("%q 应当合法", m)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{"", "PLAN", "readonly", "danger-full-access", "workspace-write"} {
|
||||
if ValidPermissionMode(m) {
|
||||
t.Fatalf("%q 不该合法", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 非法值必须落到 workspace,不能落到 full。
|
||||
// 拼错一个档位名换来全权是最不该有的失败方向。
|
||||
func TestNormalizePermissionMode_FailsClosed(t *testing.T) {
|
||||
for _, in := range []string{"", "full-access", "plan ", "FULL", "无", "workspace-write"} {
|
||||
got := NormalizePermissionMode(in)
|
||||
if got != DefaultPermissionMode {
|
||||
t.Fatalf("NormalizePermissionMode(%q) = %q,应当是默认档 %q", in, got, DefaultPermissionMode)
|
||||
}
|
||||
}
|
||||
if DefaultPermissionMode == ModeFull {
|
||||
t.Fatal("默认档不能是 full —— 「我忘了收紧」与「我确实需要全权」会无法区分")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePermissionMode_KeepsValid(t *testing.T) {
|
||||
for _, m := range []string{ModePlan, ModeWorkspace, ModeFull} {
|
||||
if got := NormalizePermissionMode(m); got != m {
|
||||
t.Fatalf("合法档位应原样返回:%q → %q", m, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ModeAtMost 取更严的一档 —— 子会话继承与平台取整共用这一个判据。
|
||||
func TestModeAtMost(t *testing.T) {
|
||||
cases := []struct{ a, b, want string }{
|
||||
{ModePlan, ModeFull, ModePlan},
|
||||
{ModeFull, ModePlan, ModePlan},
|
||||
{ModeWorkspace, ModeFull, ModeWorkspace},
|
||||
{ModeFull, ModeWorkspace, ModeWorkspace},
|
||||
{ModePlan, ModeWorkspace, ModePlan},
|
||||
{ModeWorkspace, ModePlan, ModePlan},
|
||||
{ModeFull, ModeFull, ModeFull},
|
||||
{ModePlan, ModePlan, ModePlan},
|
||||
{ModeWorkspace, ModeWorkspace, ModeWorkspace},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ModeAtMost(c.a, c.b); got != c.want {
|
||||
t.Fatalf("ModeAtMost(%q,%q) = %q,want %q", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 未知值归到默认档(workspace),而不是最严的 plan。
|
||||
//
|
||||
// 为什么不是 plan:脏数据的含义应该在整个包里只有一个 ——
|
||||
// NormalizePermissionMode / ModeNeedsHuman 都把它当默认档,ModeAtMost
|
||||
// 若单独把它当 plan,同一个脏值就有两种语义,且 ModeAtMost 不可交换
|
||||
// (单元测试当场抓到过)。一致比“局部更严”重要:默认档本身已经是安全的。
|
||||
func TestModeAtMost_UnknownFallsToDefault(t *testing.T) {
|
||||
if got := ModeAtMost("garbage", ModeFull); got != DefaultPermissionMode {
|
||||
t.Fatalf("未知档位应归默认档,得到 %q", got)
|
||||
}
|
||||
if got := ModeAtMost(ModeFull, "garbage"); got != DefaultPermissionMode {
|
||||
t.Fatalf("未知档位应归默认档,得到 %q", got)
|
||||
}
|
||||
// 脏值不得抬升权限:与 plan 相遇时仍然是 plan 胜出。
|
||||
if got := ModeAtMost("garbage", ModePlan); got != ModePlan {
|
||||
t.Fatalf("脏值不该把 plan 抬成更宽松的档,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ModeAtMost 必须可交换:两处调用点传参顺序不同,结果不能不同。
|
||||
func TestModeAtMost_Commutative(t *testing.T) {
|
||||
all := append([]string{"garbage", ""}, PermissionModes...)
|
||||
for _, a := range all {
|
||||
for _, b := range all {
|
||||
if ModeAtMost(a, b) != ModeAtMost(b, a) {
|
||||
t.Fatalf("ModeAtMost 不可交换:(%q,%q)=%q 但 (%q,%q)=%q",
|
||||
a, b, ModeAtMost(a, b), b, a, ModeAtMost(b, a))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只有 workspace 档需要人 —— 这一条直接决定「找不到人类时怎么办」。
|
||||
//
|
||||
// plan 档当场拒绝、full 档自动放行,两者都不问人,所以只有 workspace 档会
|
||||
// 走到「这条链上有没有人类」这个问题,找不到就是 409。permission.go 里那段
|
||||
// 「退回第一个管理员」的兜底正因此必须删掉:它让 409 分支永远不可达。
|
||||
func TestModeNeedsHuman(t *testing.T) {
|
||||
if ModeNeedsHuman(ModePlan) {
|
||||
t.Fatal("plan 档不该问人:语义就是这轮不动手,直接拒绝即可")
|
||||
}
|
||||
if !ModeNeedsHuman(ModeWorkspace) {
|
||||
t.Fatal("workspace 档必须问人:越界时需要人点头")
|
||||
}
|
||||
if ModeNeedsHuman(ModeFull) {
|
||||
t.Fatal("full 档不该问人:已声明全权,再问一遍只是噪音")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeNeedsHuman_NormalizesInput(t *testing.T) {
|
||||
// 脏数据走默认档(workspace)→ 需要人。宁可多问一次,不可静默放行。
|
||||
if !ModeNeedsHuman("garbage") {
|
||||
t.Fatal("认不出的档位应当按默认档处理,即需要人")
|
||||
}
|
||||
if !ModeNeedsHuman("") {
|
||||
t.Fatal("空档位应当按默认档处理,即需要人")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 强制力 ───
|
||||
|
||||
func TestValidEnforcement(t *testing.T) {
|
||||
if !ValidEnforcement(EnforcementNative) || !ValidEnforcement(EnforcementAdvisory) {
|
||||
t.Fatal("native / advisory 都应合法")
|
||||
}
|
||||
for _, e := range []string{"", "NATIVE", "none", "enforced"} {
|
||||
if ValidEnforcement(e) {
|
||||
t.Fatalf("%q 不该合法", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保守方向是 advisory:没自报过的插件,不能替它宣称档位在那里是被强制的。
|
||||
func TestNormalizeEnforcement_FailsClosed(t *testing.T) {
|
||||
for _, in := range []string{"", "garbage", "NATIVE", "native "} {
|
||||
if got := NormalizeEnforcement(in); got != EnforcementAdvisory {
|
||||
t.Fatalf("NormalizeEnforcement(%q) = %q,应当是 advisory", in, got)
|
||||
}
|
||||
}
|
||||
if got := NormalizeEnforcement(EnforcementNative); got != EnforcementNative {
|
||||
t.Fatalf("显式 native 应原样保留,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// PermissionModes 的顺序是 ModeAtMost 的依据,不能被随手改动。
|
||||
func TestPermissionModesOrder(t *testing.T) {
|
||||
if len(PermissionModes) != 3 {
|
||||
t.Fatalf("档位应当是三个,得到 %d 个", len(PermissionModes))
|
||||
}
|
||||
if PermissionModes[0] != ModePlan ||
|
||||
PermissionModes[1] != ModeWorkspace ||
|
||||
PermissionModes[2] != ModeFull {
|
||||
t.Fatalf("PermissionModes 必须按宽松程度递增排列(plan < workspace < full),得到 %v", PermissionModes)
|
||||
}
|
||||
}
|
||||
236
server/internal/notify/mail.go
Normal file
236
server/internal/notify/mail.go
Normal file
@ -0,0 +1,236 @@
|
||||
// Package notify 是「一封邮件落库之后要通知谁、推什么」的**唯一实现**。
|
||||
//
|
||||
// # 为什么单独成包
|
||||
//
|
||||
// 在此之前有两份几乎相同的推送代码:`handler.notifyRecipients`(人发信、
|
||||
// Agent 发信、转发都走它)和 `scheduler` 里日历提醒自己拼的那一份。
|
||||
//
|
||||
// 两份代码的代价在生产上兑现过一次,而且症状离原因很远:给 `new_mail` 加
|
||||
// `platform_session_id` 字段时只改了 handler 那份,调度器那份仍是旧的。
|
||||
// 于是日历提醒投进一条**接管会话**时,插件收不到 `platform_session_id`,
|
||||
// 把它当成新会话另开了一条平台会话;那条新会话的名字随后经命名同步回写,
|
||||
// **把接管会话的别名冲掉了** —— 人在补全里选中的「项目定位」变成了
|
||||
// 「日程提醒:…」,同一条会话因此在候选列表里出现两次,而另一条真实会话
|
||||
// 被按别名字符串去重吃掉了。
|
||||
//
|
||||
// 链条上每一环都不报错。根因只是「同一件事写了两遍」。
|
||||
//
|
||||
// 因此这个包对外只暴露一个入口:新增字段时不存在「另一处忘了改」的可能。
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/agentmail/gateway/internal/sse"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Mail 描述一封刚落库的邮件需要推给谁。
|
||||
type Mail struct {
|
||||
SessionID uuid.UUID
|
||||
MailID uuid.UUID
|
||||
// From 是发件方名字。人类用户名与 Agent 名共享命名空间,这里不区分。
|
||||
From string
|
||||
// To 是主收件方地址(三维寻址已解析)。
|
||||
To models.Address
|
||||
// CC 是抄送方地址列表。
|
||||
CC []models.Address
|
||||
// Subject 是邮件主题。
|
||||
Subject string
|
||||
// MailType 默认 "normal";权限请求等特殊类型由调用方指定。
|
||||
MailType string
|
||||
// Origin 标记这封信的来源,供插件与 UI 区分「定时提醒」与「有人在找它」。
|
||||
// 空串表示普通邮件。
|
||||
Origin string
|
||||
// ReplyToName 是「把回信发回这条会话」时该写的收件人名。
|
||||
//
|
||||
// 默认取 From。日历提醒必须覆盖它:发件人是 `calendar`,而那不是一个
|
||||
// 收得到信的账号 —— 回给它的信投不出去。此时应当填主收件方自己的名字,
|
||||
// 让模型把结果回报到同一条线索上。
|
||||
ReplyToName string
|
||||
// ParentMailID 非空表示这封是**回信**(回的那封的 mail_id)。
|
||||
//
|
||||
// 插件靠它区分「有人派了新活」与「我上一封信的回复到了」—— 两者对模型而言
|
||||
// 是完全不同的处境,而在此之前 payload 里没有任何信号能分开它们。
|
||||
//
|
||||
// 后果在生产上兜现过:pi 转发给 dsh,dsh 回确认,pi 把那封确认当成新任务
|
||||
// 又回一封,两边互相客套 6 轮直到撞上 hop 上限。
|
||||
ParentMailID string
|
||||
}
|
||||
|
||||
// Recipients 把一封邮件推给主收件人、所有抄送方,并刷新发件方的会话列表。
|
||||
//
|
||||
// # 每个收件方拿到的是**自己那个地址**
|
||||
//
|
||||
// 三维地址 `name@path.session` 的 path 就是工作目录,插件靠它建会话。
|
||||
// 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个不同的工作区,共用一份
|
||||
// payload 会让抄送方在别人的目录里开会话。`reply_address` / `self_address`
|
||||
// 同理,且 session 位已经把 `.new` 换成真实别名 —— `.new` 建完会话就失效了,
|
||||
// 把原文那个 `x@/p.new` 送给参与方只会让它下一次又建一条新会话。
|
||||
//
|
||||
// # 抄送方必须单独推
|
||||
//
|
||||
// 漏掉的后果很隐蔽:邮件的 cc_list 里有他们、他们**查**收件箱能看到这封信,
|
||||
// 但没有任何事件推给他们 —— 插件不会唤起会话,Agent 直到下一次补拉
|
||||
// (重启时)才发现。对「知情方」而言等于没通知。
|
||||
func Recipients(ctx context.Context, m Mail) {
|
||||
// 别名此时应已由会话解析路径保证存在(`.new` 与默认会话都过
|
||||
// EnsureSessionAlias)。仍可能为空的情形:命名写入失败(已吐日志)。
|
||||
// 此时退回省略 session 位,而不是把 "new" 写进去 —— 后者会让参与方
|
||||
// 反复建新会话。
|
||||
alias := repo.SessionAliasOf(ctx, m.SessionID)
|
||||
|
||||
// 这条会话是否接管了一条平台侧已存在的会话(人在 TUI/GUI 里开的那种),
|
||||
// 以及那条平台会话属于哪个 Agent。插件据此决定 resume 还是新建;
|
||||
// 空串就是过去的行为。
|
||||
//
|
||||
// **owner 必须参与分发判据**:platform_id 是会话级的一个值,而一封邮件
|
||||
// 可以有多个参与方。无差别下发会让抄送方拿一个属于别的平台的会话 id
|
||||
// 去自己磁盘上找文件,找不到就抛「平台侧会话已删」—— 邮件静默消失。
|
||||
// 生产实测过:pi 的会话 `01a05a5e-…` 被推给了抄送方 dsh。
|
||||
platformID, platformOwner := repo.PlatformSessionFor(ctx, m.SessionID)
|
||||
|
||||
mailType := m.MailType
|
||||
if mailType == "" {
|
||||
mailType = "normal"
|
||||
}
|
||||
replyTo := m.ReplyToName
|
||||
if replyTo == "" {
|
||||
replyTo = m.From
|
||||
}
|
||||
|
||||
// platformFor 只把 platform_session_id 给归属方。
|
||||
//
|
||||
// owner 为空(镜像里没这条、sessions.from_agent 也空)时一律不下发:
|
||||
// 宁可退回「当普通会话处理」(插件新建一条,人在界面上看不到),
|
||||
// 也不能让一个抽不到归属的 id 把邮件弄丢。
|
||||
platformFor := func(forName string) string {
|
||||
if platformID == "" || platformOwner == "" || forName != platformOwner {
|
||||
return ""
|
||||
}
|
||||
return platformID
|
||||
}
|
||||
|
||||
// 发件方是人还是 Agent。
|
||||
//
|
||||
// 插件靠它选提示词里那句关键的话:**「插件会自动把你本轮结论发回去」只对
|
||||
// 人类发件方成立**。发给另一个 Agent 时,那边的插件也会自动回一封,
|
||||
// 于是两个模型都以为「我只要把话说完就行」,实际上彼此持续唤醒 ——
|
||||
// 生产实测 pi 与 dsh 互相客套 6 轮直到撞上 hop 上限。
|
||||
fromHuman, _ := repo.IsHumanUser(ctx, m.From)
|
||||
|
||||
// 会话级权限档位与强制力。
|
||||
//
|
||||
// 为什么跑在 payload 外:一封邮件可能推给十几个参与方(收件人 + 拄送),
|
||||
// 而档位是**会话**的属性,每个人都一样 —— 放进闭包里就是每个参与方
|
||||
// 查一次库。platformFor 那个坑(会话级的值推给所有人)教过的是反面:
|
||||
// 会话级与参与方级的字段必须分清楚。档位确实是会话级的。
|
||||
perm, permErr := repo.GetSessionPermission(ctx, m.SessionID)
|
||||
if permErr != nil {
|
||||
// 查不到时给默认档 + advisory:不能因为一次查询失败就让插件以为自己拿到了全权。
|
||||
perm = repo.SessionPermission{
|
||||
Mode: models.DefaultPermissionMode,
|
||||
Enforcement: models.EnforcementAdvisory,
|
||||
}
|
||||
}
|
||||
|
||||
payload := func(role, workspace, forName string) map[string]interface{} {
|
||||
p := map[string]interface{}{
|
||||
"mail_id": m.MailID.String(),
|
||||
"session_id": m.SessionID.String(),
|
||||
"from_name": m.From,
|
||||
"subject": m.Subject,
|
||||
"mail_type": mailType,
|
||||
"role": role, // to / cc
|
||||
// to_workspace 是收件方地址的 path 位,即希望它在哪个工作目录干活。
|
||||
// 不带这一项的后果:插件只能自己拼一个临时目录,于是每封邮件都落在
|
||||
// 不同的空目录里,DSH / opencode 按 cwd 分组时全进「未分组」。
|
||||
"to_workspace": workspace,
|
||||
// session_alias 是这条会话今后的寻址名。没有它的话,收到 `.new`
|
||||
// 邮件的一方只持有一个 send_mail 不接受的 session_id。
|
||||
"session_alias": alias,
|
||||
// reply_address 是「把回信发回这条会话」的现成地址。
|
||||
// 插件不必自己拼(拼错了就是静默开新会话)。
|
||||
"reply_address": models.FormatAddress(replyTo, "", alias),
|
||||
// self_address 是对方应当用来称呼自己的地址,供转发/报告时引用。
|
||||
"self_address": models.FormatAddress(forName, workspace, alias),
|
||||
// platform_session_id 非空时,这封邮件要投进**平台侧已经存在的
|
||||
// 那条会话**(TUI 与邮箱是同一个 Agent 的两个入口)。
|
||||
//
|
||||
// 插件必须 resume 而不是新建:新建会让人在 TUI 里看不到这封邮件
|
||||
// 带来的对话,而那正是接管这条会话的目的。
|
||||
//
|
||||
// **只发给归属方**:其余参与方拿到它只会去自己磁盘上找一个
|
||||
// 不存在的会话文件,然后按 N-8 报错丢掉这封邮件。
|
||||
"platform_session_id": platformFor(forName),
|
||||
// in_reply_to 非空 = 这封是对收件方某封信的**回复**,不是新派的活。
|
||||
//
|
||||
// 插件据此换一套提示词:把它当新任务会让模型又“处理”一遍并再回一封,
|
||||
// 于是两个 Agent 互相客套直到撞上 hop 上限(生产实测 6 轮)。
|
||||
"in_reply_to": m.ParentMailID,
|
||||
// from_human 区分「人在找你」与「另一个 Agent 在找你」。
|
||||
//
|
||||
// 插件据此不再对 Agent → Agent 的信说「回信不用你自己发」:
|
||||
// 那句话在那种情形下是假的,而它让模型以为自己只需要「把话说完」。
|
||||
"from_human": fromHuman,
|
||||
// permission_mode 声明本任务允许动手到什么程度:plan / workspace / full。
|
||||
//
|
||||
// 插件必须把它**翻译成平台原生的沙箱/审批配置**,而不是自己按工具名猜着拦:
|
||||
// 那会同时违反 I-1(平台原生信号是唯一真相来源)与 I-4(插件只搬运不决策),
|
||||
// 而且四个插件对「workspace 到底管什么」必然各猜一套。
|
||||
"permission_mode": perm.Mode,
|
||||
// permission_enforcement 是建会话时快照的**事实**:native / advisory。
|
||||
//
|
||||
// 与 permission_mode 成对下发:前者是要求,后者是对方平台实际做得到。
|
||||
// 只给前者会让人以为 plan 档把 homeagent 管住了 —— 它的核心没有
|
||||
// 工具调用拦截点,档位在那里只能写进提示词。
|
||||
"permission_enforcement": perm.Enforcement,
|
||||
}
|
||||
if m.Origin != "" {
|
||||
p["origin"] = m.Origin
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
update := map[string]interface{}{
|
||||
"session_id": m.SessionID.String(),
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
// 参与方去重:收件人 + 所有抄送 + 发件方自己(刷新他的发件箱)
|
||||
seen := map[string]bool{}
|
||||
|
||||
sse.Default.SendToRecipient(m.To.Name, "new_mail", payload("to", m.To.Path, m.To.Name))
|
||||
sse.Default.SendToRecipient(m.To.Name, "session_update", update)
|
||||
seen[m.To.Name] = true
|
||||
|
||||
for _, c := range m.CC {
|
||||
if seen[c.Name] {
|
||||
continue
|
||||
}
|
||||
seen[c.Name] = true
|
||||
sse.Default.SendToRecipient(c.Name, "new_mail", payload("cc", c.Path, c.Name))
|
||||
sse.Default.SendToRecipient(c.Name, "session_update", update)
|
||||
}
|
||||
|
||||
if !seen[m.From] {
|
||||
sse.Default.SendToRecipient(m.From, "session_update", update)
|
||||
}
|
||||
}
|
||||
|
||||
// SessionActive 只刷新某一方的会话列表,不推 new_mail。
|
||||
//
|
||||
// 用于「日历事件的创建者该知道提醒发出去了」这类场景:他不是收件方,
|
||||
// 不该收到一封信的通知,但需要看到那条会话活跃起来 —— 否则
|
||||
// 「我设的提醒到底触发了没有」只能去翻 journalctl。
|
||||
func SessionActive(name string, sessionID uuid.UUID) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
sse.Default.SendToRecipient(name, "session_update", map[string]interface{}{
|
||||
"session_id": sessionID.String(),
|
||||
"status": "active",
|
||||
})
|
||||
}
|
||||
171
server/internal/notify/notify_test.go
Normal file
171
server/internal/notify/notify_test.go
Normal file
@ -0,0 +1,171 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/agentmail/gateway/internal/sse"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := db.Connect(context.Background(), filepath.Join(dir, "test.db")); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
if err := db.Migrate(context.Background()); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
func seedAgent(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
if _, err := db.DB.ExecContext(context.Background(),
|
||||
`INSERT INTO agents (agent_name, secret, platform, status) VALUES ($1, 'x', $1, 'online')`,
|
||||
name); err != nil {
|
||||
t.Fatalf("seed agent %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// attach 挂一个真实的 SSE 客户端并返回「读出这个 Agent 收到的 new_mail payload」的闭包。
|
||||
//
|
||||
// 走真实的 sse.Default 而不是替换发送函数:要验的正是「谁收到什么」,
|
||||
// 而分发逻辑就在 Manager 里 —— 把它换掉等于不验。
|
||||
func attach(t *testing.T, agentName string) func() map[string]any {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/events/stream", nil)
|
||||
c := sse.Default.AddClient(rec, req, agentName, "")
|
||||
if c == nil {
|
||||
t.Fatalf("AddClient(%s) 返回 nil", agentName)
|
||||
}
|
||||
t.Cleanup(func() { sse.Default.RemoveClient(c.ID) })
|
||||
|
||||
return func() map[string]any {
|
||||
// SSE 帧形如 `id: N\nevent: new_mail\ndata: {…}\n\n`
|
||||
for _, frame := range strings.Split(rec.Body.String(), "\n\n") {
|
||||
if !strings.Contains(frame, "event: new_mail") {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(frame, "\n") {
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &m); err == nil {
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// seedAdopted 建一条接管了 owner 的平台会话的本侧会话。
|
||||
func seedAdopted(t *testing.T, owner, platformID, workspace string) uuid.UUID {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if err := repo.ReplacePlatformSessions(ctx, owner, []repo.PlatformSession{
|
||||
{PlatformID: platformID, Workspace: workspace, Slug: "项目定位", Title: "项目定位"},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReplacePlatformSessions: %v", err)
|
||||
}
|
||||
id, err := repo.AdoptPlatformSession(ctx, owner, platformID, "项目定位", workspace, "项目定位")
|
||||
if err != nil {
|
||||
t.Fatalf("AdoptPlatformSession: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// platform_session_id 只该发给归属方。
|
||||
//
|
||||
// 生产事故:会话接管了 pi 的 `01a05a5e-…`,而那封邮件抄送了 dsh。DSH 收到同一个
|
||||
// id,在 `~/.dsh/sessions/` 里查不到(那是 `/root/.pi/agent/sessions/` 下的文件),
|
||||
// 于是按 N-8 抛「平台侧会话已删」——邮件静默消失,日志里一个字都没有。
|
||||
func TestRecipients_PlatformIDOnlyToOwner(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedAgent(t, "pi")
|
||||
seedAgent(t, "dsh")
|
||||
readPi, readDsh := attach(t, "pi"), attach(t, "dsh")
|
||||
|
||||
sessionID := seedAdopted(t, "pi", "pid-pi-1", "/w")
|
||||
|
||||
Recipients(context.Background(), Mail{
|
||||
SessionID: sessionID,
|
||||
MailID: uuid.New(),
|
||||
From: "jianf",
|
||||
To: models.Address{Name: "pi", Path: "/w"},
|
||||
CC: []models.Address{{Name: "dsh", Path: "/w"}},
|
||||
Subject: "任务",
|
||||
})
|
||||
|
||||
pi, dsh := readPi(), readDsh()
|
||||
if pi == nil {
|
||||
t.Fatal("归属方 pi 没收到 new_mail")
|
||||
}
|
||||
if dsh == nil {
|
||||
t.Fatal("抄送方 dsh 没收到 new_mail(抄送方必须单独推)")
|
||||
}
|
||||
if v := pi["platform_session_id"]; v != "pid-pi-1" {
|
||||
t.Errorf("归属方 pi 的 platform_session_id = %v, want pid-pi-1", v)
|
||||
}
|
||||
if v := dsh["platform_session_id"]; v != "" {
|
||||
t.Errorf("抄送方 dsh 的 platform_session_id = %v, want 空串(那是 pi 的会话文件)", v)
|
||||
}
|
||||
}
|
||||
|
||||
// 归属方在抄送位上也要拿到:归属与收件角色无关。
|
||||
func TestRecipients_PlatformIDToOwnerEvenAsCC(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedAgent(t, "pi")
|
||||
seedAgent(t, "dsh")
|
||||
readPi, readDsh := attach(t, "pi"), attach(t, "dsh")
|
||||
|
||||
sessionID := seedAdopted(t, "pi", "pid-pi-2", "/w")
|
||||
|
||||
Recipients(context.Background(), Mail{
|
||||
SessionID: sessionID,
|
||||
MailID: uuid.New(),
|
||||
From: "jianf",
|
||||
To: models.Address{Name: "dsh", Path: "/w"},
|
||||
CC: []models.Address{{Name: "pi", Path: "/w"}},
|
||||
Subject: "任务",
|
||||
})
|
||||
|
||||
if v := readPi()["platform_session_id"]; v != "pid-pi-2" {
|
||||
t.Errorf("抄送位上的归属方 pi = %v, want pid-pi-2", v)
|
||||
}
|
||||
if v := readDsh()["platform_session_id"]; v != "" {
|
||||
t.Errorf("主收件人 dsh = %v, want 空串", v)
|
||||
}
|
||||
}
|
||||
|
||||
// 普通(非接管)会话:谁都不该拿到 platform id。
|
||||
func TestRecipients_PlainSessionNoPlatformID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedAgent(t, "pi")
|
||||
readPi := attach(t, "pi")
|
||||
|
||||
id, err := repo.CreateSession(context.Background(), nil, "pi", "普通", "/w")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
Recipients(context.Background(), Mail{
|
||||
SessionID: id, MailID: uuid.New(), From: "jianf",
|
||||
To: models.Address{Name: "pi", Path: "/w"}, Subject: "任务",
|
||||
})
|
||||
|
||||
if v := readPi()["platform_session_id"]; v != "" {
|
||||
t.Errorf("普通会话 = %v, want 空串", v)
|
||||
}
|
||||
}
|
||||
274
server/internal/repo/adopt_alias_test.go
Normal file
274
server/internal/repo/adopt_alias_test.go
Normal file
@ -0,0 +1,274 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
)
|
||||
|
||||
// ─── 接管会话的别名不可被平台命名同步覆盖 ───
|
||||
//
|
||||
// 锁的是一次生产事故的**第二环**(第一环是调度器漏 platform_session_id):
|
||||
//
|
||||
// 12:12 人选中补全里的「项目定位」→ 接管平台会话 01a05a5e,本侧建 26e26477
|
||||
// 12:20 日历提醒省略 session 位 → 落进 26e26477(第三环,见 TestDefaultSession...)
|
||||
// 12:20 插件收不到 platform_session_id → 另开一条 pi 会话
|
||||
// 12:20 那条新会话的名字经 /sessions/{id}/sync 回写
|
||||
// → SyncSessionAlias 把 26e26477 的别名冲成「日程提醒:…」
|
||||
//
|
||||
// 结果:人在补全里选的名字凭空消失,同一条会话在候选列表里出现两次
|
||||
// (一次用被冲掉的别名、一次用镜像里的原始 slug),而另一条真实会话被
|
||||
// 按别名字符串去重吃掉了。
|
||||
//
|
||||
// 接管会话的别名是**人从补全里选中的平台 slug**,任何平台命名同步都不该动它。
|
||||
func TestSyncSessionAliasNeverOverwritesAdoptedAlias(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, err := AdoptPlatformSession(ctx, "pi", "01a05a5e", "项目定位", "/home/program/agentmail", "标题")
|
||||
if err != nil {
|
||||
t.Fatalf("接管: %v", err)
|
||||
}
|
||||
if got := SessionAliasOf(ctx, id); got != "项目定位" {
|
||||
t.Fatalf("接管后别名 = %q,期望 项目定位", got)
|
||||
}
|
||||
|
||||
// 平台侧同步一个完全不同的名字(生产上就是日历提醒的主题)
|
||||
final, err := SyncSessionAlias(ctx, id, "日程提醒:小宅自测")
|
||||
if err != nil {
|
||||
t.Fatalf("SyncSessionAlias: %v", err)
|
||||
}
|
||||
if final != "项目定位" {
|
||||
t.Errorf("同步返回 %q —— 接管会话的别名不该被改", final)
|
||||
}
|
||||
if got := SessionAliasOf(ctx, id); got != "项目定位" {
|
||||
t.Errorf("库里别名变成了 %q —— 人在补全里选的名字被冲掉了", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 普通(非接管)会话仍然接受平台命名同步 —— 别名复用平台命名是既定决策,
|
||||
// 上面那道门不能把它一起关掉。
|
||||
func TestSyncSessionAliasStillWorksForNormalSession(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, err := CreateSession(ctx, nil, "pi", "邮件驱动的会话", "/tmp/ws")
|
||||
if err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
if _, err := EnsureSessionAlias(ctx, id, "pi-初始别名"); err != nil {
|
||||
t.Fatalf("EnsureSessionAlias: %v", err)
|
||||
}
|
||||
|
||||
final, err := SyncSessionAlias(ctx, id, "平台生成的名字")
|
||||
if err != nil {
|
||||
t.Fatalf("SyncSessionAlias: %v", err)
|
||||
}
|
||||
if final != "平台生成的名字" {
|
||||
t.Errorf("普通会话应当接受同步,得到 %q", final)
|
||||
}
|
||||
}
|
||||
|
||||
// 接管会话**没有**别名时(理论上不会发生,AdoptPlatformSession 一定给一个)
|
||||
// 仍然允许写入 —— 否则那条会话永远无法寻址。
|
||||
func TestSyncSessionAliasFillsEmptyAdoptedAlias(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, err := CreateSession(ctx, nil, "pi", "标题", "/tmp/ws")
|
||||
if err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
// 手工造出「有 platform_id 但无别名」的状态
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET platform_id = 'pid-x' WHERE session_id = $1`, id); err != nil {
|
||||
t.Fatalf("置 platform_id: %v", err)
|
||||
}
|
||||
|
||||
final, err := SyncSessionAlias(ctx, id, "补上一个名字")
|
||||
if err != nil {
|
||||
t.Fatalf("SyncSessionAlias: %v", err)
|
||||
}
|
||||
if final != "补上一个名字" {
|
||||
t.Errorf("无别名的接管会话应当允许写入,得到 %q", final)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 接管会话不是「默认会话」 ───
|
||||
//
|
||||
// 事故的**第三环**:日历提醒的收件地址省略 session 位(`homeagent` 而不是
|
||||
// `homeagent@/x.某会话`),走 FindOrCreateDefaultSession。它原来只按
|
||||
// 「参与过 + workspace 匹配 + 未归档」挑最近活跃的一条 —— 于是挑中了人
|
||||
// 刚刚显式指定的那条接管会话。
|
||||
//
|
||||
// 接管会话是人**点名**要谈的一条线索,不该被省略 session 位的邮件当默认会话。
|
||||
func TestFindOrCreateDefaultSessionSkipsAdoptedSessions(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
// 一条接管会话,且有邮件(满足 EXISTS 条件)
|
||||
adopted, err := AdoptPlatformSession(ctx, "pi", "pid-adopted", "人选的线索", "/home/program/agentmail", "标题")
|
||||
if err != nil {
|
||||
t.Fatalf("接管: %v", err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, adopted, nil, "jianf", "", "pi", "/home/program/agentmail",
|
||||
"人发的第一封", "正文", nil); err != nil {
|
||||
t.Fatalf("建邮件: %v", err)
|
||||
}
|
||||
|
||||
// 省略 session 位投递 → 不该落进那条接管会话
|
||||
got, err := FindOrCreateDefaultSession(ctx, "pi", "/home/program/agentmail", "calendar", "日程提醒")
|
||||
if err != nil {
|
||||
t.Fatalf("FindOrCreateDefaultSession: %v", err)
|
||||
}
|
||||
if got == adopted {
|
||||
t.Error("省略 session 位的邮件落进了接管会话 —— 那是人显式指定的线索")
|
||||
}
|
||||
|
||||
// 该新建一条,且它不带 platform_id
|
||||
if pid := PlatformIDOf(ctx, got); pid != "" {
|
||||
t.Errorf("新建的默认会话不该有 platform_id,得到 %q", pid)
|
||||
}
|
||||
}
|
||||
|
||||
// 普通会话仍然可以作为默认会话被复用 —— 上面那道门不能把它一起关掉,
|
||||
// 否则每封省略 session 位的邮件都会新开一条会话。
|
||||
func TestFindOrCreateDefaultSessionStillReusesNormalSession(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
first, err := FindOrCreateDefaultSession(ctx, "pi", "/tmp/ws", "jianf", "第一封")
|
||||
if err != nil {
|
||||
t.Fatalf("第一次: %v", err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, first, nil, "jianf", "", "pi", "/tmp/ws", "第一封", "正文", nil); err != nil {
|
||||
t.Fatalf("建邮件: %v", err)
|
||||
}
|
||||
|
||||
second, err := FindOrCreateDefaultSession(ctx, "pi", "/tmp/ws", "jianf", "第二封")
|
||||
if err != nil {
|
||||
t.Fatalf("第二次: %v", err)
|
||||
}
|
||||
if second != first {
|
||||
t.Error("普通默认会话应当被复用,否则每封省略 session 位的邮件都开新会话")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 候选列表按 platform_id 去重 ───
|
||||
//
|
||||
// 事故的**第四环**:`SuggestSessionCandidates` 的去重只比别名字符串。
|
||||
// 别名一被冲掉,同一条会话就在列表里出现两次:
|
||||
//
|
||||
// 候选 1 日程提醒:…(被冲掉的别名) source=mail ← 26e26477
|
||||
// 候选 2 项目定位(镜像里的原始 slug) source=platform ← 也是 26e26477
|
||||
//
|
||||
// 更糟的是**另一条真实会话被吃掉了**:它的 slug 恰好等于候选 1 那个
|
||||
// 被冲掉的别名,于是 `seen[slug]` 命中、被 continue 跳过。
|
||||
// 人在界面上看到两条,实际只有一条能选,而第三条不存在于列表里。
|
||||
func TestSuggestSessionCandidatesDedupesByPlatformID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedPlatformAgent(t, "pi")
|
||||
|
||||
// 接管一条平台会话
|
||||
adopted, err := AdoptPlatformSession(ctx, "pi", "01a05a5e", "项目定位", "/home/program/agentmail", "标题")
|
||||
if err != nil {
|
||||
t.Fatalf("接管: %v", err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, adopted, nil, "jianf", "", "pi", "/home/program/agentmail",
|
||||
"主题", "正文", nil); err != nil {
|
||||
t.Fatalf("建邮件: %v", err)
|
||||
}
|
||||
|
||||
// 镜像里同时有它与另一条真实会话
|
||||
now := time.Now()
|
||||
if err := ReplacePlatformSessions(ctx, "pi", []PlatformSession{
|
||||
{PlatformID: "01a05a5e", Workspace: "/home/program/agentmail", Slug: "项目定位",
|
||||
Title: "项目定位", MailDriven: true, UpdatedAt: &now},
|
||||
{PlatformID: "01a06aa5", Workspace: "/home/program/agentmail", Slug: "另一条真实会话",
|
||||
Title: "另一条", MailDriven: true, UpdatedAt: &now},
|
||||
}); err != nil {
|
||||
t.Fatalf("上报镜像: %v", err)
|
||||
}
|
||||
|
||||
got, err := SuggestSessionCandidates(ctx, "jianf", "pi", "/home/program/agentmail")
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestSessionCandidates: %v", err)
|
||||
}
|
||||
|
||||
// 应当恰好两条:接管那条(mail 来源)+ 另一条真实会话(platform 来源)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("应有 2 个候选,实际 %d:%+v", len(got), got)
|
||||
}
|
||||
|
||||
byAlias := map[string]SessionCandidate{}
|
||||
for _, c := range got {
|
||||
byAlias[c.Alias] = c
|
||||
}
|
||||
if c, ok := byAlias["项目定位"]; !ok {
|
||||
t.Error("接管会话应当在候选里")
|
||||
} else if c.Source != "mail" {
|
||||
t.Errorf("接管会话的来源应是 mail(保证送得到),得到 %q", c.Source)
|
||||
}
|
||||
if c, ok := byAlias["另一条真实会话"]; !ok {
|
||||
t.Error("另一条真实会话被吃掉了 —— 那正是 bug 的表现")
|
||||
} else if c.Source != "platform" {
|
||||
t.Errorf("未接管的平台会话来源应是 platform,得到 %q", c.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// 别名被冲掉之后也不该出现重复项。
|
||||
//
|
||||
// 这是事故现场的**精确复现**:本侧别名与镜像 slug 不一致(别名被另一条会话的
|
||||
// 命名同步冲掉了),此时按别名字符串去重必然漏,只有按 platform_id 才对。
|
||||
func TestSuggestSessionCandidatesNoDupWhenAliasDiverged(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedPlatformAgent(t, "pi")
|
||||
|
||||
adopted, err := AdoptPlatformSession(ctx, "pi", "01a05a5e", "项目定位", "/home/program/agentmail", "标题")
|
||||
if err != nil {
|
||||
t.Fatalf("接管: %v", err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, adopted, nil, "jianf", "", "pi", "/home/program/agentmail",
|
||||
"主题", "正文", nil); err != nil {
|
||||
t.Fatalf("建邮件: %v", err)
|
||||
}
|
||||
// 模拟别名被冲掉(绕过 SyncSessionAlias 的守卫直接改库 ——
|
||||
// 存量数据里可能已经有这种状态)
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET session_alias = $1 WHERE session_id = $2`,
|
||||
"日程提醒:小宅自测", adopted); err != nil {
|
||||
t.Fatalf("改别名: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := ReplacePlatformSessions(ctx, "pi", []PlatformSession{
|
||||
{PlatformID: "01a05a5e", Workspace: "/home/program/agentmail", Slug: "项目定位",
|
||||
Title: "项目定位", MailDriven: true, UpdatedAt: &now},
|
||||
}); err != nil {
|
||||
t.Fatalf("上报镜像: %v", err)
|
||||
}
|
||||
|
||||
got, err := SuggestSessionCandidates(ctx, "jianf", "pi", "/home/program/agentmail")
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestSessionCandidates: %v", err)
|
||||
}
|
||||
|
||||
// 只有一条会话,就该只有一个候选 —— 别名分叉不该让它变成两个
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("同一条会话应只有 1 个候选,实际 %d:%+v", len(got), got)
|
||||
}
|
||||
if got[0].Source != "mail" {
|
||||
t.Errorf("应保留 mail 来源(它保证送得到),得到 %q", got[0].Source)
|
||||
}
|
||||
}
|
||||
|
||||
// 存量数据里可能已经有这种状态(守卫是后加的)。
|
||||
228
server/internal/repo/adopt_test.go
Normal file
228
server/internal/repo/adopt_test.go
Normal file
@ -0,0 +1,228 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
)
|
||||
|
||||
func seedPlatformMirror(t *testing.T, agentName string, list []PlatformSession) {
|
||||
t.Helper()
|
||||
if err := ReplacePlatformSessions(context.Background(), agentName, list); err != nil {
|
||||
t.Fatalf("上报镜像: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 补全把平台会话列为候选,投递侧必须能命中同一条。
|
||||
// 此前 FindNamedSessionFor 只查 sessions 表 —— 候选列表在承诺一件做不到的事。
|
||||
func TestFindPlatformSession(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
seedPlatformMirror(t, "pi", []PlatformSession{
|
||||
{PlatformID: "pi-sess-1", Workspace: "/home/program/agentmail",
|
||||
Slug: "设计文档-项目定位", Title: "邮件驱动·多智能体协作平台", UpdatedAt: &now},
|
||||
{PlatformID: "pi-sess-2", Workspace: "/tmp/other",
|
||||
Slug: "别处的会话", Title: "无关", UpdatedAt: &now},
|
||||
})
|
||||
|
||||
t.Run("按 slug + workspace 命中", func(t *testing.T) {
|
||||
pid, ws, title, err := FindPlatformSession(ctx, "pi", "设计文档-项目定位", "/home/program/agentmail")
|
||||
if err != nil {
|
||||
t.Fatalf("查找: %v", err)
|
||||
}
|
||||
if pid != "pi-sess-1" {
|
||||
t.Errorf("platform_id = %q", pid)
|
||||
}
|
||||
if ws != "/home/program/agentmail" {
|
||||
t.Errorf("workspace = %q", ws)
|
||||
}
|
||||
if title != "邮件驱动·多智能体协作平台" {
|
||||
t.Errorf("title = %q", title)
|
||||
}
|
||||
})
|
||||
|
||||
// 地址省略 path 位时不限工作区
|
||||
t.Run("workspace 为空时不限", func(t *testing.T) {
|
||||
if pid, _, _, err := FindPlatformSession(ctx, "pi", "别处的会话", ""); err != nil || pid != "pi-sess-2" {
|
||||
t.Errorf("得到 %q err=%v", pid, err)
|
||||
}
|
||||
})
|
||||
|
||||
// workspace 不匹配时不该命中 —— 那会让邮件投进另一个项目的会话
|
||||
t.Run("workspace 不匹配不命中", func(t *testing.T) {
|
||||
if _, _, _, err := FindPlatformSession(ctx, "pi", "别处的会话", "/home/program/agentmail"); err == nil {
|
||||
t.Error("workspace 不同却命中了")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("别的 Agent 的镜像不串", func(t *testing.T) {
|
||||
if _, _, _, err := FindPlatformSession(ctx, "dsh", "设计文档-项目定位", ""); err == nil {
|
||||
t.Error("dsh 命中了 pi 的会话")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空参数返回 not found 而不是 panic", func(t *testing.T) {
|
||||
if _, _, _, err := FindPlatformSession(ctx, "", "x", ""); err != ErrSessionNotFound {
|
||||
t.Errorf("空 agent 应给 ErrSessionNotFound,得到 %v", err)
|
||||
}
|
||||
if _, _, _, err := FindPlatformSession(ctx, "pi", "", ""); err != ErrSessionNotFound {
|
||||
t.Errorf("空 slug 应给 ErrSessionNotFound,得到 %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 接管后本侧有正式身份:可寻址(别名)、绑定 platform_id、workspace 用会话真实的。
|
||||
func TestAdoptPlatformSession(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, err := AdoptPlatformSession(ctx, "pi", "pi-sess-1", "设计文档-项目定位",
|
||||
"/home/program/agentmail", "邮件驱动·多智能体协作平台")
|
||||
if err != nil {
|
||||
t.Fatalf("接管: %v", err)
|
||||
}
|
||||
|
||||
// 别名复用平台 slug:人在补全里看到的就是那个名字,换掉会让他找不到
|
||||
if alias := SessionAliasOf(ctx, id); alias != "设计文档-项目定位" {
|
||||
t.Errorf("别名 = %q,期望复用平台 slug", alias)
|
||||
}
|
||||
if pid := PlatformIDOf(ctx, id); pid != "pi-sess-1" {
|
||||
t.Errorf("platform_id = %q", pid)
|
||||
}
|
||||
|
||||
// workspace 取平台会话的真实 cwd
|
||||
var ws string
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT workspace FROM sessions WHERE session_id = $1`, id).Scan(&ws); err != nil {
|
||||
t.Fatalf("读 workspace: %v", err)
|
||||
}
|
||||
if ws != "/home/program/agentmail" {
|
||||
t.Errorf("workspace = %q", ws)
|
||||
}
|
||||
}
|
||||
|
||||
// 普通会话的 platform_id 必须是空串(不是接管来的)。
|
||||
func TestPlatformIDOfEmptyForNormalSession(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, err := CreateSession(ctx, nil, "pi", "普通邮件会话", "/tmp/x")
|
||||
if err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
if pid := PlatformIDOf(ctx, id); pid != "" {
|
||||
t.Errorf("普通会话的 platform_id 应为空,得到 %q", pid)
|
||||
}
|
||||
}
|
||||
|
||||
// 一条平台会话只能被接管一次。
|
||||
//
|
||||
// 第二次投递必须复用第一次建的本侧会话 —— 否则同一条 TUI 对话会在邮箱里
|
||||
// 裂成多条互不相干的线索:人看到三个同名会话,而回信只落在其中一条上。
|
||||
func TestFindSessionByPlatformIDPreventsDoubleAdopt(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, err := AdoptPlatformSession(ctx, "pi", "pi-sess-1", "某会话", "/tmp/ws", "标题")
|
||||
if err != nil {
|
||||
t.Fatalf("接管: %v", err)
|
||||
}
|
||||
// 接管后还没有邮件 —— 此时反查不到(EXISTS 子句要求有本 Agent 参与的邮件)
|
||||
if _, err := FindSessionByPlatformID(ctx, "pi", "pi-sess-1"); err == nil {
|
||||
t.Log("注意:无邮件时也能反查到")
|
||||
}
|
||||
|
||||
// 投一封进去,让参与关系成立
|
||||
if _, err := CreateMail(ctx, id, nil, "jianf", "", "pi", "/tmp/ws", "主题", "正文", nil); err != nil {
|
||||
t.Fatalf("建邮件: %v", err)
|
||||
}
|
||||
|
||||
got, err := FindSessionByPlatformID(ctx, "pi", "pi-sess-1")
|
||||
if err != nil {
|
||||
t.Fatalf("反查: %v", err)
|
||||
}
|
||||
if got != id {
|
||||
t.Errorf("反查到 %v,期望 %v", got, id)
|
||||
}
|
||||
|
||||
// 别的 platform_id 查不到
|
||||
if _, err := FindSessionByPlatformID(ctx, "pi", "pi-sess-999"); err != ErrSessionNotFound {
|
||||
t.Errorf("不存在的 platform_id 应给 ErrSessionNotFound,得到 %v", err)
|
||||
}
|
||||
// 别的 Agent 查不到(参与关系不成立)
|
||||
if _, err := FindSessionByPlatformID(ctx, "dsh", "pi-sess-1"); err != ErrSessionNotFound {
|
||||
t.Errorf("dsh 不该查到 pi 的接管会话,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 整表替换镜像后,已接管的本侧会话不受影响。
|
||||
//
|
||||
// 镜像是平台当前状态的快照、会被整表替换;而 sessions.platform_id 是本侧的
|
||||
// 持久绑定。平台侧那条会话被删掉之后,本侧线索与历史邮件仍然要在。
|
||||
func TestAdoptedSessionSurvivesMirrorReplace(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
now := time.Now()
|
||||
seedPlatformMirror(t, "pi", []PlatformSession{
|
||||
{PlatformID: "pi-sess-1", Workspace: "/tmp/ws", Slug: "会话甲", UpdatedAt: &now},
|
||||
})
|
||||
id, err := AdoptPlatformSession(ctx, "pi", "pi-sess-1", "会话甲", "/tmp/ws", "标题")
|
||||
if err != nil {
|
||||
t.Fatalf("接管: %v", err)
|
||||
}
|
||||
|
||||
// 平台侧删了那条会话(新快照里没有它)
|
||||
seedPlatformMirror(t, "pi", []PlatformSession{
|
||||
{PlatformID: "pi-sess-2", Workspace: "/tmp/ws", Slug: "会话乙", UpdatedAt: &now},
|
||||
})
|
||||
|
||||
// 本侧绑定与别名都还在
|
||||
if pid := PlatformIDOf(ctx, id); pid != "pi-sess-1" {
|
||||
t.Errorf("镜像替换后 platform_id 丢了:%q", pid)
|
||||
}
|
||||
if alias := SessionAliasOf(ctx, id); alias != "会话甲" {
|
||||
t.Errorf("别名丢了:%q", alias)
|
||||
}
|
||||
// 但镜像里查不到了(补全不再列它,符合预期)
|
||||
if _, _, _, err := FindPlatformSession(ctx, "pi", "会话甲", ""); err != ErrSessionNotFound {
|
||||
t.Errorf("镜像里应已消失,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 接管用的 slug 与本侧某条无关会话撞名时要自动加后缀(别名全局唯一)。
|
||||
func TestAdoptHandlesAliasCollision(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
// 先占掉这个别名
|
||||
taken := "撞名的别名"
|
||||
if _, err := CreateSession(ctx, &taken, "pi", "已存在", "/tmp/a"); err != nil {
|
||||
t.Fatalf("建占位会话: %v", err)
|
||||
}
|
||||
|
||||
id, err := AdoptPlatformSession(ctx, "pi", "pi-sess-x", taken, "/tmp/b", "标题")
|
||||
if err != nil {
|
||||
t.Fatalf("接管: %v", err)
|
||||
}
|
||||
alias := SessionAliasOf(ctx, id)
|
||||
if alias == "" {
|
||||
t.Fatal("接管后没有别名 —— 这条会话将无法寻址")
|
||||
}
|
||||
if alias == taken {
|
||||
t.Errorf("别名与已存在的重复了:%q", alias)
|
||||
}
|
||||
// 绑定仍然正确
|
||||
if pid := PlatformIDOf(ctx, id); pid != "pi-sess-x" {
|
||||
t.Errorf("platform_id = %q", pid)
|
||||
}
|
||||
}
|
||||
215
server/internal/repo/agent_calendar_test.go
Normal file
215
server/internal/repo/agent_calendar_test.go
Normal file
@ -0,0 +1,215 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
)
|
||||
|
||||
// Agent 只能看到自己建的日程。别人的日程里可能有它无权知道的会议与地址。
|
||||
func TestListCalendarEventsCreatedBy(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mine := seedEvent(t, &models.CalendarEvent{
|
||||
Title: "pi 自己建的", EventTime: time.Now().Add(time.Hour), CreatedBy: "pi",
|
||||
})
|
||||
seedEvent(t, &models.CalendarEvent{
|
||||
Title: "dsh 建的", EventTime: time.Now().Add(time.Hour), CreatedBy: "dsh",
|
||||
})
|
||||
seedEvent(t, &models.CalendarEvent{
|
||||
Title: "人建的", EventTime: time.Now().Add(time.Hour), CreatedBy: "jianf",
|
||||
})
|
||||
|
||||
from := time.Now().Add(-time.Hour)
|
||||
to := time.Now().AddDate(0, 1, 0)
|
||||
|
||||
got, err := ListCalendarEventsCreatedBy(ctx, "pi", from, to, "active")
|
||||
if err != nil {
|
||||
t.Fatalf("列出: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("pi 应只看到 1 条,得到 %d", len(got))
|
||||
}
|
||||
if got[0].EventID != mine.EventID {
|
||||
t.Errorf("看到了别人的事件:%s", got[0].Title)
|
||||
}
|
||||
|
||||
// 没建过任何事件的 Agent 得到空数组而不是 nil(nil 序列化成 null 前端会崩)
|
||||
empty, err := ListCalendarEventsCreatedBy(ctx, "opencode", from, to, "active")
|
||||
if err != nil {
|
||||
t.Fatalf("列出: %v", err)
|
||||
}
|
||||
if empty == nil {
|
||||
t.Error("应返回空数组而不是 nil")
|
||||
}
|
||||
if len(empty) != 0 {
|
||||
t.Errorf("应为空,得到 %d 条", len(empty))
|
||||
}
|
||||
}
|
||||
|
||||
// 「发给我但不是我建的」同样不返回:那些事件的编辑权不属于我,
|
||||
// 列出来只会让模型试图改它然后拿到 404。
|
||||
func TestListCalendarEventsCreatedByIgnoresRecipient(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
seedEvent(t, &models.CalendarEvent{
|
||||
Title: "dsh 建的、发给 pi",
|
||||
EventTime: time.Now().Add(time.Hour),
|
||||
CreatedBy: "dsh",
|
||||
Recipients: []string{"pi"},
|
||||
})
|
||||
|
||||
got, err := ListCalendarEventsCreatedBy(ctx, "pi",
|
||||
time.Now().Add(-time.Hour), time.Now().AddDate(0, 1, 0), "active")
|
||||
if err != nil {
|
||||
t.Fatalf("列出: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("收件人不等于创建者,不该出现在列表里(得到 %d 条)", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCalendarEventsCreatedByStatusFilter(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
seedEvent(t, &models.CalendarEvent{
|
||||
Title: "生效中", EventTime: time.Now().Add(time.Hour), CreatedBy: "pi", Status: "active",
|
||||
})
|
||||
seedEvent(t, &models.CalendarEvent{
|
||||
Title: "已暂停", EventTime: time.Now().Add(time.Hour), CreatedBy: "pi", Status: "paused",
|
||||
})
|
||||
|
||||
from := time.Now().Add(-time.Hour)
|
||||
to := time.Now().AddDate(0, 1, 0)
|
||||
|
||||
if got, _ := ListCalendarEventsCreatedBy(ctx, "pi", from, to, "active"); len(got) != 1 {
|
||||
t.Errorf("active 过滤应给 1 条,得到 %d", len(got))
|
||||
}
|
||||
// 空串 = 不过滤(handler 里 status=all 映射成空串)
|
||||
if got, _ := ListCalendarEventsCreatedBy(ctx, "pi", from, to, ""); len(got) != 2 {
|
||||
t.Errorf("不过滤应给 2 条,得到 %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// 总量上限的依据。速率限制压不住「每小时建 19 条连建一周」,
|
||||
// 而日历事件是长效的 —— 攒下来的每条都持续产生提醒。
|
||||
func TestCountActiveEventsBy(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
seedEvent(t, &models.CalendarEvent{
|
||||
Title: "生效", EventTime: time.Now().Add(time.Hour), CreatedBy: "pi", Status: "active",
|
||||
})
|
||||
}
|
||||
// cancelled 与 paused 不该计入「生效中」
|
||||
seedEvent(t, &models.CalendarEvent{
|
||||
Title: "取消了", EventTime: time.Now().Add(time.Hour), CreatedBy: "pi", Status: "cancelled",
|
||||
})
|
||||
seedEvent(t, &models.CalendarEvent{
|
||||
Title: "暂停了", EventTime: time.Now().Add(time.Hour), CreatedBy: "pi", Status: "paused",
|
||||
})
|
||||
seedEvent(t, &models.CalendarEvent{
|
||||
Title: "别人的", EventTime: time.Now().Add(time.Hour), CreatedBy: "dsh", Status: "active",
|
||||
})
|
||||
|
||||
n, err := CountActiveEventsBy(ctx, "pi")
|
||||
if err != nil {
|
||||
t.Fatalf("计数: %v", err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Errorf("pi 的生效事件应为 3,得到 %d", n)
|
||||
}
|
||||
|
||||
if n, _ := CountActiveEventsBy(ctx, "从来没建过"); n != 0 {
|
||||
t.Errorf("没建过应为 0,得到 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 速率限制 ───
|
||||
|
||||
func TestAllowAgentCalendarEvent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
limit := CalendarRateLimit()
|
||||
for i := 0; i < limit; i++ {
|
||||
ok, _ := AllowAgentCalendarEvent(ctx, "pi")
|
||||
if !ok {
|
||||
t.Fatalf("第 %d 次(上限 %d)就被拒了", i+1, limit)
|
||||
}
|
||||
}
|
||||
ok, retry := AllowAgentCalendarEvent(ctx, "pi")
|
||||
if ok {
|
||||
t.Error("超过上限应被拒")
|
||||
}
|
||||
if retry <= 0 {
|
||||
t.Errorf("被拒时应给出 retryAfter,得到 %d", retry)
|
||||
}
|
||||
}
|
||||
|
||||
// 日历桶与新建会话桶必须独立:建满 20 条日程不该连带堵住新建会话。
|
||||
func TestCalendarRateBucketIsSeparateFromSession(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < CalendarRateLimit(); i++ {
|
||||
AllowAgentCalendarEvent(ctx, "pi")
|
||||
}
|
||||
if ok, _ := AllowAgentCalendarEvent(ctx, "pi"); ok {
|
||||
t.Fatal("准备阶段:日历桶应已满")
|
||||
}
|
||||
// 新建会话桶应完全不受影响
|
||||
if ok, _ := AllowNewSession(ctx, "pi"); !ok {
|
||||
t.Error("日历桶满不该堵住新建会话 —— 两个桶必须独立")
|
||||
}
|
||||
}
|
||||
|
||||
// 不同 Agent 的桶互不干扰。
|
||||
func TestCalendarRateBucketPerAgent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < CalendarRateLimit(); i++ {
|
||||
AllowAgentCalendarEvent(ctx, "pi")
|
||||
}
|
||||
if ok, _ := AllowAgentCalendarEvent(ctx, "dsh"); !ok {
|
||||
t.Error("pi 建满不该影响 dsh")
|
||||
}
|
||||
}
|
||||
|
||||
// 创建失败要归还名额:那次创建实际没有发生。
|
||||
func TestReleaseAgentCalendarEvent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < CalendarRateLimit(); i++ {
|
||||
AllowAgentCalendarEvent(ctx, "pi")
|
||||
}
|
||||
if ok, _ := AllowAgentCalendarEvent(ctx, "pi"); ok {
|
||||
t.Fatal("准备阶段:应已满")
|
||||
}
|
||||
// 归还一个(模拟刚才那次被拒之前的失败创建)
|
||||
ReleaseAgentCalendarEvent(ctx, "pi")
|
||||
if ok, _ := AllowAgentCalendarEvent(ctx, "pi"); !ok {
|
||||
t.Error("归还名额后应能再建一条")
|
||||
}
|
||||
}
|
||||
|
||||
// 空 Agent 名放行且不记账:这条路径只在鉴权已经失败时才可能走到,
|
||||
// 记账会污染桶(bucket 名变成 "calendar:")。
|
||||
func TestCalendarRateEmptyAgentPassesThrough(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < CalendarRateLimit()+5; i++ {
|
||||
if ok, _ := AllowAgentCalendarEvent(ctx, ""); !ok {
|
||||
t.Fatal("空 Agent 名应一律放行")
|
||||
}
|
||||
}
|
||||
}
|
||||
253
server/internal/repo/agent_disable_test.go
Normal file
253
server/internal/repo/agent_disable_test.go
Normal file
@ -0,0 +1,253 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 停用是可逆的「归档」,不是删除。这组测试钉住三件事:
|
||||
// 停用后从候选里消失、密钥被撤销、重新注册不能复活它。
|
||||
|
||||
func TestSetAgentDisabledHidesFromCandidates(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, n := range []string{"keeper", "goner"} {
|
||||
if err := CreateOrUpdateAgent(ctx, n, "s", "test", nil); err != nil {
|
||||
t.Fatalf("注册 %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := SetAgentDisabled(ctx, "goner", true); err != nil {
|
||||
t.Fatalf("停用: %v", err)
|
||||
}
|
||||
|
||||
// 默认列表(地址补全、GET /agents、可授权范围都走这条)不含已停用的
|
||||
got, err := ListAgents(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListAgents: %v", err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, a := range got {
|
||||
names[a.Name] = true
|
||||
}
|
||||
if names["goner"] {
|
||||
t.Error("已停用的 Agent 仍出现在默认列表里 —— 人会把任务派给一个不会响应的地址")
|
||||
}
|
||||
if !names["keeper"] {
|
||||
t.Error("停用一个把别的也弄没了")
|
||||
}
|
||||
|
||||
// statusFilter="all" 时要能看到 —— 那是管理页恢复它的唯一入口
|
||||
all, err := ListAgents(ctx, "all")
|
||||
if err != nil {
|
||||
t.Fatalf("ListAgents(all): %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, a := range all {
|
||||
if a.Name == "goner" {
|
||||
found = true
|
||||
if a.Status != "disabled" {
|
||||
t.Errorf("状态应为 disabled,实际 %q", a.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("statusFilter=all 也看不到已停用的,就再也无法恢复它了")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAgentDisabledRevokesKeys(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||||
t.Fatalf("注册: %v", err)
|
||||
}
|
||||
admin := seedAdminForTest(t, ctx)
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := CreateAgentKey(ctx, "bot", "permanent", "k", 0, admin, ""); err != nil {
|
||||
t.Fatalf("建密钥: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
revoked, err := SetAgentDisabled(ctx, "bot", true)
|
||||
if err != nil {
|
||||
t.Fatalf("停用: %v", err)
|
||||
}
|
||||
if revoked != 2 {
|
||||
t.Errorf("应撤销 2 把密钥,实际 %d", revoked)
|
||||
}
|
||||
|
||||
keys, err := ListAgentKeys(ctx, "bot")
|
||||
if err != nil {
|
||||
t.Fatalf("ListAgentKeys: %v", err)
|
||||
}
|
||||
if len(keys) != 0 {
|
||||
t.Errorf("停用后仍留着 %d 把密钥 —— 插件还能用它调 /mail/send,"+
|
||||
"停用的语义是「不再参与工作」而不只是「不出现在补全里」", len(keys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledAgentCannotReRegister(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||||
t.Fatalf("首次注册: %v", err)
|
||||
}
|
||||
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
|
||||
t.Fatalf("停用: %v", err)
|
||||
}
|
||||
|
||||
// 插件启动时会重新注册。不拒的话 status 被写回 online,停用等于没做。
|
||||
err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil)
|
||||
if !errors.Is(err, ErrAgentDisabled) {
|
||||
t.Fatalf("已停用的 Agent 重新注册应当被拒,实际 err=%v", err)
|
||||
}
|
||||
|
||||
disabled, err := AgentDisabled(ctx, "bot")
|
||||
if err != nil {
|
||||
t.Fatalf("AgentDisabled: %v", err)
|
||||
}
|
||||
if !disabled {
|
||||
t.Error("注册尝试把停用状态冲掉了")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatDoesNotReviveDisabledAgent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||||
t.Fatalf("注册: %v", err)
|
||||
}
|
||||
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
|
||||
t.Fatalf("停用: %v", err)
|
||||
}
|
||||
|
||||
// 心跳是 30 秒一次的。不排除 disabled 的话停用最多维持半分钟。
|
||||
if _, err := HeartbeatAgent(ctx, "bot"); err != nil {
|
||||
t.Fatalf("心跳本身不该报错: %v", err)
|
||||
}
|
||||
|
||||
disabled, _ := AgentDisabled(ctx, "bot")
|
||||
if !disabled {
|
||||
t.Error("心跳把已停用的 Agent 改回在线了")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreAgentGoesOfflineNotOnline(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||||
t.Fatalf("注册: %v", err)
|
||||
}
|
||||
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
|
||||
t.Fatalf("停用: %v", err)
|
||||
}
|
||||
if _, err := SetAgentDisabled(ctx, "bot", false); err != nil {
|
||||
t.Fatalf("恢复: %v", err)
|
||||
}
|
||||
|
||||
all, _ := ListAgents(ctx, "all")
|
||||
for _, a := range all {
|
||||
if a.Name != "bot" {
|
||||
continue
|
||||
}
|
||||
// 恢复成 online 会让界面显示一个其实没在跑的 Agent 为在线;
|
||||
// 它是否真的活着由下一次心跳决定。
|
||||
if a.Status != "offline" {
|
||||
t.Errorf("恢复后应为 offline,实际 %q", a.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复后能重新注册
|
||||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||||
t.Errorf("恢复后应当能重新注册: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAgentDisabledUnknownAgent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := SetAgentDisabled(ctx, "nope", true)
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Errorf("停用不存在的 Agent 应回 ErrNoRows,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 停用不得动邮件与会话 —— 往来里有一半是人自己写的。
|
||||
func TestSetAgentDisabledKeepsMailAndSessions(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||||
t.Fatalf("注册: %v", err)
|
||||
}
|
||||
sid, err := CreateSession(ctx, nil, "bot", "一件事", "/tmp/ws")
|
||||
if err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, sid, nil,
|
||||
"human", "", "bot", "/tmp/ws", "主题", "正文", nil); err != nil {
|
||||
t.Fatalf("建邮件: %v", err)
|
||||
}
|
||||
|
||||
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
|
||||
t.Fatalf("停用: %v", err)
|
||||
}
|
||||
|
||||
mails, err := ListInbox(ctx, "bot", "all", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListInbox: %v", err)
|
||||
}
|
||||
if len(mails) != 1 {
|
||||
t.Errorf("停用把邮件删了:剩 %d 封。那些往来里有一半是人自己写的", len(mails))
|
||||
}
|
||||
}
|
||||
|
||||
// 模型范围与平台会话镜像也保留:恢复后不必重配。
|
||||
func TestSetAgentDisabledKeepsModelScope(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||||
t.Fatalf("注册: %v", err)
|
||||
}
|
||||
if err := SetAllowedModels(ctx, "bot", []ModelRef{{Provider: "p", Model: "m"}}); err != nil {
|
||||
t.Fatalf("设范围: %v", err)
|
||||
}
|
||||
|
||||
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
|
||||
t.Fatalf("停用: %v", err)
|
||||
}
|
||||
|
||||
allowed, err := ListAllowedModels(ctx, "bot")
|
||||
if err != nil {
|
||||
t.Fatalf("ListAllowedModels: %v", err)
|
||||
}
|
||||
if len(allowed) != 1 {
|
||||
t.Errorf("停用把模型范围清了,恢复后管理员得重配一遍:%+v", allowed)
|
||||
}
|
||||
}
|
||||
|
||||
// seedAdminForTest 插一个管理员并返回它的 user_id(CreateAgentKey 要 created_by)。
|
||||
func seedAdminForTest(t *testing.T, ctx context.Context) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`INSERT INTO users (username, display_name, password_hash, role)
|
||||
VALUES ('key-admin', 'Admin', 'x', 'admin') RETURNING user_id`).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("seed admin: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
381
server/internal/repo/attachments.go
Normal file
381
server/internal/repo/attachments.go
Normal file
@ -0,0 +1,381 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 附件 ----------
|
||||
//
|
||||
// 元数据在库、内容在磁盘(internal/blob)。两者的一致性由调用顺序保证:
|
||||
// 先落盘再入库 —— 反过来会出现「库里有记录但文件不存在」的下载 500。
|
||||
// 落盘成功但入库失败时最多留下一个无引用的文件,由 GC 回收,不影响正确性。
|
||||
|
||||
var (
|
||||
// ErrAttachmentNotFound 附件不存在
|
||||
ErrAttachmentNotFound = errors.New("attachment not found")
|
||||
// ErrAttachmentNotOwned 附件不属于该上传者
|
||||
ErrAttachmentNotOwned = errors.New("attachment not owned by uploader")
|
||||
// ErrAttachmentAlreadyAttached 附件已挂到别的邮件上
|
||||
ErrAttachmentAlreadyAttached = errors.New("attachment already attached")
|
||||
)
|
||||
|
||||
const attachmentCols = `attachment_id, mail_id, uploader, filename, content_type, size_bytes, sha256, created_at`
|
||||
|
||||
func scanAttachment(sc interface{ Scan(...any) error }) (*models.Attachment, error) {
|
||||
var a models.Attachment
|
||||
if err := sc.Scan(&a.ID, &a.MailID, &a.Uploader, &a.Filename,
|
||||
&a.ContentType, &a.SizeBytes, &a.SHA256, &a.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// CreateAttachment 登记一条待挂载的附件(mail_id 为空)。
|
||||
func CreateAttachment(ctx context.Context, uploader, filename, contentType string, size int64, sum string) (*models.Attachment, error) {
|
||||
a := &models.Attachment{
|
||||
Uploader: uploader,
|
||||
Filename: filename,
|
||||
ContentType: contentType,
|
||||
SizeBytes: size,
|
||||
SHA256: sum,
|
||||
}
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO attachments (uploader, filename, content_type, size_bytes, sha256)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING attachment_id, created_at
|
||||
`, uploader, filename, contentType, size, sum).Scan(&a.ID, &a.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// GetAttachment 读取一条附件元数据。
|
||||
func GetAttachment(ctx context.Context, id uuid.UUID) (*models.Attachment, error) {
|
||||
a, err := scanAttachment(db.DB.QueryRowContext(ctx,
|
||||
`SELECT `+attachmentCols+` FROM attachments WHERE attachment_id = $1`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAttachmentNotFound
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
|
||||
// ListAttachmentsFor 列出某封邮件的附件。
|
||||
func ListAttachmentsFor(ctx context.Context, mailID uuid.UUID) ([]models.Attachment, error) {
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT `+attachmentCols+` FROM attachments WHERE mail_id = $1 ORDER BY created_at`, mailID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []models.Attachment{}
|
||||
for rows.Next() {
|
||||
a, err := scanAttachment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// EnsureAttachable 只做**读取校验**:这批附件是否存在、属于该上传者、且尚未挂载。
|
||||
//
|
||||
// # 为什么要有一个「只查不改」的版本
|
||||
//
|
||||
// 原先只有 AttachToMail,而它在 CreateMail **之后**调用。于是附件不合法时
|
||||
// (不属于我 / 已随别的邮件发出)请求返回 403/409,但那封邮件**已经入库、已经
|
||||
// 通知了收件人、已经扣掉了会话预算**。实测两封探针邮件(403 与 409)都躺在库里,
|
||||
// used_rounds 也涨了。发件方看到 4xx 会重试,收件方于是收到两封。
|
||||
//
|
||||
// 纯输入校验必须在产生任何副作用之前做完 —— 与「400 之后会话已建好」是同一个教训。
|
||||
//
|
||||
// 它不能取代 AttachToMail 里的原子判断:两次调用之间仍有竞态窗口
|
||||
// (另一个请求把同一个附件挂走了)。那条路径靠调用方回滚,见 handler.attachAll。
|
||||
func EnsureAttachable(ctx context.Context, ids []uuid.UUID, uploader string) error {
|
||||
for _, id := range ids {
|
||||
a, err := GetAttachment(ctx, id)
|
||||
if err != nil {
|
||||
return err // ErrAttachmentNotFound 或库错误
|
||||
}
|
||||
if a.Uploader != uploader {
|
||||
return ErrAttachmentNotOwned
|
||||
}
|
||||
if a.MailID != nil {
|
||||
return ErrAttachmentAlreadyAttached
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttachToMail 把一批待挂载附件绑到某封邮件上。
|
||||
//
|
||||
// 每条都要求:存在、属于该上传者、且尚未挂载。
|
||||
// 用 WHERE mail_id IS NULL AND uploader = ? 一条 UPDATE 完成判断与写入,
|
||||
// 避免「先查后改」在并发下把同一个附件挂到两封邮件上。
|
||||
func AttachToMail(ctx context.Context, mailID uuid.UUID, ids []uuid.UUID, uploader string) error {
|
||||
for _, id := range ids {
|
||||
tag, err := db.DB.ExecContext(ctx, `
|
||||
UPDATE attachments SET mail_id = $1
|
||||
WHERE attachment_id = $2 AND uploader = $3 AND mail_id IS NULL
|
||||
`, mailID, id, uploader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 没改到:查明原因,给调用方一个能照着修的错误
|
||||
a, gErr := GetAttachment(ctx, id)
|
||||
if gErr != nil {
|
||||
return gErr
|
||||
}
|
||||
if a.Uploader != uploader {
|
||||
return ErrAttachmentNotOwned
|
||||
}
|
||||
return ErrAttachmentAlreadyAttached
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyAttachmentsTo 把源邮件的附件复制到目标邮件(转发时用)。
|
||||
//
|
||||
// 内容寻址下「复制」只是新增一条指向同一 sha256 的元数据,不拷磁盘文件。
|
||||
// uploader 记为转发人:附件随新邮件重新分发,其可见范围由新邮件的参与方决定,
|
||||
// 而不是沿用原上传者。返回复制的数量。
|
||||
func CopyAttachmentsTo(ctx context.Context, srcMailID, dstMailID uuid.UUID, forwarder string) (int, error) {
|
||||
src, err := ListAttachmentsFor(ctx, srcMailID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, a := range src {
|
||||
_, err := db.DB.ExecContext(ctx, `
|
||||
INSERT INTO attachments (mail_id, uploader, filename, content_type, size_bytes, sha256)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, dstMailID, forwarder, a.Filename, a.ContentType, a.SizeBytes, a.SHA256)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(src), nil
|
||||
}
|
||||
|
||||
// DeleteAttachment 删除一条附件元数据,返回它的 sha256 以及该内容是否已无人引用。
|
||||
// 内容寻址下多条记录可能共享同一个文件,只有最后一条引用消失才能删磁盘文件。
|
||||
func DeleteAttachment(ctx context.Context, id uuid.UUID) (sum string, orphaned bool, err error) {
|
||||
a, err := GetAttachment(ctx, id)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if _, err = db.DB.ExecContext(ctx,
|
||||
`DELETE FROM attachments WHERE attachment_id = $1`, id); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
var refs int
|
||||
if err = db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM attachments WHERE sha256 = $1`, a.SHA256).Scan(&refs); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return a.SHA256, refs == 0, nil
|
||||
}
|
||||
|
||||
// SweepOrphanAttachments 清理超过 age 仍未挂载到邮件的附件记录,
|
||||
// 返回可以从磁盘删除的 sha256 列表(已确认无任何记录引用)。
|
||||
//
|
||||
// 上传后没走完发信流程(用户取消、Agent 崩溃)会留下这类记录,
|
||||
// 不清理的话磁盘只会单调增长。
|
||||
func SweepOrphanAttachments(ctx context.Context, age time.Duration) ([]string, error) {
|
||||
cutoff := time.Now().Add(-age)
|
||||
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT attachment_id, sha256 FROM attachments
|
||||
WHERE mail_id IS NULL AND created_at < $1`, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type orphan struct {
|
||||
id uuid.UUID
|
||||
sum string
|
||||
}
|
||||
var found []orphan
|
||||
for rows.Next() {
|
||||
var o orphan
|
||||
if err := rows.Scan(&o.id, &o.sum); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
found = append(found, o)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var removable []string
|
||||
for _, o := range found {
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`DELETE FROM attachments WHERE attachment_id = $1`, o.id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var refs int
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM attachments WHERE sha256 = $1`, o.sum).Scan(&refs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if refs == 0 {
|
||||
removable = append(removable, o.sum)
|
||||
}
|
||||
}
|
||||
return removable, nil
|
||||
}
|
||||
|
||||
// SweepUnreferencedBlobs 删掉磁盘上没有任何库记录指向的内容文件。
|
||||
//
|
||||
// # 为什么 SweepOrphanAttachments 不够
|
||||
//
|
||||
// 那个函数走的是 `SELECT … FROM attachments WHERE mail_id IS NULL` —— 它只能看见
|
||||
// **库里还有记录**的孤儿。一旦记录本身消失(清库、手工 DELETE、迁移),
|
||||
// 对应的文件就永远脱离了 GC 的视野:本机实测磁盘 8 个 blob 里 7 个没有任何库记录,
|
||||
// 全部来自 09-03 那次清库,之后一直躺在那里。
|
||||
//
|
||||
// 这个反向清理从**磁盘**出发:枚举全部内容文件,凡是 attachments 与
|
||||
// calendar_attachments 都不引用的就删。返回删掉的数量。
|
||||
//
|
||||
// # 为什么要 minAge
|
||||
//
|
||||
// 上传是「先落盘、再入库」(顺序不能反,否则会出现「库里有记录、磁盘没文件」的
|
||||
// 下载 500)。那两步之间有一个窗口,此刻文件确实没有任何库记录 —— 不设年龄下限
|
||||
// 会把正在上传的文件删掉。取一个远大于单次上传耗时的值。
|
||||
func SweepUnreferencedBlobs(ctx context.Context, blobs BlobLister, minAge time.Duration) (int, error) {
|
||||
if blobs == nil {
|
||||
return 0, nil
|
||||
}
|
||||
sums, err := blobs.List()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(sums) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// 一次查回全部被引用的 sha256。逐个文件查一次库是 N 次往返,
|
||||
// 而这两张表加起来通常只有几百行。
|
||||
referenced := map[string]struct{}{}
|
||||
for _, q := range []string{
|
||||
`SELECT sha256 FROM attachments`,
|
||||
`SELECT sha256 FROM calendar_attachments`,
|
||||
} {
|
||||
rows, qErr := db.DB.QueryContext(ctx, q)
|
||||
if qErr != nil {
|
||||
return 0, qErr
|
||||
}
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if sErr := rows.Scan(&s); sErr != nil {
|
||||
rows.Close()
|
||||
return 0, sErr
|
||||
}
|
||||
referenced[s] = struct{}{}
|
||||
}
|
||||
rows.Close()
|
||||
if rErr := rows.Err(); rErr != nil {
|
||||
return 0, rErr
|
||||
}
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-minAge)
|
||||
removed := 0
|
||||
for sum, mod := range sums {
|
||||
if _, ok := referenced[sum]; ok {
|
||||
continue
|
||||
}
|
||||
if mod.After(cutoff) {
|
||||
continue // 可能正在上传(落盘与入库之间的窗口)
|
||||
}
|
||||
if rErr := blobs.Remove(sum); rErr != nil {
|
||||
continue // 删不掉就下一轮再试,不该让整次清理中断
|
||||
}
|
||||
removed++
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
// BlobLister 是 SweepUnreferencedBlobs 需要的存储能力。
|
||||
//
|
||||
// 用 map[string]time.Time 而不是自定义结构体:那样 blob 包就不必 import repo
|
||||
// (底层存储依赖上层仓储会很怪),而 Go 的接口是结构化匹配的,签名一致即可。
|
||||
type BlobLister interface {
|
||||
// List 返回 sha256 → 该内容文件的修改时间。
|
||||
List() (map[string]time.Time, error)
|
||||
Remove(sum string) error
|
||||
}
|
||||
|
||||
// AttachmentAccessible 判断某人是否有权读取某附件:
|
||||
// 已挂载的看邮件所属会话的参与关系,未挂载的只有上传者本人能看。
|
||||
func AttachmentAccessible(ctx context.Context, a *models.Attachment, name string) (bool, error) {
|
||||
if a.MailID == nil {
|
||||
return a.Uploader == name, nil
|
||||
}
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM mails m
|
||||
WHERE m.mail_id = $1
|
||||
AND (m.from_name = $2 OR m.to_name = $2 OR `+db.CCHas("m.cc_list", 2)+`)
|
||||
`, *a.MailID, name).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// ListAttachmentsForMails 批量取多封邮件的附件,返回 mail_id → 附件列表。
|
||||
//
|
||||
// 为什么要批量:会话线程与收发件箱都是「一批邮件」,逐封调 ListAttachmentsFor
|
||||
// 就是 N+1 —— 一个 200 封的会话打开一次要打 200 次库。
|
||||
// 用 IN (...) 一次取回后在内存里分组。
|
||||
//
|
||||
// 占位符手工拼而非用数组参数:SQLite 驱动不支持 PG 的 = ANY($1),
|
||||
// 而这里的元素是已解析的 uuid.UUID,不存在注入面。
|
||||
func ListAttachmentsForMails(ctx context.Context, mailIDs []uuid.UUID) (map[uuid.UUID][]models.Attachment, error) {
|
||||
out := map[uuid.UUID][]models.Attachment{}
|
||||
if len(mailIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
ph := make([]string, len(mailIDs))
|
||||
args := make([]any, len(mailIDs))
|
||||
for i, id := range mailIDs {
|
||||
ph[i] = fmt.Sprintf("$%d", i+1)
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT `+attachmentCols+` FROM attachments
|
||||
WHERE mail_id IN (`+strings.Join(ph, ",")+`)
|
||||
ORDER BY created_at`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
a, err := scanAttachment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.MailID == nil {
|
||||
continue // WHERE 已排除,只是防御
|
||||
}
|
||||
out[*a.MailID] = append(out[*a.MailID], *a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
142
server/internal/repo/attachments_test.go
Normal file
142
server/internal/repo/attachments_test.go
Normal file
@ -0,0 +1,142 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// seedClock 给测试数据发严格递增的时间戳。
|
||||
//
|
||||
// 不靠挂钟:测试在一个循环里连插几封,很可能落在同一毫秒里,
|
||||
// 于是「会话里最早/最后那封」的排序由 mail_id(随机 UUID)决定 —— 结果随机。
|
||||
// 生产里两封邮件至少隔着一次模型推理,同毫秒撞车不现实;
|
||||
// 但测试必须确定,所以显式发号。
|
||||
var seedClock = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func nextSeedTime() string {
|
||||
seedClock = seedClock.Add(time.Second)
|
||||
return seedClock.Format("2006-01-02 15:04:05.000")
|
||||
}
|
||||
|
||||
// seedMailIn 在指定会话里插一封邮件,返回其 id。
|
||||
// 时间戳严格递增,因此调用顺序就是邮件的先后顺序。
|
||||
func seedMailIn(t *testing.T, sessionID uuid.UUID, from, to, subject string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := db.DB.QueryRowContext(context.Background(), `
|
||||
INSERT INTO mails (session_id, from_name, from_workspace, to_name, to_workspace,
|
||||
subject, body, cc_list, created_at)
|
||||
VALUES ($1, $2, '', $3, '', $4, 'body', '[]', $5)
|
||||
RETURNING mail_id
|
||||
`, sessionID, from, to, subject, nextSeedTime()).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("seed mail: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func seedSessionRow(t *testing.T, alias string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := db.DB.QueryRowContext(context.Background(), `
|
||||
INSERT INTO sessions (from_agent, subject, session_alias)
|
||||
VALUES ('opencode', 'attach test', $1)
|
||||
RETURNING session_id
|
||||
`, alias).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("seed session: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func attach(t *testing.T, mailID uuid.UUID, name, sum string) {
|
||||
t.Helper()
|
||||
a, err := CreateAttachment(context.Background(), "admin", name, "text/plain", 3, sum)
|
||||
if err != nil {
|
||||
t.Fatalf("create attachment: %v", err)
|
||||
}
|
||||
if err := AttachToMail(context.Background(), mailID, []uuid.UUID{a.ID}, "admin"); err != nil {
|
||||
t.Fatalf("attach: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ListAttachmentsForMails 存在的理由是消掉 N+1:
|
||||
// 原先每封邮件单独查一次,一个 200 封的会话打开要打 200 次库。
|
||||
func TestListAttachmentsForMails(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
sid := seedSessionRow(t, "batch-attach")
|
||||
|
||||
m1 := seedMailIn(t, sid, "admin", "opencode", "两个附件")
|
||||
m2 := seedMailIn(t, sid, "opencode", "admin", "一个附件")
|
||||
m3 := seedMailIn(t, sid, "admin", "opencode", "没有附件")
|
||||
|
||||
attach(t, m1, "a.txt", "sum-a")
|
||||
attach(t, m1, "b.txt", "sum-b")
|
||||
attach(t, m2, "c.txt", "sum-c")
|
||||
|
||||
got, err := ListAttachmentsForMails(context.Background(),
|
||||
[]uuid.UUID{m1, m2, m3})
|
||||
if err != nil {
|
||||
t.Fatalf("批量查询失败: %v", err)
|
||||
}
|
||||
|
||||
if n := len(got[m1]); n != 2 {
|
||||
t.Errorf("m1 应有 2 个附件,实际 %d", n)
|
||||
}
|
||||
if n := len(got[m2]); n != 1 {
|
||||
t.Errorf("m2 应有 1 个附件,实际 %d", n)
|
||||
}
|
||||
// 无附件的邮件不该出现在 map 里:调用方据此保持 Attachments 为 nil,
|
||||
// 这样带 omitempty 的字段不会给每封邮件的 JSON 白加一个 "attachments":[]
|
||||
if _, ok := got[m3]; ok {
|
||||
t.Errorf("m3 无附件却出现在结果里:%#v", got[m3])
|
||||
}
|
||||
|
||||
// 同一封内按 created_at 排序,顺序不能乱
|
||||
if len(got[m1]) == 2 && got[m1][0].Filename != "a.txt" {
|
||||
t.Errorf("同一封内应按上传顺序,首个是 %s", got[m1][0].Filename)
|
||||
}
|
||||
|
||||
// 每条都要带回 mail_id,否则调用方分不清是谁的
|
||||
for _, a := range got[m1] {
|
||||
if a.MailID == nil || *a.MailID != m1 {
|
||||
t.Errorf("附件 %s 的 mail_id 不对:%v", a.Filename, a.MailID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 空输入必须返回空 map 而非 nil:调用方直接索引不该 panic。
|
||||
func TestListAttachmentsForMailsEmpty(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
for _, ids := range [][]uuid.UUID{nil, {}} {
|
||||
got, err := ListAttachmentsForMails(context.Background(), ids)
|
||||
if err != nil {
|
||||
t.Fatalf("空输入不该报错: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("空输入应返回空 map 而非 nil")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("空输入应返回空结果,实际 %d 项", len(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 传入不存在的 mail_id 不该报错,只是查不到 —— 调用方可能拿着已删邮件的 id。
|
||||
func TestListAttachmentsForMailsUnknownID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
got, err := ListAttachmentsForMails(context.Background(),
|
||||
[]uuid.UUID{uuid.New(), uuid.New()})
|
||||
if err != nil {
|
||||
t.Fatalf("未知 id 不该报错: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("未知 id 应查不到,实际 %d 项", len(got))
|
||||
}
|
||||
}
|
||||
177
server/internal/repo/autoalias.go
Normal file
177
server/internal/repo/autoalias.go
Normal file
@ -0,0 +1,177 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 自动别名 —— 让 `.new` 建出来的会话立刻可被寻址。
|
||||
//
|
||||
// # 为什么必须自动命名
|
||||
//
|
||||
// `session` 位三态里 `new` 是**一次性动作**:它建出会话就用完了。之后要再投进
|
||||
// 同一条会话,只有两条路 —— `reply_to` 某封具体邮件,或者 `name@path.<别名>`。
|
||||
// 而 `CreateSession(alias=nil)` 建出来的会话别名是 NULL,于是:
|
||||
//
|
||||
// - `FindNamedSessionFor` 查不到它(`WHERE session_alias = $1` 对 NULL 不成立)
|
||||
// - `SuggestSessionCandidates` 跳过它(`session_alias IS NOT NULL AND <> ''`)
|
||||
// - 参与方拿到的 `new_mail` 里 `session_alias` 是空串
|
||||
//
|
||||
// 结果是:被抄送方收到一封 `x@/p.new` 的邮件,**除了回复那一封之外无法再投进这条
|
||||
// 会话**。再发一次 `x@/p.new` 只会建第三条会话。这不是能力缺失,是寻址断链。
|
||||
//
|
||||
// 原先的设计假定平台插件会通过 `POST /sessions/{id}/sync` 把模型生成的标题回写成
|
||||
// 别名,于是「未命名」只是短暂状态。但两件事让这个假定不成立:
|
||||
//
|
||||
// 1. 人类发的邮件根本没有平台侧,永远等不到回写;
|
||||
// 2. 回写发生在模型跑完第一轮之后,而抄送方**在那之前**就要决定回信地址。
|
||||
//
|
||||
// 因此本侧先给一个可用的别名,平台随后仍可用 `SyncSessionAlias` 改写它 ——
|
||||
// `alias_source` 保持 `platform` 正是为此:自动名不是人定的名,不该挡住平台命名。
|
||||
//
|
||||
// # 为什么不复用 SyncSessionAlias
|
||||
//
|
||||
// 那个函数假定「会话已存在、现在要改名」,并且会跳过 `manual`。这里的场景是
|
||||
// 「刚建完、还没有名字」,且必须在**建会话的同一个请求里**完成,否则中间那一瞬
|
||||
// 发出的 SSE 仍然带空别名。
|
||||
|
||||
// aliasMaxBytes 与 normalizeAlias 的截断上限一致(sessions.session_alias 为 VARCHAR(128))。
|
||||
const aliasMaxBytes = 128
|
||||
|
||||
// autoAliasAttempts 是撞名后追加 -2、-3… 的尝试次数上限。
|
||||
// 与 SyncSessionAlias 取同一个数量级:同一主题在同一天内开几十条会话已属异常,
|
||||
// 真到了上限说明调用方在刷会话,此时报错比继续找空位更有价值。
|
||||
const autoAliasAttempts = 50
|
||||
|
||||
// AutoAliasFor 依据收件人与主题拼一个候选别名(未做唯一性检查)。
|
||||
//
|
||||
// 形如 `dsh-重构导入路径`:前缀用收件方名字,后缀用主题。**两者都要**——
|
||||
// 只用主题时「服务恢复验证」这类通用主题会在不同 Agent 之间反复撞名,
|
||||
// 只用名字则同一个 Agent 的所有会话都叫 `dsh-2`、`dsh-3`,看不出在聊什么。
|
||||
//
|
||||
// 主题为空(少见但合法)时退回单独的名字,由调用方靠后缀去重。
|
||||
func AutoAliasFor(toName, subject string) string {
|
||||
base := sanitizeAliasPart(toName)
|
||||
topic := sanitizeAliasPart(subject)
|
||||
|
||||
switch {
|
||||
case base == "" && topic == "":
|
||||
// 两边都拿不出可用字符(例如主题全是标点、名字为空)。
|
||||
// 返回空串让调用方走随机兜底,不要在这里编造。
|
||||
return ""
|
||||
case base == "":
|
||||
return truncateAlias(topic)
|
||||
case topic == "":
|
||||
return truncateAlias(base)
|
||||
default:
|
||||
return truncateAlias(base + "-" + topic)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureSessionAlias 保证会话拥有一个可寻址的别名,返回最终别名。
|
||||
//
|
||||
// 已有别名时原样返回,不做任何写入 —— 这让它可以被无条件调用,
|
||||
// 包括「默认会话」路径上那条可能是刚建的、也可能是复用的会话。
|
||||
//
|
||||
// 撞名时追加 -2、-3… 后缀;`want` 为空或全部被占用时退回
|
||||
// `session-<uuid 前 8 位>`:一个能寻址的丑名字,远胜于没有名字。
|
||||
func EnsureSessionAlias(ctx context.Context, id uuid.UUID, want string) (string, error) {
|
||||
if cur := SessionAliasOf(ctx, id); cur != "" {
|
||||
return cur, nil
|
||||
}
|
||||
|
||||
cands := make([]string, 0, autoAliasAttempts+1)
|
||||
if want != "" {
|
||||
for i := 0; i < autoAliasAttempts; i++ {
|
||||
if i == 0 {
|
||||
cands = append(cands, want)
|
||||
continue
|
||||
}
|
||||
cands = append(cands, truncateAlias(fmt.Sprintf("%s-%d", want, i+1)))
|
||||
}
|
||||
}
|
||||
// 兜底:uuid 前 8 位。碰撞概率可忽略,且与 want 无关,
|
||||
// 因此即便主题里一个可用字符都没有也总能拿到别名。
|
||||
cands = append(cands, "session-"+id.String()[:8])
|
||||
|
||||
for _, c := range cands {
|
||||
// 条件写入:`session_alias IS NULL OR = ''` 保证并发下只有一方写成功,
|
||||
// 另一方 RowsAffected=0,随后重读拿到对方写的名字 ——
|
||||
// 两个请求都返回同一个别名,而不是各自以为自己命名成功。
|
||||
res, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET session_alias = $1, updated_at = NOW()
|
||||
WHERE session_id = $2 AND (session_alias IS NULL OR session_alias = '')`,
|
||||
c, id)
|
||||
if err != nil {
|
||||
if db.IsUniqueViolation(err) {
|
||||
continue // 别名被别的会话占了,试下一个后缀
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
// 期间别人(并发请求或平台同步)已经命名过,尊重那个名字
|
||||
if cur := SessionAliasOf(ctx, id); cur != "" {
|
||||
return cur, nil
|
||||
}
|
||||
// 写不进去且读不到名字,只可能是会话刚被删
|
||||
return "", fmt.Errorf("会话 %s 已不存在,无法分配别名", id)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("别名 %q 连同 -2..-%d 后缀与 uuid 兜底均被占用", want, autoAliasAttempts)
|
||||
}
|
||||
|
||||
// sanitizeAliasPart 把任意文本压成别名可用的片段。
|
||||
//
|
||||
// 规则与 normalizeAlias 一致(非法字符换 -、压缩连续 -、去首尾 -),
|
||||
// 另外多做两件事:
|
||||
//
|
||||
// - **去掉 Markdown / 标点噪声**:主题里的 `[联调]`、`—`、`:` 变成一串
|
||||
// 破折号毫无信息量。只保留字母、数字与非标点的 Unicode 字符(中文、日文等)。
|
||||
// - **压缩空白**:`Re: 服务恢复验证` → `Re-服务恢复验证`,而不是 `Re--服务恢复验证`。
|
||||
//
|
||||
// 保留中文是刻意的:本项目的会话主题多为中文,转拼音需要额外依赖,
|
||||
// 而 `dsh-重构导入路径` 作为地址完全可用(三维寻址只忌 `. / @` 与空白)。
|
||||
func sanitizeAliasPart(s string) string {
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range s {
|
||||
keep := unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
if keep {
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
// 其余一切(空白、标点、符号、寻址保留字符)都折成单个 -
|
||||
if !lastDash && b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
// "new" 是寻址保留字,作为整体别名时必须避开。
|
||||
// 加前缀而不是拒绝:调用方给的素材没有错,是这个词恰好被占用。
|
||||
if out == "new" {
|
||||
return "session-new"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// truncateAlias 按字节截断且不切坏多字节字符(中文主题很容易超 128 字节)。
|
||||
func truncateAlias(s string) string {
|
||||
if len(s) <= aliasMaxBytes {
|
||||
return strings.Trim(s, "-")
|
||||
}
|
||||
cut := s[:aliasMaxBytes]
|
||||
for len(cut) > 0 && !utf8.ValidString(cut) {
|
||||
cut = cut[:len(cut)-1]
|
||||
}
|
||||
return strings.Trim(cut, "-")
|
||||
}
|
||||
283
server/internal/repo/autoalias_test.go
Normal file
283
server/internal/repo/autoalias_test.go
Normal file
@ -0,0 +1,283 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 这一组测试守的是一条不变量:**`.new` 建出来的会话必须立刻可寻址**。
|
||||
//
|
||||
// 破坏方式很隐蔽 —— 邮件照样送达、收件人照样能回复那一封,只有「指名投进同一条
|
||||
// 会话」这个动作静默失败(`FindNamedSessionFor` 查不到未命名会话),再发一次
|
||||
// `.new` 就多一条平行会话。所以这里的断言都落在「事后能不能按别名找回来」上,
|
||||
// 而不是「有没有报错」。
|
||||
|
||||
func TestAutoAliasForCombinesNameAndSubject(t *testing.T) {
|
||||
// 名字与主题都要在:只用主题时「服务恢复验证」这类通用主题会跨 Agent 撞名,
|
||||
// 只用名字则同一个 Agent 的会话全叫 dsh-2、dsh-3,看不出在聊什么。
|
||||
got := AutoAliasFor("dsh", "重构导入路径")
|
||||
if got != "dsh-重构导入路径" {
|
||||
t.Fatalf("want dsh-重构导入路径, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoAliasForStripsAddressingChars(t *testing.T) {
|
||||
// 别名要参与 name@path.session 的切分,含 . / @ 或空白会让地址解析歧义。
|
||||
// 主题里的 Markdown 与标点噪声([联调]、—、:)也不该变成一串破折号。
|
||||
cases := []struct{ in, want string }{
|
||||
{"[联调] llmsproxy / ModelRouter — 请提供部署现状", "x-联调-llmsproxy-ModelRouter-请提供部署现状"},
|
||||
{"a.b.c", "x-a-b-c"},
|
||||
{"has spaces", "x-has-spaces"},
|
||||
{"user@host", "x-user-host"},
|
||||
{"Re: 服务恢复验证", "x-Re-服务恢复验证"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := AutoAliasFor("x", c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("AutoAliasFor(x, %q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
if strings.ContainsAny(got, ". \t/@") {
|
||||
t.Errorf("别名 %q 含寻址保留字符,会破坏地址解析", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoAliasForAvoidsReservedNew(t *testing.T) {
|
||||
// "new" 是 session 位的保留字。别名若正好是它,`x@/p.new` 就同时是
|
||||
// 「投进这条会话」与「再建一条」两种意思。
|
||||
if got := AutoAliasFor("", "new"); got == "new" {
|
||||
t.Fatal("别名不得为保留字 new")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoAliasForEmptyMaterial(t *testing.T) {
|
||||
// 素材里一个可用字符都没有时返回空串,交由 EnsureSessionAlias 走 uuid 兜底,
|
||||
// 而不是在这里编造一个名字。
|
||||
if got := AutoAliasFor("", "···"); got != "" {
|
||||
t.Fatalf("want empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoAliasForTruncatesAtByteLimit(t *testing.T) {
|
||||
// session_alias 是 VARCHAR(128),而中文主题很容易超;按字节截断时
|
||||
// 不能把多字节字符切坏(切坏后写库会得到非法 UTF-8)。
|
||||
got := AutoAliasFor("bot", strings.Repeat("中", 200))
|
||||
if len(got) > aliasMaxBytes {
|
||||
t.Fatalf("别名 %d 字节,超过上限 %d", len(got), aliasMaxBytes)
|
||||
}
|
||||
if !utf8Valid(got) {
|
||||
t.Fatal("截断切坏了多字节字符")
|
||||
}
|
||||
}
|
||||
|
||||
func utf8Valid(s string) bool {
|
||||
for _, r := range s {
|
||||
if r == '\uFFFD' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestEnsureSessionAliasMakesNewSessionAddressable(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 复刻 `.new` 的真实路径:CreateSession(alias=nil) —— 别名是 NULL。
|
||||
sid, err := CreateSession(ctx, nil, "admin", "重构导入路径", "/home/program/agentmail")
|
||||
if err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
seedMailForSession(t, sid, "admin", "dsh", "/home/program/agentmail")
|
||||
|
||||
// 命名前:按别名找不回来(这正是线上那条断链)
|
||||
if _, err := FindNamedSessionFor(ctx, "dsh", "/home/program/agentmail", "dsh-重构导入路径"); err == nil {
|
||||
t.Fatal("未命名会话竟然能按别名找到,测试前提不成立")
|
||||
}
|
||||
|
||||
alias, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "重构导入路径"))
|
||||
if err != nil {
|
||||
t.Fatalf("命名: %v", err)
|
||||
}
|
||||
if alias == "" {
|
||||
t.Fatal("别名为空")
|
||||
}
|
||||
|
||||
// 命名后:收件方能指名投回这条会话,而不是又开一条
|
||||
got, err := FindNamedSessionFor(ctx, "dsh", "/home/program/agentmail", alias)
|
||||
if err != nil {
|
||||
t.Fatalf("按别名寻址: %v", err)
|
||||
}
|
||||
if got != sid {
|
||||
t.Fatalf("别名 %q 指向 %s,应指向 %s", alias, got, sid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSessionAliasKeepsExistingName(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 调用方显式命名过(发信时传了 session_alias),标记为 manual。
|
||||
// 自动命名绝不能覆盖它 —— 人记住的地址不该下一秒失效。
|
||||
want := "llmsproxy-joint"
|
||||
sid, err := CreateSession(ctx, &want, "dsh", "联调", "/home/program/llmsproxy")
|
||||
if err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
|
||||
got, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("opencode", "别的主题"))
|
||||
if err != nil {
|
||||
t.Fatalf("命名: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("已有别名被改写成 %q,应保持 %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSessionAliasIsIdempotent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 默认会话路径上 EnsureSessionAlias 会被每封邮件调用一次
|
||||
// (会话可能是刚建的也可能是复用的),因此重复调用必须返回同一个名字。
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "服务恢复验证", "/tmp/ws")
|
||||
first, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "服务恢复验证"))
|
||||
if err != nil {
|
||||
t.Fatalf("首次命名: %v", err)
|
||||
}
|
||||
second, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "服务恢复验证"))
|
||||
if err != nil {
|
||||
t.Fatalf("二次命名: %v", err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatalf("重复调用给出两个别名: %q vs %q", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSessionAliasSuffixesOnCollision(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 同一个 Agent + 同一主题会反复出现(「服务恢复验证」发两次)。
|
||||
// 别名全局唯一(负责寻址),撞名必须让位而不是报错 —— 发信不该因为
|
||||
// 主题重复而失败。
|
||||
want := AutoAliasFor("dsh", "服务恢复验证")
|
||||
|
||||
a, _ := CreateSession(ctx, nil, "admin", "服务恢复验证", "/tmp/ws")
|
||||
aliasA, err := EnsureSessionAlias(ctx, a, want)
|
||||
if err != nil {
|
||||
t.Fatalf("首个会话命名: %v", err)
|
||||
}
|
||||
|
||||
b, _ := CreateSession(ctx, nil, "admin", "服务恢复验证", "/tmp/ws")
|
||||
aliasB, err := EnsureSessionAlias(ctx, b, want)
|
||||
if err != nil {
|
||||
t.Fatalf("第二个会话命名: %v", err)
|
||||
}
|
||||
|
||||
if aliasA == aliasB {
|
||||
t.Fatalf("两条会话拿到同一个别名 %q", aliasA)
|
||||
}
|
||||
if aliasB != want+"-2" {
|
||||
t.Fatalf("撞名后缀应为 %s-2,实际 %q", want, aliasB)
|
||||
}
|
||||
|
||||
// 两个别名各自指向自己那条会话,没有相互覆盖
|
||||
for alias, expect := range map[string]uuid.UUID{aliasA: a, aliasB: b} {
|
||||
var got uuid.UUID
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT session_id FROM sessions WHERE session_alias = $1`, alias).Scan(&got)
|
||||
if err != nil {
|
||||
t.Fatalf("查别名 %q: %v", alias, err)
|
||||
}
|
||||
if got != expect {
|
||||
t.Errorf("别名 %q 指向 %s,应指向 %s", alias, got, expect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSessionAliasFallsBackToUUID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 主题与名字都拿不出可用字符时(AutoAliasFor 返回空串),
|
||||
// 仍必须得到一个能寻址的别名 —— 丑名字远胜于没有名字。
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "···", "/tmp/ws")
|
||||
alias, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("", "···"))
|
||||
if err != nil {
|
||||
t.Fatalf("兜底命名: %v", err)
|
||||
}
|
||||
if alias == "" {
|
||||
t.Fatal("兜底后别名仍为空")
|
||||
}
|
||||
if !strings.HasPrefix(alias, "session-") {
|
||||
t.Fatalf("兜底别名应形如 session-xxxxxxxx,实际 %q", alias)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSessionAliasSurfacesInSuggestions(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 补全候选只收「有别名的非归档会话」(`session_alias IS NOT NULL AND <> ''`)。
|
||||
// 自动命名的另一半价值就在这里:命名前这条会话在人类的三段式补全里
|
||||
// 也是不可见的,人同样只能靠回复某封邮件才能续谈。
|
||||
seedUser(t, ctx, "admin")
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "重构导入路径", "/home/program/agentmail")
|
||||
seedMailForSession(t, sid, "admin", "dsh", "/home/program/agentmail")
|
||||
|
||||
before, err := SuggestSessionCandidates(ctx, "admin", "dsh", "/home/program/agentmail")
|
||||
if err != nil {
|
||||
t.Fatalf("补全(命名前): %v", err)
|
||||
}
|
||||
for _, c := range before {
|
||||
if c.Source == "mail" {
|
||||
t.Fatalf("未命名会话不该出现在补全里,却拿到 %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
alias, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "重构导入路径"))
|
||||
if err != nil {
|
||||
t.Fatalf("命名: %v", err)
|
||||
}
|
||||
|
||||
after, err := SuggestSessionCandidates(ctx, "admin", "dsh", "/home/program/agentmail")
|
||||
if err != nil {
|
||||
t.Fatalf("补全(命名后): %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, c := range after {
|
||||
if c.Alias == alias {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("命名后 %q 仍未出现在补全候选里: %+v", alias, after)
|
||||
}
|
||||
}
|
||||
|
||||
// seedMailForSession 往会话里塞一封邮件。
|
||||
// FindNamedSessionFor 与 SuggestSessionCandidates 都要求「该收件人参与过」,
|
||||
// 只建会话不建邮件的话两者都查不到,测试会得出错误结论。
|
||||
func seedMailForSession(t *testing.T, sid uuid.UUID, from, to, workspace string) {
|
||||
t.Helper()
|
||||
if _, err := CreateMail(context.Background(), sid, nil,
|
||||
from, "", to, workspace, "主题", "正文", nil); err != nil {
|
||||
t.Fatalf("seed mail: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedUser 插一个用户。SuggestSessionCandidates 的可见性条件要查 users 表。
|
||||
func seedUser(t *testing.T, ctx context.Context, username string) {
|
||||
t.Helper()
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`INSERT INTO users (username, display_name, password_hash, role)
|
||||
VALUES ($1, $1, 'x', 'admin')`, username)
|
||||
if err != nil {
|
||||
t.Fatalf("seed user %s: %v", username, err)
|
||||
}
|
||||
}
|
||||
165
server/internal/repo/budget_test.go
Normal file
165
server/internal/repo/budget_test.go
Normal file
@ -0,0 +1,165 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// setupBudgetDB 复用 quota_test.go 的临时库,再建一个会话。
|
||||
// 用真实 SQLite 而非 mock:预算的正确性核心是「判断与自增在同一条 UPDATE 里」,
|
||||
// 那正是只有真实数据库才能验证的部分。
|
||||
func setupBudgetDB(t *testing.T) uuid.UUID {
|
||||
t.Helper()
|
||||
setupTestDB(t)
|
||||
id, err := CreateSession(context.Background(), nil, "bot", "预算测试", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestSessionBudgetZeroMeansUnlimited(t *testing.T) {
|
||||
id := setupBudgetDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 默认 0 = 不限:引入预算不该把已在进行的会话卡死
|
||||
b, err := GetSessionBudget(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !b.Unlimited || b.Remaining != -1 {
|
||||
t.Fatalf("默认应为不限:%+v", b)
|
||||
}
|
||||
// 不限时反复占用都成功
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := ConsumeSessionBudget(ctx, id); err != nil {
|
||||
t.Fatalf("不限额下第 %d 次占用失败: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionBudgetExhausts(t *testing.T) {
|
||||
id := setupBudgetDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := SetSessionBudget(ctx, id, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 1; i <= 2; i++ {
|
||||
b, err := ConsumeSessionBudget(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 次应成功: %v", i, err)
|
||||
}
|
||||
if b.Used != i {
|
||||
t.Fatalf("第 %d 次后 used = %d", i, b.Used)
|
||||
}
|
||||
}
|
||||
b, err := ConsumeSessionBudget(ctx, id)
|
||||
if !errors.Is(err, ErrSessionBudgetExhausted) {
|
||||
t.Fatalf("第 3 次应耗尽,得到 err=%v b=%+v", err, b)
|
||||
}
|
||||
if b.Remaining != 0 {
|
||||
t.Fatalf("耗尽后剩余应为 0:%+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// 判断与自增必须在同一条 UPDATE 里,否则并发下会把预算刷穿
|
||||
func TestSessionBudgetConcurrentDoesNotOverdraw(t *testing.T) {
|
||||
id := setupBudgetDB(t)
|
||||
ctx := context.Background()
|
||||
const limit = 10
|
||||
if _, err := SetSessionBudget(ctx, id, limit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
ok := 0
|
||||
for i := 0; i < 40; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := ConsumeSessionBudget(ctx, id); err == nil {
|
||||
mu.Lock()
|
||||
ok++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if ok != limit {
|
||||
t.Fatalf("40 并发下成功 %d 次,期望恰好 %d 次(预算被刷穿或误拒)", ok, limit)
|
||||
}
|
||||
b, _ := GetSessionBudget(ctx, id)
|
||||
if b.Used != limit {
|
||||
t.Fatalf("used = %d,期望 %d", b.Used, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionBudgetResetAndLowerBelowUsed(t *testing.T) {
|
||||
id := setupBudgetDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
SetSessionBudget(ctx, id, 5)
|
||||
for i := 0; i < 3; i++ {
|
||||
ConsumeSessionBudget(ctx, id)
|
||||
}
|
||||
|
||||
// 调到低于已用次数 = 「就到这里为止」,是人的合法意图,不该报错
|
||||
b, err := SetSessionBudget(ctx, id, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("下调预算不该失败: %v", err)
|
||||
}
|
||||
if b.Remaining != 0 {
|
||||
t.Fatalf("已用 3 上限 1 时剩余应为 0:%+v", b)
|
||||
}
|
||||
if _, err := ConsumeSessionBudget(ctx, id); !errors.Is(err, ErrSessionBudgetExhausted) {
|
||||
t.Fatal("下调后应立即拦住")
|
||||
}
|
||||
|
||||
// 重置只清已用次数,不动上限
|
||||
b, err = ResetSessionBudget(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.Used != 0 || b.Max != 1 {
|
||||
t.Fatalf("重置后应为 0/1:%+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// 会话预算先扣、全局配额后扣;全局拦下时必须把会话那次退回去,
|
||||
// 否则那格白掉了 —— 那次往返实际上没有发生
|
||||
func TestRefundSessionBudget(t *testing.T) {
|
||||
id := setupBudgetDB(t)
|
||||
ctx := context.Background()
|
||||
SetSessionBudget(ctx, id, 3)
|
||||
ConsumeSessionBudget(ctx, id)
|
||||
|
||||
RefundSessionBudget(ctx, id)
|
||||
b, _ := GetSessionBudget(ctx, id)
|
||||
if b.Used != 0 {
|
||||
t.Fatalf("退还后 used 应为 0:%+v", b)
|
||||
}
|
||||
|
||||
// 已经是 0 时再退不该变成负数
|
||||
RefundSessionBudget(ctx, id)
|
||||
b, _ = GetSessionBudget(ctx, id)
|
||||
if b.Used != 0 {
|
||||
t.Fatalf("重复退还把 used 变成了 %d", b.Used)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSessionBudgetMissingSession(t *testing.T) {
|
||||
setupBudgetDB(t)
|
||||
if _, err := GetSessionBudget(context.Background(), uuid.New()); err == nil {
|
||||
t.Fatal("不存在的会话应报错")
|
||||
} else if errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatal("应包装成可读错误而不是裸 sql.ErrNoRows")
|
||||
}
|
||||
}
|
||||
541
server/internal/repo/calendar.go
Normal file
541
server/internal/repo/calendar.go
Normal file
@ -0,0 +1,541 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/lunar"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var ErrEventNotFound = errors.New("calendar event not found")
|
||||
|
||||
// calendarCols 是所有 SELECT 共用的列清单。
|
||||
//
|
||||
// 抽出来是因为原先有**四处**手抄同一串列名(Get / List / DueEvents 各一处),
|
||||
// 而 Scan 的参数顺序必须与之逐一对应。加一列时漏改任何一处都不会编译报错 ——
|
||||
// 只会在运行时得到 "Scan: expected N destination arguments" 或者更糟:
|
||||
// 列数恰好相同而值错位(曾在 ListSessionsFor 上真的发生过,
|
||||
// 加了预算两列没加进 Scan,整个联系人栏 500)。
|
||||
const calendarCols = `event_id, title, description, reminder_text, agent_name, to_address,
|
||||
recipients, delivery_mode, event_time, remind_before, recurrence, recurrence_end,
|
||||
status, last_fired_at, fired_for, permission_mode, created_at, updated_at, created_by`
|
||||
|
||||
// rowScanner 让 QueryRow 与 Rows 共用同一个 scan 实现。
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// scanCalendarEvent 按 calendarCols 的顺序读一行。
|
||||
//
|
||||
// recipients 存的是 JSON 文本,必须先读进 []byte 再 Unmarshal ——
|
||||
// 直接 Scan 进 []string 会静默失败(driver 不知道怎么转)。
|
||||
func scanCalendarEvent(sc rowScanner) (*models.CalendarEvent, error) {
|
||||
var e models.CalendarEvent
|
||||
var recipientsJSON []byte
|
||||
if err := sc.Scan(
|
||||
&e.EventID, &e.Title, &e.Description, &e.ReminderText,
|
||||
&e.AgentName, &e.ToAddress,
|
||||
&recipientsJSON, &e.DeliveryMode,
|
||||
&e.EventTime, &e.RemindBefore, &e.Recurrence, &e.RecurrenceEnd,
|
||||
&e.Status, &e.LastFiredAt, &e.FiredFor, &e.PermissionMode,
|
||||
&e.CreatedAt, &e.UpdatedAt, &e.CreatedBy,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(recipientsJSON) > 0 {
|
||||
// 解析失败不算致命:退回 to_address/agent_name 兜底链,
|
||||
// 事件仍能投递。让一条脏 JSON 把整个列表打成 500 更糟。
|
||||
_ = json.Unmarshal(recipientsJSON, &e.Recipients)
|
||||
}
|
||||
if e.Recipients == nil {
|
||||
// Go 的 nil slice 序列化成 null,前端 .map 会崩
|
||||
e.Recipients = []string{}
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// marshalRecipients 把收件人列表序列化成入库的 JSON 文本。
|
||||
func marshalRecipients(list []string) string {
|
||||
if list == nil {
|
||||
list = []string{}
|
||||
}
|
||||
b, err := json.Marshal(list)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ─── CRUD ───
|
||||
|
||||
func CreateCalendarEvent(ctx context.Context, e *models.CalendarEvent) (*models.CalendarEvent, error) {
|
||||
e.EventID = uuid.New().String()
|
||||
e.CreatedAt = time.Now()
|
||||
e.UpdatedAt = e.CreatedAt
|
||||
if e.Status == "" {
|
||||
e.Status = "active"
|
||||
}
|
||||
if e.Recurrence == "" {
|
||||
e.Recurrence = "none"
|
||||
}
|
||||
|
||||
if e.DeliveryMode == "" {
|
||||
e.DeliveryMode = models.DeliverSeparate
|
||||
}
|
||||
// 档位合法化:脏值 fail-closed 到默认档,不透传成库里的非法值
|
||||
//(否则后续读路径会拿到一个 ModeNeedsHuman 判定不了的值)。
|
||||
e.PermissionMode = models.NormalizePermissionMode(e.PermissionMode)
|
||||
|
||||
if e.Recipients == nil {
|
||||
e.Recipients = []string{}
|
||||
}
|
||||
|
||||
_, err := db.DB.ExecContext(ctx, `
|
||||
INSERT INTO calendar_events
|
||||
(event_id, title, description, reminder_text, agent_name, to_address,
|
||||
recipients, delivery_mode,
|
||||
event_time, remind_before, recurrence, recurrence_end, status, permission_mode, created_by,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.EventID, e.Title, e.Description, e.ReminderText,
|
||||
e.AgentName, e.ToAddress,
|
||||
marshalRecipients(e.Recipients), e.DeliveryMode,
|
||||
e.EventTime, e.RemindBefore, e.Recurrence, e.RecurrenceEnd,
|
||||
e.Status, e.PermissionMode, e.CreatedBy, e.CreatedAt, e.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func GetCalendarEvent(ctx context.Context, eventID string) (*models.CalendarEvent, error) {
|
||||
e, err := scanCalendarEvent(db.DB.QueryRowContext(ctx,
|
||||
`SELECT `+calendarCols+` FROM calendar_events WHERE event_id = ?`, eventID))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrEventNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func UpdateCalendarEvent(ctx context.Context, eventID string, e *models.CalendarEvent) error {
|
||||
e.UpdatedAt = time.Now()
|
||||
// 档位同样在 update 路径上规范化
|
||||
e.PermissionMode = models.NormalizePermissionMode(e.PermissionMode)
|
||||
result, err := db.DB.ExecContext(ctx, `
|
||||
UPDATE calendar_events SET
|
||||
title = ?, description = ?, reminder_text = ?,
|
||||
agent_name = ?, to_address = ?,
|
||||
recipients = ?, delivery_mode = ?,
|
||||
event_time = ?, remind_before = ?, recurrence = ?, recurrence_end = ?,
|
||||
status = ?, permission_mode = ?, updated_at = ?
|
||||
WHERE event_id = ?`,
|
||||
e.Title, e.Description, e.ReminderText,
|
||||
e.AgentName, e.ToAddress,
|
||||
marshalRecipients(e.Recipients), e.EffectiveDeliveryMode(),
|
||||
e.EventTime, e.RemindBefore, e.Recurrence, e.RecurrenceEnd,
|
||||
e.Status, e.PermissionMode, e.UpdatedAt, eventID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrEventNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteCalendarEvent(ctx context.Context, eventID string) error {
|
||||
result, err := db.DB.ExecContext(ctx, `DELETE FROM calendar_events WHERE event_id = ?`, eventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrEventNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── 查询 ───
|
||||
|
||||
// ListCalendarEvents 返回指定时间范围内的事件(日历视图)。
|
||||
func ListCalendarEvents(ctx context.Context, from, to time.Time, status string) ([]models.CalendarEvent, error) {
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT `+calendarCols+`
|
||||
FROM calendar_events
|
||||
WHERE event_time >= ? AND event_time <= ?
|
||||
AND (status = ? OR ? = '')
|
||||
ORDER BY event_time ASC`, from, to, status, status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var events []models.CalendarEvent
|
||||
for rows.Next() {
|
||||
e, err := scanCalendarEvent(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, *e)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
// ─── 调度器 ───
|
||||
|
||||
// DueEvents 返回下一分钟内需要触发的事件。
|
||||
//
|
||||
// 调度器每分钟调用一次:event_time + remind_before <= now+60s 且尚未触发(last_fired_at 为 NULL
|
||||
// 或小于 event_time)的 active 事件。
|
||||
// DueEvents 取出该触发的事件。
|
||||
//
|
||||
// 60 秒 lookahead 让提醒宁早不晚:调度周期是 30 秒,不提前看的话
|
||||
// 一个刚好落在两个 tick 之间的提醒会迟到最多 30 秒。
|
||||
//
|
||||
// **去重判据是 fired_for(已触发的 occurrence)与 event_time 相等**,
|
||||
// 不是 last_fired_at 与 event_time 比大小 —— 后者在 lookahead 窗口内
|
||||
// 恒为真(触发时刻早于 event_time),会让同一条提醒每个 tick 重发一次。
|
||||
func DueEvents(ctx context.Context) ([]models.CalendarEvent, error) {
|
||||
now := time.Now()
|
||||
deadline := now.Add(60 * time.Second)
|
||||
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT `+calendarCols+`
|
||||
FROM calendar_events
|
||||
WHERE status = 'active'
|
||||
AND datetime(event_time, '-' || remind_before || ' minutes') <= ?
|
||||
AND (fired_for IS NULL OR fired_for <> event_time)
|
||||
ORDER BY event_time ASC`, deadline)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var events []models.CalendarEvent
|
||||
for rows.Next() {
|
||||
e, err := scanCalendarEvent(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, *e)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
// MarkEventFired 标记事件已触发,防止重复。
|
||||
func MarkEventFired(ctx context.Context, eventID string) error {
|
||||
// fired_for 直接从 event_time 列复制而不是在 Go 侧格式化再写回:
|
||||
// 两者必须逐字节相同(判据是字符串相等),经过一轮 time.Time 往返
|
||||
// 有可能改变表示形式。
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE calendar_events SET last_fired_at = ?, fired_for = event_time
|
||||
WHERE event_id = ?`,
|
||||
time.Now(), eventID)
|
||||
return err
|
||||
}
|
||||
|
||||
// AdvanceRecurrence 为重复事件计算下一次触发时间。
|
||||
//
|
||||
// 返回 false 表示重复已过期(recurrence_end 已过),事件应置为 cancelled。
|
||||
func AdvanceRecurrence(ctx context.Context, eventID string) (bool, error) {
|
||||
var recurrence string
|
||||
var eventTime time.Time
|
||||
var recurrenceEnd *time.Time
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT recurrence, event_time, recurrence_end
|
||||
FROM calendar_events WHERE event_id = ?`, eventID).Scan(
|
||||
&recurrence, &eventTime, &recurrenceEnd)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if recurrence == models.RecurNone {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// **一路推到未来**,不是只推一步。
|
||||
//
|
||||
// 只推一步的后果(实测):一条 100 天前设的每日事件,每轮扫描都判定
|
||||
// 「已过期该触发」→ 发一封 → event_time 只前进一天 → 下一轮又过期。
|
||||
// 30 轮扫描触发 30 次,而调度周期是 30 秒 —— 人会收到一串垃圾提醒,
|
||||
// 连发 100 封才追上今天。
|
||||
//
|
||||
// 跳过的那些 occurrence **不补发**:定时提醒的价值在于「按时」,
|
||||
// 三个月前那次站会提醒现在发出去毫无意义,只会淹掉真正该看的那封。
|
||||
// 本轮仍会发一封(fireEvent 已经在发了),代表「这条规则还活着」。
|
||||
next, err := advanceToFuture(recurrence, eventTime, time.Now(), recurrenceEnd)
|
||||
if err != nil {
|
||||
// 推不出下一次(例如「每年农历闰六月」而目标年无闰六月):
|
||||
// 置为 cancelled 而不是留在 active 空转。留着会让调度器每 30 秒
|
||||
// 重试同一个算不出来的规则,日志里刷同一条错误直到有人发现。
|
||||
_, cErr := db.DB.ExecContext(ctx,
|
||||
`UPDATE calendar_events SET status = 'cancelled' WHERE event_id = ?`, eventID)
|
||||
if cErr != nil {
|
||||
return false, cErr
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
// 零值 = 已越过 recurrence_end("none" 在函数开头就返回了,到不了这里)。
|
||||
// **必须置 cancelled**:留在 active 会让 DueEvents 每轮都捞到这条
|
||||
// 早已过期的事件,而 fired_for 已经等于 event_time 所以它又不会被触发 ——
|
||||
// 表现是一条永远排在到期列表里、永远不动的僵尸事件。
|
||||
if next.IsZero() {
|
||||
_, cErr := db.DB.ExecContext(ctx,
|
||||
`UPDATE calendar_events SET status = 'cancelled', updated_at = ? WHERE event_id = ?`,
|
||||
time.Now(), eventID)
|
||||
return false, cErr
|
||||
}
|
||||
|
||||
// 走到这里说明 next 既在未来又在终止时间之内。
|
||||
_, err = db.DB.ExecContext(ctx,
|
||||
`UPDATE calendar_events SET event_time = ?, updated_at = ? WHERE event_id = ?`,
|
||||
next, time.Now(), eventID)
|
||||
return true, err
|
||||
}
|
||||
|
||||
// advanceToFuture 从 from 起反复按规则推进,直到越过 now。
|
||||
//
|
||||
// 单独成不碰数据库的函数是为了可测。三个终止条件,缺一不可:
|
||||
//
|
||||
// 1. 越过 now —— 正常出口
|
||||
// 2. 越过 recurrenceEnd —— 返回零值,调用方据此置 cancelled
|
||||
// 3. maxAdvanceSteps 上限 —— 防御性的。规则算得出但不前进(理论上
|
||||
// NextOccurrence 不会返回 <= 当前值,但农历那条路径依赖外部库,
|
||||
// 一旦它某年给出反直觉结果,没有上限就是个死循环 goroutine,
|
||||
// 而它跑在调度器里 —— 整个提醒系统会一起卡住)
|
||||
//
|
||||
// 上限取 4000:按每日重复算约 11 年,足够覆盖「很久以前设的提醒」,
|
||||
// 而 4000 次纯内存日期运算在一个 tick 里跑完毫无压力。
|
||||
const maxAdvanceSteps = 4000
|
||||
|
||||
func advanceToFuture(recurrence string, from, now time.Time, recurrenceEnd *time.Time) (time.Time, error) {
|
||||
cur := from
|
||||
for i := 0; i < maxAdvanceSteps; i++ {
|
||||
next, err := NextOccurrence(recurrence, cur)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if next.IsZero() {
|
||||
return time.Time{}, nil // 不重复
|
||||
}
|
||||
if !next.After(cur) {
|
||||
// 规则不前进 —— 与死循环等价,当作算不出来
|
||||
return time.Time{}, fmt.Errorf("重复规则 %q 未能前进(停在 %s)", recurrence, cur.Format(time.RFC3339))
|
||||
}
|
||||
cur = next
|
||||
if recurrenceEnd != nil && cur.After(*recurrenceEnd) {
|
||||
return time.Time{}, nil // 已过终止时间
|
||||
}
|
||||
if cur.After(now) {
|
||||
return cur, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("重复规则 %q 推进 %d 次仍未越过当前时刻", recurrence, maxAdvanceSteps)
|
||||
}
|
||||
|
||||
// NextOccurrence 按重复规则算出下一次触发时刻。
|
||||
//
|
||||
// 独立成不碰数据库的纯函数是为了可测:农历推进错了不会报错,
|
||||
// 只会让提醒发在错误的日子,而那种错误要等真的过了一个月才看得见。
|
||||
//
|
||||
// 返回零值 time 且 err == nil 表示「不重复」(规则是 none 或未知值)。
|
||||
//
|
||||
// **农历规则不能用 AddDate 近似**:农历月 29~30 天不定、农历年 353~385 天
|
||||
// (闰年多一整月)。用固定天数推进一年能偏半个月 —— 农历生日提醒会
|
||||
// 逐年漂移到完全不相干的日子上。
|
||||
func NextOccurrence(recurrence string, from time.Time) (time.Time, error) {
|
||||
switch recurrence {
|
||||
case models.RecurDaily:
|
||||
return from.AddDate(0, 0, 1), nil
|
||||
case models.RecurWeekly:
|
||||
return from.AddDate(0, 0, 7), nil
|
||||
case models.RecurMonthly:
|
||||
// 公历每月:AddDate 在月末会溢出(1 月 31 日 +1 月 = 3 月 3 日)。
|
||||
// 夹到目标月的最后一天 —— 与农历那边的 clamp 语义一致:
|
||||
// 「每月 31 日」的意思是「月末」,滚到下月初是错的。
|
||||
return addSolarMonthClamped(from, 1), nil
|
||||
case models.RecurYearly:
|
||||
// 公历每年:2 月 29 日在平年会溢出成 3 月 1 日,同样要夹。
|
||||
// 闰日生日的约定是「平年过 2 月 28」,不是 3 月 1 日。
|
||||
return addSolarMonthClamped(from, 12), nil
|
||||
case models.RecurLunarMonthly:
|
||||
d := lunar.FromSolar(from).AddMonths(1)
|
||||
t, _, err := d.ToSolar(from.Location(), from.Hour(), from.Minute(), from.Second(), from.Nanosecond())
|
||||
return t, err
|
||||
case models.RecurLunarYearly:
|
||||
d := lunar.FromSolar(from).AddYears(1)
|
||||
t, _, err := d.ToSolar(from.Location(), from.Hour(), from.Minute(), from.Second(), from.Nanosecond())
|
||||
return t, err
|
||||
default:
|
||||
return time.Time{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// addSolarMonthClamped 在公历上加月份,日期夹到目标月的实际天数内。
|
||||
//
|
||||
// time.AddDate 的溢出行为(3 月 31 日 +1 月 = 5 月 1 日)对「每月同一日」
|
||||
// 的提醒是错的:31 日的事件会在 2 月变成 3 月 3 日,然后从此每月 3 日提醒
|
||||
// —— 一次溢出永久改变了规则。
|
||||
func addSolarMonthClamped(t time.Time, n int) time.Time {
|
||||
y, m, d := t.Date()
|
||||
m += time.Month(n)
|
||||
for m > 12 {
|
||||
m -= 12
|
||||
y++
|
||||
}
|
||||
// 目标月第 0 天 = 上个月最后一天,用它拿到月长
|
||||
last := time.Date(y, m+1, 0, 0, 0, 0, 0, t.Location()).Day()
|
||||
if d > last {
|
||||
d = last
|
||||
}
|
||||
return time.Date(y, m, d, t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), t.Location())
|
||||
}
|
||||
|
||||
// ─── 附件 ───
|
||||
|
||||
func AddCalendarAttachment(ctx context.Context, a *models.CalendarAttachment) error {
|
||||
a.AttachmentID = uuid.New().String()
|
||||
a.CreatedAt = time.Now()
|
||||
_, err := db.DB.ExecContext(ctx, `
|
||||
INSERT INTO calendar_attachments (attachment_id, event_id, filename, sha256, size_bytes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
a.AttachmentID, a.EventID, a.Filename, a.SHA256, a.SizeBytes, a.CreatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func ListCalendarAttachments(ctx context.Context, eventID string) ([]models.CalendarAttachment, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT attachment_id, event_id, filename, sha256, size_bytes, created_at
|
||||
FROM calendar_attachments WHERE event_id = ? ORDER BY created_at`, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var atts []models.CalendarAttachment
|
||||
for rows.Next() {
|
||||
var a models.CalendarAttachment
|
||||
if err := rows.Scan(&a.AttachmentID, &a.EventID, &a.Filename, &a.SHA256, &a.SizeBytes, &a.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
atts = append(atts, a)
|
||||
}
|
||||
return atts, rows.Err()
|
||||
}
|
||||
|
||||
func DeleteCalendarAttachments(ctx context.Context, eventID string) error {
|
||||
_, err := db.DB.ExecContext(ctx, `DELETE FROM calendar_attachments WHERE event_id = ?`, eventID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteCalendarAttachment 删单条附件。
|
||||
//
|
||||
// 返回 false 表示这条不存在(而不是报错):调用方据此回 404 而非 500。
|
||||
// 只删元数据,磁盘 blob 留给 GC —— 内容寻址下同一个 sha256 可能被别的
|
||||
// 附件引用着,跟着删会让那些引用一起坏掉。
|
||||
func DeleteCalendarAttachment(ctx context.Context, attachmentID string) (bool, error) {
|
||||
res, err := db.DB.ExecContext(ctx,
|
||||
`DELETE FROM calendar_attachments WHERE attachment_id = ?`, attachmentID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// AttachCalendarFilesToMail 把事件的附件复制成邮件附件。
|
||||
//
|
||||
// 提醒邮件是新建的,附件必须重新挂一份指向同一 sha256 的元数据 ——
|
||||
// 内容寻址下这不拷磁盘文件,只是多一条记录。
|
||||
//
|
||||
// 缺了这一步的后果:人在事件上传了附件、UI 里看得见、提醒也按时发出,
|
||||
// 但 Agent 收到的那封信里附件清单是空的 —— 事件附件与邮件附件是两张表,
|
||||
// 不复制就永远只存在于日历侧。这是「日历附件只记元数据未接投递」的另一半。
|
||||
//
|
||||
// uploader 记为 calendarSender("calendar"):附件随提醒邮件重新分发,
|
||||
// 其可见范围由该邮件的参与方决定,而不是沿用事件创建者。
|
||||
func AttachCalendarFilesToMail(ctx context.Context, eventID string, mailID uuid.UUID, uploader string) (int, error) {
|
||||
atts, err := ListCalendarAttachments(ctx, eventID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n := 0
|
||||
for _, a := range atts {
|
||||
// sha256 为空说明这条记录没有真实内容(历史脏数据),跳过而不是
|
||||
// 挂一个下载必然 404 的附件
|
||||
if a.SHA256 == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := db.DB.ExecContext(ctx, `
|
||||
INSERT INTO attachments (mail_id, uploader, filename, content_type, size_bytes, sha256)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, mailID, uploader, a.Filename, "application/octet-stream", a.SizeBytes, a.SHA256); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ListCalendarEventsCreatedBy 只返回某个创建者建的事件。
|
||||
//
|
||||
// Agent 侧列表用这个而不是 ListCalendarEvents:Agent 不该看到别人(人类或
|
||||
// 其他 Agent)的日程 —— 那里可能有它无权知道的会议、地址、附件名。
|
||||
//
|
||||
// 注意**不是**「发给我的事件」:`recipients` 里有我但我没建的,同样不返回。
|
||||
// 理由是那些事件的编辑权不属于我,列出来只会让模型试图改它然后拿到 403。
|
||||
// 想知道「谁给我设了提醒」,那条信息在提醒邮件本身里。
|
||||
func ListCalendarEventsCreatedBy(ctx context.Context, creator string, from, to time.Time, status string) ([]models.CalendarEvent, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT `+calendarCols+`
|
||||
FROM calendar_events
|
||||
WHERE created_by = ?
|
||||
AND event_time >= ? AND event_time <= ?
|
||||
AND (status = ? OR ? = '')
|
||||
ORDER BY event_time ASC`, creator, from, to, status, status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
events := []models.CalendarEvent{}
|
||||
for rows.Next() {
|
||||
e, err := scanCalendarEvent(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, *e)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
// CountActiveEventsBy 数某个创建者当前有多少条生效中的事件。
|
||||
//
|
||||
// 给 Agent 侧的总量上限用。速率限制只压住「短时间内暴建」,
|
||||
// 压不住「每小时建 19 条、连建一周」—— 而日历事件是长效的,
|
||||
// 攒下来的每一条都会持续产生提醒邮件。
|
||||
func CountActiveEventsBy(ctx context.Context, creator string) (int, error) {
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM calendar_events WHERE created_by = ? AND status = 'active'`,
|
||||
creator).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
972
server/internal/repo/calendar_test.go
Normal file
972
server/internal/repo/calendar_test.go
Normal file
@ -0,0 +1,972 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/lunar"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func seedEvent(t *testing.T, e *models.CalendarEvent) *models.CalendarEvent {
|
||||
t.Helper()
|
||||
if e.Title == "" {
|
||||
e.Title = "测试事件"
|
||||
}
|
||||
if e.EventTime.IsZero() {
|
||||
e.EventTime = time.Now().Add(time.Hour)
|
||||
}
|
||||
out, err := CreateCalendarEvent(context.Background(), e)
|
||||
if err != nil {
|
||||
t.Fatalf("建事件: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCalendarEventCRUD(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
at := time.Now().Add(2 * time.Hour).Truncate(time.Second)
|
||||
e := seedEvent(t, &models.CalendarEvent{
|
||||
Title: "每日站会",
|
||||
Description: "同步进展",
|
||||
ReminderText: "日程提醒:{title}",
|
||||
AgentName: "dsh",
|
||||
ToAddress: "dsh@/home",
|
||||
EventTime: at,
|
||||
RemindBefore: 15,
|
||||
Recurrence: "daily",
|
||||
CreatedBy: "jianf",
|
||||
})
|
||||
|
||||
if e.EventID == "" {
|
||||
t.Fatal("建完事件必须有 event_id")
|
||||
}
|
||||
if e.Status != "active" {
|
||||
t.Errorf("新事件默认应为 active,得到 %q", e.Status)
|
||||
}
|
||||
|
||||
got, err := GetCalendarEvent(ctx, e.EventID)
|
||||
if err != nil {
|
||||
t.Fatalf("读事件: %v", err)
|
||||
}
|
||||
if got.Title != "每日站会" || got.RemindBefore != 15 || got.Recurrence != "daily" {
|
||||
t.Errorf("读回的字段不符:%+v", got)
|
||||
}
|
||||
if !got.EventTime.Equal(at) {
|
||||
t.Errorf("event_time 读回错位:写 %v 读 %v", at, got.EventTime)
|
||||
}
|
||||
|
||||
got.Title = "改名后的站会"
|
||||
got.Status = "paused"
|
||||
if err := UpdateCalendarEvent(ctx, e.EventID, got); err != nil {
|
||||
t.Fatalf("改事件: %v", err)
|
||||
}
|
||||
again, _ := GetCalendarEvent(ctx, e.EventID)
|
||||
if again.Title != "改名后的站会" || again.Status != "paused" {
|
||||
t.Errorf("改后没生效:%+v", again)
|
||||
}
|
||||
|
||||
if err := DeleteCalendarEvent(ctx, e.EventID); err != nil {
|
||||
t.Fatalf("删事件: %v", err)
|
||||
}
|
||||
if _, err := GetCalendarEvent(ctx, e.EventID); err != ErrEventNotFound {
|
||||
t.Errorf("删掉后应报 ErrEventNotFound,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarNotFoundIsTyped(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 不存在的 id 要给出可判定的错误,而不是 sql.ErrNoRows ——
|
||||
// handler 靠它区分 404 与 500。
|
||||
if _, err := GetCalendarEvent(ctx, "00000000-0000-0000-0000-000000000000"); err != ErrEventNotFound {
|
||||
t.Errorf("Get 应报 ErrEventNotFound,得到 %v", err)
|
||||
}
|
||||
if err := DeleteCalendarEvent(ctx, "00000000-0000-0000-0000-000000000000"); err != ErrEventNotFound {
|
||||
t.Errorf("Delete 应报 ErrEventNotFound,得到 %v", err)
|
||||
}
|
||||
if err := UpdateCalendarEvent(ctx, "00000000-0000-0000-0000-000000000000",
|
||||
&models.CalendarEvent{Title: "x", EventTime: time.Now()}); err != ErrEventNotFound {
|
||||
t.Errorf("Update 应报 ErrEventNotFound,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueEventsOnlyReturnsRipe(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// 已经该响的(事件时间在过去)
|
||||
ripe := seedEvent(t, &models.CalendarEvent{Title: "该响了", EventTime: now.Add(-time.Minute)})
|
||||
// 提前 30 分钟提醒、事件在 20 分钟后 —— 提醒点已过
|
||||
early := seedEvent(t, &models.CalendarEvent{
|
||||
Title: "提前提醒已到", EventTime: now.Add(20 * time.Minute), RemindBefore: 30,
|
||||
})
|
||||
// 还早(1 小时后,无提前提醒)
|
||||
future := seedEvent(t, &models.CalendarEvent{Title: "还早", EventTime: now.Add(time.Hour)})
|
||||
// 已暂停的不该响
|
||||
paused := seedEvent(t, &models.CalendarEvent{Title: "暂停的", EventTime: now.Add(-time.Minute)})
|
||||
p, _ := GetCalendarEvent(ctx, paused.EventID)
|
||||
p.Status = "paused"
|
||||
if err := UpdateCalendarEvent(ctx, paused.EventID, p); err != nil {
|
||||
t.Fatalf("暂停: %v", err)
|
||||
}
|
||||
|
||||
due, err := DueEvents(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DueEvents: %v", err)
|
||||
}
|
||||
|
||||
got := map[string]bool{}
|
||||
for _, e := range due {
|
||||
got[e.EventID] = true
|
||||
}
|
||||
if !got[ripe.EventID] {
|
||||
t.Error("到期事件没被取出")
|
||||
}
|
||||
if !got[early.EventID] {
|
||||
t.Error("remind_before 已过的事件没被取出")
|
||||
}
|
||||
if got[future.EventID] {
|
||||
t.Error("未到期事件被取出了")
|
||||
}
|
||||
if got[paused.EventID] {
|
||||
t.Error("已暂停的事件被取出了")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkEventFiredStopsRefiring(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 幂等的关键:标记后同一条不该再出现在 DueEvents 里,
|
||||
// 否则调度器每 30 秒把同一封提醒重发一遍。
|
||||
e := seedEvent(t, &models.CalendarEvent{Title: "只该响一次", EventTime: time.Now().Add(-time.Minute)})
|
||||
|
||||
due, _ := DueEvents(ctx)
|
||||
if len(due) != 1 {
|
||||
t.Fatalf("标记前应有 1 条到期,得到 %d", len(due))
|
||||
}
|
||||
|
||||
if err := MarkEventFired(ctx, e.EventID); err != nil {
|
||||
t.Fatalf("标记: %v", err)
|
||||
}
|
||||
|
||||
due, _ = DueEvents(ctx)
|
||||
for _, d := range due {
|
||||
if d.EventID == e.EventID {
|
||||
t.Error("已标记触发的事件仍出现在 DueEvents 里")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvanceRecurrence(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
base := time.Now().Add(-time.Minute).Truncate(time.Second)
|
||||
|
||||
t.Run("一次性事件不推进", func(t *testing.T) {
|
||||
e := seedEvent(t, &models.CalendarEvent{Title: "一次性", EventTime: base, Recurrence: "none"})
|
||||
advanced, err := AdvanceRecurrence(ctx, e.EventID)
|
||||
if err != nil {
|
||||
t.Fatalf("推进: %v", err)
|
||||
}
|
||||
if advanced {
|
||||
t.Error("recurrence=none 不该推进")
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
rule string
|
||||
want time.Time
|
||||
}{
|
||||
{"daily", base.AddDate(0, 0, 1)},
|
||||
{"weekly", base.AddDate(0, 0, 7)},
|
||||
{"monthly", base.AddDate(0, 1, 0)},
|
||||
} {
|
||||
t.Run(tc.rule+" 推进一个周期", func(t *testing.T) {
|
||||
e := seedEvent(t, &models.CalendarEvent{
|
||||
Title: tc.rule, EventTime: base, Recurrence: tc.rule,
|
||||
})
|
||||
advanced, err := AdvanceRecurrence(ctx, e.EventID)
|
||||
if err != nil {
|
||||
t.Fatalf("推进: %v", err)
|
||||
}
|
||||
if !advanced {
|
||||
t.Fatal("应该推进")
|
||||
}
|
||||
got, _ := GetCalendarEvent(ctx, e.EventID)
|
||||
if !got.EventTime.Equal(tc.want) {
|
||||
t.Errorf("下次时间应为 %v,得到 %v", tc.want, got.EventTime)
|
||||
}
|
||||
// 推进后 event_time 已在未来,且 last_fired_at 仍为旧值 →
|
||||
// 必须重新出现在 DueEvents 里等待下一轮(否则重复事件只响一次)。
|
||||
if got.Status != "active" {
|
||||
t.Errorf("推进后应仍为 active,得到 %q", got.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("超过 recurrence_end 则取消", func(t *testing.T) {
|
||||
end := base.Add(12 * time.Hour) // 下一次(+1 天)会越过它
|
||||
e := seedEvent(t, &models.CalendarEvent{
|
||||
Title: "快结束了", EventTime: base, Recurrence: "daily", RecurrenceEnd: &end,
|
||||
})
|
||||
advanced, err := AdvanceRecurrence(ctx, e.EventID)
|
||||
if err != nil {
|
||||
t.Fatalf("推进: %v", err)
|
||||
}
|
||||
if advanced {
|
||||
t.Error("越过 recurrence_end 时不该报告推进成功")
|
||||
}
|
||||
got, _ := GetCalendarEvent(ctx, e.EventID)
|
||||
if got.Status != "cancelled" {
|
||||
t.Errorf("越过结束时间应置为 cancelled,得到 %q", got.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListCalendarEventsRange(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
inRange := seedEvent(t, &models.CalendarEvent{Title: "范围内", EventTime: now.Add(time.Hour)})
|
||||
seedEvent(t, &models.CalendarEvent{Title: "太远", EventTime: now.AddDate(0, 3, 0)})
|
||||
|
||||
events, err := ListCalendarEvents(ctx, now, now.Add(24*time.Hour), "active")
|
||||
if err != nil {
|
||||
t.Fatalf("列事件: %v", err)
|
||||
}
|
||||
if len(events) != 1 || events[0].EventID != inRange.EventID {
|
||||
t.Errorf("时间范围过滤不对,得到 %d 条", len(events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarAttachments(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
e := seedEvent(t, &models.CalendarEvent{Title: "带附件"})
|
||||
|
||||
if err := AddCalendarAttachment(ctx, &models.CalendarAttachment{
|
||||
EventID: e.EventID, Filename: "报表.xlsx", SHA256: "abc", SizeBytes: 2048,
|
||||
}); err != nil {
|
||||
t.Fatalf("加附件: %v", err)
|
||||
}
|
||||
|
||||
atts, err := ListCalendarAttachments(ctx, e.EventID)
|
||||
if err != nil {
|
||||
t.Fatalf("列附件: %v", err)
|
||||
}
|
||||
if len(atts) != 1 || atts[0].Filename != "报表.xlsx" {
|
||||
t.Fatalf("附件读回不符:%+v", atts)
|
||||
}
|
||||
if atts[0].AttachmentID == "" {
|
||||
t.Error("附件必须有 attachment_id —— 没有它模型无法在 send_mail 里引用")
|
||||
}
|
||||
|
||||
// 事件没有附件时返回空而不是报错
|
||||
other := seedEvent(t, &models.CalendarEvent{Title: "没附件"})
|
||||
if atts, err := ListCalendarAttachments(ctx, other.EventID); err != nil || len(atts) != 0 {
|
||||
t.Errorf("无附件事件应返回空列表,得到 %d 条 err=%v", len(atts), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCalendarAttachment(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
e := seedEvent(t, &models.CalendarEvent{Title: "要删附件"})
|
||||
for _, name := range []string{"甲.pdf", "乙.pdf"} {
|
||||
if err := AddCalendarAttachment(ctx, &models.CalendarAttachment{
|
||||
EventID: e.EventID, Filename: name, SHA256: "sum-" + name, SizeBytes: 10,
|
||||
}); err != nil {
|
||||
t.Fatalf("加附件 %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
atts, _ := ListCalendarAttachments(ctx, e.EventID)
|
||||
if len(atts) != 2 {
|
||||
t.Fatalf("准备阶段应有 2 个附件,得到 %d", len(atts))
|
||||
}
|
||||
|
||||
ok, err := DeleteCalendarAttachment(ctx, atts[0].AttachmentID)
|
||||
if err != nil {
|
||||
t.Fatalf("删附件: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("删掉存在的附件应返回 true")
|
||||
}
|
||||
|
||||
left, _ := ListCalendarAttachments(ctx, e.EventID)
|
||||
if len(left) != 1 {
|
||||
t.Fatalf("删一个后应剩 1 个,得到 %d", len(left))
|
||||
}
|
||||
if left[0].AttachmentID == atts[0].AttachmentID {
|
||||
t.Error("删错了对象")
|
||||
}
|
||||
|
||||
// 不存在的 id 返回 false 而不是报错 —— 调用方据此回 404 而非 500
|
||||
ok, err = DeleteCalendarAttachment(ctx, "00000000-0000-0000-0000-000000000000")
|
||||
if err != nil {
|
||||
t.Errorf("删不存在的附件不该报错,得到 %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("删不存在的附件应返回 false")
|
||||
}
|
||||
}
|
||||
|
||||
// 事件附件必须能复制成邮件附件。
|
||||
//
|
||||
// 少了这一步,附件只存在于日历侧:UI 里看得见、提醒按时发出、
|
||||
// 而 Agent 收到的那封信附件清单是空的 —— 两张表互不相通。
|
||||
func TestAttachCalendarFilesToMail(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
e := seedEvent(t, &models.CalendarEvent{Title: "带附件的提醒"})
|
||||
if err := AddCalendarAttachment(ctx, &models.CalendarAttachment{
|
||||
EventID: e.EventID, Filename: "周报.md", SHA256: "deadbeef", SizeBytes: 512,
|
||||
}); err != nil {
|
||||
t.Fatalf("加附件: %v", err)
|
||||
}
|
||||
// sha256 为空的脏数据必须被跳过:挂上去只会得到一个下载必然 404 的附件
|
||||
if err := AddCalendarAttachment(ctx, &models.CalendarAttachment{
|
||||
EventID: e.EventID, Filename: "没内容.bin", SHA256: "", SizeBytes: 0,
|
||||
}); err != nil {
|
||||
t.Fatalf("加空附件: %v", err)
|
||||
}
|
||||
|
||||
seedAgentForAttach(t, "pi")
|
||||
sessionID := seedSessionForAttach(t, "pi")
|
||||
mailID, err := CreateMail(ctx, sessionID, nil, "calendar", "", "pi", "", "日程提醒:带附件的提醒", "正文", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("建邮件: %v", err)
|
||||
}
|
||||
|
||||
n, err := AttachCalendarFilesToMail(ctx, e.EventID, mailID, "calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("挂附件: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("应只挂 1 个(空 sha256 那条跳过),得到 %d", n)
|
||||
}
|
||||
|
||||
mailAtts, err := ListAttachmentsFor(ctx, mailID)
|
||||
if err != nil {
|
||||
t.Fatalf("列邮件附件: %v", err)
|
||||
}
|
||||
if len(mailAtts) != 1 {
|
||||
t.Fatalf("邮件上应有 1 个附件,得到 %d", len(mailAtts))
|
||||
}
|
||||
if mailAtts[0].Filename != "周报.md" || mailAtts[0].SHA256 != "deadbeef" {
|
||||
t.Errorf("附件内容不符:%+v", mailAtts[0])
|
||||
}
|
||||
// 内容寻址:复制不产生新的 sha256,指向同一份磁盘文件
|
||||
if mailAtts[0].Uploader != "calendar" {
|
||||
t.Errorf("uploader 应是 calendar,得到 %q", mailAtts[0].Uploader)
|
||||
}
|
||||
|
||||
// 没有附件的事件挂 0 个且不报错
|
||||
empty := seedEvent(t, &models.CalendarEvent{Title: "无附件"})
|
||||
if n, err := AttachCalendarFilesToMail(ctx, empty.EventID, mailID, "calendar"); err != nil || n != 0 {
|
||||
t.Errorf("无附件事件应挂 0 个,得到 %d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedAgentForAttach(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
if _, err := db.DB.ExecContext(context.Background(),
|
||||
`INSERT INTO agents (agent_name, secret, platform) VALUES ($1, 'x', 'test')`,
|
||||
name); err != nil {
|
||||
t.Fatalf("seed agent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedSessionForAttach(t *testing.T, agentName string) uuid.UUID {
|
||||
t.Helper()
|
||||
id := uuid.New()
|
||||
// 列名是 from_agent 而不是 agent_name(后者是 agents 表的主键名)
|
||||
if _, err := db.DB.ExecContext(context.Background(),
|
||||
`INSERT INTO sessions (session_id, from_agent, subject, session_alias, status)
|
||||
VALUES ($1, $2, '日程提醒', 'cal-test', 'active')`,
|
||||
id, agentName); err != nil {
|
||||
t.Fatalf("seed session: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// 落在 60 秒 lookahead 窗口内的**未来**事件,标记后不得再次到期。
|
||||
//
|
||||
// 生产实测的重发现场:一条 event_time=12:53:17 的事件在
|
||||
// 12:52:30 / 12:53:00 / 12:53:06 / 12:53:36 各发了一封相同提醒。
|
||||
// 根因是去重判据写成 `last_fired_at < event_time` —— 触发时刻(now)
|
||||
// 本来就早于 event_time,条件恒真,于是每个 tick 重发一次,
|
||||
// 直到 event_time 真正过去才自己停下。
|
||||
//
|
||||
// 改成按 occurrence 相等(fired_for = 当时的 event_time)才精确:
|
||||
// AdvanceRecurrence 改了 event_time 就该再触发,没改就永不重发。
|
||||
func TestFiredEventInLookaheadWindowDoesNotRefire(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 47 秒后 —— 在 lookahead 窗口内,所以第一次扫描就会入选
|
||||
e := seedEvent(t, &models.CalendarEvent{
|
||||
Title: "窗口内的未来事件", EventTime: time.Now().Add(47 * time.Second),
|
||||
})
|
||||
|
||||
due, err := DueEvents(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("首次扫描: %v", err)
|
||||
}
|
||||
if len(due) != 1 {
|
||||
t.Fatalf("lookahead 应让它提前入选,得到 %d 条", len(due))
|
||||
}
|
||||
|
||||
if err := MarkEventFired(ctx, e.EventID); err != nil {
|
||||
t.Fatalf("标记: %v", err)
|
||||
}
|
||||
|
||||
// 模拟后续几个 tick
|
||||
for i := 0; i < 3; i++ {
|
||||
due, err = DueEvents(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 次重扫: %v", i+2, err)
|
||||
}
|
||||
for _, d := range due {
|
||||
if d.EventID == e.EventID {
|
||||
t.Fatalf("第 %d 次扫描仍判定到期 —— 提醒会被重发", i+2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重复事件推进 event_time 之后必须重新到期:
|
||||
// 按 occurrence 去重的另一半,漏了它就变成「每个重复事件只响一次」。
|
||||
func TestRecurringEventRefiresAfterAdvance(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
e := seedEvent(t, &models.CalendarEvent{
|
||||
Title: "每天都要响",
|
||||
EventTime: time.Now().Add(-time.Minute),
|
||||
Recurrence: "daily",
|
||||
})
|
||||
|
||||
if err := MarkEventFired(ctx, e.EventID); err != nil {
|
||||
t.Fatalf("标记: %v", err)
|
||||
}
|
||||
due, _ := DueEvents(ctx)
|
||||
for _, d := range due {
|
||||
if d.EventID == e.EventID {
|
||||
t.Fatal("标记后不该立刻再次到期")
|
||||
}
|
||||
}
|
||||
|
||||
// 推进到下一次(+1 天)后,把时间挪到过去模拟「第二天到了」
|
||||
if _, err := AdvanceRecurrence(ctx, e.EventID); err != nil {
|
||||
t.Fatalf("推进重复: %v", err)
|
||||
}
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE calendar_events SET event_time = ? WHERE event_id = ?`,
|
||||
time.Now().Add(-30*time.Second), e.EventID); err != nil {
|
||||
t.Fatalf("模拟次日: %v", err)
|
||||
}
|
||||
|
||||
due, _ = DueEvents(ctx)
|
||||
found := false
|
||||
for _, d := range due {
|
||||
if d.EventID == e.EventID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("event_time 推进后应重新到期,否则重复事件只响一次")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 重复规则推进(NextOccurrence 是纯函数,不碰数据库)───
|
||||
|
||||
func TestNextOccurrenceSolar(t *testing.T) {
|
||||
base := time.Date(2026, 9, 3, 9, 30, 0, 0, time.Local)
|
||||
|
||||
cases := []struct {
|
||||
rule string
|
||||
want string
|
||||
}{
|
||||
{models.RecurDaily, "2026-09-04"},
|
||||
{models.RecurWeekly, "2026-09-10"},
|
||||
{models.RecurMonthly, "2026-10-03"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := NextOccurrence(c.rule, base)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", c.rule, err)
|
||||
continue
|
||||
}
|
||||
if got.Format("2006-01-02") != c.want {
|
||||
t.Errorf("%s: 得到 %s,期望 %s", c.rule, got.Format("2006-01-02"), c.want)
|
||||
}
|
||||
// 时钟必须原样保留
|
||||
if got.Hour() != 9 || got.Minute() != 30 {
|
||||
t.Errorf("%s: 时钟被改动 %v", c.rule, got)
|
||||
}
|
||||
}
|
||||
|
||||
// none 与未知值都返回零值 + nil error
|
||||
for _, r := range []string{models.RecurNone, "", "每隔一个蓝月亮"} {
|
||||
got, err := NextOccurrence(r, base)
|
||||
if err != nil || !got.IsZero() {
|
||||
t.Errorf("%q 应返回零值无错,得到 %v err=%v", r, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// time.AddDate 的溢出对「每月同一日」是错的:3 月 31 日 +1 月 = 5 月 1 日。
|
||||
// 一次溢出会永久改变规则 —— 31 日的事件在 2 月变成 3 月 3 日,
|
||||
// 然后从此每月 3 日提醒。
|
||||
func TestNextOccurrenceMonthlyClampsMonthEnd(t *testing.T) {
|
||||
cases := []struct {
|
||||
from string
|
||||
want string
|
||||
why string
|
||||
}{
|
||||
{"2026-01-31", "2026-02-28", "1月31日 +1月 → 2月末(2026 非闰年)"},
|
||||
{"2026-03-31", "2026-04-30", "3月31日 +1月 → 4月30日"},
|
||||
{"2026-05-31", "2026-06-30", "5月31日 +1月 → 6月30日"},
|
||||
{"2028-01-31", "2028-02-29", "闰年 2 月有 29 天"},
|
||||
{"2026-01-15", "2026-02-15", "月中日期不受影响"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
from, _ := time.ParseInLocation("2006-01-02", c.from, time.Local)
|
||||
got, err := NextOccurrence(models.RecurMonthly, from)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", c.why, err)
|
||||
continue
|
||||
}
|
||||
if got.Format("2006-01-02") != c.want {
|
||||
t.Errorf("%s: 得到 %s,期望 %s", c.why, got.Format("2006-01-02"), c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 农历月推进:公历间隔在 29~30 天之间浮动,不是固定值。
|
||||
// 这正是不能用 AddDate 的原因。
|
||||
func TestNextOccurrenceLunarMonthly(t *testing.T) {
|
||||
// 2026-09-03 = 农历七月廿二
|
||||
cur := time.Date(2026, 9, 3, 9, 0, 0, 0, time.Local)
|
||||
gaps := map[int]bool{}
|
||||
for i := 0; i < 6; i++ {
|
||||
next, err := NextOccurrence(models.RecurLunarMonthly, cur)
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 次推进: %v", i+1, err)
|
||||
}
|
||||
if !next.After(cur) {
|
||||
t.Fatalf("第 %d 次推进没有前进:%v → %v", i+1, cur, next)
|
||||
}
|
||||
gap := int(next.Sub(cur).Hours() / 24)
|
||||
gaps[gap] = true
|
||||
// 农历同一日:连续推进后农历「日」应保持
|
||||
if d := lunar.FromSolar(next); d.Day != 22 {
|
||||
t.Errorf("第 %d 次推进后农历日变成 %d(期望 22):%s", i+1, d.Day, d.String())
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
// 间隔必须出现过多种值,证明不是固定天数
|
||||
if len(gaps) < 2 {
|
||||
t.Errorf("六次农历月推进的公历间隔只有 %v —— 疑似退化成固定天数", gaps)
|
||||
}
|
||||
for g := range gaps {
|
||||
if g < 28 || g > 31 {
|
||||
t.Errorf("农历月间隔 %d 天不合理", g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 农历年推进:公历日期每年漂移。用公历 yearly 会固定在同一天,
|
||||
// 与「过农历生日/祭日」的期望不符 —— 这是农历规则存在的理由。
|
||||
func TestNextOccurrenceLunarYearly(t *testing.T) {
|
||||
cur := time.Date(2026, 9, 3, 9, 0, 0, 0, time.Local)
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 5; i++ {
|
||||
next, err := NextOccurrence(models.RecurLunarYearly, cur)
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 次: %v", i+1, err)
|
||||
}
|
||||
if !next.After(cur) {
|
||||
t.Fatalf("第 %d 次没有前进:%v → %v", i+1, cur, next)
|
||||
}
|
||||
// 农历月日应保持
|
||||
d := lunar.FromSolar(next)
|
||||
if d.Month != 7 || d.Day != 22 {
|
||||
t.Errorf("第 %d 次推进后农历变成 %d-%d(期望 7-22)", i+1, d.Month, d.Day)
|
||||
}
|
||||
seen[next.Format("01-02")] = true
|
||||
cur = next
|
||||
}
|
||||
if len(seen) < 3 {
|
||||
t.Errorf("五年公历月日只有 %d 种 —— 农历年重复应漂移", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
// 农历规则经过数据库这一轮也要正确(AdvanceRecurrence 里调 NextOccurrence)。
|
||||
func TestAdvanceRecurrenceLunar(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
start := time.Date(2026, 9, 3, 9, 0, 0, 0, time.Local)
|
||||
e := seedEvent(t, &models.CalendarEvent{
|
||||
Title: "农历每月十五(这里用廿二)",
|
||||
EventTime: start,
|
||||
Recurrence: models.RecurLunarMonthly,
|
||||
})
|
||||
|
||||
advanced, err := AdvanceRecurrence(ctx, e.EventID)
|
||||
if err != nil {
|
||||
t.Fatalf("推进: %v", err)
|
||||
}
|
||||
if !advanced {
|
||||
t.Fatal("农历重复应能推进")
|
||||
}
|
||||
|
||||
after, err := GetCalendarEvent(ctx, e.EventID)
|
||||
if err != nil {
|
||||
t.Fatalf("读回: %v", err)
|
||||
}
|
||||
if !after.EventTime.After(start) {
|
||||
t.Errorf("event_time 未前进:%v", after.EventTime)
|
||||
}
|
||||
// 农历日保持
|
||||
if d := lunar.FromSolar(after.EventTime); d.Day != 22 {
|
||||
t.Errorf("农历日变成 %d,期望 22(%s)", d.Day, d.String())
|
||||
}
|
||||
// 公历间隔应在一个农历月内
|
||||
gap := int(after.EventTime.Sub(start).Hours() / 24)
|
||||
if gap < 28 || gap > 31 {
|
||||
t.Errorf("间隔 %d 天不像一个农历月", gap)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 多收件人 ───
|
||||
|
||||
func TestRecipientsRoundtrip(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
e := seedEvent(t, &models.CalendarEvent{
|
||||
Title: "三个 Agent 各自汇报",
|
||||
Recipients: []string{"pi@/home/program/agentmail", "dsh", "opencode@/tmp"},
|
||||
DeliveryMode: models.DeliverSeparate,
|
||||
})
|
||||
|
||||
got, err := GetCalendarEvent(ctx, e.EventID)
|
||||
if err != nil {
|
||||
t.Fatalf("读回: %v", err)
|
||||
}
|
||||
if len(got.Recipients) != 3 {
|
||||
t.Fatalf("收件人应有 3 个,得到 %d:%v", len(got.Recipients), got.Recipients)
|
||||
}
|
||||
if got.Recipients[0] != "pi@/home/program/agentmail" {
|
||||
t.Errorf("顺序或内容不符:%v", got.Recipients)
|
||||
}
|
||||
if got.EffectiveDeliveryMode() != models.DeliverSeparate {
|
||||
t.Errorf("投递模式 = %q", got.EffectiveDeliveryMode())
|
||||
}
|
||||
}
|
||||
|
||||
// 空收件人列表必须序列化成 [](而不是 null):Go 的 nil slice 会变 null,
|
||||
// 前端 .map 直接崩。
|
||||
func TestRecipientsNeverNull(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
e := seedEvent(t, &models.CalendarEvent{Title: "没写收件人"})
|
||||
got, err := GetCalendarEvent(ctx, e.EventID)
|
||||
if err != nil {
|
||||
t.Fatalf("读回: %v", err)
|
||||
}
|
||||
if got.Recipients == nil {
|
||||
t.Error("Recipients 为 nil —— 会序列化成 null 让前端崩")
|
||||
}
|
||||
if len(got.Recipients) != 0 {
|
||||
t.Errorf("应是空数组,得到 %v", got.Recipients)
|
||||
}
|
||||
}
|
||||
|
||||
// 旧数据(只有 agent_name / to_address)必须继续工作 —— 历史事件不迁移。
|
||||
func TestEffectiveRecipientsFallbackChain(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
e models.CalendarEvent
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
"Recipients 优先",
|
||||
models.CalendarEvent{Recipients: []string{"a", "b"}, ToAddress: "c", AgentName: "d"},
|
||||
[]string{"a", "b"},
|
||||
},
|
||||
{
|
||||
"退回 to_address",
|
||||
models.CalendarEvent{ToAddress: "pi@/tmp.alias", AgentName: "pi"},
|
||||
[]string{"pi@/tmp.alias"},
|
||||
},
|
||||
{
|
||||
"再退回 agent_name",
|
||||
models.CalendarEvent{AgentName: "dsh"},
|
||||
[]string{"dsh"},
|
||||
},
|
||||
{
|
||||
"全空给 nil",
|
||||
models.CalendarEvent{},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"Recipients 里全是空白时继续退回",
|
||||
models.CalendarEvent{Recipients: []string{"", " "}, AgentName: "pi"},
|
||||
[]string{"pi"},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := c.e.EffectiveRecipients()
|
||||
if len(got) != len(c.want) {
|
||||
t.Errorf("%s: 得到 %v,期望 %v", c.name, got, c.want)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Errorf("%s: 第 %d 项 %q,期望 %q", c.name, i, got[i], c.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 未知投递模式按 separate 处理:它的失败模式更轻。
|
||||
// together 用错会让本该独立判断的 Agent 互相看到回复而趋同,事后无法分离。
|
||||
func TestEffectiveDeliveryModeDefaultsToSeparate(t *testing.T) {
|
||||
for _, in := range []string{"", "separate", "垃圾值", "SEPARATE"} {
|
||||
e := models.CalendarEvent{DeliveryMode: in}
|
||||
if got := e.EffectiveDeliveryMode(); got != models.DeliverSeparate {
|
||||
t.Errorf("DeliveryMode=%q → %q,期望 separate", in, got)
|
||||
}
|
||||
}
|
||||
e := models.CalendarEvent{DeliveryMode: models.DeliverTogether}
|
||||
if e.EffectiveDeliveryMode() != models.DeliverTogether {
|
||||
t.Error("together 应被保留")
|
||||
}
|
||||
}
|
||||
|
||||
// 公历每年:2 月 29 日在平年必须夹到 2 月 28,不能溢出成 3 月 1 日。
|
||||
// 闰日生日的约定是「平年过 2 月 28」。
|
||||
func TestNextOccurrenceYearlyClampsLeapDay(t *testing.T) {
|
||||
cases := []struct {
|
||||
from string
|
||||
want string
|
||||
why string
|
||||
}{
|
||||
{"2028-02-29", "2029-02-28", "闰日 +1 年 → 平年 2 月 28"},
|
||||
{"2026-03-15", "2027-03-15", "普通日期不受影响"},
|
||||
{"2027-02-28", "2028-02-28", "平年 2/28 → 闰年仍是 2/28(不跳到 29)"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
from, _ := time.ParseInLocation("2006-01-02", c.from, time.Local)
|
||||
got, err := NextOccurrence(models.RecurYearly, from)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", c.why, err)
|
||||
continue
|
||||
}
|
||||
if got.Format("2006-01-02") != c.want {
|
||||
t.Errorf("%s: 得到 %s,期望 %s", c.why, got.Format("2006-01-02"), c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过期的重复事件必须一次推到未来,不能每轮补发一封。
|
||||
//
|
||||
// 实测的 bug:AdvanceRecurrence 只推进一步 —— 一条 100 天前设的每日事件,
|
||||
// 每轮扫描都判定「已过期该触发」→ 发一封 → event_time 只前进一天 →
|
||||
// 下一轮又过期。30 轮扫描触发 30 次,而调度周期是 30 秒,
|
||||
// 人会收到一串垃圾提醒,连发 100 封才追上今天。
|
||||
func TestStaleRecurringEventDoesNotFlood(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
e := seedEvent(t, &models.CalendarEvent{
|
||||
Title: "很久以前设的每日提醒",
|
||||
EventTime: time.Now().AddDate(0, 0, -100),
|
||||
Recurrence: models.RecurDaily,
|
||||
})
|
||||
|
||||
fires := 0
|
||||
// 模拟调度器连续跑 30 轮(生产上就是 15 分钟)
|
||||
for i := 0; i < 30; i++ {
|
||||
due, err := DueEvents(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 轮扫描: %v", i+1, err)
|
||||
}
|
||||
hit := false
|
||||
for _, d := range due {
|
||||
if d.EventID == e.EventID {
|
||||
hit = true
|
||||
}
|
||||
}
|
||||
if !hit {
|
||||
break
|
||||
}
|
||||
fires++
|
||||
if err := MarkEventFired(ctx, e.EventID); err != nil {
|
||||
t.Fatalf("标记: %v", err)
|
||||
}
|
||||
if _, err := AdvanceRecurrence(ctx, e.EventID); err != nil {
|
||||
t.Fatalf("推进: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if fires != 1 {
|
||||
t.Errorf("过期的每日重复事件触发了 %d 次,应只触发 1 次", fires)
|
||||
}
|
||||
after, err := GetCalendarEvent(ctx, e.EventID)
|
||||
if err != nil {
|
||||
t.Fatalf("读回: %v", err)
|
||||
}
|
||||
if !after.EventTime.After(time.Now()) {
|
||||
t.Errorf("推进后 event_time 仍在过去:%v", after.EventTime)
|
||||
}
|
||||
// 只跳到「刚过现在」的那一次,不是跳到很远的将来
|
||||
if after.EventTime.After(time.Now().AddDate(0, 0, 2)) {
|
||||
t.Errorf("推得太远了:%v", after.EventTime)
|
||||
}
|
||||
}
|
||||
|
||||
// 农历规则的过期事件同样不能刷屏。
|
||||
func TestStaleLunarRecurringDoesNotFlood(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
e := seedEvent(t, &models.CalendarEvent{
|
||||
Title: "去年设的农历每月提醒",
|
||||
EventTime: time.Now().AddDate(-1, 0, 0),
|
||||
Recurrence: models.RecurLunarMonthly,
|
||||
})
|
||||
|
||||
if _, err := AdvanceRecurrence(ctx, e.EventID); err != nil {
|
||||
t.Fatalf("推进: %v", err)
|
||||
}
|
||||
after, _ := GetCalendarEvent(ctx, e.EventID)
|
||||
if !after.EventTime.After(time.Now()) {
|
||||
t.Errorf("一年前的农历事件推进后仍在过去:%v", after.EventTime)
|
||||
}
|
||||
// 农历日必须保持
|
||||
if d := lunar.FromSolar(after.EventTime); d.Day != lunar.FromSolar(e.EventTime).Day {
|
||||
t.Errorf("农历日从 %d 变成 %d", lunar.FromSolar(e.EventTime).Day, d.Day)
|
||||
}
|
||||
}
|
||||
|
||||
// 越过 recurrence_end 时必须置 cancelled 而不是留在 active。
|
||||
//
|
||||
// 留着的表现是一条僵尸事件:DueEvents 每轮都捞到它(event_time 在过去),
|
||||
// 但 fired_for 已等于 event_time 所以又不触发 —— 永远排在到期列表里不动。
|
||||
func TestAdvanceCancelsAfterRecurrenceEnd(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
end := time.Now().AddDate(0, 0, -1) // 昨天就该停
|
||||
e := seedEvent(t, &models.CalendarEvent{
|
||||
Title: "已到期的每日重复",
|
||||
EventTime: time.Now().AddDate(0, 0, -5),
|
||||
Recurrence: models.RecurDaily,
|
||||
RecurrenceEnd: &end,
|
||||
})
|
||||
|
||||
advanced, err := AdvanceRecurrence(ctx, e.EventID)
|
||||
if err != nil {
|
||||
t.Fatalf("推进: %v", err)
|
||||
}
|
||||
if advanced {
|
||||
t.Error("已过 recurrence_end 不该报告推进成功")
|
||||
}
|
||||
after, err := GetCalendarEvent(ctx, e.EventID)
|
||||
if err != nil {
|
||||
t.Fatalf("读回: %v", err)
|
||||
}
|
||||
if after.Status != "cancelled" {
|
||||
t.Errorf("状态应是 cancelled,得到 %q —— 留在 active 会变僵尸事件", after.Status)
|
||||
}
|
||||
// 且不该再出现在到期列表里
|
||||
due, _ := DueEvents(ctx)
|
||||
for _, d := range due {
|
||||
if d.EventID == e.EventID {
|
||||
t.Error("已 cancelled 的事件仍出现在 DueEvents")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// advanceToFuture 是纯函数,单独测三个终止条件。
|
||||
func TestAdvanceToFuture(t *testing.T) {
|
||||
now := time.Date(2026, 9, 3, 12, 0, 0, 0, time.Local)
|
||||
|
||||
t.Run("跨过 now 就停", func(t *testing.T) {
|
||||
from := now.AddDate(0, 0, -100)
|
||||
got, err := advanceToFuture(models.RecurDaily, from, now, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("推进: %v", err)
|
||||
}
|
||||
if !got.After(now) {
|
||||
t.Errorf("结果 %v 不在 now 之后", got)
|
||||
}
|
||||
// 恰好是越过 now 的第一次,不是更远
|
||||
if got.After(now.AddDate(0, 0, 1)) {
|
||||
t.Errorf("推过头了:%v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("越过 recurrenceEnd 给零值", func(t *testing.T) {
|
||||
end := now.AddDate(0, 0, -1)
|
||||
got, err := advanceToFuture(models.RecurDaily, now.AddDate(0, 0, -5), now, &end)
|
||||
if err != nil {
|
||||
t.Fatalf("不该报错:%v", err)
|
||||
}
|
||||
if !got.IsZero() {
|
||||
t.Errorf("应给零值,得到 %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("不重复给零值无错", func(t *testing.T) {
|
||||
got, err := advanceToFuture(models.RecurNone, now, now, nil)
|
||||
if err != nil || !got.IsZero() {
|
||||
t.Errorf("得到 %v err=%v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("未来的事件原地推一步", func(t *testing.T) {
|
||||
from := now.AddDate(0, 0, 5)
|
||||
got, err := advanceToFuture(models.RecurDaily, from, now, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("推进: %v", err)
|
||||
}
|
||||
// from 已在未来,第一次推进就该返回
|
||||
if !got.Equal(from.AddDate(0, 0, 1)) {
|
||||
t.Errorf("得到 %v,期望 %v", got, from.AddDate(0, 0, 1))
|
||||
}
|
||||
})
|
||||
|
||||
// 上限是防御性的:农历路径依赖外部库,一旦某年给出反直觉结果,
|
||||
// 没有上限就是个死循环 goroutine,而它跑在调度器里 —— 整个提醒系统一起卡住
|
||||
t.Run("十年前的每日事件也能在上限内追上", func(t *testing.T) {
|
||||
got, err := advanceToFuture(models.RecurDaily, now.AddDate(-10, 0, 0), now, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("十年(约 3650 步)应在 %d 上限内:%v", maxAdvanceSteps, err)
|
||||
}
|
||||
if !got.After(now) {
|
||||
t.Errorf("结果 %v 不在 now 之后", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
269
server/internal/repo/defaultsession_test.go
Normal file
269
server/internal/repo/defaultsession_test.go
Normal file
@ -0,0 +1,269 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 这一组测试钉住「省略 session 位复用默认会话」与「真的新建会话」必须可区分。
|
||||
//
|
||||
// 事故背景:handler 层曾用 `parentMailID == nil` 判断「是不是新建会话」,
|
||||
// 据此决定要不要写往返预算与权限档位。但省略 session 位复用默认会话时
|
||||
// parentMailID 也是 nil —— 于是每一封续谈的信都会把这两个字段重置成默认值。
|
||||
//
|
||||
// 线上实测(修复前):
|
||||
//
|
||||
// 第一封 to=pi@/tmp/budgetprobe max_rounds=7 → budget_max 7
|
||||
// 第二封 to=pi@/tmp/budgetprobe(省略该字段) → budget_max 20 ← 被静默改写
|
||||
//
|
||||
// 而那段代码的注释本身正在论证这不该发生(「续谈已有会话若也接受这个字段,
|
||||
// 每封新信都会悄悄改掉对方正在遵守的预算」)—— 意图是对的,守卫写错了。
|
||||
//
|
||||
// 修法:FindOrCreateDefaultSessionCreated 额外返回 created,
|
||||
// handler 改用它而不是 parentMailID。
|
||||
|
||||
func TestDefaultSessionFirstCallCreates(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !created {
|
||||
t.Fatal("从未通信过的 name@path,第一次必须报告 created=true")
|
||||
}
|
||||
if id.String() == "" {
|
||||
t.Fatal("应返回有效会话 id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSessionReuseReportsNotCreated(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
first, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil || !created {
|
||||
t.Fatalf("首封应新建:created=%v err=%v", created, err)
|
||||
}
|
||||
// 复用的前提是这条会话里有该收件人参与过的邮件(EXISTS 子查询)
|
||||
if _, err := CreateMail(ctx, first, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second, created2, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "第二封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second != first {
|
||||
t.Fatalf("第二封应复用同一条默认会话:first=%s second=%s", first, second)
|
||||
}
|
||||
if created2 {
|
||||
t.Fatal("复用已有默认会话时 created 必须为 false —— 这正是预算被冲掉的根因")
|
||||
}
|
||||
}
|
||||
|
||||
// 这条是上面那个线上事故的最小复现:走 created 判据时预算不被改写。
|
||||
func TestBudgetSurvivesDefaultSessionReuse(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil || !created {
|
||||
t.Fatalf("首封应新建:created=%v err=%v", created, err)
|
||||
}
|
||||
// 模拟 handler:只有 created 为真才设预算
|
||||
if _, err := SetSessionBudget(ctx, id, 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, id, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, created2, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "第二封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created2 {
|
||||
// 若这里为真,handler 就会重设预算 —— 事故重现
|
||||
t.Fatal("复用时 created 为真会让 handler 重设预算")
|
||||
}
|
||||
|
||||
b, err := GetSessionBudget(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.Max != 7 {
|
||||
t.Fatalf("续谈不得改写预算:want 7, got %d", b.Max)
|
||||
}
|
||||
}
|
||||
|
||||
// 档位与预算同一个判据,一起钉住:plan 档不能因为第二封信而升成 workspace。
|
||||
func TestPermissionModeSurvivesDefaultSessionReuse(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil || !created {
|
||||
t.Fatalf("首封应新建:created=%v err=%v", created, err)
|
||||
}
|
||||
if _, err := SetSessionPermissionMode(ctx, id, "plan"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, id, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, created2, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "第二封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created2 {
|
||||
t.Fatal("复用时 created 为真会让 handler 把档位重置成默认档")
|
||||
}
|
||||
|
||||
if got := SessionPermissionMode(ctx, id); got != "plan" {
|
||||
t.Fatalf("续谈不得改写档位:want plan, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 不同工作目录是不同的默认会话,两边各自新建。
|
||||
// 这条防的是「把 created 实现成一个全局标志」之类的偷懒写法。
|
||||
func TestDefaultSessionPerWorkspace(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
a, createdA, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w1", "jianf", "甲")
|
||||
if err != nil || !createdA {
|
||||
t.Fatalf("/w1 应新建:%v %v", createdA, err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, a, nil, "jianf", "", "pi", "/w1", "甲", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b, createdB, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w2", "jianf", "乙")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !createdB {
|
||||
t.Fatal("/w2 是另一个工作目录,应当另建一条默认会话")
|
||||
}
|
||||
if a == b {
|
||||
t.Fatal("不同工作目录不该共用同一条默认会话")
|
||||
}
|
||||
}
|
||||
|
||||
// 旧签名仍在别处被调用,保持行为不变(只是丢掉 created)。
|
||||
func TestLegacyWrapperStillWorks(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, err := FindOrCreateDefaultSession(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, id, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := FindOrCreateDefaultSession(ctx, "pi", "/w", "jianf", "第二封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again != id {
|
||||
t.Fatalf("包装函数应与原行为一致:%s vs %s", id, again)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 预算不被冲的回归用例(P0 第一项,P3 钉死) ───
|
||||
//
|
||||
// 守卫原本是 `parentMailID == nil`(表示新建),但省略 session 位复用默认会话时
|
||||
// parentMailID 也是 nil —— 于是「仅在新建时生效」的字段(预算、档位)在每封
|
||||
// 省略 session 位的信上都被重写了。实测:第一封 max_rounds=7 → 第二封省略该
|
||||
// 字段 → 预算被静默改成默认的 20。
|
||||
//
|
||||
// 修法:handler 改用 FindOrCreateDefaultSessionCreated 返回的 `created` 判据。
|
||||
// 本测试钉死「复用默认会话时 created=false」这一事实,让守卫不会倒退回去。
|
||||
func TestDefaultSessionReuseBudgetNotReset(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
first, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil || !created {
|
||||
t.Fatalf("首封应新建:created=%v err=%v", created, err)
|
||||
}
|
||||
// 模拟发信路径:新建会话时定预算为 7(低于默认 20)
|
||||
if _, err := SetSessionBudget(ctx, first, 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 复用的前提是会话里有该收件人参与过的邮件
|
||||
if _, err := CreateMail(ctx, first, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 第二封省略 session 位 → 复用默认会话,created 必须为 false
|
||||
second, created2, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "第二封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second != first {
|
||||
t.Fatalf("第二封应复用同一会话:%s vs %s", first, second)
|
||||
}
|
||||
if created2 {
|
||||
t.Fatal("复用默认会话时 created 必须为 false,否则预算会被默认值冲掉")
|
||||
}
|
||||
|
||||
// 既然 created2=false,发信路径不会调 SetSessionBudget → 预算仍为 7
|
||||
b, err := GetSessionBudget(ctx, first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.Max != 7 {
|
||||
t.Errorf("预算被冲掉:got max=%d,want 7(复用不应重设)", b.Max)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 档位同样不被冲的回归用例 ───
|
||||
//
|
||||
// 与预算同理:复用默认会话时档位也不该被重设成默认档。
|
||||
// 人指定 plan 档后,第二封信省略 session 位复用同一条会话 →
|
||||
// created=false → SetSessionPermissionMode 不被调 → 档位仍为 plan。
|
||||
func TestDefaultSessionReusePermissionModeNotReset(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
first, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil || !created {
|
||||
t.Fatalf("首封应新建:created=%v err=%v", created, err)
|
||||
}
|
||||
// 新建会话时定档位为 plan(比默认 workspace 更严)
|
||||
if _, err := SetSessionPermissionMode(ctx, first, "plan"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, first, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second, created2, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "第二封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second != first {
|
||||
t.Fatalf("第二封应复用同一会话:%s vs %s", first, second)
|
||||
}
|
||||
if created2 {
|
||||
t.Fatal("复用默认会话时 created 必须为 false,否则档位会被默认值冲掉")
|
||||
}
|
||||
|
||||
mode := SessionPermissionMode(ctx, first)
|
||||
if mode != "plan" {
|
||||
t.Errorf("档位被冲掉:got %q,want plan(复用不应重设)", mode)
|
||||
}
|
||||
}
|
||||
135
server/internal/repo/deliverable_test.go
Normal file
135
server/internal/repo/deliverable_test.go
Normal file
@ -0,0 +1,135 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
)
|
||||
|
||||
// RecipientDeliverable 是「邮件会不会进黑洞」的唯一判据。
|
||||
//
|
||||
// 事故背景:发信路径原来只校验地址语法、调用权限与会话别名,从不问
|
||||
// 「这个名字存在吗」。实测发给已彻底删除的 remotebot 返回 200,邮件入库、
|
||||
// 分配了 20 个来回预算、建好会话,而那一端永远不会有人读。发件人看到 200
|
||||
// 和一个 session_id,以为送出去了 —— 静默丢件比报错严重,报错能立刻改,
|
||||
// 静默丢件要等对方追问才发现。
|
||||
|
||||
func TestRecipientDeliverable_Human(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`INSERT INTO users (username, password_hash) VALUES ('alice', 'x')`); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
|
||||
if err := RecipientDeliverable(ctx, "alice"); err != nil {
|
||||
t.Fatalf("人类用户应当可达,得到: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipientDeliverable_OnlineAgent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
if err := RecipientDeliverable(ctx, "pi"); err != nil {
|
||||
t.Fatalf("在册且未停用的 Agent 应当可达,得到: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipientDeliverable_UnknownName(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
err := RecipientDeliverable(ctx, "ghost")
|
||||
if !errors.Is(err, ErrRecipientUnknown) {
|
||||
t.Fatalf("不存在的收件人应当返回 ErrRecipientUnknown,得到: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除后立刻不可达 —— 这正是本次事故的场景。
|
||||
func TestRecipientDeliverable_AfterDelete(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "remotebot", 20)
|
||||
|
||||
if err := RecipientDeliverable(ctx, "remotebot"); err != nil {
|
||||
t.Fatalf("删除前应当可达,得到: %v", err)
|
||||
}
|
||||
|
||||
if _, err := DeleteAgent(ctx, "remotebot"); err != nil {
|
||||
t.Fatalf("delete agent: %v", err)
|
||||
}
|
||||
|
||||
err := RecipientDeliverable(ctx, "remotebot")
|
||||
if !errors.Is(err, ErrRecipientUnknown) {
|
||||
t.Fatalf("删除后必须不可达,得到: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 停用选择「当场拒收」而不是「入库等恢复后补投」:停用的语义就是这个
|
||||
// Agent 现在不干活,让发件人以为信已送达更坏 —— 它会照常等回信。
|
||||
func TestRecipientDeliverable_Disabled(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "opencode", 20)
|
||||
|
||||
if _, err := SetAgentDisabled(ctx, "opencode", true); err != nil {
|
||||
t.Fatalf("disable: %v", err)
|
||||
}
|
||||
|
||||
err := RecipientDeliverable(ctx, "opencode")
|
||||
if !errors.Is(err, ErrRecipientDisabled) {
|
||||
t.Fatalf("已停用的 Agent 应当返回 ErrRecipientDisabled,得到: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复后重新可达,否则停用就成了不可逆操作。
|
||||
func TestRecipientDeliverable_ReenabledAgain(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "opencode", 20)
|
||||
|
||||
if _, err := SetAgentDisabled(ctx, "opencode", true); err != nil {
|
||||
t.Fatalf("disable: %v", err)
|
||||
}
|
||||
if _, err := SetAgentDisabled(ctx, "opencode", false); err != nil {
|
||||
t.Fatalf("re-enable: %v", err)
|
||||
}
|
||||
|
||||
if err := RecipientDeliverable(ctx, "opencode"); err != nil {
|
||||
t.Fatalf("恢复后应当重新可达,得到: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 空名交给上层的地址解析处理,这里放行 —— 不然人类给自己发信
|
||||
// (to 位省略 name)会被这道检查误伤。
|
||||
func TestRecipientDeliverable_EmptyName(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := RecipientDeliverable(context.Background(), ""); err != nil {
|
||||
t.Fatalf("空名应当放行,得到: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 人类用户名与 Agent 名共用命名空间。一个名字同时是人类用户时按人类算 ——
|
||||
// 人的收件箱一直在,不受 Agent 停用影响。
|
||||
func TestRecipientDeliverable_HumanWinsOverDisabledAgent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`INSERT INTO users (username, password_hash) VALUES ('dual', 'x')`); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
seedAgent(t, "dual", 20)
|
||||
if _, err := SetAgentDisabled(ctx, "dual", true); err != nil {
|
||||
t.Fatalf("disable: %v", err)
|
||||
}
|
||||
|
||||
if err := RecipientDeliverable(ctx, "dual"); err != nil {
|
||||
t.Fatalf("同名人类用户应当优先放行,得到: %v", err)
|
||||
}
|
||||
}
|
||||
351
server/internal/repo/keys.go
Normal file
351
server/internal/repo/keys.go
Normal file
@ -0,0 +1,351 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 密钥认证 ----------
|
||||
//
|
||||
// 两类密钥共享一个全局唯一的 token 命名空间:验证时先查 agent_keys 再查 user_keys。
|
||||
// 这样一个 token 永远只有一种身份,不会出现「同一串字符既能注册 Agent 又能读人类邮箱」。
|
||||
|
||||
var (
|
||||
// ErrKeyNotFound 密钥不存在
|
||||
ErrKeyNotFound = errors.New("key not found")
|
||||
// ErrKeyUsed 一次性密钥已被使用
|
||||
ErrKeyUsed = errors.New("key already used")
|
||||
// ErrKeyExpired 定时密钥已过期
|
||||
ErrKeyExpired = errors.New("key expired")
|
||||
// ErrKeyTypeInvalid 密钥类型不受支持
|
||||
ErrKeyTypeInvalid = errors.New("invalid key type")
|
||||
// ErrKeyNeedsExpiry timed 密钥缺少有效的 expires_hours
|
||||
ErrKeyNeedsExpiry = errors.New("timed key requires positive expires_hours")
|
||||
// ErrKeyTooShort 登记的客户端密钥长度不足
|
||||
ErrKeyTooShort = errors.New("key token too short")
|
||||
)
|
||||
|
||||
// expiryFor 依据密钥类型算出过期时间。
|
||||
// 只有 timed 需要 expires_at;permanent 与 one_time 都是 NULL,
|
||||
// 各自的失效条件由 key_type 本身表达,不混用 expires_at。
|
||||
func expiryFor(keyType string, hours int) (*time.Time, error) {
|
||||
if !models.ValidKeyType(keyType) {
|
||||
return nil, ErrKeyTypeInvalid
|
||||
}
|
||||
if keyType != models.KeyTimed {
|
||||
return nil, nil
|
||||
}
|
||||
if hours <= 0 {
|
||||
return nil, ErrKeyNeedsExpiry
|
||||
}
|
||||
t := time.Now().Add(time.Duration(hours) * time.Hour)
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// checkKeyUsable 判断一条密钥记录当前是否可用。
|
||||
func checkKeyUsable(keyType string, expiresAt, usedAt *time.Time) error {
|
||||
switch keyType {
|
||||
case models.KeyOneTime:
|
||||
if usedAt != nil {
|
||||
return ErrKeyUsed
|
||||
}
|
||||
case models.KeyTimed:
|
||||
if expiresAt == nil || time.Now().After(*expiresAt) {
|
||||
return ErrKeyExpired
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- Agent 密钥(管理员签发) ----------
|
||||
|
||||
// ErrKeyTokenTaken 登记的密钥已被占用
|
||||
var ErrKeyTokenTaken = errors.New("key token already registered")
|
||||
|
||||
// CreateAgentKey 签发一条 Agent 接入密钥。agentName 为空表示待绑定。
|
||||
//
|
||||
// presetToken 非空时登记客户端已在本地生成的密钥(插件首装场景),
|
||||
// 这样密钥全文只从客户端往服务器走一次,不需要反方向传递;留空则由服务器生成。
|
||||
func CreateAgentKey(ctx context.Context, agentName, keyType, label string, expiresHours int, createdBy uuid.UUID, presetToken string) (*models.AgentKey, error) {
|
||||
expires, err := expiryFor(keyType, expiresHours)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token := presetToken
|
||||
if token == "" {
|
||||
if token, err = newToken(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if len(token) < 32 {
|
||||
// 太短的客户端密钥不接受,否则等于把弱口令当凭证
|
||||
return nil, ErrKeyTooShort
|
||||
}
|
||||
|
||||
// 已退役的名字不可重建 —— 登记密钥会级联建 agents 行,绕过注册检查。
|
||||
if agentName != "" {
|
||||
if retired, rErr := IsRetiredAgentName(ctx, agentName); rErr != nil {
|
||||
return nil, rErr
|
||||
} else if retired {
|
||||
return nil, fmt.Errorf("该名字已退役,不可重建")
|
||||
}
|
||||
}
|
||||
|
||||
var namePtr *string
|
||||
if agentName != "" {
|
||||
namePtr = &agentName
|
||||
}
|
||||
|
||||
k := &models.AgentKey{
|
||||
Token: token,
|
||||
TokenHint: models.TokenHint(token),
|
||||
AgentName: namePtr,
|
||||
KeyType: keyType,
|
||||
Label: label,
|
||||
ExpiresAt: expires,
|
||||
CreatedBy: &createdBy,
|
||||
}
|
||||
err = db.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO agent_keys (key_token, agent_name, key_type, label, expires_at, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING key_id, created_at
|
||||
`, token, namePtr, keyType, label, expires, createdBy).Scan(&k.ID, &k.CreatedAt)
|
||||
if db.IsUniqueViolation(err) {
|
||||
return nil, ErrKeyTokenTaken
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// ListAgentKeys 列出 Agent 密钥;agentName 非空时按 Agent 过滤。
|
||||
// 返回值不含 token 全文,只有 hint。
|
||||
func ListAgentKeys(ctx context.Context, agentName string) ([]models.AgentKey, error) {
|
||||
q := `SELECT key_id, key_token, agent_name, key_type, label, expires_at, used_at, created_by, created_at
|
||||
FROM agent_keys`
|
||||
args := []any{}
|
||||
if agentName != "" {
|
||||
q += ` WHERE agent_name = $1`
|
||||
args = append(args, agentName)
|
||||
}
|
||||
q += ` ORDER BY created_at DESC`
|
||||
|
||||
rows, err := db.DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := []models.AgentKey{}
|
||||
for rows.Next() {
|
||||
var k models.AgentKey
|
||||
var token string
|
||||
if err := rows.Scan(&k.ID, &token, &k.AgentName, &k.KeyType, &k.Label,
|
||||
&k.ExpiresAt, &k.UsedAt, &k.CreatedBy, &k.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.TokenHint = models.TokenHint(token) // 不回传全文
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteAgentKey 吊销一条 Agent 密钥。
|
||||
func DeleteAgentKey(ctx context.Context, id uuid.UUID) error {
|
||||
tag, err := db.DB.ExecContext(ctx, `DELETE FROM agent_keys WHERE key_id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return ErrKeyNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BindAgentKey 把一条密钥绑定到指定 Agent 名。
|
||||
func BindAgentKey(ctx context.Context, id uuid.UUID, agentName string) error {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agent_keys SET agent_name = $2 WHERE key_id = $1`, id, agentName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return ErrKeyNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyAgentKey 校验 Agent 密钥并返回它绑定的 Agent 名(未绑定时返回空串)。
|
||||
//
|
||||
// 一次性密钥在校验通过时立刻写 used_at —— 用 WHERE used_at IS NULL 保证并发下
|
||||
// 只有一个请求能把它标记掉,避免两个 Agent 拿同一把一次性密钥同时注册成功。
|
||||
func VerifyAgentKey(ctx context.Context, token string) (string, error) {
|
||||
var (
|
||||
id uuid.UUID
|
||||
agentName *string
|
||||
keyType string
|
||||
expiresAt *time.Time
|
||||
usedAt *time.Time
|
||||
)
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT key_id, agent_name, key_type, expires_at, used_at
|
||||
FROM agent_keys WHERE key_token = $1
|
||||
`, token).Scan(&id, &agentName, &keyType, &expiresAt, &usedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrKeyNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := checkKeyUsable(keyType, expiresAt, usedAt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if keyType == models.KeyOneTime {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agent_keys SET used_at = NOW() WHERE key_id = $1 AND used_at IS NULL`, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return "", ErrKeyUsed // 并发下被别人抢先用掉了
|
||||
}
|
||||
}
|
||||
|
||||
if agentName == nil {
|
||||
return "", nil
|
||||
}
|
||||
return *agentName, nil
|
||||
}
|
||||
|
||||
// ClaimAgentKey 在待绑定密钥首次注册时把它落定到该 Agent 名。
|
||||
// 已绑定的密钥不受影响(WHERE agent_name IS NULL),因此不能借一把已绑定的密钥改注册别的 Agent。
|
||||
func ClaimAgentKey(ctx context.Context, token, agentName string) error {
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agent_keys SET agent_name = $2 WHERE key_token = $1 AND agent_name IS NULL`,
|
||||
token, agentName)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- 用户密钥(用户自助签发) ----------
|
||||
|
||||
// CreateUserKey 为用户签发一条客户端连接密钥。
|
||||
func CreateUserKey(ctx context.Context, userID uuid.UUID, label, keyType string, expiresHours int) (*models.UserKey, error) {
|
||||
expires, err := expiryFor(keyType, expiresHours)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := newToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
k := &models.UserKey{
|
||||
Token: token,
|
||||
TokenHint: models.TokenHint(token),
|
||||
UserID: userID,
|
||||
Label: label,
|
||||
KeyType: keyType,
|
||||
ExpiresAt: expires,
|
||||
}
|
||||
err = db.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO user_keys (key_token, user_id, label, key_type, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING key_id, created_at
|
||||
`, token, userID, label, keyType, expires).Scan(&k.ID, &k.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// ListUserKeys 列出某用户的连接密钥(不含 token 全文)。
|
||||
func ListUserKeys(ctx context.Context, userID uuid.UUID) ([]models.UserKey, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT key_id, key_token, user_id, label, key_type, expires_at, used_at, created_at
|
||||
FROM user_keys WHERE user_id = $1 ORDER BY created_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := []models.UserKey{}
|
||||
for rows.Next() {
|
||||
var k models.UserKey
|
||||
var token string
|
||||
if err := rows.Scan(&k.ID, &token, &k.UserID, &k.Label, &k.KeyType,
|
||||
&k.ExpiresAt, &k.UsedAt, &k.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.TokenHint = models.TokenHint(token)
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteUserKey 删除自己的一条密钥。带 user_id 条件,避免删掉别人的。
|
||||
func DeleteUserKey(ctx context.Context, userID, keyID uuid.UUID) error {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`DELETE FROM user_keys WHERE key_id = $1 AND user_id = $2`, keyID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return ErrKeyNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyUserKey 校验用户密钥并返回对应用户。
|
||||
// 用户必须仍处于 active 状态——禁用账号后其密钥应当立即失效。
|
||||
func VerifyUserKey(ctx context.Context, token string) (*models.User, error) {
|
||||
var (
|
||||
id uuid.UUID
|
||||
userID uuid.UUID
|
||||
keyType string
|
||||
expiresAt *time.Time
|
||||
usedAt *time.Time
|
||||
)
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT key_id, user_id, key_type, expires_at, used_at
|
||||
FROM user_keys WHERE key_token = $1
|
||||
`, token).Scan(&id, &userID, &keyType, &expiresAt, &usedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrKeyNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkKeyUsable(keyType, expiresAt, usedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if keyType == models.KeyOneTime {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE user_keys SET used_at = NOW() WHERE key_id = $1 AND used_at IS NULL`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return nil, ErrKeyUsed
|
||||
}
|
||||
}
|
||||
|
||||
u, err := GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Status != "active" {
|
||||
return nil, ErrKeyNotFound // 账号已禁用,密钥一并失效
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
144
server/internal/repo/markread_test.go
Normal file
144
server/internal/repo/markread_test.go
Normal file
@ -0,0 +1,144 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// seedMailTo 造一封给 recipient 的未读邮件,可选带抄送。
|
||||
func seedMailTo(t *testing.T, recipient string, cc string) uuid.UUID {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
sid, err := CreateSession(ctx, nil, "sender", "t", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ccJSON := "[]"
|
||||
if cc != "" {
|
||||
ccJSON = `[{"name":"` + cc + `","path":"","session":"","raw":"` + cc + `"}]`
|
||||
}
|
||||
var id uuid.UUID
|
||||
err = db.DB.QueryRowContext(ctx,
|
||||
`INSERT INTO mails (session_id, from_name, to_name, subject, body, cc_list)
|
||||
VALUES ($1, 'sender', $2, 's', 'b', $3) RETURNING mail_id`,
|
||||
sid, recipient, ccJSON).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func statusOf(t *testing.T, id uuid.UUID) string {
|
||||
t.Helper()
|
||||
var s string
|
||||
if err := db.DB.QueryRowContext(context.Background(),
|
||||
`SELECT status FROM mails WHERE mail_id = $1`, id).Scan(&s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestMarkMailsReadForOnlyOwnMail(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mine := seedMailTo(t, "bot", "")
|
||||
others := seedMailTo(t, "other", "")
|
||||
|
||||
// 一次请求里混着别人的邮件:自己的标掉,别人的动不了。
|
||||
// 鉴权写在 UPDATE 的 WHERE 里,所以这不是「先查后拒」而是根本改不动。
|
||||
n, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{mine, others})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("影响行数 = %d,期望 1(只有自己那封)", n)
|
||||
}
|
||||
if statusOf(t, mine) != "read" {
|
||||
t.Fatal("自己的邮件没被标记")
|
||||
}
|
||||
if statusOf(t, others) != "unread" {
|
||||
t.Fatal("别人的邮件被标记了 —— 鉴权失效")
|
||||
}
|
||||
}
|
||||
|
||||
// 重复标记是幂等的:Agent 通常把上一轮列出的 id 原样传回来,
|
||||
// 其中混着已读的不该算错误
|
||||
func TestMarkMailsReadForIsIdempotent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
id := seedMailTo(t, "bot", "")
|
||||
|
||||
if n, _ := MarkMailsReadFor(ctx, "bot", []uuid.UUID{id}); n != 1 {
|
||||
t.Fatalf("首次应标掉 1 封,实际 %d", n)
|
||||
}
|
||||
n, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{id})
|
||||
if err != nil {
|
||||
t.Fatalf("重复标记不该报错: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("重复标记影响行数 = %d,期望 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 被抄送的邮件也在收件箱里,也该能标掉
|
||||
func TestMarkMailsReadForCoversCC(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
id := seedMailTo(t, "other", "bot") // 主收件人是 other,bot 被抄送
|
||||
|
||||
n, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{id})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("被抄送的邮件应可标记,影响行数 = %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkMailsReadForEmptyList(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
// 空列表直接返回,不该拼出 `IN ()` 这种非法 SQL
|
||||
n, err := MarkMailsReadFor(context.Background(), "bot", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("空列表不该报错: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("空列表影响行数 = %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkAllInboxReadForSkipsArchivedAndOthers(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
a := seedMailTo(t, "bot", "")
|
||||
b := seedMailTo(t, "bot", "")
|
||||
others := seedMailTo(t, "other", "")
|
||||
|
||||
// 把 b 所在会话归档:那封在收件箱里根本看不到,
|
||||
// 标掉它只会让「标记了 N 封」与用户看到的对不上
|
||||
var sid uuid.UUID
|
||||
db.DB.QueryRowContext(ctx, `SELECT session_id FROM mails WHERE mail_id = $1`, b).Scan(&sid)
|
||||
db.DB.ExecContext(ctx, `UPDATE sessions SET status = 'archived' WHERE session_id = $1`, sid)
|
||||
|
||||
n, err := MarkAllInboxReadFor(ctx, "bot")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("影响行数 = %d,期望 1(排除归档会话)", n)
|
||||
}
|
||||
if statusOf(t, a) != "read" {
|
||||
t.Fatal("活跃会话里的未读没被标掉")
|
||||
}
|
||||
if statusOf(t, b) != "unread" {
|
||||
t.Fatal("归档会话里的邮件被标掉了")
|
||||
}
|
||||
if statusOf(t, others) != "unread" {
|
||||
t.Fatal("别人的邮件被标掉了")
|
||||
}
|
||||
}
|
||||
238
server/internal/repo/models_scope.go
Normal file
238
server/internal/repo/models_scope.go
Normal file
@ -0,0 +1,238 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
)
|
||||
|
||||
// ---------- 邮件场景下的可用模型 ----------
|
||||
//
|
||||
// 两张表,两种真相:
|
||||
//
|
||||
// agent_model_catalog —— 平台**上报**它当前看得见哪些模型(注册时整表替换)
|
||||
// agent_allowed_models —— 管理员**选定**其中哪些能在邮件场景下用,rank 即优先级
|
||||
//
|
||||
// 为什么不合成一张带 allowed 标记的表:模型会从平台目录里消失(换了 provider 配置、
|
||||
// 上游临时下线),那时整行被删掉就连带把管理员的选择也删了,模型回来还得重配一遍。
|
||||
// 分开存之后,「选了什么」是持久的,目录只决定「这一项现在是否可用」。
|
||||
//
|
||||
// 为什么让平台上报而不是在 Gateway 里配一张静态表:模型清单是平台侧的事实 ——
|
||||
// opencode 的 provider 配置、DSH 的 llm 适配器注册,都可能随时变。
|
||||
// Gateway 猜不出来,猜错的后果是管理员在配置页选了一个平台其实调不到的模型。
|
||||
|
||||
// ModelRef 是一次「provider + model」路由。
|
||||
type ModelRef struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// CatalogModel 是平台上报的一个可选模型。
|
||||
type CatalogModel struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
// Allowed 表示它已被管理员选入邮件场景。
|
||||
// 与目录合并后一起返回,前端才能画出「已勾选」的复选框。
|
||||
Allowed bool `json:"allowed"`
|
||||
// Rank 仅在 Allowed 为真时有意义,越小越先试。
|
||||
Rank int `json:"rank,omitempty"`
|
||||
}
|
||||
|
||||
// maxCatalogModels 限制单个 Agent 上报的模型数。
|
||||
//
|
||||
// 有平台会把上游的全部模型都列出来(实测 opencode 的一个 provider 就有几十个),
|
||||
// 无上限的话一次注册能写进几千行,而配置页面上几千个复选框对人毫无用处。
|
||||
const maxCatalogModels = 300
|
||||
|
||||
// ReplaceModelCatalog 整表替换某 Agent 上报的模型目录。
|
||||
//
|
||||
// 整表替换而非增量合并:目录是平台当前状态的快照,
|
||||
// 增量合并会让已经下线的模型永远留在列表里,而那正是「选了却调不到」的来源。
|
||||
//
|
||||
// 事务包住删+插:中途失败留下一个空目录,会让配置页显示「该平台没有可用模型」
|
||||
// 而管理员根本没做任何操作。
|
||||
func ReplaceModelCatalog(ctx context.Context, agentName string, models []CatalogModel) error {
|
||||
agentName = strings.TrimSpace(agentName)
|
||||
if agentName == "" {
|
||||
return nil
|
||||
}
|
||||
if len(models) > maxCatalogModels {
|
||||
models = models[:maxCatalogModels]
|
||||
}
|
||||
|
||||
tx, err := db.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM agent_model_catalog WHERE agent_name = $1`, agentName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, m := range models {
|
||||
p := strings.TrimSpace(m.Provider)
|
||||
id := strings.TrimSpace(m.Model)
|
||||
if p == "" || id == "" {
|
||||
continue // 半条记录不如不要:它在配置页上是一个点不动的空复选框
|
||||
}
|
||||
key := p + "/" + id
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO agent_model_catalog (agent_name, provider, model, display_name, reported_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())`,
|
||||
agentName, p, id, strings.TrimSpace(m.DisplayName)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ListModelCatalog 返回某 Agent 的模型目录,并标出哪些已被选入邮件场景。
|
||||
//
|
||||
// LEFT JOIN 而不是两次查询:前端要的是一份「带勾选状态的清单」,
|
||||
// 在 SQL 里合完比让前端自己对齐两个数组更难出错。
|
||||
func ListModelCatalog(ctx context.Context, agentName string) ([]CatalogModel, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT c.provider, c.model, c.display_name,
|
||||
CASE WHEN a.model IS NULL THEN 0 ELSE 1 END AS allowed,
|
||||
COALESCE(a.rank, 0)
|
||||
FROM agent_model_catalog c
|
||||
LEFT JOIN agent_allowed_models a
|
||||
ON a.agent_name = c.agent_name
|
||||
AND a.provider = c.provider
|
||||
AND a.model = c.model
|
||||
WHERE c.agent_name = $1
|
||||
ORDER BY c.provider, c.model
|
||||
`, agentName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []CatalogModel{}
|
||||
for rows.Next() {
|
||||
var m CatalogModel
|
||||
var allowed int
|
||||
if err := rows.Scan(&m.Provider, &m.Model, &m.DisplayName, &allowed, &m.Rank); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Allowed = allowed == 1
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListAllowedModels 按 rank 返回该 Agent 在邮件场景下可用的模型。
|
||||
//
|
||||
// **不与目录做 JOIN**:目录是平台上次注册时的快照,插件重启前可能已经过期。
|
||||
// 真正能不能调通只有插件试过才知道 —— 这也正是插件要按顺序降级的原因。
|
||||
// 在这里用目录过滤,只会把「目录暂时没上报但其实可用」的模型挡掉。
|
||||
func ListAllowedModels(ctx context.Context, agentName string) ([]ModelRef, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT provider, model FROM agent_allowed_models
|
||||
WHERE agent_name = $1
|
||||
ORDER BY rank ASC, provider ASC, model ASC
|
||||
`, agentName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []ModelRef{}
|
||||
for rows.Next() {
|
||||
var m ModelRef
|
||||
if err := rows.Scan(&m.Provider, &m.Model); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListStaleAllowedModels 返回已选但已不在平台目录里的模型。
|
||||
//
|
||||
// 平台可能临时下线了某个模型(换了 provider 配置、上游故障),
|
||||
// 而管理员的选择是持久的。界面上不显示这些项的话,管理员会以为自己
|
||||
// 没选过它们 —— 而它们其实还在被插件尝试(ListAllowedModels 不与目录 JOIN)。
|
||||
func ListStaleAllowedModels(ctx context.Context, agentName string) ([]ModelRef, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT a.provider, a.model
|
||||
FROM agent_allowed_models a
|
||||
WHERE a.agent_name = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM agent_model_catalog c
|
||||
WHERE c.agent_name = a.agent_name
|
||||
AND c.provider = a.provider
|
||||
AND c.model = a.model
|
||||
)
|
||||
ORDER BY a.rank ASC
|
||||
`, agentName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []ModelRef{}
|
||||
for rows.Next() {
|
||||
var m ModelRef
|
||||
if err := rows.Scan(&m.Provider, &m.Model); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetAllowedModels 整表替换某 Agent 的邮件场景可用模型,入参顺序即优先级。
|
||||
//
|
||||
// 允许传空列表:那表示「不限定」——插件回退到平台自己的默认模型。
|
||||
// 这与「一个都不许用」不同,后者等于让 Agent 彻底哑掉,不该是一次误删的后果。
|
||||
func SetAllowedModels(ctx context.Context, agentName string, picks []ModelRef) error {
|
||||
agentName = strings.TrimSpace(agentName)
|
||||
if agentName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := db.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM agent_allowed_models WHERE agent_name = $1`, agentName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rank := 0
|
||||
seen := map[string]bool{}
|
||||
for _, m := range picks {
|
||||
p := strings.TrimSpace(m.Provider)
|
||||
id := strings.TrimSpace(m.Model)
|
||||
if p == "" || id == "" {
|
||||
continue
|
||||
}
|
||||
key := p + "/" + id
|
||||
if seen[key] {
|
||||
// 重复项直接跳过而不是报错:它对最终顺序没有影响,
|
||||
// 为一次无害的重复让整次保存失败只会让人以为配置没生效。
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO agent_allowed_models (agent_name, provider, model, rank)
|
||||
VALUES ($1, $2, $3, $4)`, agentName, p, id, rank); err != nil {
|
||||
return err
|
||||
}
|
||||
rank++
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
299
server/internal/repo/models_scope_test.go
Normal file
299
server/internal/repo/models_scope_test.go
Normal file
@ -0,0 +1,299 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 目录与选择分两张表,是为了让「已选」在模型从平台目录里消失后仍然留存。
|
||||
// 这个测试钉住那个行为 —— 合并成一张带 allowed 标记的表就会失败。
|
||||
func TestAllowedModelsSurviveCatalogChurn(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := ReplaceModelCatalog(ctx, "dsh", []CatalogModel{
|
||||
{Provider: "llmsproxy", Model: "AUTO", DisplayName: "AUTO"},
|
||||
{Provider: "llmsproxy", Model: "claude-sonnet-4-6"},
|
||||
}); err != nil {
|
||||
t.Fatalf("首次上报目录: %v", err)
|
||||
}
|
||||
if err := SetAllowedModels(ctx, "dsh", []ModelRef{
|
||||
{Provider: "llmsproxy", Model: "AUTO"},
|
||||
}); err != nil {
|
||||
t.Fatalf("保存选择: %v", err)
|
||||
}
|
||||
|
||||
// 平台侧 AUTO 临时下线,只上报另一个
|
||||
if err := ReplaceModelCatalog(ctx, "dsh", []CatalogModel{
|
||||
{Provider: "llmsproxy", Model: "claude-sonnet-4-6"},
|
||||
}); err != nil {
|
||||
t.Fatalf("二次上报目录: %v", err)
|
||||
}
|
||||
|
||||
allowed, err := ListAllowedModels(ctx, "dsh")
|
||||
if err != nil {
|
||||
t.Fatalf("ListAllowedModels: %v", err)
|
||||
}
|
||||
if len(allowed) != 1 || allowed[0].Model != "AUTO" {
|
||||
t.Fatalf("模型从目录消失后选择也被删了:%+v —— "+
|
||||
"两张表分开存的意义就在于此", allowed)
|
||||
}
|
||||
|
||||
// 它应当被标为 stale,界面上才能提示「已选但平台没上报」
|
||||
stale, err := ListStaleAllowedModels(ctx, "dsh")
|
||||
if err != nil {
|
||||
t.Fatalf("ListStaleAllowedModels: %v", err)
|
||||
}
|
||||
if len(stale) != 1 || stale[0].Model != "AUTO" {
|
||||
t.Errorf("应有 1 个 stale,实际 %+v", stale)
|
||||
}
|
||||
|
||||
// 模型回来后不该再是 stale,也不需要重新勾选
|
||||
if err := ReplaceModelCatalog(ctx, "dsh", []CatalogModel{
|
||||
{Provider: "llmsproxy", Model: "AUTO"},
|
||||
{Provider: "llmsproxy", Model: "claude-sonnet-4-6"},
|
||||
}); err != nil {
|
||||
t.Fatalf("三次上报: %v", err)
|
||||
}
|
||||
stale2, _ := ListStaleAllowedModels(ctx, "dsh")
|
||||
if len(stale2) != 0 {
|
||||
t.Errorf("模型回来后不该再是 stale:%+v", stale2)
|
||||
}
|
||||
}
|
||||
|
||||
// 目录整表替换:平台下线的模型必须从配置页消失,
|
||||
// 否则管理员会勾选一个平台其实调不到的模型。
|
||||
func TestReplaceModelCatalogIsFullReplace(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := ReplaceModelCatalog(ctx, "opencode", []CatalogModel{
|
||||
{Provider: "p", Model: "a"},
|
||||
{Provider: "p", Model: "b"},
|
||||
}); err != nil {
|
||||
t.Fatalf("首次: %v", err)
|
||||
}
|
||||
if err := ReplaceModelCatalog(ctx, "opencode", []CatalogModel{
|
||||
{Provider: "p", Model: "a"},
|
||||
}); err != nil {
|
||||
t.Fatalf("二次: %v", err)
|
||||
}
|
||||
got, err := ListModelCatalog(ctx, "opencode")
|
||||
if err != nil {
|
||||
t.Fatalf("ListModelCatalog: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Model != "a" {
|
||||
t.Fatalf("整表替换失效:%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ListModelCatalog 要在同一次查询里标出「已选」与 rank,
|
||||
// 前端才能画出带勾选与顺序的清单。
|
||||
func TestListModelCatalogMarksAllowedAndRank(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := ReplaceModelCatalog(ctx, "dsh", []CatalogModel{
|
||||
{Provider: "p", Model: "first", DisplayName: "第一"},
|
||||
{Provider: "p", Model: "second"},
|
||||
{Provider: "p", Model: "unpicked"},
|
||||
}); err != nil {
|
||||
t.Fatalf("上报: %v", err)
|
||||
}
|
||||
// 顺序即优先级:second 排前面
|
||||
if err := SetAllowedModels(ctx, "dsh", []ModelRef{
|
||||
{Provider: "p", Model: "second"},
|
||||
{Provider: "p", Model: "first"},
|
||||
}); err != nil {
|
||||
t.Fatalf("保存: %v", err)
|
||||
}
|
||||
|
||||
got, err := ListModelCatalog(ctx, "dsh")
|
||||
if err != nil {
|
||||
t.Fatalf("ListModelCatalog: %v", err)
|
||||
}
|
||||
byModel := map[string]CatalogModel{}
|
||||
for _, m := range got {
|
||||
byModel[m.Model] = m
|
||||
}
|
||||
if !byModel["second"].Allowed || byModel["second"].Rank != 0 {
|
||||
t.Errorf("second 应为 rank 0 的已选项:%+v", byModel["second"])
|
||||
}
|
||||
if !byModel["first"].Allowed || byModel["first"].Rank != 1 {
|
||||
t.Errorf("first 应为 rank 1 的已选项:%+v", byModel["first"])
|
||||
}
|
||||
if byModel["unpicked"].Allowed {
|
||||
t.Error("unpicked 不该被标为已选")
|
||||
}
|
||||
if byModel["first"].DisplayName != "第一" {
|
||||
t.Errorf("display_name 未带出:%q", byModel["first"].DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
// 顺序就是插件的降级顺序,必须原样保存。
|
||||
func TestSetAllowedModelsPreservesOrder(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
want := []ModelRef{
|
||||
{Provider: "c", Model: "3"},
|
||||
{Provider: "a", Model: "1"},
|
||||
{Provider: "b", Model: "2"},
|
||||
}
|
||||
if err := SetAllowedModels(ctx, "dsh", want); err != nil {
|
||||
t.Fatalf("SetAllowedModels: %v", err)
|
||||
}
|
||||
got, err := ListAllowedModels(ctx, "dsh")
|
||||
if err != nil {
|
||||
t.Fatalf("ListAllowedModels: %v", err)
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("数量不符:%d vs %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("第 %d 项顺序错:%+v,期望 %+v —— "+
|
||||
"顺序就是插件的降级顺序,不能按字典序重排", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 空列表表示「不限定」,是合法输入。
|
||||
// 报错会让「取消所有限定」变成一件做不到的事。
|
||||
func TestSetAllowedModelsAcceptsEmpty(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := SetAllowedModels(ctx, "dsh", []ModelRef{{Provider: "p", Model: "m"}}); err != nil {
|
||||
t.Fatalf("先设一个: %v", err)
|
||||
}
|
||||
if err := SetAllowedModels(ctx, "dsh", []ModelRef{}); err != nil {
|
||||
t.Fatalf("清空应当合法: %v", err)
|
||||
}
|
||||
got, _ := ListAllowedModels(ctx, "dsh")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("清空后应为空,实际 %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 重复项跳过而不报错:它对最终顺序没有影响,
|
||||
// 为一次无害的重复让整次保存失败只会让人以为配置没生效。
|
||||
func TestSetAllowedModelsSkipsDuplicates(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
err := SetAllowedModels(ctx, "dsh", []ModelRef{
|
||||
{Provider: "p", Model: "m"},
|
||||
{Provider: "p", Model: "m"},
|
||||
{Provider: "p", Model: "other"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("重复项不该报错: %v", err)
|
||||
}
|
||||
got, _ := ListAllowedModels(ctx, "dsh")
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("应保留 2 项,实际 %+v", got)
|
||||
}
|
||||
// rank 要连续:跳过重复项后不该在序号上留空洞
|
||||
if got[0].Model != "m" || got[1].Model != "other" {
|
||||
t.Errorf("顺序错:%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 字段不全的项跳过:半条记录在配置页上是一个点不动的空复选框。
|
||||
func TestModelCatalogSkipsIncomplete(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := ReplaceModelCatalog(ctx, "dsh", []CatalogModel{
|
||||
{Provider: "", Model: "m"},
|
||||
{Provider: "p", Model: ""},
|
||||
{Provider: " ", Model: " "},
|
||||
{Provider: "p", Model: "ok"},
|
||||
}); err != nil {
|
||||
t.Fatalf("上报: %v", err)
|
||||
}
|
||||
got, _ := ListModelCatalog(ctx, "dsh")
|
||||
if len(got) != 1 || got[0].Model != "ok" {
|
||||
t.Fatalf("应只留 1 项:%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 目录里重复的 provider/model 不该让整次事务失败(主键冲突)。
|
||||
func TestReplaceModelCatalogDedupes(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := ReplaceModelCatalog(ctx, "dsh", []CatalogModel{
|
||||
{Provider: "p", Model: "m", DisplayName: "第一次"},
|
||||
{Provider: "p", Model: "m", DisplayName: "第二次"},
|
||||
}); err != nil {
|
||||
t.Fatalf("重复不该报错: %v", err)
|
||||
}
|
||||
got, _ := ListModelCatalog(ctx, "dsh")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("应去重到 1 项:%+v", got)
|
||||
}
|
||||
if got[0].DisplayName != "第一次" {
|
||||
t.Errorf("应保留第一条:%q", got[0].DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
// 各 Agent 的目录与选择互不影响。
|
||||
func TestModelScopeIsolatedPerAgent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := ReplaceModelCatalog(ctx, "dsh", []CatalogModel{{Provider: "p", Model: "dsh-only"}}); err != nil {
|
||||
t.Fatalf("dsh 上报: %v", err)
|
||||
}
|
||||
if err := ReplaceModelCatalog(ctx, "opencode", []CatalogModel{{Provider: "p", Model: "oc-only"}}); err != nil {
|
||||
t.Fatalf("opencode 上报: %v", err)
|
||||
}
|
||||
if err := SetAllowedModels(ctx, "dsh", []ModelRef{{Provider: "p", Model: "dsh-only"}}); err != nil {
|
||||
t.Fatalf("dsh 选择: %v", err)
|
||||
}
|
||||
|
||||
ocCatalog, _ := ListModelCatalog(ctx, "opencode")
|
||||
if len(ocCatalog) != 1 || ocCatalog[0].Model != "oc-only" {
|
||||
t.Fatalf("opencode 的目录被污染:%+v", ocCatalog)
|
||||
}
|
||||
if ocCatalog[0].Allowed {
|
||||
t.Error("dsh 的选择串到 opencode 上了")
|
||||
}
|
||||
ocAllowed, _ := ListAllowedModels(ctx, "opencode")
|
||||
if len(ocAllowed) != 0 {
|
||||
t.Errorf("opencode 不该有已选项:%+v", ocAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// 上报数量超上限时截断而不报错:平台把上游几千个模型全列出来是它的自由,
|
||||
// 但配置页上几千个复选框对人没有用。
|
||||
func TestReplaceModelCatalogCaps(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
many := make([]CatalogModel, maxCatalogModels+50)
|
||||
for i := range many {
|
||||
many[i] = CatalogModel{Provider: "p", Model: string(rune('a'+i%26)) + itoaTest(i)}
|
||||
}
|
||||
if err := ReplaceModelCatalog(ctx, "dsh", many); err != nil {
|
||||
t.Fatalf("超量上报不该报错: %v", err)
|
||||
}
|
||||
got, _ := ListModelCatalog(ctx, "dsh")
|
||||
if len(got) != maxCatalogModels {
|
||||
t.Errorf("应截断到 %d,实际 %d", maxCatalogModels, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func itoaTest(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for n > 0 {
|
||||
b = append([]byte{byte('0' + n%10)}, b...)
|
||||
n /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
119
server/internal/repo/nearesthuman_test.go
Normal file
119
server/internal/repo/nearesthuman_test.go
Normal file
@ -0,0 +1,119 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
)
|
||||
|
||||
// 权限询问的决策者必须是人:Agent 收不到 SendToUser,桥的 await 永不 resolve。
|
||||
// 生产事故:pi 把任务派给自己的另一条会话 → 那条会话要跑 bash → 权限邮件发给 "pi"
|
||||
// → pi 不是人类用户 → 整条会话永久阻塞。
|
||||
//
|
||||
// 修法是顺着会话的邮件链上溯找最近的人类 —— 派活的人才是该点头的人。
|
||||
func TestNearestHumanInThread(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mustExec(t, ctx, `INSERT INTO users (username, display_name, password_hash, role)
|
||||
VALUES ('alice','Alice','x','user')`)
|
||||
for _, a := range []string{"opencode", "dsh", "pi"} {
|
||||
if err := CreateOrUpdateAgent(ctx, a, "s", "test", nil); err != nil {
|
||||
t.Fatalf("注册 %s: %v", a, err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("沿链上溯找到派活的人", func(t *testing.T) {
|
||||
sid, err := CreateSession(ctx, nil, "opencode", "任务链", "/home")
|
||||
if err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
// alice → opencode → dsh,dsh 触发权限询问
|
||||
m1, err := CreateMail(ctx, sid, nil, "alice", "", "opencode", "", "任务", "请帮忙", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("封1: %v", err)
|
||||
}
|
||||
m2, err := CreateMail(ctx, sid, &m1, "opencode", "", "dsh", "", "转派", "你来看", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("封2: %v", err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, sid, &m2, "dsh", "", "opencode", "", "进展", "做了一半", nil); err != nil {
|
||||
t.Fatalf("封3: %v", err)
|
||||
}
|
||||
|
||||
for _, agent := range []string{"dsh", "opencode"} {
|
||||
got, err := NearestHumanInThread(ctx, sid, agent)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", agent, err)
|
||||
}
|
||||
if got != "alice" {
|
||||
t.Errorf("%s 触发权限时应路由到 alice,得到 %q", agent, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("全 Agent 链返回空串", func(t *testing.T) {
|
||||
// 没有人类参与的链条:调用方据此拒绝请求,而不是转给一个
|
||||
// 对上下文一无所知的管理员。
|
||||
sid, err := CreateSession(ctx, nil, "opencode", "纯 Agent", "/tmp")
|
||||
if err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
m1, err := CreateMail(ctx, sid, nil, "opencode", "", "dsh", "", "干活", "go", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("封1: %v", err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, sid, &m1, "dsh", "", "opencode", "", "好", "ok", nil); err != nil {
|
||||
t.Fatalf("封2: %v", err)
|
||||
}
|
||||
|
||||
got, err := NearestHumanInThread(ctx, sid, "dsh")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("链上没有人类时应返回空串,得到 %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skipAgent 是自己时不会把自己当人", func(t *testing.T) {
|
||||
// pi 给自己的另一条会话派活正是事故场景:链上只有 pi 一个名字。
|
||||
sid, err := CreateSession(ctx, nil, "pi", "自派", "/home")
|
||||
if err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, sid, nil, "pi", "", "pi", "", "拆任务", "自己干", nil); err != nil {
|
||||
t.Fatalf("封1: %v", err)
|
||||
}
|
||||
|
||||
got, err := NearestHumanInThread(ctx, sid, "pi")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("自派链上没有人类,应返回空串,得到 %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空会话不报错", func(t *testing.T) {
|
||||
sid, err := CreateSession(ctx, nil, "opencode", "空", "/tmp")
|
||||
if err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
got, err := NearestHumanInThread(ctx, sid, "opencode")
|
||||
if err != nil {
|
||||
t.Fatalf("空会话应返回空串而非报错,得到 err=%v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("空会话应返回空串,得到 %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func mustExec(t *testing.T, ctx context.Context, q string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.DB.ExecContext(ctx, q, args...); err != nil {
|
||||
t.Fatalf("exec %s: %v", q, err)
|
||||
}
|
||||
}
|
||||
135
server/internal/repo/participants.go
Normal file
135
server/internal/repo/participants.go
Normal file
@ -0,0 +1,135 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Participant 是一条会话里的一个参与方。
|
||||
//
|
||||
// Path 是该参与方**自己那个地址的 path 位**,不是别人的:一封主发给 dsh@/b、
|
||||
// 抄送给 opencode@/a 的邮件里,两人的工作目录不同,混用会让对方在别人的目录里
|
||||
// 开会话(生产上已发生过一次,见 PLUGIN-CONTRACT 9.3)。
|
||||
type Participant struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
// Roles 是该参与方在这条会话里出现过的全部身份,from / to / cc 的并集。
|
||||
// 用集合而非单值:同一个人常常既发过信也被抄送过,只留最后一个身份会让
|
||||
// 「谁是这件事的负责人」这个判断出错。
|
||||
Roles []string `json:"roles"`
|
||||
// MailCount 是该参与方作为发件人的邮件数。用来回答「谁还没回」——
|
||||
// 参与方列表里 from 计数为 0 的那个就是还没开口的人。
|
||||
MailCount int `json:"mail_count"`
|
||||
}
|
||||
|
||||
// SessionParticipants 列出会话的全部参与方及各自的地址素材。
|
||||
//
|
||||
// 为什么要逐封扫而不是看 sessions 表:**参与方是随往来增长的**。会话建立时
|
||||
// 只有发件人与收件人,一封抄送、一次转发都会带进新的人。sessions 表里只有
|
||||
// from_agent 一个名字,回答不了「这条线索上现在有谁」。
|
||||
//
|
||||
// 排序:按首次出现顺序(created_at)。这让主收件人稳定排在抄送方之前,
|
||||
// 模型据此判断「谁是负责人、谁是配合方」——按名字排序会丢掉这个信息。
|
||||
func SessionParticipants(ctx context.Context, sessionID uuid.UUID) ([]Participant, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT from_name, COALESCE(from_workspace,''),
|
||||
to_name, COALESCE(to_workspace,''),
|
||||
cc_list
|
||||
FROM mails
|
||||
WHERE session_id = $1
|
||||
ORDER BY created_at ASC, mail_id ASC
|
||||
`, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type acc struct {
|
||||
p Participant
|
||||
roles map[string]bool
|
||||
order int
|
||||
}
|
||||
seen := map[string]*acc{}
|
||||
next := 0
|
||||
|
||||
// note 记录一次「某人以某身份出现」。
|
||||
//
|
||||
// path 只在**当前为空且新值非空**时补写:同一个人可能在不同邮件里带不同
|
||||
// path(先被抄送到 /a,后被主发到 /b)。保留首个非空值而不是最后一个,
|
||||
// 与排序口径一致(首次出现顺序),也避免一封转发把地址改指到别处。
|
||||
note := func(name, path, role string, isSender bool) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
a, ok := seen[name]
|
||||
if !ok {
|
||||
a = &acc{
|
||||
p: Participant{Name: name, Path: path},
|
||||
roles: map[string]bool{},
|
||||
order: next,
|
||||
}
|
||||
next++
|
||||
seen[name] = a
|
||||
}
|
||||
if a.p.Path == "" && path != "" {
|
||||
a.p.Path = path
|
||||
}
|
||||
a.roles[role] = true
|
||||
if isSender {
|
||||
a.p.MailCount++
|
||||
}
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var fromName, fromWS, toName, toWS string
|
||||
var ccRaw []byte
|
||||
if err := rows.Scan(&fromName, &fromWS, &toName, &toWS, &ccRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// **发件人一侧不取 from_workspace 当 path。** Agent 回信时那一列存的是
|
||||
// Agent 名而不是路径(历史遗留,FindOrCreateDefaultSession 的注释里也提到
|
||||
// 同一个坑)。拿它拼地址会得到 `dsh@dsh.alias` 这种投不出去的东西。
|
||||
note(fromName, "", "from", true)
|
||||
note(toName, toWS, "to", false)
|
||||
|
||||
if len(ccRaw) > 0 {
|
||||
var cc []models.Address
|
||||
if json.Unmarshal(ccRaw, &cc) == nil {
|
||||
for _, c := range cc {
|
||||
note(c.Name, c.Path, "cc", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Participant, 0, len(seen))
|
||||
for _, a := range seen {
|
||||
a.p.Roles = sortedKeys(a.roles)
|
||||
out = append(out, a.p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return seen[out[i].Name].order < seen[out[j].Name].order
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// sortedKeys 给出稳定顺序的角色列表。
|
||||
// map 迭代顺序随机,不排序的话同一条会话每次返回的 roles 顺序都不同,
|
||||
// 插件侧做 diff 或缓存时会误判为「参与方变了」。
|
||||
func sortedKeys(m map[string]bool) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
170
server/internal/repo/participants_test.go
Normal file
170
server/internal/repo/participants_test.go
Normal file
@ -0,0 +1,170 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 参与方列表要回答的是「这条线索上现在有谁、用什么地址找到他、谁还没开口」。
|
||||
// 三个问题里每一个都曾经答错过:
|
||||
// - 有谁:sessions 表只有 from_agent 一个名字,抄送方与转发引入的人都不在里面
|
||||
// - 什么地址:拿 from_workspace 当 path 会拼出 dsh@dsh.alias 这种投不出去的东西
|
||||
// - 谁还没回:只留最后一个身份的话,既发过信又被抄送过的人会被算成纯配合方
|
||||
|
||||
func TestSessionParticipantsIncludesCC(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "抄收联调", "/home/program/llmsproxy")
|
||||
// 复刻线上那封:admin 主发 dsh,抄送 opencode
|
||||
mustMail(t, sid, "admin", "", "dsh", "/home/program/llmsproxy",
|
||||
[]models.Address{{Name: "opencode", Path: "/home", Session: "new", Raw: "opencode@/home.new"}})
|
||||
|
||||
parts, err := SessionParticipants(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatalf("列参与方: %v", err)
|
||||
}
|
||||
|
||||
byName := map[string]Participant{}
|
||||
for _, p := range parts {
|
||||
byName[p.Name] = p
|
||||
}
|
||||
for _, want := range []string{"admin", "dsh", "opencode"} {
|
||||
if _, ok := byName[want]; !ok {
|
||||
t.Errorf("参与方缺 %s,实得 %+v", want, parts)
|
||||
}
|
||||
}
|
||||
// 抄送方的 path 必须是它自己那个地址的 path 位,不是主收件人的。
|
||||
// 用错的后果:对方在别人的工作目录里开会话。
|
||||
if got := byName["opencode"].Path; got != "/home" {
|
||||
t.Errorf("opencode 的 path = %q,应为 /home(它自己地址的 path 位)", got)
|
||||
}
|
||||
if got := byName["dsh"].Path; got != "/home/program/llmsproxy" {
|
||||
t.Errorf("dsh 的 path = %q,应为 /home/program/llmsproxy", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionParticipantsSenderPathStaysEmpty(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "回信", "/tmp/ws")
|
||||
// Agent 回信时 from_workspace 存的是 Agent 名而非路径(历史遗留)。
|
||||
// 若把它当 path,地址会拼成 dsh@dsh.alias —— 投不出去。
|
||||
mustMail(t, sid, "dsh", "dsh", "admin", "", nil)
|
||||
|
||||
parts, _ := SessionParticipants(ctx, sid)
|
||||
for _, p := range parts {
|
||||
if p.Name == "dsh" && p.Path == "dsh" {
|
||||
t.Fatal("发件人的 path 取了 from_workspace(那列存的是 Agent 名),会拼出无效地址")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionParticipantsMergesRoles(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "往返", "/tmp/ws")
|
||||
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws", nil) // admin=from, dsh=to
|
||||
mustMail(t, sid, "dsh", "dsh", "admin", "", nil) // dsh=from, admin=to
|
||||
|
||||
parts, _ := SessionParticipants(ctx, sid)
|
||||
for _, p := range parts {
|
||||
if len(p.Roles) != 2 {
|
||||
t.Errorf("%s 的 roles = %v,双方都该同时有 from 与 to", p.Name, p.Roles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionParticipantsCountsOnlySends(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "谁还没回", "/tmp/ws")
|
||||
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws",
|
||||
[]models.Address{{Name: "opencode", Path: "/tmp/ws", Raw: "opencode@/tmp/ws"}})
|
||||
mustMail(t, sid, "dsh", "dsh", "admin", "", nil)
|
||||
|
||||
parts, _ := SessionParticipants(ctx, sid)
|
||||
got := map[string]int{}
|
||||
for _, p := range parts {
|
||||
got[p.Name] = p.MailCount
|
||||
}
|
||||
// MailCount 只数「作为发件人」的邮件:抄送方 opencode 一封都没发,
|
||||
// 计数为 0 正是「还没开口的人」这个判断的依据。
|
||||
if got["opencode"] != 0 {
|
||||
t.Errorf("opencode 只被抄送未发信,MailCount 应为 0,实为 %d", got["opencode"])
|
||||
}
|
||||
if got["admin"] != 1 || got["dsh"] != 1 {
|
||||
t.Errorf("admin/dsh 各发过一封,实为 %d/%d", got["admin"], got["dsh"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionParticipantsKeepsFirstSeenOrder(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "顺序", "/tmp/ws")
|
||||
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws",
|
||||
[]models.Address{{Name: "opencode", Path: "/home", Raw: "opencode@/home"}})
|
||||
|
||||
parts, _ := SessionParticipants(ctx, sid)
|
||||
// 按首次出现排序,让主收件人稳定排在抄送方之前 ——
|
||||
// 模型据此判断谁是负责人、谁是配合方;按名字排序会丢掉这个信息。
|
||||
want := []string{"admin", "dsh", "opencode"}
|
||||
if len(parts) != len(want) {
|
||||
t.Fatalf("参与方数量 %d,期望 %d: %+v", len(parts), len(want), parts)
|
||||
}
|
||||
for i, w := range want {
|
||||
if parts[i].Name != w {
|
||||
t.Errorf("第 %d 位是 %s,期望 %s", i, parts[i].Name, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionParticipantsPrefersFirstNonEmptyPath(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "改指", "/tmp/a")
|
||||
// 同一个人先被抄送到 /home,后被主发到 /tmp/b。
|
||||
// 保留首个非空值,与排序口径一致,也避免一封转发把地址改指到别处。
|
||||
mustMail(t, sid, "admin", "", "dsh", "/tmp/a",
|
||||
[]models.Address{{Name: "opencode", Path: "/home", Raw: "opencode@/home"}})
|
||||
mustMail(t, sid, "admin", "", "opencode", "/tmp/b", nil)
|
||||
|
||||
parts, _ := SessionParticipants(ctx, sid)
|
||||
for _, p := range parts {
|
||||
if p.Name == "opencode" && p.Path != "/home" {
|
||||
t.Fatalf("opencode 的 path = %q,应保持首次出现的 /home", p.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionParticipantsEmptySession(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 会话刚建、还没有邮件。返回空列表而不是报错:
|
||||
// 调用方拿到空表能正常渲染「暂无参与方」,拿到 error 只能整个失败。
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "空会话", "/tmp/ws")
|
||||
parts, err := SessionParticipants(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatalf("空会话应正常返回: %v", err)
|
||||
}
|
||||
if len(parts) != 0 {
|
||||
t.Fatalf("空会话应无参与方,实得 %+v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func mustMail(t *testing.T, sid uuid.UUID, from, fromWS, to, toWS string, cc []models.Address) {
|
||||
t.Helper()
|
||||
if _, err := CreateMail(context.Background(), sid, nil,
|
||||
from, fromWS, to, toWS, "主题", "正文", cc); err != nil {
|
||||
t.Fatalf("建邮件: %v", err)
|
||||
}
|
||||
}
|
||||
148
server/internal/repo/permission_mode.go
Normal file
148
server/internal/repo/permission_mode.go
Normal file
@ -0,0 +1,148 @@
|
||||
package repo
|
||||
|
||||
// 会话级权限档位的读写。
|
||||
//
|
||||
// ## 为什么档位挂在会话上而不是每封邮件上
|
||||
//
|
||||
// 与配额同一个理由(见 quota.go 的注释):档位是**任务**的属性。
|
||||
// 「这件事只许你看不许你动」描述的是任务性质,不是某一封信的性质。
|
||||
//
|
||||
// 如果续谈的邮件也能带档位,每封新信都会悄悄改掉对方正在遵守的规则 ——
|
||||
// 而 plan 档的会话里模型已经被告知「只许看」,第二封信把它改成 full,
|
||||
// 是在一段已有上下文里换规则。人不一定意识到自己改了。
|
||||
//
|
||||
// 所以:**新建会话时设,续谈时忽略该字段,在对话页里显式编辑。**
|
||||
//
|
||||
// ## 为什么 Agent 不能自己指定档位
|
||||
//
|
||||
// 否则 Agent 发一封 mode=full 的信就给自己提权了。Agent 派活时子会话的档位
|
||||
// 由 InheritedMode 从父会话推导,且**只能同档或更严**(models.ModeAtMost)。
|
||||
// 这保证 plan 档的任务派不出 full 档的子任务 —— 与 hop_limit 一个形状。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// SessionPermission 是一条会话的档位与实际强制力。
|
||||
//
|
||||
// 两个字段必须一起返回:档位是「要求什么」,强制力是「平台实际做到了什么」。
|
||||
// 只给前者会让人以为 plan 档管住了 homeagent(它的核心没有工具调用拦截点)。
|
||||
type SessionPermission struct {
|
||||
Mode string `json:"permission_mode"`
|
||||
Enforcement string `json:"permission_enforcement"`
|
||||
}
|
||||
|
||||
// GetSessionPermission 读一条会话的档位与强制力。
|
||||
//
|
||||
// 读出来的值一律过 Normalize:库里可能有历史脏数据(手工改库、旧版本写入),
|
||||
// 而调用方拿到一个认不出的档位时的行为无法预期。归一化在这里做一次,
|
||||
// 后续所有判断就都能假定值是合法的。
|
||||
func GetSessionPermission(ctx context.Context, id uuid.UUID) (SessionPermission, error) {
|
||||
var p SessionPermission
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(NULLIF(permission_mode, ''), 'workspace'),
|
||||
COALESCE(NULLIF(permission_enforcement, ''), 'advisory')
|
||||
FROM sessions WHERE session_id = $1`, id).Scan(&p.Mode, &p.Enforcement)
|
||||
if err != nil {
|
||||
return SessionPermission{}, err
|
||||
}
|
||||
p.Mode = models.NormalizePermissionMode(p.Mode)
|
||||
p.Enforcement = models.NormalizeEnforcement(p.Enforcement)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// SessionPermissionMode 只取档位,读不到时回落默认档。
|
||||
//
|
||||
// 供投递路径使用:那里拿不到档位也得继续走(不能因为查询失败就拒收邮件),
|
||||
// 但回落必须是默认档而不是 full —— 查询失败不该换来更大的权限。
|
||||
func SessionPermissionMode(ctx context.Context, id uuid.UUID) string {
|
||||
p, err := GetSessionPermission(ctx, id)
|
||||
if err != nil {
|
||||
return models.DefaultPermissionMode
|
||||
}
|
||||
return p.Mode
|
||||
}
|
||||
|
||||
// SetSessionPermissionMode 设置会话档位。
|
||||
//
|
||||
// 非法档位一律收敛成默认档而不是报错:这个函数的调用方包括人在界面上操作,
|
||||
// 而界面传来一个拼错的值时,静默用默认档比让整次操作失败更合理 ——
|
||||
// 默认档本身是安全的。
|
||||
func SetSessionPermissionMode(ctx context.Context, id uuid.UUID, mode string) (SessionPermission, error) {
|
||||
m := models.NormalizePermissionMode(mode)
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET permission_mode = $2, updated_at = NOW() WHERE session_id = $1`,
|
||||
id, m)
|
||||
if err != nil {
|
||||
return SessionPermission{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return SessionPermission{}, fmt.Errorf("会话 %s 不存在", id)
|
||||
}
|
||||
return GetSessionPermission(ctx, id)
|
||||
}
|
||||
|
||||
// SetSessionEnforcement 记录接收平台实际做到的强制力。
|
||||
//
|
||||
// 由投递路径在建会话时按收件 Agent 的自报能力写入 —— 它是**事实快照**
|
||||
// 而不是配置:插件升级后能力会变,但已结束的会话不该被改写成「其实当时
|
||||
// 是被强制的」。所以不跟着 agents.mode_enforcement 走,而是建会话时定死。
|
||||
func SetSessionEnforcement(ctx context.Context, id uuid.UUID, enforcement string) error {
|
||||
e := models.NormalizeEnforcement(enforcement)
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET permission_enforcement = $2 WHERE session_id = $1`, id, e)
|
||||
return err
|
||||
}
|
||||
|
||||
// AgentModeEnforcement 取某个 Agent 自报的档位强制力。
|
||||
//
|
||||
// Agent 不存在或没自报过时返回 advisory:不能替一个没说过话的插件宣称
|
||||
// 「档位在它那里是被强制的」。保守方向是承认做不到。
|
||||
func AgentModeEnforcement(ctx context.Context, agentName string) string {
|
||||
var e string
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(NULLIF(mode_enforcement, ''), 'advisory')
|
||||
FROM agents WHERE agent_name = $1`, agentName).Scan(&e)
|
||||
if err != nil {
|
||||
return models.EnforcementAdvisory
|
||||
}
|
||||
return models.NormalizeEnforcement(e)
|
||||
}
|
||||
|
||||
// SetAgentModeEnforcement 落库 Agent 心跳自报的档位强制力。
|
||||
//
|
||||
// 走心跳而不是注册:注册只在插件启动时发生一次,而能力可能因为配置变化
|
||||
// (比如 DSH 的 sandbox 被换成 danger-full-access)而改变。与模型目录上报
|
||||
// 同一条通道 —— I-1:平台自己说的才算。
|
||||
func SetAgentModeEnforcement(ctx context.Context, agentName, enforcement string) error {
|
||||
e := models.NormalizeEnforcement(enforcement)
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agents SET mode_enforcement = $2 WHERE agent_name = $1`, agentName, e)
|
||||
return err
|
||||
}
|
||||
|
||||
// InheritedMode 推导子会话应当继承的档位。
|
||||
//
|
||||
// parentSessionID 为 nil(人直接发起、或没有父会话可依据)时返回 requested
|
||||
// 归一化后的值;有父会话时取**父档位与请求档位里更严的那一个**。
|
||||
//
|
||||
// 为什么必须取更严:Agent 派活时若能给子会话一个更宽松的档位,plan 档的
|
||||
// 任务就能通过「派给自己一条 full 档子会话」来提权,档位形同虚设。
|
||||
// 这与 hop_limit 防自激的形状一样 —— 约束必须沿着链条传递下去。
|
||||
func InheritedMode(ctx context.Context, parentSessionID *uuid.UUID, requested string) string {
|
||||
req := models.NormalizePermissionMode(requested)
|
||||
if parentSessionID == nil {
|
||||
return req
|
||||
}
|
||||
parent, err := GetSessionPermission(ctx, *parentSessionID)
|
||||
if err != nil {
|
||||
// 父会话查不到时按默认档与请求档取更严 —— 不能因为查询失败而放宽。
|
||||
return models.ModeAtMost(models.DefaultPermissionMode, req)
|
||||
}
|
||||
return models.ModeAtMost(parent.Mode, req)
|
||||
}
|
||||
369
server/internal/repo/permission_mode_test.go
Normal file
369
server/internal/repo/permission_mode_test.go
Normal file
@ -0,0 +1,369 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ─── InheritedMode:继承、收紧、不存在的父会话 ───
|
||||
|
||||
// parent nil → 返回 requested 的规范化值
|
||||
func TestInheritedMode_NilParent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
got := InheritedMode(ctx, nil, "plan")
|
||||
if got != models.ModePlan {
|
||||
t.Errorf("nil parent + plan: got %q, want plan", got)
|
||||
}
|
||||
|
||||
got = InheritedMode(ctx, nil, "workspace")
|
||||
if got != models.ModeWorkspace {
|
||||
t.Errorf("nil parent + workspace: got %q, want workspace", got)
|
||||
}
|
||||
|
||||
got = InheritedMode(ctx, nil, "full")
|
||||
if got != models.ModeFull {
|
||||
t.Errorf("nil parent + full: got %q, want full", got)
|
||||
}
|
||||
}
|
||||
|
||||
// parent plan → 子会话只能 plan(不能提权)
|
||||
func TestInheritedMode_ParentPlan_Tightens(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id := createTestSession(t, ctx, "pi", "/ws")
|
||||
perm, err := SetSessionPermissionMode(ctx, id, models.ModePlan)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm.Mode != models.ModePlan {
|
||||
t.Fatal("expected plan")
|
||||
}
|
||||
|
||||
// 请求 workspace(更宽松)→ 应被收紧为 plan
|
||||
got := InheritedMode(ctx, &id, models.ModeWorkspace)
|
||||
if got != models.ModePlan {
|
||||
t.Errorf("plan parent + workspace request: got %q, want plan", got)
|
||||
}
|
||||
|
||||
// 请求 full → 同样收紧
|
||||
got = InheritedMode(ctx, &id, models.ModeFull)
|
||||
if got != models.ModePlan {
|
||||
t.Errorf("plan parent + full request: got %q, want plan", got)
|
||||
}
|
||||
|
||||
// 请求 plan → 保持 plan
|
||||
got = InheritedMode(ctx, &id, models.ModePlan)
|
||||
if got != models.ModePlan {
|
||||
t.Errorf("plan parent + plan request: got %q, want plan", got)
|
||||
}
|
||||
}
|
||||
|
||||
// parent workspace → 子会话 workspace 或更严
|
||||
func TestInheritedMode_ParentWorkspace(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id := createTestSession(t, ctx, "pi", "/ws")
|
||||
_, err := SetSessionPermissionMode(ctx, id, models.ModeWorkspace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 请求 full → 收紧为 workspace(子不能比父更松)
|
||||
got := InheritedMode(ctx, &id, models.ModeFull)
|
||||
if got != models.ModeWorkspace {
|
||||
t.Errorf("workspace parent + full: got %q, want workspace", got)
|
||||
}
|
||||
|
||||
// 请求 plan → 保留 plan(比父更严,允许)
|
||||
got = InheritedMode(ctx, &id, models.ModePlan)
|
||||
if got != models.ModePlan {
|
||||
t.Errorf("workspace parent + plan: got %q, want plan", got)
|
||||
}
|
||||
}
|
||||
|
||||
// parent full → 子会话可请求任意档位
|
||||
func TestInheritedMode_ParentFull(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id := createTestSession(t, ctx, "pi", "/ws")
|
||||
_, err := SetSessionPermissionMode(ctx, id, models.ModeFull)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := InheritedMode(ctx, &id, models.ModeWorkspace)
|
||||
if got != models.ModeWorkspace {
|
||||
t.Errorf("full parent + workspace: got %q, want workspace", got)
|
||||
}
|
||||
got = InheritedMode(ctx, &id, models.ModePlan)
|
||||
if got != models.ModePlan {
|
||||
t.Errorf("full parent + plan: got %q, want plan", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 脏值 fallback:非法的 requested 值在 InheritedMode 里被规范化为默认档
|
||||
func TestInheritedMode_InvalidRequested(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
got := InheritedMode(ctx, nil, "elephant")
|
||||
if got != models.DefaultPermissionMode {
|
||||
t.Errorf("invalid requested: got %q, want %q", got, models.DefaultPermissionMode)
|
||||
}
|
||||
}
|
||||
|
||||
// parent 不存在时(查不到)回落:ModeAtMost(default, req)
|
||||
func TestInheritedMode_InvalidParent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
fakeID := uuid.New()
|
||||
got := InheritedMode(ctx, &fakeID, "full")
|
||||
want := models.ModeAtMost(models.DefaultPermissionMode, "full")
|
||||
if got != want {
|
||||
t.Errorf("invalid parent + full: got %q, want %q (modeAtMost(default, full))", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SetSessionPermissionMode roundtrip + dirty value normalization ───
|
||||
|
||||
func TestSetSessionPermissionMode_Roundtrip(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "dsh", 20)
|
||||
|
||||
id := createTestSession(t, ctx, "dsh", "/ws")
|
||||
perm, err := SetSessionPermissionMode(ctx, id, models.ModeFull)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm.Mode != models.ModeFull || perm.Enforcement != "advisory" {
|
||||
t.Errorf("full mode: got mode=%q enforcement=%q", perm.Mode, perm.Enforcement)
|
||||
}
|
||||
|
||||
// 读出来一致
|
||||
got := SessionPermissionMode(ctx, id)
|
||||
if got != models.ModeFull {
|
||||
t.Errorf("read back: got %q, want full", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSessionPermissionMode_DirtyValue_FailClosed(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id := createTestSession(t, ctx, "pi", "/ws")
|
||||
perm, err := SetSessionPermissionMode(ctx, id, "BOGUS")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm.Mode != models.DefaultPermissionMode {
|
||||
t.Errorf("dirty value: got %q, want %q (fail-closed to default)", perm.Mode, models.DefaultPermissionMode)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 脏值归一化测试(覆盖 NormalizePermissionMode 本身) ───
|
||||
|
||||
func TestNormalizePermissionMode_Inputs(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"plan", "plan"},
|
||||
{"workspace", "workspace"},
|
||||
{"full", "full"},
|
||||
// 大小写/空白不归一:NormalizePermissionMode 只接受精确匹配的合法档位,
|
||||
// 其余一律 fail-closed 到默认档(workspace)—— 不 trim 不 lowercase,
|
||||
// 避免「我以为给了 plan 实际拿到别的」这种隐式转换造成的安全错觉。
|
||||
{"Plan", models.DefaultPermissionMode},
|
||||
{" PLAN ", models.DefaultPermissionMode},
|
||||
{"", models.DefaultPermissionMode},
|
||||
{"bogus", models.DefaultPermissionMode},
|
||||
{"F ULL", models.DefaultPermissionMode},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := models.NormalizePermissionMode(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizePermissionMode(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 负向对照:plan 档不能提权 ───
|
||||
|
||||
func TestInheritedMode_PlanCannotEscalate(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
parent := createTestSession(t, ctx, "pi", "/ws")
|
||||
_, _ = SetSessionPermissionMode(ctx, parent, models.ModePlan)
|
||||
|
||||
child := InheritedMode(ctx, &parent, models.ModeFull)
|
||||
if child != models.ModePlan {
|
||||
t.Errorf("SECURITY FAIL: plan session escalated to %q via InheritedMode", child)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 三层继承链 ───
|
||||
|
||||
func TestInheritedMode_ThreeLevelChain(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
root := createTestSession(t, ctx, "pi", "/ws")
|
||||
_, _ = SetSessionPermissionMode(ctx, root, models.ModeFull)
|
||||
|
||||
child := InheritedMode(ctx, &root, models.ModeWorkspace) // workspace < full → workspace
|
||||
childID := createTestSession(t, ctx, "pi", "/ws")
|
||||
_, _ = SetSessionPermissionMode(ctx, childID, child)
|
||||
|
||||
grandchild := InheritedMode(ctx, &childID, models.ModeFull) // full vs workspace → workspace
|
||||
if grandchild != models.ModeWorkspace {
|
||||
t.Errorf("grandchild: got %q, want workspace", grandchild)
|
||||
}
|
||||
|
||||
// plan → workspace → plan chain
|
||||
planChild := InheritedMode(ctx, &root, models.ModePlan) // plan < full → plan
|
||||
planChildID := createTestSession(t, ctx, "pi", "/ws")
|
||||
_, _ = SetSessionPermissionMode(ctx, planChildID, planChild)
|
||||
|
||||
grandchild2 := InheritedMode(ctx, &planChildID, models.ModeFull) // full vs plan → plan
|
||||
if grandchild2 != models.ModePlan {
|
||||
t.Errorf("plan chain grandchild: got %q, want plan", grandchild2)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── calendar_events permission_mode roundtrip ───
|
||||
|
||||
func TestCalendarEventPermissionMode_Roundtrip(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
// CreateCalendarEvent 应规范化档位
|
||||
e := &models.CalendarEvent{
|
||||
Title: "测试日程",
|
||||
AgentName: "pi",
|
||||
ToAddress: "pi@/home/program/agentmail",
|
||||
PermissionMode: "full",
|
||||
Status: "active",
|
||||
CreatedBy: "jianf",
|
||||
}
|
||||
created, err := CreateCalendarEvent(ctx, e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.PermissionMode != "full" {
|
||||
t.Errorf("created event permission_mode: got %q, want full", created.PermissionMode)
|
||||
}
|
||||
|
||||
// 读回来一致
|
||||
got, err := GetCalendarEvent(ctx, created.EventID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.PermissionMode != "full" {
|
||||
t.Errorf("read back: got %q, want full", got.PermissionMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventPermissionMode_DirtyValue_Normalized(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
e := &models.CalendarEvent{
|
||||
Title: "脏值日程",
|
||||
AgentName: "pi",
|
||||
ToAddress: "pi@/home/program/agentmail",
|
||||
PermissionMode: "INVALID",
|
||||
Status: "active",
|
||||
CreatedBy: "jianf",
|
||||
}
|
||||
created, err := CreateCalendarEvent(ctx, e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.PermissionMode != models.DefaultPermissionMode {
|
||||
t.Errorf("dirty value: got %q, want %q", created.PermissionMode, models.DefaultPermissionMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventPermissionMode_UpdateRoundtrip(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
e := &models.CalendarEvent{
|
||||
Title: "更新日程",
|
||||
AgentName: "pi",
|
||||
ToAddress: "pi@/home/program/agentmail",
|
||||
PermissionMode: "workspace",
|
||||
Status: "active",
|
||||
CreatedBy: "jianf",
|
||||
}
|
||||
created, err := CreateCalendarEvent(ctx, e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
created.PermissionMode = "plan"
|
||||
if err := UpdateCalendarEvent(ctx, created.EventID, created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := GetCalendarEvent(ctx, created.EventID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.PermissionMode != "plan" {
|
||||
t.Errorf("after update: got %q, want plan", got.PermissionMode)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── adopt 接管时会话档位必须写入 ───
|
||||
|
||||
func TestAdoptPlatformSession_WritesDefaultMode(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, err := AdoptPlatformSession(ctx, "pi", "plat-123", "my-proj", "/ws", "接管测试")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 接管会话应显式写入默认档位(不是靠 DB 默认值)
|
||||
mode := SessionPermissionMode(ctx, id)
|
||||
if mode != models.DefaultPermissionMode {
|
||||
t.Errorf("adopt session mode: got %q, want %q", mode, models.DefaultPermissionMode)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── helpers ───
|
||||
|
||||
func createTestSession(t *testing.T, ctx context.Context, agent, workspace string) uuid.UUID {
|
||||
t.Helper()
|
||||
id, err := CreateSession(ctx, nil, agent, "test subject", workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// init 确保每个 test 函数执行前 DB 足够干净
|
||||
func init() {
|
||||
// 空 —— setupTestDB 在每个测试函数内调用
|
||||
}
|
||||
78
server/internal/repo/platform_owner_test.go
Normal file
78
server/internal/repo/platform_owner_test.go
Normal file
@ -0,0 +1,78 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// platform_session_id 必须只发给归属方 —— 生产上 pi 的会话 id 被推给了抄送方 dsh,
|
||||
// DSH 在自己磁盘上找不到那个文件,按 N-8 抛错,邮件静默消失。
|
||||
func TestPlatformSessionFor_ReturnsOwnerFromMirror(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedPlatformAgent(t, "pi")
|
||||
seedPlatformAgent(t, "dsh")
|
||||
|
||||
// pi 上报一条平台会话
|
||||
if err := ReplacePlatformSessions(ctx, "pi", []PlatformSession{
|
||||
{PlatformID: "pid-pi-1", Workspace: "/w", Slug: "项目定位", Title: "项目定位"},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReplacePlatformSessions: %v", err)
|
||||
}
|
||||
|
||||
id, err := AdoptPlatformSession(ctx, "pi", "pid-pi-1", "项目定位", "/w", "项目定位")
|
||||
if err != nil {
|
||||
t.Fatalf("AdoptPlatformSession: %v", err)
|
||||
}
|
||||
|
||||
pid, owner := PlatformSessionFor(ctx, id)
|
||||
if pid != "pid-pi-1" {
|
||||
t.Errorf("platformID = %q, want pid-pi-1", pid)
|
||||
}
|
||||
if owner != "pi" {
|
||||
t.Errorf("owner = %q, want pi(镜像里 agent_name=pi)", owner)
|
||||
}
|
||||
}
|
||||
|
||||
// 未接管的普通会话不该返回任何 platform id。
|
||||
func TestPlatformSessionFor_PlainSession(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "dsh", 20)
|
||||
id, err := CreateSession(ctx, nil, "dsh", "普通会话", "/w")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
pid, owner := PlatformSessionFor(ctx, id)
|
||||
if pid != "" || owner != "" {
|
||||
t.Errorf("got (%q,%q), want ('','')", pid, owner)
|
||||
}
|
||||
}
|
||||
|
||||
// 镜像那行被整表替换掉(平台侧删了会话)时退回 sessions.from_agent,
|
||||
// 而不是让 owner 变空 —— 变空会让归属方也收不到 platform_session_id。
|
||||
func TestPlatformSessionFor_MirrorGoneFallsBackToFromAgent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedPlatformAgent(t, "pi")
|
||||
if err := ReplacePlatformSessions(ctx, "pi", []PlatformSession{
|
||||
{PlatformID: "pid-pi-2", Workspace: "/w", Slug: "s2", Title: "t2"},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReplacePlatformSessions: %v", err)
|
||||
}
|
||||
id, err := AdoptPlatformSession(ctx, "pi", "pid-pi-2", "s2", "/w", "t2")
|
||||
if err != nil {
|
||||
t.Fatalf("AdoptPlatformSession: %v", err)
|
||||
}
|
||||
// 平台侧删了这条会话 → 心跳整表替换成空
|
||||
if err := ReplacePlatformSessions(ctx, "pi", []PlatformSession{}); err != nil {
|
||||
t.Fatalf("ReplacePlatformSessions(empty): %v", err)
|
||||
}
|
||||
pid, owner := PlatformSessionFor(ctx, id)
|
||||
if pid != "pid-pi-2" {
|
||||
t.Errorf("platformID = %q, want pid-pi-2", pid)
|
||||
}
|
||||
if owner != "pi" {
|
||||
t.Errorf("owner = %q, want pi(退回 sessions.from_agent)", owner)
|
||||
}
|
||||
}
|
||||
385
server/internal/repo/platform_sessions.go
Normal file
385
server/internal/repo/platform_sessions.go
Normal file
@ -0,0 +1,385 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 平台会话镜像 ----------
|
||||
//
|
||||
// Agent 平台自己也在开会话:有些经由邮件驱动,有些是人直接在平台界面上开的。
|
||||
// 写信时想续谈某条会话,得先知道那个工作区下有哪些会话可续 —— 而 Gateway
|
||||
// 只看得见邮件驱动的那部分。
|
||||
//
|
||||
// **由插件在心跳里上报,Gateway 不反向拉取。**
|
||||
// 当前架构是单向的(Agent 持密钥主动连 Gateway,Gateway 从不外呼);
|
||||
// 让 Gateway 去调平台接口需要它保存各平台的地址与凭证,那是另一套信任模型。
|
||||
// 代价是插件没运行时同步不了 —— 但插件没运行时邮件本来也投不进去。
|
||||
|
||||
// PlatformSession 是插件上报的一条平台侧会话。
|
||||
type PlatformSession struct {
|
||||
PlatformID string `json:"platform_id"`
|
||||
Workspace string `json:"workspace"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
MailDriven bool `json:"mail_driven"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// maxPlatformSessions 限制单次上报的会话数。
|
||||
//
|
||||
// 一个长期运行的平台可以累积上千条会话,而候选列表上千项对人没有意义。
|
||||
// 插件按最近活跃排序后上报前 N 条即可。
|
||||
const maxPlatformSessions = 200
|
||||
|
||||
// ReplacePlatformSessions 整表替换某 Agent 的平台会话镜像。
|
||||
//
|
||||
// 整表替换而非增量合并:镜像是平台当前状态的快照。增量合并会让已经删掉的
|
||||
// 平台会话永远留在候选列表里,而那正是「选了却送不到」的来源
|
||||
// —— session 位是三态语义,指向一条不存在的会话会直接 404。
|
||||
func ReplacePlatformSessions(ctx context.Context, agentName string, list []PlatformSession) error {
|
||||
agentName = strings.TrimSpace(agentName)
|
||||
if agentName == "" {
|
||||
return nil
|
||||
}
|
||||
if len(list) > maxPlatformSessions {
|
||||
list = list[:maxPlatformSessions]
|
||||
}
|
||||
|
||||
tx, err := db.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM agent_platform_sessions WHERE agent_name = $1`, agentName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, ps := range list {
|
||||
id := strings.TrimSpace(ps.PlatformID)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
driven := 0
|
||||
if ps.MailDriven {
|
||||
driven = 1
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO agent_platform_sessions
|
||||
(agent_name, platform_id, workspace, slug, title, mail_driven, updated_at, reported_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
`, agentName, id, strings.TrimSpace(ps.Workspace), strings.TrimSpace(ps.Slug),
|
||||
strings.TrimSpace(ps.Title), driven, ps.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// SessionCandidate 是「续谈某条会话」的一个候选项。
|
||||
type SessionCandidate struct {
|
||||
// Alias 是填进 session 位的值 —— 候选项的实际用途就是它
|
||||
Alias string `json:"alias"`
|
||||
// Title 给人看,用来分辨两条别名相似的会话在谈什么
|
||||
Title string `json:"title,omitempty"`
|
||||
// Source 说明这条候选从哪来:
|
||||
// mail 本侧邮件线索(可直接送达)
|
||||
// platform 平台侧会话镜像(本侧还没有对应线索)
|
||||
Source string `json:"source"`
|
||||
// Unread 仅 mail 来源有意义
|
||||
Unread int `json:"unread,omitempty"`
|
||||
}
|
||||
|
||||
// SuggestSessionCandidates 汇总某 name@path 下可续谈的会话。
|
||||
//
|
||||
// 两个来源合并:
|
||||
// 1. 本侧邮件线索(sessions.workspace 匹配,或历史数据里靠 mails 反推)
|
||||
// 2. 平台会话镜像里带 slug 的那些
|
||||
//
|
||||
// 本侧优先:邮件线索是「这个别名一定送得到」的保证,而镜像只是平台的说法。
|
||||
// 同名时保留本侧那条,并把镜像的标题补上去(镜像通常有更新的标题)。
|
||||
func SuggestSessionCandidates(ctx context.Context, forUser, peerName, path string) ([]SessionCandidate, error) {
|
||||
out := []SessionCandidate{}
|
||||
seen := map[string]int{} // alias -> out 下标
|
||||
|
||||
// ---- 来源 1:本侧邮件线索 ----
|
||||
//
|
||||
// sessions.workspace 是权威来源。它是新加的列,历史会话为空串,
|
||||
// 因此保留 mails 反推作为兜底:`s.workspace = $2 OR (s.workspace = '' AND <mails 反推>)`。
|
||||
// 反推只看 to_workspace —— Agent 回信时 from_workspace 存的是 Agent 名而非路径,
|
||||
// 拿它比路径永远匹配不上。
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT s.session_alias,
|
||||
COALESCE(s.subject, ''),
|
||||
COALESCE(s.platform_id, ''),
|
||||
(SELECT COUNT(*) FROM mails u
|
||||
WHERE u.session_id = s.session_id AND u.status = 'unread')
|
||||
FROM sessions s
|
||||
WHERE s.session_alias IS NOT NULL AND s.session_alias <> ''
|
||||
AND s.status <> 'archived'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM mails m
|
||||
WHERE m.session_id = s.session_id
|
||||
AND (m.to_name = $1 OR m.from_name = $1 OR `+db.CCHas("m.cc_list", 1)+`)
|
||||
)
|
||||
AND ($2 = ''
|
||||
OR s.workspace = $2
|
||||
OR (s.workspace = '' AND EXISTS (
|
||||
SELECT 1 FROM mails w
|
||||
WHERE w.session_id = s.session_id
|
||||
AND COALESCE(w.to_workspace,'') = $2
|
||||
)))
|
||||
AND ($3 = '' OR s.owner_user_id = (SELECT user_id FROM users WHERE username = $3)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM mails mm
|
||||
WHERE mm.session_id = s.session_id
|
||||
AND (mm.from_name = $3 OR mm.to_name = $3
|
||||
OR `+db.CCHas("mm.cc_list", 3)+`)
|
||||
))
|
||||
ORDER BY s.updated_at DESC
|
||||
`, peerName, path, forUser)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var alias, title, pid string
|
||||
var unread int
|
||||
if err := rows.Scan(&alias, &title, &pid, &unread); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if alias == "" {
|
||||
continue
|
||||
}
|
||||
seen[alias] = len(out)
|
||||
out = append(out, SessionCandidate{
|
||||
Alias: alias, Title: title, Source: "mail", Unread: unread,
|
||||
})
|
||||
if pid != "" {
|
||||
seen["pid:"+pid] = len(out) - 1
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ---- 来源 2:平台会话镜像 ----
|
||||
prows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT slug, title, platform_id
|
||||
FROM agent_platform_sessions
|
||||
WHERE agent_name = $1
|
||||
AND slug <> ''
|
||||
AND ($2 = '' OR workspace = $2)
|
||||
-- 不用 NULLS LAST:它要 SQLite 3.30+,而驱动自带的版本不由我们控制。
|
||||
-- COALESCE 在两个方言里都成立,语义也更直接:没有 updated_at 就用上报时间。
|
||||
ORDER BY COALESCE(updated_at, reported_at) DESC
|
||||
`, peerName, path)
|
||||
if err != nil {
|
||||
// 镜像查不到不该让整个补全失败:本侧线索已经够用了
|
||||
return out, nil
|
||||
}
|
||||
defer prows.Close()
|
||||
|
||||
for prows.Next() {
|
||||
var slug, title, pid string
|
||||
if err := prows.Scan(&slug, &title, &pid); err != nil {
|
||||
break
|
||||
}
|
||||
if slug == "" {
|
||||
continue
|
||||
}
|
||||
// 已被接管的平台会话不再单独列:选它也会落进已有的那条本侧线索,
|
||||
// 但候选列表出现两次会让人以为有两条不同的会话(项目定位 x2 的场景)。
|
||||
if pid != "" {
|
||||
if _, dup := seen["pid:"+pid]; dup {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if i, ok := seen[slug]; ok {
|
||||
// 本侧已有同名线索:保留 mail 来源(它保证送得到),
|
||||
// 但补上镜像的标题 —— 平台侧标题通常比会话建立时的主题更贴切
|
||||
if out[i].Title == "" && title != "" {
|
||||
out[i].Title = title
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen[slug] = len(out)
|
||||
out = append(out, SessionCandidate{Alias: slug, Title: title, Source: "platform"})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetSessionWorkspace 记下会话所属的工作目录。
|
||||
//
|
||||
// 只在为空时写入:会话的工作区在建立时就定下了,之后不该被一封发往
|
||||
// 别处的邮件改掉 —— 那会让这条会话在候选列表里凭空换一个工作区。
|
||||
func SetSessionWorkspace(ctx context.Context, sessionID interface{ String() string }, workspace string) error {
|
||||
ws := strings.TrimSpace(workspace)
|
||||
if ws == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET workspace = $1 WHERE session_id = $2 AND workspace = ''`,
|
||||
ws, sessionID.String())
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- 接管平台会话 ----------
|
||||
//
|
||||
// TUI 与邮箱是同一个 Agent 的**两个入口**,不是两套隔离的世界。
|
||||
// 人在平台界面上开的会话,应该也能被邮件投进去 —— 补全早就把它们列为候选,
|
||||
// 缺的只是投递侧这一跳。
|
||||
//
|
||||
// 「接管」= 在本侧建一条会话并把 platform_id 记上。之后:
|
||||
// - 这条会话在 sessions 表里有正式身份(可寻址、有预算、能归档)
|
||||
// - 插件收到投递事件时看到 platform_id,就去 resume 那条平台会话
|
||||
// 而不是新建一条
|
||||
//
|
||||
// 一条平台会话只能被接管一次:第二次投递复用第一次建的本侧会话,
|
||||
// 否则同一条 TUI 对话会在邮箱里裂成多条互不相干的线索。
|
||||
|
||||
// FindPlatformSession 按 (agent, slug, workspace) 找一条平台会话镜像。
|
||||
//
|
||||
// workspace 为空表示不限(地址省略 path 位时)。返回 platform_id 与它的
|
||||
// 真实 workspace —— 后者是权威的:**会话的 cwd 在它创建时就定了**,
|
||||
// 地址里的 path 位若与之不同,以会话为准。人是从候选列表里选的,
|
||||
// 他要的是「那条会话」而不是「那个目录」。
|
||||
func FindPlatformSession(ctx context.Context, agentName, slug, workspace string) (platformID, realWorkspace, title string, err error) {
|
||||
agentName = strings.TrimSpace(agentName)
|
||||
slug = strings.TrimSpace(slug)
|
||||
if agentName == "" || slug == "" {
|
||||
return "", "", "", ErrSessionNotFound
|
||||
}
|
||||
ws := strings.TrimSpace(workspace)
|
||||
err = db.DB.QueryRowContext(ctx, `
|
||||
SELECT platform_id, workspace, title
|
||||
FROM agent_platform_sessions
|
||||
WHERE agent_name = $1 AND slug = $2
|
||||
AND ($3 = '' OR workspace = $3)
|
||||
ORDER BY COALESCE(updated_at, reported_at) DESC
|
||||
LIMIT 1
|
||||
`, agentName, slug, ws).Scan(&platformID, &realWorkspace, &title)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", "", ErrSessionNotFound
|
||||
}
|
||||
return platformID, realWorkspace, title, err
|
||||
}
|
||||
|
||||
// FindSessionByPlatformID 找出已经接管了某条平台会话的本侧会话。
|
||||
//
|
||||
// 返回 ErrSessionNotFound 表示还没被接管。归档的也算 —— 让归档过的会话
|
||||
// 重新被接管会造出第二条本侧会话,同一条 TUI 对话在邮箱里就裂成两截。
|
||||
// 需要恢复的话人应该去取消归档。
|
||||
func FindSessionByPlatformID(ctx context.Context, agentName, platformID string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT s.session_id
|
||||
FROM sessions s
|
||||
WHERE s.platform_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM mails m
|
||||
WHERE m.session_id = s.session_id
|
||||
AND (m.to_name = $2 OR m.from_name = $2 OR `+db.CCHas("m.cc_list", 2)+`)
|
||||
)
|
||||
ORDER BY s.updated_at DESC
|
||||
LIMIT 1
|
||||
`, platformID, agentName).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return uuid.Nil, ErrSessionNotFound
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// AdoptPlatformSession 接管一条平台会话:建本侧会话并绑定 platform_id。
|
||||
//
|
||||
// alias 用平台自己的 slug —— 「别名复用平台命名」是既定决策,而且人在补全里
|
||||
// 看到的就是那个 slug,投递后别名换成别的会让他找不到自己刚发的信。
|
||||
//
|
||||
// workspace 用平台会话的真实 cwd 而不是地址里的 path 位,理由见
|
||||
// FindPlatformSession 的注释。
|
||||
func AdoptPlatformSession(ctx context.Context, agentName, platformID, slug, workspace, subject string) (uuid.UUID, error) {
|
||||
// slug 可能与本侧某条无关会话撞名(别名全局唯一)。撞了就加后缀 ——
|
||||
// EnsureSessionAlias 已有这套逻辑,这里先建后命名即可。
|
||||
id, err := CreateSession(ctx, nil, agentName, subject, workspace)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET platform_id = $1 WHERE session_id = $2`,
|
||||
platformID, id); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
// 显式写入档位与强制力:接管一条平台会话没有父会话,
|
||||
// 只靠 DB 默认值会在「schema 列定义变动」或「迁移补列给了不同默认」时
|
||||
// 静默偏离预期 —— 显式写 'workspace' 是唯一可靠表述「这条会话是新接管的,
|
||||
// 没有继承来源」的方式。与 me.go 新建会话那条路径一致。
|
||||
if _, err := SetSessionPermissionMode(ctx, id, models.DefaultPermissionMode); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
_ = SetSessionEnforcement(ctx, id, AgentModeEnforcement(ctx, agentName))
|
||||
// 别名尽量用 slug;撞名时 EnsureSessionAlias 自动加后缀
|
||||
_, _ = EnsureSessionAlias(ctx, id, slug)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// PlatformIDOf 读一条本侧会话绑定的平台会话 id(空 = 不是接管来的)。
|
||||
//
|
||||
// 投递时要把它放进 SSE 事件:插件据此决定 resume 还是新建。
|
||||
//
|
||||
// 只在「已知这条会话只有一个参与方」时用它。有抄送时必须用
|
||||
// PlatformSessionFor 拿到归属方 —— 理由见那个函数。
|
||||
func PlatformIDOf(ctx context.Context, sessionID uuid.UUID) string {
|
||||
pid, _ := PlatformSessionFor(ctx, sessionID)
|
||||
return pid
|
||||
}
|
||||
|
||||
// PlatformSessionFor 返回一条本侧会话绑定的平台会话 id **及其归属 Agent**。
|
||||
//
|
||||
// # 为什么归属方是必须的
|
||||
//
|
||||
// `platform_id` 是**会话级**的一个值,而一封邮件可以有多个参与方。
|
||||
// 把它无差别推给所有人,收到的一方会拿它去自己的磁盘上找会话文件 ——
|
||||
// 那个 id 属于别的平台。
|
||||
//
|
||||
// 生产实测:会话 `16845133` 接管了 pi 的会话 `01a05a5e-…`,而那封邮件抄送了
|
||||
// `dsh@/home/program/agentmail.new`。DSH 收到同一个 platform_session_id,
|
||||
// 在 `~/.dsh/sessions/` 里查不到(那是 `/root/.pi/agent/sessions/` 下的文件),
|
||||
// 于是走进「平台侧会话已删」那条防线抛错。那道防线本身是对的(N-8:
|
||||
// 不能退回新建,否则人在界面上看不到这封邮件带来的对话),它拦下的却是
|
||||
// 「别人的会话」—— 邮件因此静默消失,而插件侧的日志走的是不进 journalctl
|
||||
// 的通道,连线索都没有。
|
||||
//
|
||||
// 归属方以镜像(`agent_platform_sessions.agent_name`,Agent 自己上报的)为准;
|
||||
// 镜像整表替换,平台侧删了会话那行就没了,此时退回 `sessions.from_agent` ——
|
||||
// `AdoptPlatformSession` 建会话时把归属 Agent 写在那里,是可靠的第二来源。
|
||||
func PlatformSessionFor(ctx context.Context, sessionID uuid.UUID) (platformID, owner string) {
|
||||
var pid, fromAgent string
|
||||
var mirrored *string
|
||||
if err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(s.platform_id, ''), COALESCE(s.from_agent, ''), aps.agent_name
|
||||
FROM sessions s
|
||||
LEFT JOIN agent_platform_sessions aps
|
||||
ON aps.platform_id = s.platform_id AND COALESCE(s.platform_id, '') <> ''
|
||||
WHERE s.session_id = $1`, sessionID).Scan(&pid, &fromAgent, &mirrored); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
if pid == "" {
|
||||
return "", ""
|
||||
}
|
||||
if mirrored != nil && *mirrored != "" {
|
||||
return pid, *mirrored
|
||||
}
|
||||
return pid, fromAgent
|
||||
}
|
||||
354
server/internal/repo/platform_sessions_test.go
Normal file
354
server/internal/repo/platform_sessions_test.go
Normal file
@ -0,0 +1,354 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// seedPlatformAgent 注册一个 Agent,平台会话镜像与它绑定。
|
||||
// 与 quota_test.go 的 seedAgent 区分开:那个要指定 default_rounds,这里不关心。
|
||||
func seedPlatformAgent(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
if _, err := db.DB.ExecContext(context.Background(),
|
||||
`INSERT INTO agents (agent_name, secret, platform, status) VALUES ($1, 'x', $1, 'online')`,
|
||||
name); err != nil {
|
||||
t.Fatalf("seed agent %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedSessionWS 建一个带 workspace 与别名的会话。
|
||||
func seedSessionWS(t *testing.T, alias, workspace, subject string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := db.DB.QueryRowContext(context.Background(), `
|
||||
INSERT INTO sessions (session_alias, workspace, from_agent, subject, alias_source)
|
||||
VALUES ($1, $2, 'admin', $3, 'platform')
|
||||
RETURNING session_id
|
||||
`, alias, workspace, subject).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("seed session %s: %v", alias, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// seedMailWS 插一封带明确 to_workspace 的邮件。
|
||||
func seedMailWS(t *testing.T, sessionID uuid.UUID, from, to, toWS, subject string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := db.DB.QueryRowContext(context.Background(), `
|
||||
INSERT INTO mails (session_id, from_name, from_workspace, to_name, to_workspace,
|
||||
subject, body, cc_list, created_at)
|
||||
VALUES ($1, $2, '', $3, $4, $5, 'body', '[]', $6)
|
||||
RETURNING mail_id
|
||||
`, sessionID, from, to, toWS, subject, nextSeedTime()).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("seed mail: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// 这个测试是「会话别名没有正确显示曾经发生在工作区下的会话」那次故障的回归。
|
||||
//
|
||||
// 旧实现按 mails 反推工作区,条件是
|
||||
// `to_workspace = $path OR from_workspace = $path`。
|
||||
// 而 Agent 回信时 from_workspace 存的是 **Agent 名**(如 "dsh")而不是路径,
|
||||
// 于是一旦会话里只剩 Agent 的回信可匹配,反推就落空、别名列不出来。
|
||||
// 现在 workspace 存在会话自己身上,与邮件里那些脏数据无关。
|
||||
func TestSuggestSessionCandidatesUsesSessionWorkspace(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "dsh")
|
||||
|
||||
sid := seedSessionWS(t, "brisk-harbor", "/home/program/agentmail", "缓存选型")
|
||||
// 只有 Agent 的回信:from_workspace 是脏的(Agent 名),to_workspace 是人类(空)
|
||||
if _, err := db.DB.ExecContext(context.Background(), `
|
||||
INSERT INTO mails (session_id, from_name, from_workspace, to_name, to_workspace,
|
||||
subject, body, cc_list, created_at)
|
||||
VALUES ($1, 'dsh', 'dsh', 'admin', '', 'Re: 缓存选型', 'body', '[]', $2)
|
||||
`, sid, nextSeedTime()); err != nil {
|
||||
t.Fatalf("seed agent reply: %v", err)
|
||||
}
|
||||
|
||||
got, err := SuggestSessionCandidates(context.Background(), "admin", "dsh", "/home/program/agentmail")
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestSessionCandidates: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("应有 1 个候选,实际 %d —— 会话的 workspace 列没被用上", len(got))
|
||||
}
|
||||
if got[0].Alias != "brisk-harbor" {
|
||||
t.Errorf("别名错误:%q", got[0].Alias)
|
||||
}
|
||||
if got[0].Source != "mail" {
|
||||
t.Errorf("来源应为 mail,实际 %q", got[0].Source)
|
||||
}
|
||||
if got[0].Title != "缓存选型" {
|
||||
t.Errorf("标题应带出来:%q", got[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
// 历史会话的 workspace 列是空的(新加的列),必须回退到 mails.to_workspace 反推,
|
||||
// 否则升级后所有老会话一夜之间从候选列表里消失。
|
||||
func TestSuggestSessionCandidatesFallsBackToMails(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "opencode")
|
||||
|
||||
// workspace 留空,模拟升级前建立的会话
|
||||
sid := seedSessionWS(t, "legacy-thread", "", "老线索")
|
||||
seedMailWS(t, sid, "admin", "opencode", "/home/legacy", "老线索")
|
||||
|
||||
got, err := SuggestSessionCandidates(context.Background(), "admin", "opencode", "/home/legacy")
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestSessionCandidates: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Alias != "legacy-thread" {
|
||||
t.Fatalf("老会话应能靠 mails 反推出来,实际 %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 工作区不匹配的会话不能出现:候选项点下去就会被填进 session 位,
|
||||
// 而 session 位是三态语义 —— 指向别处的会话会直接 404「无法送达」。
|
||||
func TestSuggestSessionCandidatesFiltersByWorkspace(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "dsh")
|
||||
|
||||
mine := seedSessionWS(t, "here-thread", "/home/a", "本区")
|
||||
seedMailWS(t, mine, "admin", "dsh", "/home/a", "本区")
|
||||
other := seedSessionWS(t, "there-thread", "/home/b", "别区")
|
||||
seedMailWS(t, other, "admin", "dsh", "/home/b", "别区")
|
||||
|
||||
got, err := SuggestSessionCandidates(context.Background(), "admin", "dsh", "/home/a")
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestSessionCandidates: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Alias != "here-thread" {
|
||||
t.Fatalf("只应给出本工作区的会话,实际 %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// path 为空(地址写成 `dsh` 而不带 @/path)时不按工作区过滤:
|
||||
// 用户还没写到 path 段就该看到全部可续的会话。
|
||||
func TestSuggestSessionCandidatesEmptyPathReturnsAll(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "dsh")
|
||||
|
||||
a := seedSessionWS(t, "ws-a", "/home/a", "A")
|
||||
seedMailWS(t, a, "admin", "dsh", "/home/a", "A")
|
||||
b := seedSessionWS(t, "ws-b", "/home/b", "B")
|
||||
seedMailWS(t, b, "admin", "dsh", "/home/b", "B")
|
||||
|
||||
got, err := SuggestSessionCandidates(context.Background(), "admin", "dsh", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestSessionCandidates: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("path 为空应给出全部 2 条,实际 %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// 平台侧会话(人直接在 opencode/DSH 界面上开的)经心跳上报后也要能被选中 ——
|
||||
// 这正是「定期从 agent 平台同步会话」要解决的问题。
|
||||
func TestSuggestSessionCandidatesIncludesPlatformMirror(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "opencode")
|
||||
|
||||
now := time.Now()
|
||||
err := ReplacePlatformSessions(context.Background(), "opencode", []PlatformSession{
|
||||
{PlatformID: "ses_1", Workspace: "/home/program/agentmail", Slug: "witty-planet",
|
||||
Title: "重构导入路径", MailDriven: false, UpdatedAt: &now},
|
||||
{PlatformID: "ses_2", Workspace: "/home/other", Slug: "brave-comet",
|
||||
Title: "别的工作区", MailDriven: false, UpdatedAt: &now},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ReplacePlatformSessions: %v", err)
|
||||
}
|
||||
|
||||
got, err := SuggestSessionCandidates(context.Background(), "admin", "opencode", "/home/program/agentmail")
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestSessionCandidates: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("应有 1 个平台候选,实际 %d:%+v", len(got), got)
|
||||
}
|
||||
if got[0].Alias != "witty-planet" || got[0].Source != "platform" {
|
||||
t.Errorf("平台候选错误:%+v", got[0])
|
||||
}
|
||||
if got[0].Title != "重构导入路径" {
|
||||
t.Errorf("标题应带出来:%q", got[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一别名两边都有时保留 mail 来源:它是「一定送得到」的保证,
|
||||
// 镜像只是平台的说法。但镜像的标题该补上去 —— 平台标题通常比会话主题更贴切。
|
||||
func TestSuggestSessionCandidatesMailWinsOverMirror(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "opencode")
|
||||
|
||||
// 本侧线索:有别名但主题为空
|
||||
sid := seedSessionWS(t, "witty-planet", "/home/x", "")
|
||||
seedMailWS(t, sid, "admin", "opencode", "/home/x", "某事")
|
||||
|
||||
now := time.Now()
|
||||
if err := ReplacePlatformSessions(context.Background(), "opencode", []PlatformSession{
|
||||
{PlatformID: "ses_1", Workspace: "/home/x", Slug: "witty-planet",
|
||||
Title: "平台侧的标题", UpdatedAt: &now},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReplacePlatformSessions: %v", err)
|
||||
}
|
||||
|
||||
got, err := SuggestSessionCandidates(context.Background(), "admin", "opencode", "/home/x")
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestSessionCandidates: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("同名应合并成 1 条,实际 %d:%+v", len(got), got)
|
||||
}
|
||||
if got[0].Source != "mail" {
|
||||
t.Errorf("应保留 mail 来源(它保证送得到),实际 %q", got[0].Source)
|
||||
}
|
||||
if got[0].Title != "平台侧的标题" {
|
||||
t.Errorf("本侧标题为空时应补上镜像的:%q", got[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
// 上报是整表替换:平台侧删掉的会话必须从候选列表里消失。
|
||||
// 增量合并会让它永远留着,而 session 位指向一条不存在的会话会直接 404。
|
||||
func TestReplacePlatformSessionsIsFullReplace(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "dsh")
|
||||
ctx := context.Background()
|
||||
|
||||
if err := ReplacePlatformSessions(ctx, "dsh", []PlatformSession{
|
||||
{PlatformID: "s1", Workspace: "/w", Slug: "one"},
|
||||
{PlatformID: "s2", Workspace: "/w", Slug: "two"},
|
||||
}); err != nil {
|
||||
t.Fatalf("首次上报: %v", err)
|
||||
}
|
||||
if got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/w"); len(got) != 2 {
|
||||
t.Fatalf("首次上报应有 2 条,实际 %d", len(got))
|
||||
}
|
||||
|
||||
// 第二次只报一条:另一条在平台侧已被删除
|
||||
if err := ReplacePlatformSessions(ctx, "dsh", []PlatformSession{
|
||||
{PlatformID: "s1", Workspace: "/w", Slug: "one"},
|
||||
}); err != nil {
|
||||
t.Fatalf("二次上报: %v", err)
|
||||
}
|
||||
got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/w")
|
||||
if len(got) != 1 || got[0].Alias != "one" {
|
||||
t.Fatalf("整表替换失效,实际 %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 无 slug 的平台会话不进候选:slug 是填进 session 位的值,
|
||||
// 没有它这一项点下去只能得到一个空的 session 段。
|
||||
func TestPlatformSessionsWithoutSlugAreSkipped(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "dsh")
|
||||
ctx := context.Background()
|
||||
|
||||
if err := ReplacePlatformSessions(ctx, "dsh", []PlatformSession{
|
||||
{PlatformID: "s1", Workspace: "/w", Slug: ""},
|
||||
{PlatformID: "s2", Workspace: "/w", Slug: "named"},
|
||||
}); err != nil {
|
||||
t.Fatalf("上报: %v", err)
|
||||
}
|
||||
got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/w")
|
||||
if len(got) != 1 || got[0].Alias != "named" {
|
||||
t.Fatalf("无 slug 的应被跳过,实际 %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 上报里的重复 platform_id 不该让整次事务失败(主键冲突)。
|
||||
func TestReplacePlatformSessionsDedupes(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "dsh")
|
||||
ctx := context.Background()
|
||||
|
||||
if err := ReplacePlatformSessions(ctx, "dsh", []PlatformSession{
|
||||
{PlatformID: "dup", Workspace: "/w", Slug: "first"},
|
||||
{PlatformID: "dup", Workspace: "/w", Slug: "second"},
|
||||
}); err != nil {
|
||||
t.Fatalf("重复 id 不该报错: %v", err)
|
||||
}
|
||||
got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/w")
|
||||
if len(got) != 1 || got[0].Alias != "first" {
|
||||
t.Fatalf("应保留第一条,实际 %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// SetSessionWorkspace 只在为空时写入:会话的工作区在建立时就定下了,
|
||||
// 之后不该被一封发往别处的邮件改掉 —— 那会让它在候选列表里凭空换工作区。
|
||||
func TestSetSessionWorkspaceDoesNotOverwrite(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sid := seedSessionWS(t, "fixed-ws", "/home/original", "某事")
|
||||
if err := SetSessionWorkspace(ctx, sid, "/home/hijacked"); err != nil {
|
||||
t.Fatalf("SetSessionWorkspace: %v", err)
|
||||
}
|
||||
var ws string
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT workspace FROM sessions WHERE session_id = $1`, sid).Scan(&ws); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if ws != "/home/original" {
|
||||
t.Errorf("已有 workspace 被覆盖成 %q", ws)
|
||||
}
|
||||
|
||||
// 空的那种要能补上(历史会话回填)
|
||||
empty := seedSessionWS(t, "empty-ws", "", "某事")
|
||||
if err := SetSessionWorkspace(ctx, empty, "/home/filled"); err != nil {
|
||||
t.Fatalf("SetSessionWorkspace(empty): %v", err)
|
||||
}
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT workspace FROM sessions WHERE session_id = $1`, empty).Scan(&ws); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if ws != "/home/filled" {
|
||||
t.Errorf("空 workspace 未被补上,实际 %q", ws)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSession 要把 workspace 存下来 —— 这是整条链的起点,
|
||||
// 漏在这里的话后面所有查询都只能靠 mails 反推。
|
||||
func TestCreateSessionStoresWorkspace(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := CreateSession(ctx, nil, "admin", "带工作区", "/home/program/agentmail")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
var ws string
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT workspace FROM sessions WHERE session_id = $1`, id).Scan(&ws); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if ws != "/home/program/agentmail" {
|
||||
t.Errorf("workspace 未落库:%q", ws)
|
||||
}
|
||||
}
|
||||
|
||||
// 归档的会话不进候选:归档就是「这条线索结束了」,
|
||||
// 还出现在补全里等于邀请用户往一条已关闭的线索里发信。
|
||||
func TestSuggestSessionCandidatesExcludesArchived(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "dsh")
|
||||
ctx := context.Background()
|
||||
|
||||
sid := seedSessionWS(t, "done-thread", "/home/a", "已完成")
|
||||
seedMailWS(t, sid, "admin", "dsh", "/home/a", "已完成")
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET status = 'archived' WHERE session_id = $1`, sid); err != nil {
|
||||
t.Fatalf("archive: %v", err)
|
||||
}
|
||||
|
||||
got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/home/a")
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("归档会话不该出现,实际 %+v", got)
|
||||
}
|
||||
}
|
||||
310
server/internal/repo/quota.go
Normal file
310
server/internal/repo/quota.go
Normal file
@ -0,0 +1,310 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 配额 ----------
|
||||
//
|
||||
// **配额是任务的属性,不是 Agent 的属性。**
|
||||
//
|
||||
// 真正的约束在 `sessions.max_rounds`(见本文件末尾的「会话级往返预算」):
|
||||
// 每条会话独立计数,人在派活时给、在对话页里随时调。
|
||||
//
|
||||
// `agents` 表这边只剩两样东西:
|
||||
//
|
||||
// default_rounds —— 派给这个 Agent 的**新任务**默认多少个来回。
|
||||
// 不同 Agent 能力不同(跑测试的小工具 vs 重构整个模块),
|
||||
// 默认值分开设才合理。
|
||||
//
|
||||
// used_rounds —— 纯统计,累计发信数。**不再拦任何请求。**
|
||||
// 它原本是「终身额度」:跑满就得管理员手工重置才能再干活,
|
||||
// 而 Agent 是长期在线的 —— 终身额度是错的工具。
|
||||
// 保留是因为「这个 Agent 一共发了多少信」本身有观测价值。
|
||||
//
|
||||
// 防止 Agent 用 `.new` 开一串新会话绕过预算,靠的是**新建会话速率限制**
|
||||
// (见 sessionRateLimiter),而不是终身额度。
|
||||
// AgentStats 是一个 Agent 的配额默认值与累计统计。
|
||||
//
|
||||
// 没有 Remaining / Unlimited 字段:这里不再有「剩余额度」的概念 ——
|
||||
// 额度属于会话(SessionBudget),这里只有「新任务默认多少来回」与「一共发了多少信」。
|
||||
type AgentStats struct {
|
||||
AgentName string `json:"agent_name"`
|
||||
// DefaultRounds 派给该 Agent 的新任务默认多少个来回(0 = 不限)
|
||||
DefaultRounds int `json:"default_rounds"`
|
||||
// SentTotal 累计发信数(纯统计,不拦请求)
|
||||
SentTotal int `json:"sent_total"`
|
||||
// ActiveSessions 该 Agent 参与的未归档会话数,配合默认值判断设多少合适
|
||||
ActiveSessions int `json:"active_sessions"`
|
||||
// Status 是 agents.status:online / offline / disabled。
|
||||
// 管理页靠它决定显示「停用」还是「恢复」。
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// DefaultRoundsFor 读取该 Agent 的新任务默认预算。
|
||||
//
|
||||
// Agent 不存在时返回全局兜底值而非报错:派活的人不该因为「对方还没注册」
|
||||
// 就拿不到一个合理的默认预算 —— 邮件本来就支持发给尚未上线的收件人。
|
||||
func DefaultRoundsFor(ctx context.Context, agentName string) int {
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(default_rounds, 0) FROM agents WHERE agent_name = $1`,
|
||||
agentName).Scan(&n)
|
||||
if err != nil || n < 0 {
|
||||
return fallbackDefaultRounds
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// fallbackDefaultRounds 是 Agent 未注册时的兜底默认预算。
|
||||
// 与建表默认值保持一致;改这里要同时改两份 schema。
|
||||
const fallbackDefaultRounds = 20
|
||||
|
||||
// SetDefaultRounds 设置该 Agent 的新任务默认预算(0 = 不限)。
|
||||
func SetDefaultRounds(ctx context.Context, agentName string, n int) (AgentStats, error) {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agents SET default_rounds = $2 WHERE agent_name = $1`, agentName, n)
|
||||
if err != nil {
|
||||
return AgentStats{}, err
|
||||
}
|
||||
if k, _ := tag.RowsAffected(); k == 0 {
|
||||
return AgentStats{}, fmt.Errorf("agent %q 不存在", agentName)
|
||||
}
|
||||
return GetAgentStats(ctx, agentName)
|
||||
}
|
||||
|
||||
// GetAgentStats 读取某 Agent 的默认预算与累计统计。
|
||||
func GetAgentStats(ctx context.Context, agentName string) (AgentStats, error) {
|
||||
var st AgentStats
|
||||
st.AgentName = agentName
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(default_rounds, 0), COALESCE(used_rounds, 0)
|
||||
FROM agents WHERE agent_name = $1`, agentName,
|
||||
).Scan(&st.DefaultRounds, &st.SentTotal)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AgentStats{}, fmt.Errorf("agent %q 不存在", agentName)
|
||||
}
|
||||
if err != nil {
|
||||
return AgentStats{}, err
|
||||
}
|
||||
st.ActiveSessions = countActiveSessionsFor(ctx, agentName)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// countActiveSessionsFor 统计该 Agent 参与的未归档会话数。
|
||||
// 查不出来返回 0:这只是个展示用的数字,不该让整个统计接口失败。
|
||||
func countActiveSessionsFor(ctx context.Context, agentName string) int {
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(DISTINCT s.session_id)
|
||||
FROM sessions s
|
||||
JOIN mails m ON m.session_id = s.session_id
|
||||
WHERE s.status <> 'archived'
|
||||
AND (m.from_name = $1 OR m.to_name = $1 OR `+db.CCHas("m.cc_list", 1)+`)
|
||||
`, agentName).Scan(&n)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// BumpSentCount 累加发信统计。
|
||||
//
|
||||
// **绝不拦请求**:它是观测数据,不是额度。返回值只有 error,
|
||||
// 而且调用方应当忽略它 —— 统计写失败不该让一封已经该发出的邮件失败。
|
||||
func BumpSentCount(ctx context.Context, agentName string) {
|
||||
_, _ = db.DB.ExecContext(ctx,
|
||||
`UPDATE agents SET used_rounds = COALESCE(used_rounds, 0) + 1 WHERE agent_name = $1`,
|
||||
agentName)
|
||||
}
|
||||
|
||||
// ListAgentStats 列出所有 Agent 的默认预算与统计(管理员视图)。
|
||||
func ListAgentStats(ctx context.Context) ([]AgentStats, error) {
|
||||
// 带上 status:管理页靠它区分「在线 / 离线 / 已停用」并决定显示
|
||||
// 「停用」还是「恢复」按钮。不过滤 disabled —— 这里是唯一能把已停用的
|
||||
// Agent 恢复回来的地方,过滤掉就再也找不到它了。
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT agent_name, COALESCE(default_rounds, 0), COALESCE(used_rounds, 0),
|
||||
COALESCE(status, 'offline')
|
||||
FROM agents ORDER BY agent_name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []AgentStats{}
|
||||
for rows.Next() {
|
||||
var st AgentStats
|
||||
if err := rows.Scan(&st.AgentName, &st.DefaultRounds, &st.SentTotal, &st.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 会话数逐个查:Agent 数量是个位数到几十,不值得为它写一个 GROUP BY 的联合查询
|
||||
for i := range out {
|
||||
out[i].ActiveSessions = countActiveSessionsFor(ctx, out[i].AgentName)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------- 转发 ----------
|
||||
|
||||
// ForwardSource 是被转发邮件的必要信息。
|
||||
type ForwardSource struct {
|
||||
Mail *models.Mail
|
||||
Session uuid.UUID
|
||||
}
|
||||
|
||||
// LoadForwardSource 读取待转发的邮件,并校验转发者确实参与过该邮件
|
||||
//(收件人、发件人或被抄送方之一)。防止凭 mail_id 转发别人的邮件。
|
||||
func LoadForwardSource(ctx context.Context, mailID uuid.UUID, actor string) (*models.Mail, error) {
|
||||
m, err := GetMailByID(ctx, mailID)
|
||||
if err != nil {
|
||||
return nil, ErrMailNotFound
|
||||
}
|
||||
|
||||
if m.FromName == actor || m.ToName == actor {
|
||||
return m, nil
|
||||
}
|
||||
for _, cc := range m.CCList {
|
||||
if cc.Name == actor {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrForwardNotAllowed
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrMailNotFound 待转发的邮件不存在
|
||||
ErrMailNotFound = errors.New("mail not found")
|
||||
// ErrForwardNotAllowed 转发者未参与该邮件
|
||||
ErrForwardNotAllowed = errors.New("not a participant of that mail")
|
||||
)
|
||||
|
||||
// ---------- 会话级往返预算 ----------
|
||||
//
|
||||
// 配额的真实语义是「这件事值得多少个来回」——那是**任务**的属性,不是 Agent 的属性。
|
||||
// 只有 agents.max_rounds 一个全局计数器时有两个问题:
|
||||
// 1. 两个并行任务互相抢额度:给紧急任务留的份被另一条线索吃掉
|
||||
// 2. used_rounds 单调递增,跑满就得管理员手工重置才能再干活
|
||||
// 因此预算下沉到会话,由人在写信时给、在对话页里随时调。
|
||||
//
|
||||
// **两层都要过**:会话预算 + Agent 全局配额。少了后者,Agent 自己 `.new` 开一串会话
|
||||
// 每条都是全新预算,全局上限形同虚设;少了前者,就回到抢额度的老问题。
|
||||
|
||||
// SessionBudget 是一个会话的往返预算快照。
|
||||
type SessionBudget struct {
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
Max int `json:"max_rounds"` // 0 = 本会话不限
|
||||
Used int `json:"used_rounds"`
|
||||
Remaining int `json:"remaining"` // 不限时为 -1
|
||||
Unlimited bool `json:"unlimited"`
|
||||
}
|
||||
|
||||
func makeSessionBudget(id uuid.UUID, max, used int) SessionBudget {
|
||||
b := SessionBudget{SessionID: id, Max: max, Used: used, Unlimited: max <= 0}
|
||||
if b.Unlimited {
|
||||
b.Remaining = -1
|
||||
return b
|
||||
}
|
||||
if r := max - used; r > 0 {
|
||||
b.Remaining = r
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ErrSessionBudgetExhausted 表示该会话的往返预算已用尽。
|
||||
var ErrSessionBudgetExhausted = errors.New("session budget exhausted")
|
||||
|
||||
// GetSessionBudget 读取会话预算。
|
||||
func GetSessionBudget(ctx context.Context, id uuid.UUID) (SessionBudget, error) {
|
||||
var max, used int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(max_rounds,0), COALESCE(used_rounds,0) FROM sessions WHERE session_id = $1`,
|
||||
id).Scan(&max, &used)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return SessionBudget{}, fmt.Errorf("会话 %s 不存在", id)
|
||||
}
|
||||
if err != nil {
|
||||
return SessionBudget{}, err
|
||||
}
|
||||
return makeSessionBudget(id, max, used), nil
|
||||
}
|
||||
|
||||
// ConsumeSessionBudget 原子地占用会话的一次往返。
|
||||
//
|
||||
// 与 ConsumeQuota 同理:判断与自增必须在同一条 UPDATE 里(WHERE used_rounds < max_rounds),
|
||||
// 否则并发发信会双双通过检查再各自 +1,把预算刷穿。
|
||||
func ConsumeSessionBudget(ctx context.Context, id uuid.UUID) (SessionBudget, error) {
|
||||
tag, err := db.DB.ExecContext(ctx, `
|
||||
UPDATE sessions SET used_rounds = COALESCE(used_rounds,0) + 1
|
||||
WHERE session_id = $1
|
||||
AND (COALESCE(max_rounds,0) <= 0 OR COALESCE(used_rounds,0) < max_rounds)
|
||||
`, id)
|
||||
if err != nil {
|
||||
return SessionBudget{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
b, bErr := GetSessionBudget(ctx, id)
|
||||
if bErr != nil {
|
||||
return SessionBudget{}, bErr
|
||||
}
|
||||
return b, ErrSessionBudgetExhausted
|
||||
}
|
||||
return GetSessionBudget(ctx, id)
|
||||
}
|
||||
|
||||
// SetSessionBudget 设置会话预算上限(0 = 不限)。
|
||||
//
|
||||
// 允许把上限调到低于已用次数:那表示「就到这里为止」,是人的合法意图,
|
||||
// 不该因为算不出正的剩余量就拒绝。此时 Remaining 为 0,下次发信即被拦。
|
||||
func SetSessionBudget(ctx context.Context, id uuid.UUID, max int) (SessionBudget, error) {
|
||||
if max < 0 {
|
||||
max = 0
|
||||
}
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET max_rounds = $2, updated_at = NOW() WHERE session_id = $1`, id, max)
|
||||
if err != nil {
|
||||
return SessionBudget{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return SessionBudget{}, fmt.Errorf("会话 %s 不存在", id)
|
||||
}
|
||||
return GetSessionBudget(ctx, id)
|
||||
}
|
||||
|
||||
// ResetSessionBudget 把该会话的已用次数归零(上限不变)。
|
||||
func ResetSessionBudget(ctx context.Context, id uuid.UUID) (SessionBudget, error) {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET used_rounds = 0, updated_at = NOW() WHERE session_id = $1`, id)
|
||||
if err != nil {
|
||||
return SessionBudget{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return SessionBudget{}, fmt.Errorf("会话 %s 不存在", id)
|
||||
}
|
||||
return GetSessionBudget(ctx, id)
|
||||
}
|
||||
|
||||
// RefundSessionBudget 退还一次往返。
|
||||
//
|
||||
// 会话预算先扣、Agent 全局配额后扣,全局那层拦下时必须把会话这次还回去,
|
||||
// 否则会话预算白掉一格 —— 那次往返实际上没有发生。
|
||||
func RefundSessionBudget(ctx context.Context, id uuid.UUID) {
|
||||
_, _ = db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET used_rounds = COALESCE(used_rounds,0) - 1
|
||||
WHERE session_id = $1 AND COALESCE(used_rounds,0) > 0`, id)
|
||||
}
|
||||
125
server/internal/repo/quota_test.go
Normal file
125
server/internal/repo/quota_test.go
Normal file
@ -0,0 +1,125 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
)
|
||||
|
||||
// setupTestDB 起一个临时 SQLite 库并建表,供配额测试使用。
|
||||
// 直接用真实的 SQLite 而非 mock:配额的正确性核心在于「判断与自增在同一条 UPDATE 里」,
|
||||
// 这正是只有真实数据库才能验证的部分。
|
||||
func setupTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := db.Connect(context.Background(), filepath.Join(dir, "test.db")); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
if err := db.Migrate(context.Background()); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
func seedAgent(t *testing.T, name string, max int) {
|
||||
t.Helper()
|
||||
_, err := db.DB.ExecContext(context.Background(),
|
||||
`INSERT INTO agents (agent_name, secret, platform, default_rounds) VALUES ($1, 'x', 'test', $2)`,
|
||||
name, max)
|
||||
if err != nil {
|
||||
t.Fatalf("seed agent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// default_rounds 是「派给这个 Agent 的新任务默认多少个来回」,
|
||||
// 不是会拦请求的终身额度 —— 真正的额度在 sessions.max_rounds 上。
|
||||
func TestDefaultRoundsRoundTrip(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedAgent(t, "bot", 0)
|
||||
ctx := context.Background()
|
||||
|
||||
st, err := SetDefaultRounds(ctx, "bot", 15)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.DefaultRounds != 15 {
|
||||
t.Fatalf("default_rounds = %d,期望 15", st.DefaultRounds)
|
||||
}
|
||||
if got := DefaultRoundsFor(ctx, "bot"); got != 15 {
|
||||
t.Fatalf("DefaultRoundsFor = %d,期望 15", got)
|
||||
}
|
||||
|
||||
// 负数归一为 0(不限),而不是造出一个永远发不出信的默认值
|
||||
if st, err = SetDefaultRounds(ctx, "bot", -3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.DefaultRounds != 0 {
|
||||
t.Fatalf("负数应归一为 0,实际 %d", st.DefaultRounds)
|
||||
}
|
||||
}
|
||||
|
||||
// 未注册的 Agent 取默认预算时给兜底值而不是报错:
|
||||
// 派活的人不该因为「对方还没上线」就拿不到一个合理默认值 ——
|
||||
// 邮件本来就支持发给尚未上线的收件人。
|
||||
func TestDefaultRoundsForUnknownAgentFallsBack(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if got := DefaultRoundsFor(context.Background(), "ghost"); got != fallbackDefaultRounds {
|
||||
t.Fatalf("未注册 Agent 应回落到 %d,实际 %d", fallbackDefaultRounds, got)
|
||||
}
|
||||
}
|
||||
|
||||
// BumpSentCount 是纯统计:只累加,绝不拦请求,也绝不返回错误
|
||||
func TestBumpSentCountOnlyCounts(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedAgent(t, "bot", 0)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
BumpSentCount(ctx, "bot")
|
||||
}
|
||||
st, err := GetAgentStats(ctx, "bot")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.SentTotal != 5 {
|
||||
t.Fatalf("累计发信 = %d,期望 5", st.SentTotal)
|
||||
}
|
||||
|
||||
// 不存在的 Agent 也不该 panic 或报错 —— 它只是没有行可更新
|
||||
BumpSentCount(ctx, "ghost")
|
||||
}
|
||||
|
||||
func TestGetAgentStatsUnknownAgent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if _, err := GetAgentStats(context.Background(), "ghost"); err == nil {
|
||||
t.Fatal("不存在的 Agent 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAgentStats(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedAgent(t, "alpha", 0)
|
||||
seedAgent(t, "beta", 0)
|
||||
ctx := context.Background()
|
||||
SetDefaultRounds(ctx, "alpha", 5)
|
||||
|
||||
list, err := ListAgentStats(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("应有 2 个 Agent,实际 %d", len(list))
|
||||
}
|
||||
// 按名字排序,alpha 在前
|
||||
if list[0].AgentName != "alpha" || list[0].DefaultRounds != 5 {
|
||||
t.Fatalf("alpha 的记录不对:%+v", list[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
64
server/internal/repo/ratelimit.go
Normal file
64
server/internal/repo/ratelimit.go
Normal file
@ -0,0 +1,64 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
)
|
||||
|
||||
// RateLimitCheckAndRecord 原子地检查 bucket 在 window 内的事件数是否超过 limit。
|
||||
// 未超过则同时记录本次事件(判断与写入在同一个事务里,防并发刷穿)。
|
||||
// DB 不可用时放行(宁可放开限速也不能让用户完全无法使用)。
|
||||
func RateLimitCheckAndRecord(ctx context.Context, bucket string, window time.Duration, limit int) (allowed bool, retryAfter int) {
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-window)
|
||||
|
||||
// 用 IMMEDIATE 事务:SQLite 的 IMMEDIATE 会在开始时获取 RESERVED 锁,
|
||||
// 防止其他写事务同时进入 COMMIT 阶段。这是 SQLite 并发写的正确方式。
|
||||
tx, err := db.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return true, 0 // DB 不可用 → 放行
|
||||
}
|
||||
defer tx.Rollback() // Commit 成功后 Rollback 是 no-op
|
||||
|
||||
// 清理过期记录
|
||||
tx.ExecContext(ctx,
|
||||
`DELETE FROM rate_limits WHERE bucket = $1 AND ts < $2`, bucket, cutoff)
|
||||
|
||||
// 统计当前窗口内事件数
|
||||
var count int
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM rate_limits WHERE bucket = $1 AND ts >= $2`,
|
||||
bucket, cutoff).Scan(&count)
|
||||
if err != nil {
|
||||
return true, 0
|
||||
}
|
||||
|
||||
if count >= limit {
|
||||
var earliest time.Time
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`SELECT MIN(ts) FROM rate_limits WHERE bucket = $1 AND ts >= $2`,
|
||||
bucket, cutoff).Scan(&earliest)
|
||||
if err == nil && !earliest.IsZero() {
|
||||
retry := int(earliest.Add(window).Sub(now).Seconds()) + 1
|
||||
if retry < 1 {
|
||||
retry = 1
|
||||
}
|
||||
return false, retry
|
||||
}
|
||||
return false, 60
|
||||
}
|
||||
|
||||
// 记账
|
||||
tx.ExecContext(ctx,
|
||||
`INSERT INTO rate_limits (bucket, ts) VALUES ($1, $2)`, bucket, now)
|
||||
tx.Commit()
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// RateLimitReset 清除指定 bucket 的所有记录(登录成功后调用)。
|
||||
func RateLimitReset(ctx context.Context, bucket string) {
|
||||
_, _ = db.DB.ExecContext(ctx,
|
||||
`DELETE FROM rate_limits WHERE bucket = $1`, bucket)
|
||||
}
|
||||
87
server/internal/repo/relay.go
Normal file
87
server/internal/repo/relay.go
Normal file
@ -0,0 +1,87 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 插件自动转发(免配额通道) ----------
|
||||
//
|
||||
// **基本原则:配额约束的是模型的自主发信,不是 harness 的转发。**
|
||||
//
|
||||
// 配额存在的意义是防止 Agent 无限自我循环。而插件代劳搬运的两类消息不属于此列:
|
||||
// 1. 平台原生的权限询问(opencode 的 permission.updated)—— 不转给人,人就看不到,
|
||||
// Agent 卡在那里等一个永远不会来的回答
|
||||
// 2. 本轮的最终总结(session.idle 时最后一条 assistant 消息)—— 模型已经把话说完了,
|
||||
// 插件只是把它搬到邮件里;对它收费会导致配额用尽时 Agent 连交代都做不了
|
||||
//
|
||||
// 防滥用不靠计数,靠**幂等键**:relay_key 是上游那条消息的稳定标识
|
||||
// (permission id / assistant message id)。唯一约束让同一条上游消息只能转一次,
|
||||
// 于是插件重试与 SSE 重放不会产生第二封,想多转就得拿出不同的上游消息 id ——
|
||||
// 而那些 id 由平台生成,模型伪造不出来。
|
||||
|
||||
// ErrRelayDuplicate 表示这条上游消息已经转发过了。
|
||||
var ErrRelayDuplicate = errors.New("relay already recorded")
|
||||
|
||||
// ClaimRelay 占用一次免配额转发名额。
|
||||
//
|
||||
// 判断与占用在同一条 INSERT 里(靠主键唯一约束),并发重试下只有一个能成功 ——
|
||||
// 分成「先查有没有、再插入」两步的话,插件的两次重试会双双通过检查各插一条。
|
||||
//
|
||||
// 返回 ErrRelayDuplicate 表示重复,调用方应当据此跳过发信而不是报错:
|
||||
// 重复转发是插件重试的正常结果,不是故障。
|
||||
func ClaimRelay(ctx context.Context, agentName, relayKey, kind string) error {
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`INSERT INTO relayed_mails (agent_name, relay_key, kind) VALUES ($1, $2, $3)`,
|
||||
agentName, relayKey, kind)
|
||||
if err != nil {
|
||||
if db.IsUniqueViolation(err) {
|
||||
return ErrRelayDuplicate
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BindRelayMail 把已占用的名额关联到真正发出的邮件,便于事后审计
|
||||
// 「这封免配额的信是从哪条上游消息来的」。
|
||||
//
|
||||
// 关联失败不该让发信失败:邮件已经入库,缺一条审计关联不影响功能。
|
||||
func BindRelayMail(ctx context.Context, agentName, relayKey string, mailID uuid.UUID) error {
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE relayed_mails SET mail_id = $1 WHERE agent_name = $2 AND relay_key = $3`,
|
||||
mailID, agentName, relayKey)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReleaseRelay 撤销名额占用。
|
||||
//
|
||||
// 占用成功但发信失败时必须还回去,否则那条上游消息永远转不出来了 ——
|
||||
// 幂等键会一直认为它已经转过。
|
||||
func ReleaseRelay(ctx context.Context, agentName, relayKey string) error {
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`DELETE FROM relayed_mails WHERE agent_name = $1 AND relay_key = $2 AND mail_id IS NULL`,
|
||||
agentName, relayKey)
|
||||
return err
|
||||
}
|
||||
|
||||
// RelayKeyForMail 反查某封邮件对应的上游消息 id。
|
||||
//
|
||||
// 人类决策一条权限请求后,插件需要知道该回复 opencode 的哪个 permission ——
|
||||
// 光有 AgentMail 的 mail_id 是不够的,两边的 id 空间不同。
|
||||
// 插件重启后内存映射会丢,所以这个映射必须在服务端持久化。
|
||||
//
|
||||
// 无记录时返回空串(例如旧数据,或压根没走 relay 通道的请求)。
|
||||
func RelayKeyForMail(ctx context.Context, mailID uuid.UUID) (string, string) {
|
||||
var key, kind string
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT relay_key, kind FROM relayed_mails WHERE mail_id = $1 LIMIT 1`,
|
||||
mailID).Scan(&key, &kind)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return key, kind
|
||||
}
|
||||
83
server/internal/repo/relayhops.go
Normal file
83
server/internal/repo/relayhops.go
Normal file
@ -0,0 +1,83 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 连续 relay 跳数限制 —— 防止两个 Agent 靠自动转发互相唤醒到无穷。
|
||||
//
|
||||
// # 这是什么问题
|
||||
//
|
||||
// 每个插件都在「一轮结束时把模型最后那段话自动发回去」(契约 B-5)。
|
||||
// 当收件方也是一个装了同类插件的 Agent 时,这封信唤醒对方 → 对方跑一轮 →
|
||||
// 对方也自动回一封 → 循环。**双方都没有「决定继续」,因为双方都不在做决定** ——
|
||||
// 发信是插件代劳的。
|
||||
//
|
||||
// 生产上真实发生过:会话 f3d824ce(dsh 与 opencode 联调 llmsproxy)共 41 封,
|
||||
// 最后一封人类意图的邮件之后,**每一封都是 relay:summary**,
|
||||
// 间隔从 15 分钟一路缩到 5 秒,内容已无新增信息。
|
||||
//
|
||||
// # 为什么 relay_key 拦不住
|
||||
//
|
||||
// 它是幂等键,职责是「同一条上游消息不重复转发」,这一点它做对了。
|
||||
// 但每一轮都是**真正不同**的新消息:opencode 侧是 assistant message id
|
||||
// (msg_0655bbf6…、msg_0656cc7fc…),dsh 侧是事件计数(…:12220、…:13347)。
|
||||
// 每次 ClaimRelay 都合法通过。
|
||||
//
|
||||
// # 为什么需要两道防线
|
||||
//
|
||||
// 主防线是「免配额只给发往人类的 relay」(见 handler.SendMail):
|
||||
// Agent→Agent 的自动转发转而消耗会话预算,max_rounds 会截断它。
|
||||
//
|
||||
// 但那还不够:预算给得大(比如 200)时,两个 Agent 仍能烧掉 200 个来回;
|
||||
// 而故障报告这类**必须**走 relay 的邮件也需要受约束。因此这里再加一道
|
||||
// 与预算无关的硬上限:一条会话里**连续**的 relay 邮件不得超过 maxRelayHops。
|
||||
//
|
||||
// 「连续」是关键:只要中间有一封自主发信(模型真的决定说什么)或人类插话,
|
||||
// 计数就归零。这让正常的「模型回一封、插件补一封总结」不受影响,
|
||||
// 只掐住「全程无人决策」的那种回路。
|
||||
//
|
||||
// hop_limit 列早就在 schema 里(DEFAULT 5)却从没有人读它 —— 它显然
|
||||
// 就是为这件事准备的。这里把它接上,取同一个默认值。
|
||||
const maxRelayHops = 5
|
||||
|
||||
// CountTrailingRelayHops 数会话尾部**连续**的 relay 邮件数。
|
||||
//
|
||||
// 从最新一封往前扫,遇到第一封非 relay 邮件即停。返回值即「若本次再发一封
|
||||
// relay,它会是第几跳」的前一个数。
|
||||
//
|
||||
// 判据用 relayed_mails 的存在性而不是 mails 上的某个标记:
|
||||
// relay 身份本来就记在那张表里,在 mails 上再存一份等于给同一事实留两个答案。
|
||||
func CountTrailingRelayHops(ctx context.Context, sessionID uuid.UUID) (int, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT CASE WHEN r.mail_id IS NULL THEN 0 ELSE 1 END AS is_relay
|
||||
FROM mails m
|
||||
LEFT JOIN relayed_mails r ON r.mail_id = m.mail_id
|
||||
WHERE m.session_id = $1
|
||||
ORDER BY m.created_at DESC, m.mail_id DESC
|
||||
`, sessionID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
hops := 0
|
||||
for rows.Next() {
|
||||
var isRelay int
|
||||
if err := rows.Scan(&isRelay); err != nil {
|
||||
return hops, err
|
||||
}
|
||||
if isRelay == 0 {
|
||||
// 遇到一封自主发信/人类邮件:链条到此为止
|
||||
break
|
||||
}
|
||||
hops++
|
||||
}
|
||||
return hops, rows.Err()
|
||||
}
|
||||
|
||||
// MaxRelayHops 暴露上限供错误文案与测试使用。
|
||||
func MaxRelayHops() int { return maxRelayHops }
|
||||
126
server/internal/repo/relayhops_test.go
Normal file
126
server/internal/repo/relayhops_test.go
Normal file
@ -0,0 +1,126 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 连续 relay 跳数上限守的是一个真实事故:会话 f3d824ce(dsh 与 opencode 联调
|
||||
// llmsproxy)共 41 封,最后一封人类意图的邮件之后每一封都是 relay:summary,
|
||||
// 间隔从 15 分钟一路缩到 5 秒。双方都没有「决定继续」,因为双方都不在做决定 ——
|
||||
// 发信是插件代劳的,而免配额通道让整个回路里没有任何一处在计数。
|
||||
|
||||
func TestTrailingRelayHopsEmptySession(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "空会话", "/tmp/ws")
|
||||
n, err := CountTrailingRelayHops(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatalf("空会话应正常返回: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("空会话跳数应为 0,实为 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrailingRelayHopsCountsOnlyRelay(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "混合", "/tmp/ws")
|
||||
// 人类发一封(非 relay)
|
||||
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws", nil)
|
||||
// 插件连续转发三封
|
||||
for i := 0; i < 3; i++ {
|
||||
seedRelayMail(t, ctx, sid, "dsh", "opencode")
|
||||
}
|
||||
|
||||
n, err := CountTrailingRelayHops(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatalf("数跳数: %v", err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Fatalf("尾部连续 relay 应为 3,实为 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrailingRelayHopsResetsOnAutonomousSend(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 「连续」是这条规则的关键:中间只要有一封自主发信(模型真的决定说什么)
|
||||
// 或人类插话,计数就归零。否则正常的「模型回一封、插件补一封总结」
|
||||
// 会被误判成回路。
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "打断", "/tmp/ws")
|
||||
for i := 0; i < 4; i++ {
|
||||
seedRelayMail(t, ctx, sid, "dsh", "opencode")
|
||||
}
|
||||
// 模型亲手发了一封 —— 链条到此为止
|
||||
mustMail(t, sid, "dsh", "", "opencode", "/tmp/ws", nil)
|
||||
seedRelayMail(t, ctx, sid, "opencode", "dsh")
|
||||
|
||||
n, err := CountTrailingRelayHops(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatalf("数跳数: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("自主发信之后只剩 1 跳,实为 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrailingRelayHopsReachesLimit(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 攒到上限:此时 handler 应当拒绝下一封 relay。
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "到顶", "/tmp/ws")
|
||||
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws", nil)
|
||||
for i := 0; i < MaxRelayHops(); i++ {
|
||||
seedRelayMail(t, ctx, sid, "dsh", "opencode")
|
||||
}
|
||||
|
||||
n, _ := CountTrailingRelayHops(ctx, sid)
|
||||
if n < MaxRelayHops() {
|
||||
t.Fatalf("应达到上限 %d,实为 %d", MaxRelayHops(), n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrailingRelayHopsIsPerSession(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 一条会话的回路不该影响另一条:两个 Agent 在 A 会话里刷爆了,
|
||||
// B 会话的正常自动转发仍应放行。
|
||||
a, _ := CreateSession(ctx, nil, "admin", "A", "/tmp/a")
|
||||
b, _ := CreateSession(ctx, nil, "admin", "B", "/tmp/b")
|
||||
for i := 0; i < 5; i++ {
|
||||
seedRelayMail(t, ctx, a, "dsh", "opencode")
|
||||
}
|
||||
seedRelayMail(t, ctx, b, "dsh", "admin")
|
||||
|
||||
na, _ := CountTrailingRelayHops(ctx, a)
|
||||
nb, _ := CountTrailingRelayHops(ctx, b)
|
||||
if na != 5 || nb != 1 {
|
||||
t.Fatalf("跳数应按会话独立计:A=%d(期望 5)B=%d(期望 1)", na, nb)
|
||||
}
|
||||
}
|
||||
|
||||
// seedRelayMail 建一封走 relay 通道的邮件(同时占幂等键并关联 mail_id),
|
||||
// 复刻 handler.SendMail 的真实写入顺序。
|
||||
func seedRelayMail(t *testing.T, ctx context.Context, sid uuid.UUID, from, to string) {
|
||||
t.Helper()
|
||||
key := "relay-" + uuid.NewString()
|
||||
if err := ClaimRelay(ctx, from, key, "summary"); err != nil {
|
||||
t.Fatalf("占幂等键: %v", err)
|
||||
}
|
||||
mid, err := CreateMail(ctx, sid, nil, from, "", to, "", "Re: 主题", "正文", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("建邮件: %v", err)
|
||||
}
|
||||
if err := BindRelayMail(ctx, from, key, mid); err != nil {
|
||||
t.Fatalf("关联 relay: %v", err)
|
||||
}
|
||||
}
|
||||
1502
server/internal/repo/repo.go
Normal file
1502
server/internal/repo/repo.go
Normal file
File diff suppressed because it is too large
Load Diff
228
server/internal/repo/repo_test.go
Normal file
228
server/internal/repo/repo_test.go
Normal file
@ -0,0 +1,228 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ListSessionsFor 的 Scan 列数必须与 SELECT 一致。
|
||||
//
|
||||
// 这个测试存在的理由:预算两列(max_rounds/used_rounds)加进了 SELECT 却忘了加进
|
||||
// Scan,于是 /me/sessions 整个 500 —— 联系人栏一条数据都拉不到,
|
||||
// 而错误信息只是 "Failed to list sessions",看不出是列数不匹配。
|
||||
// 列数错位是纯结构问题,一个最小用例就能钉住。
|
||||
func TestListSessionsForScanMatchesSelect(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
if _, err := db.DB.ExecContext(context.Background(),
|
||||
`INSERT INTO users (username, display_name, password_hash, role)
|
||||
VALUES ('alice', 'Alice', 'x', 'user')`); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
|
||||
sid := seedSessionRow(t, "list-scan")
|
||||
if _, err := db.DB.ExecContext(context.Background(),
|
||||
`UPDATE sessions SET max_rounds = 7, used_rounds = 3 WHERE session_id = $1`,
|
||||
sid); err != nil {
|
||||
t.Fatalf("set budget: %v", err)
|
||||
}
|
||||
seedMailIn(t, sid, "alice", "opencode", "hello")
|
||||
|
||||
// 无过滤(管理员 all=true 走这条)
|
||||
all, err := ListSessionsFor(context.Background(), "", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("列出全部会话失败: %v", err)
|
||||
}
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("应有 1 个会话,实际 %d", len(all))
|
||||
}
|
||||
// 预算两列要真的读出来,不是零值
|
||||
if all[0].MaxRounds != 7 || all[0].UsedRounds != 3 {
|
||||
t.Errorf("预算未读出:max=%d used=%d(期望 7/3)",
|
||||
all[0].MaxRounds, all[0].UsedRounds)
|
||||
}
|
||||
if all[0].MailCount != 1 {
|
||||
t.Errorf("邮件数应为 1,实际 %d —— 列顺序可能错位", all[0].MailCount)
|
||||
}
|
||||
|
||||
// 带用户过滤(普通用户走这条,SQL 分支不同,要分别验)
|
||||
mine, err := ListSessionsFor(context.Background(), "alice", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("列出自己的会话失败: %v", err)
|
||||
}
|
||||
if len(mine) != 1 {
|
||||
t.Fatalf("alice 参与过该会话,应能看到,实际 %d 个", len(mine))
|
||||
}
|
||||
if mine[0].MaxRounds != 7 || mine[0].MailCount != 1 {
|
||||
t.Errorf("过滤分支的列顺序错位:%+v", mine[0])
|
||||
}
|
||||
|
||||
// 与自己无关的人看不到
|
||||
other, err := ListSessionsFor(context.Background(), "bob", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("列出 bob 的会话失败: %v", err)
|
||||
}
|
||||
if len(other) != 0 {
|
||||
t.Errorf("bob 未参与该会话,不该看到,实际 %d 个", len(other))
|
||||
}
|
||||
|
||||
// 归档会话不出现在列表里
|
||||
if _, err := db.DB.ExecContext(context.Background(),
|
||||
`UPDATE sessions SET status = 'archived' WHERE session_id = $1`, sid); err != nil {
|
||||
t.Fatalf("archive: %v", err)
|
||||
}
|
||||
after, err := ListSessionsFor(context.Background(), "", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("归档后列出失败: %v", err)
|
||||
}
|
||||
if len(after) != 0 {
|
||||
t.Errorf("归档会话不该出现在列表里,实际 %d 个", len(after))
|
||||
}
|
||||
}
|
||||
|
||||
// 工作列表卡片视图需要「这条线索在干什么 / 还剩几个来回 / 最新进展是什么」,
|
||||
// 这些都从 ListContactsFor 一次取回 —— 否则卡片要为每条会话再打一次库。
|
||||
func TestListContactsForCardFields(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
if _, err := db.DB.ExecContext(context.Background(),
|
||||
`INSERT INTO users (username, display_name, password_hash, role)
|
||||
VALUES ('alice', 'Alice', 'x', 'user')`); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
|
||||
sid := seedSessionRow(t, "card-fields")
|
||||
if _, err := db.DB.ExecContext(context.Background(),
|
||||
`UPDATE sessions SET subject = '缓存层选型评估', max_rounds = 5, used_rounds = 2
|
||||
WHERE session_id = $1`, sid); err != nil {
|
||||
t.Fatalf("set session: %v", err)
|
||||
}
|
||||
|
||||
// 三封:最早一封决定联系人身份,最后一封决定「最新进展」
|
||||
seedMailIn(t, sid, "alice", "opencode", "第一封")
|
||||
seedMailIn(t, sid, "opencode", "alice", "第二封")
|
||||
last := seedMailIn(t, sid, "opencode", "alice", "第三封")
|
||||
if _, err := db.DB.ExecContext(context.Background(),
|
||||
`UPDATE mails SET body = '已经跑完压测,Redis 方案在这个负载下明显更稳。'
|
||||
WHERE mail_id = $1`, last); err != nil {
|
||||
t.Fatalf("set body: %v", err)
|
||||
}
|
||||
|
||||
got, err := ListContactsFor(context.Background(), "alice", false)
|
||||
if err != nil {
|
||||
t.Fatalf("列出联系人失败: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("应有 1 个联系人,实际 %d", len(got))
|
||||
}
|
||||
c := got[0]
|
||||
|
||||
if c.Subject != "缓存层选型评估" {
|
||||
t.Errorf("主题未带回:%q", c.Subject)
|
||||
}
|
||||
if c.MaxRounds != 5 || c.UsedRounds != 2 {
|
||||
t.Errorf("预算未带回:%d/%d(期望 5/2)", c.UsedRounds, c.MaxRounds)
|
||||
}
|
||||
// 最新进展取的是【最后】一封,不是第一封
|
||||
if c.LastFrom != "opencode" {
|
||||
t.Errorf("最新发件人应为 opencode,实际 %q", c.LastFrom)
|
||||
}
|
||||
if !strings.Contains(c.LastPreview, "Redis 方案") {
|
||||
t.Errorf("最新摘要应来自最后一封,实际 %q", c.LastPreview)
|
||||
}
|
||||
// 联系人身份仍取最早一封的对端
|
||||
if c.AgentName != "opencode" {
|
||||
t.Errorf("联系人应为 opencode,实际 %q", c.AgentName)
|
||||
}
|
||||
if c.MailCount != 3 {
|
||||
t.Errorf("邮件数应为 3,实际 %d —— 列顺序可能错位", c.MailCount)
|
||||
}
|
||||
if c.Address != "opencode@.card-fields" && !strings.HasSuffix(c.Address, ".card-fields") {
|
||||
t.Errorf("地址应带会话别名,实际 %q", c.Address)
|
||||
}
|
||||
}
|
||||
|
||||
// 摘要按字符截断,不按字节 —— 中文一字三字节,裸切会留半个字符。
|
||||
func TestPreviewRunes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
n int
|
||||
want string
|
||||
}{
|
||||
{"短文本", 10, "短文本"},
|
||||
{" 两边有空白 ", 10, "两边有空白"},
|
||||
{"", 5, ""},
|
||||
{"一二三四五六", 3, "一二三…"},
|
||||
{"abcdefgh", 3, "abc…"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := previewRunes(c.in, c.n); got != c.want {
|
||||
t.Errorf("previewRunes(%q, %d) = %q,期望 %q", c.in, c.n, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// 截断结果必须是合法 UTF-8(不含替换字符)
|
||||
long := strings.Repeat("汉字", 200)
|
||||
got := previewRunes(long, 90)
|
||||
if strings.ContainsRune(got, '\uFFFD') {
|
||||
t.Error("截断产生了 U+FFFD,说明按字节切了")
|
||||
}
|
||||
if n := len([]rune(got)); n != 91 { // 90 + 省略号
|
||||
t.Errorf("截断后应为 90 字符 + 省略号,实际 %d 字符", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 时间戳精度回归:SQLite 的 CURRENT_TIMESTAMP 只有秒,同秒插入的多行排序不确定,
|
||||
// 「会话里最早那封」(决定联系人身份)与「最后那封」(决定最新进展)都会取错。
|
||||
// NOW() 现在返回毫秒精度,且 mails 的 INSERT 显式传它 —— 这两点都要钉住。
|
||||
func TestMailTimestampSubSecond(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
sid := seedSessionRow(t, "ts-precision")
|
||||
|
||||
// 连续插 8 封(不显式给时间戳,走 CreateMail 里的 NOW())
|
||||
ids := make([]uuid.UUID, 0, 8)
|
||||
for i := 0; i < 8; i++ {
|
||||
id, err := CreateMail(context.Background(), sid, nil,
|
||||
"alice", "", "opencode", "", fmt.Sprintf("第%d封", i), "body", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("创建邮件 %d 失败: %v", i, err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
// 至少要出现亚秒差异,否则说明 NOW() 又退回秒精度
|
||||
var distinct int
|
||||
if err := db.DB.QueryRowContext(context.Background(),
|
||||
`SELECT COUNT(DISTINCT created_at) FROM mails WHERE session_id = $1`,
|
||||
sid).Scan(&distinct); err != nil {
|
||||
t.Fatalf("统计不同时间戳失败: %v", err)
|
||||
}
|
||||
if distinct < 2 {
|
||||
var sample string
|
||||
db.DB.QueryRowContext(context.Background(),
|
||||
`SELECT CAST(created_at AS TEXT) FROM mails WHERE session_id = $1 LIMIT 1`,
|
||||
sid).Scan(&sample)
|
||||
t.Fatalf("8 封邮件只有 %d 个不同时间戳(样例 %q)—— NOW() 精度不足,"+
|
||||
"同秒邮件的先后顺序会由随机 UUID 决定", distinct, sample)
|
||||
}
|
||||
|
||||
// GetSessionMails 按时间升序,顺序必须与插入顺序一致
|
||||
got, err := GetSessionMails(context.Background(), sid)
|
||||
if err != nil {
|
||||
t.Fatalf("取会话邮件失败: %v", err)
|
||||
}
|
||||
if len(got) != len(ids) {
|
||||
t.Fatalf("应有 %d 封,实际 %d", len(ids), len(got))
|
||||
}
|
||||
for i, m := range got {
|
||||
if m.ID != ids[i] {
|
||||
t.Errorf("第 %d 封顺序错位:期望 %s,实际 %s(主题 %q)",
|
||||
i, ids[i], m.ID, m.Subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
94
server/internal/repo/sessionrate.go
Normal file
94
server/internal/repo/sessionrate.go
Normal file
@ -0,0 +1,94 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
)
|
||||
|
||||
// ---------- 新建会话速率限制 ----------
|
||||
//
|
||||
// Agent 可以用 name@path.new 开一串新会话,每条都是全新预算 ——
|
||||
// 速率限制只压住「短时间内暴开」这个滥用形态,过一个窗口自动恢复。
|
||||
|
||||
// ErrSessionRateLimited 表示该 Agent 短时间内新建会话过多。
|
||||
// (目前未使用,直接返回 retryAfter 由 handler 构造 429 响应)
|
||||
|
||||
const (
|
||||
sessionRateWindow = time.Hour
|
||||
sessionRateLimit = 20
|
||||
)
|
||||
|
||||
// AllowNewSession 供 handler 调用:Agent 新建会话前先过速率限制。
|
||||
// 人类用户不走这条路径(手工点「新建邮件」的频率天然受限)。
|
||||
// 返回 (allowed, retryAfter)。DB 不可用时放行。
|
||||
func AllowNewSession(ctx context.Context, agentName string) (bool, int) {
|
||||
if agentName == "" {
|
||||
return true, 0
|
||||
}
|
||||
return RateLimitCheckAndRecord(ctx, "session:"+agentName, sessionRateWindow, sessionRateLimit)
|
||||
}
|
||||
|
||||
// ReleaseNewSession 建会话失败后归还名额。
|
||||
// DB-backed 方式下记账在 AllowNewSession 里已完成,失败时需手动删除最近一条。
|
||||
func ReleaseNewSession(ctx context.Context, agentName string) {
|
||||
if agentName == "" {
|
||||
return
|
||||
}
|
||||
bucket := "session:" + agentName
|
||||
// 删掉最近一条(建会话失败,那次不该占名额)
|
||||
_, _ = db.DB.ExecContext(ctx,
|
||||
`DELETE FROM rate_limits WHERE bucket = $1 AND ts = (
|
||||
SELECT MAX(ts) FROM rate_limits WHERE bucket = $1
|
||||
)`, bucket)
|
||||
}
|
||||
|
||||
// SessionRateLimit 暴露窗口内的新建上限,供错误文案使用。
|
||||
func SessionRateLimit() int { return sessionRateLimit }
|
||||
|
||||
// ---------- Agent 建日历事件的速率限制 ----------
|
||||
//
|
||||
// 与新建会话同一套机制、独立的桶。为什么必须限:
|
||||
//
|
||||
// 日历事件是**长效**的 —— 一条每日重复的提醒会一直发下去,直到有人去删。
|
||||
// 模型在循环里每轮建一个「10 分钟后提醒我检查」,攒出几十条定时任务后,
|
||||
// 即使那条会话早已归档,提醒仍会按时发出。这比 `.new` 洪泛更难收拾:
|
||||
// 后者只是多几条空会话,前者是持续产生新邮件的源头。
|
||||
//
|
||||
// 上限与新建会话一致(20 次/小时):正常用法下 Agent 一次任务里建
|
||||
// 一两条日程,20 条足够宽松;而循环失控时一小时内就会撞上限。
|
||||
const (
|
||||
calendarRateWindow = time.Hour
|
||||
calendarRateLimit = 20
|
||||
)
|
||||
|
||||
// AllowAgentCalendarEvent 供 handler 调用:Agent 建日历事件前先过速率限制。
|
||||
//
|
||||
// 人类不走这条路径(在界面上手工填表的频率天然受限),
|
||||
// 因此桶名带 agent: 前缀,与人类操作完全隔离。
|
||||
// 返回 (allowed, retryAfter)。DB 不可用时放行 —— 限速不该成为可用性的单点。
|
||||
func AllowAgentCalendarEvent(ctx context.Context, agentName string) (bool, int) {
|
||||
if agentName == "" {
|
||||
return true, 0
|
||||
}
|
||||
return RateLimitCheckAndRecord(ctx, "calendar:"+agentName, calendarRateWindow, calendarRateLimit)
|
||||
}
|
||||
|
||||
// ReleaseAgentCalendarEvent 建事件失败后归还名额。
|
||||
//
|
||||
// 与 ReleaseNewSession 同理:记账发生在检查那一刻,
|
||||
// 后续的写库失败意味着「那次创建实际没有发生」,不该占名额。
|
||||
func ReleaseAgentCalendarEvent(ctx context.Context, agentName string) {
|
||||
if agentName == "" {
|
||||
return
|
||||
}
|
||||
bucket := "calendar:" + agentName
|
||||
_, _ = db.DB.ExecContext(ctx,
|
||||
`DELETE FROM rate_limits WHERE bucket = $1 AND ts = (
|
||||
SELECT MAX(ts) FROM rate_limits WHERE bucket = $1
|
||||
)`, bucket)
|
||||
}
|
||||
|
||||
// CalendarRateLimit 暴露窗口内的上限,供错误文案使用。
|
||||
func CalendarRateLimit() int { return calendarRateLimit }
|
||||
127
server/internal/repo/sessionrate_test.go
Normal file
127
server/internal/repo/sessionrate_test.go
Normal file
@ -0,0 +1,127 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
)
|
||||
|
||||
// 新建会话速率限制:DB 版
|
||||
//
|
||||
// 这些测试用真实的 SQLite(setupTestDB),验证速率限制的原子性与窗口滑动。
|
||||
// 原来的内存版测试依赖 sessionRateLimiter 结构体,替换为 DB 版后重写。
|
||||
|
||||
func TestSessionRateAllowsUpToLimit(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 1; i <= sessionRateLimit; i++ {
|
||||
if ok, _ := AllowNewSession(ctx, "bot"); !ok {
|
||||
t.Fatalf("第 %d 次应放行(上限 %d)", i, sessionRateLimit)
|
||||
}
|
||||
}
|
||||
ok, retry := AllowNewSession(ctx, "bot")
|
||||
if ok {
|
||||
t.Fatal("超过上限应拦下")
|
||||
}
|
||||
if retry < 1 {
|
||||
t.Fatalf("应给出正的重试等待秒数,实际 %d", retry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRateIsPerAgent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < sessionRateLimit; i++ {
|
||||
AllowNewSession(ctx, "busy")
|
||||
}
|
||||
if ok, _ := AllowNewSession(ctx, "busy"); ok {
|
||||
t.Fatal("busy 应已被拦")
|
||||
}
|
||||
if ok, _ := AllowNewSession(ctx, "idle"); !ok {
|
||||
t.Fatal("另一个 Agent 不该被牵连")
|
||||
}
|
||||
}
|
||||
|
||||
// 并发请求不能把上限刷穿(判断与记账必须原子)
|
||||
func TestSessionRateConcurrentDoesNotOverrun(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
passed := 0
|
||||
|
||||
for i := 0; i < sessionRateLimit*4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if ok, _ := AllowNewSession(ctx, "bot"); ok {
|
||||
mu.Lock()
|
||||
passed++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if passed != sessionRateLimit {
|
||||
t.Fatalf("%d 并发下放行 %d 次,期望恰好 %d 次",
|
||||
sessionRateLimit*4, passed, sessionRateLimit)
|
||||
}
|
||||
}
|
||||
|
||||
// 建会话失败时要还名额
|
||||
func TestSessionRateRelease(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < sessionRateLimit; i++ {
|
||||
AllowNewSession(ctx, "bot")
|
||||
}
|
||||
if ok, _ := AllowNewSession(ctx, "bot"); ok {
|
||||
t.Fatal("应已刷满")
|
||||
}
|
||||
ReleaseNewSession(ctx, "bot")
|
||||
if ok, _ := AllowNewSession(ctx, "bot"); !ok {
|
||||
t.Fatal("归还名额后应能再开一条")
|
||||
}
|
||||
}
|
||||
|
||||
// 人类不走限速(空 agentName)
|
||||
func TestAllowNewSessionSkipsHumans(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < sessionRateLimit*3; i++ {
|
||||
if ok, _ := AllowNewSession(ctx, ""); !ok {
|
||||
t.Fatal("人类不该被限速")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口滑过后自动恢复(过期记录自动清理)
|
||||
func TestSessionRateWindowSlides(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 插入过期记录(1小时前)
|
||||
cutoff := time.Now().Add(-sessionRateWindow - time.Minute)
|
||||
for i := 0; i < sessionRateLimit; i++ {
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`INSERT INTO rate_limits (bucket, ts) VALUES ($1, $2)`,
|
||||
"session:bot", cutoff)
|
||||
if err != nil {
|
||||
t.Fatalf("插入过期记录失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口外的记录应被清理,此次应放行
|
||||
if ok, _ := AllowNewSession(ctx, "bot"); !ok {
|
||||
t.Fatal("窗口外的记录应被清掉,此次应放行")
|
||||
}
|
||||
}
|
||||
246
server/internal/repo/thread.go
Normal file
246
server/internal/repo/thread.go
Normal file
@ -0,0 +1,246 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 对话树。
|
||||
//
|
||||
// **不另建 tree_nodes 表**:`mails.parent_mail_id` 已经完整编码了树结构 ——
|
||||
// 回复指向来信,转发指向被转发的原件。再维护一张 tree_nodes 就是第二份真相,
|
||||
// 两处不一致时无法判断谁对。这里直接用递归 CTE 在 mails 上查。
|
||||
//
|
||||
// 树可以跨会话:转发把线索引到新会话,但 parent 仍指向原件。这正是「对话树」比
|
||||
// 「会话内平铺」更有价值的地方 —— 能看出一条线索分叉去了哪里。
|
||||
// 也正因如此,读取时必须按会话逐个鉴权(见 handler):
|
||||
// A 转发给 B 之后,B 与 C 在新会话里的往来不能回流给 A。
|
||||
//
|
||||
// **从根展开,而不是从锚点展开**:曾经的实现是「锚点的祖先链 + 锚点的子树」,
|
||||
// 于是兄弟节点整条分支都在盲区里 —— 一封抄送给两个 Agent 的邮件,两个回复
|
||||
// 互为兄弟,从其中一个看树看不到另一个;挂在原件上的转发同理。
|
||||
// 兄弟既不是锚点的祖先也不是它的子孙,只有先上溯到根、再整棵 BFS 才能覆盖。
|
||||
//
|
||||
// **分块加载而非截断**:线索可以有几百封,一次全取要把几 MB 预览塞给前端。
|
||||
// 从根 BFS 后只剩一个方向,游标就是「已取到的节点数」。
|
||||
|
||||
// TreeMail 是树里的一个节点。正文只带预览:整棵线索带全文可能几百 KB,
|
||||
// 前端点开某封时再单取全文与附件清单。
|
||||
type TreeMail struct {
|
||||
models.Mail
|
||||
// Depth 是**距线索根**的层级:0 = 根,1 = 它的直接回复。
|
||||
// 从根展开后根一定在结果里,绝对深度因此总是可知的(早先按相对锚点算,
|
||||
// 是因为那时根可能还没取到)。
|
||||
Depth int `json:"depth"`
|
||||
AttachmentCount int `json:"attachment_count"`
|
||||
}
|
||||
|
||||
// descendantDepthCap 只是数据损坏时的兜底。
|
||||
//
|
||||
// parent_mail_id 正常不成环(新邮件只能指向已存在的旧邮件),但一旦被外部工具改坏,
|
||||
// 无上限的递归 CTE 会把进程拖死。取得足够大,正常数据碰不到。
|
||||
const descendantDepthCap = 10000
|
||||
|
||||
const threadCols = `m.mail_id, m.session_id, m.parent_mail_id,
|
||||
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
|
||||
m.cc_list, m.subject, m.body, m.mail_type,
|
||||
COALESCE(m.permission_result,'') AS permission_result,
|
||||
m.status, m.created_at, s.session_alias, s.workspace,
|
||||
(SELECT COUNT(*) FROM attachments a WHERE a.mail_id = m.mail_id) AS attach_count,
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human`
|
||||
|
||||
// ThreadRootOf 沿 parent_mail_id 上溯到线索的根,返回根的 mail_id 与锚点到根的层数。
|
||||
//
|
||||
// 「根」= 链条最上面那封:parent_mail_id 为 NULL,或者指向一封已被删掉的邮件
|
||||
// (JOIN 断掉,递归自然停在这一层)。锚点自己没有父时返回它自己、depth 0。
|
||||
//
|
||||
// **不做可见性过滤**:不可见的中间段必须能穿过 —— 转发把线索引进别人的会话,
|
||||
// 再往上却可能仍是自己参与的往来。只返回 id 与层数,不泄露任何内容。
|
||||
func ThreadRootOf(ctx context.Context, anchorID uuid.UUID) (uuid.UUID, int, error) {
|
||||
var rootID uuid.UUID
|
||||
var lvl int
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
WITH RECURSIVE up(mail_id, parent_mail_id, lvl) AS (
|
||||
SELECT mail_id, parent_mail_id, 0 FROM mails WHERE mail_id = $1
|
||||
UNION ALL
|
||||
SELECT m.mail_id, m.parent_mail_id, up.lvl + 1
|
||||
FROM mails m JOIN up ON m.mail_id = up.parent_mail_id
|
||||
WHERE up.lvl < $2
|
||||
)
|
||||
SELECT mail_id, lvl FROM up ORDER BY lvl DESC LIMIT 1
|
||||
`, anchorID, descendantDepthCap).Scan(&rootID, &lvl)
|
||||
if err != nil {
|
||||
return uuid.Nil, 0, err
|
||||
}
|
||||
return rootID, lvl, nil
|
||||
}
|
||||
|
||||
// AncestorsRaw 沿 parent_mail_id 上溯,取第 offset+1 .. offset+limit 层的祖先。
|
||||
// 层号 1 = 父,2 = 祖父;返回的 Depth 为负数(相对锚点)。
|
||||
//
|
||||
// 从根 BFS 之后这个函数只在一处还有用:巨型线索里锚点没落在 BFS 首页时,
|
||||
// 用它把「根到锚点」这条路径单独补齐,保证点开的那封一定看得见。
|
||||
// 调用方需要自己把负 depth 换算成绝对深度(锚点绝对深度由 ThreadRootOf 给出)。
|
||||
//
|
||||
// **不做可见性过滤**,理由同 ThreadRootOf。过滤放在 handler 层(那里知道调用者是谁)。
|
||||
//
|
||||
// 第二个返回值表示 offset+limit 层之上还有节点。
|
||||
func AncestorsRaw(ctx context.Context, anchorID uuid.UUID, offset, limit int) ([]TreeMail, bool, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
WITH RECURSIVE up(mail_id, parent_mail_id, lvl) AS (
|
||||
SELECT mail_id, parent_mail_id, 0 FROM mails WHERE mail_id = $1
|
||||
UNION ALL
|
||||
SELECT m.mail_id, m.parent_mail_id, up.lvl + 1
|
||||
FROM mails m JOIN up ON m.mail_id = up.parent_mail_id
|
||||
WHERE up.lvl < $2
|
||||
)
|
||||
SELECT `+threadCols+`, u.lvl
|
||||
FROM up u
|
||||
JOIN mails m ON m.mail_id = u.mail_id
|
||||
JOIN sessions s ON m.session_id = s.session_id
|
||||
WHERE u.lvl > $3
|
||||
ORDER BY u.lvl ASC
|
||||
`, anchorID, offset+limit+1, offset)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
// 多取一层用来判断「上面还有没有」,不返回给调用方
|
||||
out, err := scanTreeRows(rows, true)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
hasMore := len(out) > limit
|
||||
if hasMore {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
// DescendantsRaw 取给定节点及其全部子孙,BFS 顺序(同层按时间),按节点数分页。
|
||||
//
|
||||
// 传线索的根(见 ThreadRootOf)就能覆盖整棵树:兄弟、抄送产生的平行回复、
|
||||
// 挂在原件上的转发分支,全都是根的子孙。offset = 0 时结果第一个是起点自己(Depth 0)。
|
||||
//
|
||||
// 同样不做可见性过滤:不可见的子节点下面可能挂着可见的孙节点
|
||||
// (别人把线索转走又转回来给我)。
|
||||
//
|
||||
// 注意 CTE 每次都会走完整棵子树,LIMIT 只截断输出。一条邮件线索通常几十封,
|
||||
// 这个代价可以接受;真出现巨型线索时再加物化。
|
||||
func DescendantsRaw(ctx context.Context, anchorID uuid.UUID, offset, limit int) ([]TreeMail, bool, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
WITH RECURSIVE down(mail_id, lvl) AS (
|
||||
SELECT mail_id, 0 FROM mails WHERE mail_id = $1
|
||||
UNION ALL
|
||||
SELECT m.mail_id, down.lvl + 1
|
||||
FROM mails m JOIN down ON m.parent_mail_id = down.mail_id
|
||||
WHERE down.lvl < $2
|
||||
)
|
||||
SELECT `+threadCols+`, d.lvl
|
||||
FROM down d
|
||||
JOIN mails m ON m.mail_id = d.mail_id
|
||||
JOIN sessions s ON m.session_id = s.session_id
|
||||
ORDER BY d.lvl ASC, m.created_at ASC, m.mail_id ASC
|
||||
LIMIT $3 OFFSET $4
|
||||
`, anchorID, descendantDepthCap, limit+1, offset)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out, err := scanTreeRows(rows, false)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
hasMore := len(out) > limit
|
||||
if hasMore {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
// TreeMailByID 取单封邮件的树节点形式,深度由调用方给定。
|
||||
//
|
||||
// 补齐「根 → 锚点」路径时用得上:AncestorsRaw 从父开始,不含锚点自己。
|
||||
// 同样不做可见性过滤,由 handler 负责。
|
||||
func TreeMailByID(ctx context.Context, id uuid.UUID, depth int) (*TreeMail, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT `+threadCols+`, $2
|
||||
FROM mails m
|
||||
JOIN sessions s ON m.session_id = s.session_id
|
||||
WHERE m.mail_id = $1
|
||||
`, id, depth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := scanTreeRows(rows, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return &out[0], nil
|
||||
}
|
||||
|
||||
// scanTreeRows 读出节点。negate 为真时把层号取负(祖先方向)。
|
||||
func scanTreeRows(rows interface {
|
||||
Next() bool
|
||||
Scan(...interface{}) error
|
||||
Err() error
|
||||
Close() error
|
||||
}, negate bool) ([]TreeMail, error) {
|
||||
defer rows.Close()
|
||||
|
||||
out := []TreeMail{}
|
||||
for rows.Next() {
|
||||
var t TreeMail
|
||||
var alias *string
|
||||
var ccJSON []byte
|
||||
var lvl int
|
||||
if err := rows.Scan(&t.ID, &t.SessionID, &t.ParentMailID,
|
||||
&t.FromName, &t.FromWorkspace, &t.ToName, &t.ToWorkspace,
|
||||
&ccJSON, &t.Subject, &t.Body, &t.MailType, &t.PermResult,
|
||||
&t.Status, &t.CreatedAt, &alias, &t.SessionWorkspace, &t.AttachmentCount,
|
||||
&t.FromHuman, &t.ToHuman, &lvl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ccJSON) > 0 {
|
||||
json.Unmarshal(ccJSON, &t.CCList)
|
||||
}
|
||||
if t.CCList == nil {
|
||||
t.CCList = []models.Address{}
|
||||
}
|
||||
if alias != nil {
|
||||
t.SessionAlias = *alias
|
||||
}
|
||||
if negate {
|
||||
t.Depth = -lvl
|
||||
} else {
|
||||
t.Depth = lvl
|
||||
}
|
||||
t.BodyPreview = preview(t.Body, 240)
|
||||
t.Body = "" // 树视图只要预览,全文按需单取
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// preview 按 UTF-8 边界截断正文。
|
||||
// 直接切字节会把多字节字符切成半个,前端渲染出 U+FFFD 替换符。
|
||||
func preview(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
cut := max
|
||||
for cut > 0 && !utf8Start(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut] + "..."
|
||||
}
|
||||
|
||||
// utf8Start 判断某字节是否为一个 UTF-8 序列的首字节
|
||||
func utf8Start(b byte) bool { return b&0xC0 != 0x80 }
|
||||
286
server/internal/repo/thread_test.go
Normal file
286
server/internal/repo/thread_test.go
Normal file
@ -0,0 +1,286 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestPreviewTruncatesOnUTF8Boundary(t *testing.T) {
|
||||
// 「巡」是 3 字节;在 max=4 处切会切进第 2 个字符中间
|
||||
s := "巡检报告"
|
||||
got := preview(s, 4)
|
||||
if got != "巡..." {
|
||||
t.Fatalf("按 UTF-8 边界截断失败:%q", got)
|
||||
}
|
||||
for i, r := range got {
|
||||
if r == 0xFFFD {
|
||||
t.Fatalf("位置 %d 出现替换符,说明切在了字符中间", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewKeepsShortBodyIntact(t *testing.T) {
|
||||
if got := preview("短正文", 240); got != "短正文" {
|
||||
t.Fatalf("未超长却被改动:%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// seedReply 插一封回复:parent 指向来信。
|
||||
func seedReply(t *testing.T, sessionID uuid.UUID, parent uuid.UUID, from, to, subject string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := db.DB.QueryRowContext(context.Background(), `
|
||||
INSERT INTO mails (session_id, parent_mail_id, from_name, from_workspace,
|
||||
to_name, to_workspace, subject, body, cc_list, created_at)
|
||||
VALUES ($1, $2, $3, '', $4, '', $5, 'body', '[]', $6)
|
||||
RETURNING mail_id
|
||||
`, sessionID, parent, from, to, subject, nextSeedTime()).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("seed reply: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// 线索根定位:整棵树从根展开,所以这一步错了后面全错。
|
||||
func TestThreadRootOf(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
sid := seedSessionRow(t, "root-of")
|
||||
|
||||
root := seedMailIn(t, sid, "admin", "dsh", "原件")
|
||||
mid := seedReply(t, sid, root, "dsh", "admin", "Re: 原件")
|
||||
leaf := seedReply(t, sid, mid, "admin", "dsh", "Re: Re: 原件")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
from uuid.UUID
|
||||
depth int
|
||||
}{
|
||||
{"从根本身出发", root, 0},
|
||||
{"从中间一封出发", mid, 1},
|
||||
{"从叶子出发", leaf, 2},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
gotRoot, gotDepth, err := ThreadRootOf(context.Background(), c.from)
|
||||
if err != nil {
|
||||
t.Fatalf("ThreadRootOf: %v", err)
|
||||
}
|
||||
if gotRoot != root {
|
||||
t.Errorf("根定位错误:得到 %s,期望 %s", gotRoot, root)
|
||||
}
|
||||
if gotDepth != c.depth {
|
||||
t.Errorf("层数错误:得到 %d,期望 %d", gotDepth, c.depth)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 这个测试是对话树那次故障的回归:
|
||||
// 一封抄送给两个 Agent 的邮件收到两个回复,它们互为**兄弟**。
|
||||
// 旧实现从锚点分「祖先方向 + 子孙方向」两路展开,兄弟既不是锚点的祖先
|
||||
// 也不是它的子孙,于是整条分支在树里根本不出现。
|
||||
// 从线索根 BFS 之后,兄弟都是根的子孙,必须一次全出来。
|
||||
func TestDescendantsFromRootIncludesSiblings(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
sid := seedSessionRow(t, "siblings")
|
||||
|
||||
root := seedMailIn(t, sid, "admin", "dsh", "测试抄送")
|
||||
replyA := seedReply(t, sid, root, "dsh", "admin", "Re: 测试抄送")
|
||||
replyB := seedReply(t, sid, root, "opencode", "admin", "Re: 测试抄送")
|
||||
|
||||
// 从 replyA 出发定位根,再从根整树展开
|
||||
gotRoot, anchorDepth, err := ThreadRootOf(context.Background(), replyA)
|
||||
if err != nil {
|
||||
t.Fatalf("ThreadRootOf: %v", err)
|
||||
}
|
||||
if gotRoot != root || anchorDepth != 1 {
|
||||
t.Fatalf("根定位错误:root=%s depth=%d", gotRoot, anchorDepth)
|
||||
}
|
||||
|
||||
nodes, hasMore, err := DescendantsRaw(context.Background(), gotRoot, 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("DescendantsRaw: %v", err)
|
||||
}
|
||||
if hasMore {
|
||||
t.Error("三封邮件不该报 hasMore")
|
||||
}
|
||||
|
||||
byID := map[uuid.UUID]TreeMail{}
|
||||
for _, n := range nodes {
|
||||
byID[n.ID] = n
|
||||
}
|
||||
for name, id := range map[string]uuid.UUID{"根": root, "回复A": replyA, "回复B": replyB} {
|
||||
if _, ok := byID[id]; !ok {
|
||||
t.Errorf("%s 不在树里 —— 兄弟分支又丢了", name)
|
||||
}
|
||||
}
|
||||
if byID[root].Depth != 0 {
|
||||
t.Errorf("根的深度应为 0,实际 %d", byID[root].Depth)
|
||||
}
|
||||
if byID[replyA].Depth != 1 || byID[replyB].Depth != 1 {
|
||||
t.Errorf("两个回复都应在深度 1:A=%d B=%d", byID[replyA].Depth, byID[replyB].Depth)
|
||||
}
|
||||
}
|
||||
|
||||
// 转发落在**另一个会话**里,但 parent 仍指向原件。
|
||||
// 树必须跨会话展开,否则「这条线索转发给谁了」就看不见了。
|
||||
func TestDescendantsCrossSession(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
srcSession := seedSessionRow(t, "fwd-src")
|
||||
dstSession := seedSessionRow(t, "fwd-dst")
|
||||
|
||||
root := seedMailIn(t, srcSession, "admin", "dsh", "原件")
|
||||
// 转发:新会话,parent 仍指原件
|
||||
fwd := seedReply(t, dstSession, root, "admin", "opencode", "Fwd: 原件")
|
||||
// 转发的下游回复,还在新会话里
|
||||
fwdReply := seedReply(t, dstSession, fwd, "opencode", "admin", "Re: Fwd: 原件")
|
||||
|
||||
nodes, _, err := DescendantsRaw(context.Background(), root, 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("DescendantsRaw: %v", err)
|
||||
}
|
||||
found := map[uuid.UUID]int{}
|
||||
for _, n := range nodes {
|
||||
found[n.ID] = n.Depth
|
||||
}
|
||||
if _, ok := found[fwd]; !ok {
|
||||
t.Error("转发不在树里 —— 跨会话展开失效")
|
||||
}
|
||||
if _, ok := found[fwdReply]; !ok {
|
||||
t.Error("转发的下游回复不在树里")
|
||||
}
|
||||
if found[fwd] != 1 || found[fwdReply] != 2 {
|
||||
t.Errorf("跨会话深度错误:fwd=%d fwdReply=%d(期望 1/2)", found[fwd], found[fwdReply])
|
||||
}
|
||||
// 会话不同 → session_id 必须如实反映,否则前端无法标出「线索去了别的会话」
|
||||
for _, n := range nodes {
|
||||
if n.ID == fwd && n.SessionID != dstSession {
|
||||
t.Errorf("转发的 session_id 错误:%s,期望 %s", n.SessionID, dstSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 分页:BFS 顺序稳定,两页拼起来等于一次全取。
|
||||
func TestDescendantsPaginationStable(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
sid := seedSessionRow(t, "paging")
|
||||
|
||||
root := seedMailIn(t, sid, "admin", "dsh", "原件")
|
||||
for i := 0; i < 5; i++ {
|
||||
seedReply(t, sid, root, "dsh", "admin", "Re: 原件")
|
||||
}
|
||||
|
||||
full, hasMoreFull, err := DescendantsRaw(context.Background(), root, 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("全取: %v", err)
|
||||
}
|
||||
if hasMoreFull {
|
||||
t.Error("6 封邮件一次取完不该报 hasMore")
|
||||
}
|
||||
if len(full) != 6 {
|
||||
t.Fatalf("应有 6 个节点,实际 %d", len(full))
|
||||
}
|
||||
|
||||
page1, hasMore1, err := DescendantsRaw(context.Background(), root, 0, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("第一页: %v", err)
|
||||
}
|
||||
if !hasMore1 {
|
||||
t.Error("还有 2 封没取,hasMore 应为真")
|
||||
}
|
||||
page2, hasMore2, err := DescendantsRaw(context.Background(), root, 4, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("第二页: %v", err)
|
||||
}
|
||||
if hasMore2 {
|
||||
t.Error("第二页已取完,hasMore 应为假")
|
||||
}
|
||||
|
||||
joined := append(append([]TreeMail{}, page1...), page2...)
|
||||
if len(joined) != len(full) {
|
||||
t.Fatalf("两页拼接 %d 个,全取 %d 个", len(joined), len(full))
|
||||
}
|
||||
for i := range full {
|
||||
if joined[i].ID != full[i].ID {
|
||||
t.Fatalf("第 %d 个节点顺序不一致:分页 %s,全取 %s —— BFS 顺序不稳定,"+
|
||||
"分页加载会重复或漏掉节点", i, joined[i].ID, full[i].ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TreeMailByID 是「锚点没落进 BFS 首页」时的回填手段,深度由调用方给。
|
||||
func TestTreeMailByIDUsesGivenDepth(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
sid := seedSessionRow(t, "by-id")
|
||||
id := seedMailIn(t, sid, "admin", "dsh", "某封")
|
||||
|
||||
got, err := TreeMailByID(context.Background(), id, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("TreeMailByID: %v", err)
|
||||
}
|
||||
if got.ID != id {
|
||||
t.Errorf("取错了邮件:%s", got.ID)
|
||||
}
|
||||
if got.Depth != 7 {
|
||||
t.Errorf("深度应取调用方给的 7,实际 %d", got.Depth)
|
||||
}
|
||||
// 树视图只要预览,全文必须被清空 —— 否则整条线索会把几百 KB 正文塞给前端
|
||||
if got.Body != "" {
|
||||
t.Errorf("Body 应清空,实际 %q", got.Body)
|
||||
}
|
||||
if got.BodyPreview == "" {
|
||||
t.Error("BodyPreview 应有内容")
|
||||
}
|
||||
}
|
||||
|
||||
// 抄送列表必须原样带出来:树上两个兄弟节点为什么并列,
|
||||
// 唯一的解释就是父邮件抄送给了两个人。丢了 cc_list 前端就没法说明。
|
||||
func TestTreeCarriesCCList(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
sid := seedSessionRow(t, "cc-carry")
|
||||
|
||||
var root uuid.UUID
|
||||
err := db.DB.QueryRowContext(context.Background(), `
|
||||
INSERT INTO mails (session_id, from_name, from_workspace, to_name, to_workspace,
|
||||
subject, body, cc_list, created_at)
|
||||
VALUES ($1, 'admin', '', 'dsh', '', '抄送两人', 'body',
|
||||
'[{"name":"opencode","path":"/home","session":"new","raw":"opencode@/home.new"}]', $2)
|
||||
RETURNING mail_id
|
||||
`, sid, nextSeedTime()).Scan(&root)
|
||||
if err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
nodes, _, err := DescendantsRaw(context.Background(), root, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("DescendantsRaw: %v", err)
|
||||
}
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("应有 1 个节点,实际 %d", len(nodes))
|
||||
}
|
||||
if len(nodes[0].CCList) != 1 {
|
||||
t.Fatalf("抄送应有 1 人,实际 %d —— cc_list 没带出来", len(nodes[0].CCList))
|
||||
}
|
||||
if nodes[0].CCList[0].Raw != "opencode@/home.new" {
|
||||
t.Errorf("抄送 raw 错误:%q", nodes[0].CCList[0].Raw)
|
||||
}
|
||||
}
|
||||
|
||||
// 无抄送时 cc_list 必须是空数组而不是 null:
|
||||
// Go 的 nil slice 会序列化成 null,前端 node.cc_list.length 直接抛异常。
|
||||
func TestTreeEmptyCCIsArrayNotNull(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
sid := seedSessionRow(t, "cc-empty")
|
||||
root := seedMailIn(t, sid, "admin", "dsh", "无抄送")
|
||||
|
||||
nodes, _, err := DescendantsRaw(context.Background(), root, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("DescendantsRaw: %v", err)
|
||||
}
|
||||
if nodes[0].CCList == nil {
|
||||
t.Error("cc_list 为 nil,会序列化成 null")
|
||||
}
|
||||
}
|
||||
625
server/internal/repo/users.go
Normal file
625
server/internal/repo/users.go
Normal file
@ -0,0 +1,625 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
bcryptCost = 12
|
||||
sessionTTL = 7 * 24 * time.Hour
|
||||
userSelectCols = `user_id, username, display_name, password_hash, role, status, created_at, last_login, allowed_agents, allowed_paths`
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrBadCredentials = errors.New("invalid username or password")
|
||||
ErrUserDisabled = errors.New("user disabled")
|
||||
ErrNameTaken = errors.New("name already taken by an agent or user")
|
||||
ErrSessionInvalid = errors.New("session invalid or expired")
|
||||
ErrInvalidUsername = errors.New("username must be 2-64 chars of [a-z0-9._-]")
|
||||
ErrAlreadySetup = errors.New("system already initialized")
|
||||
)
|
||||
|
||||
// ---------- 命名空间校验 ----------
|
||||
|
||||
// 三维地址的 name 位由人类用户与 Agent 共用,因此必须全局唯一
|
||||
func nameTaken(ctx context.Context, name string) (bool, error) {
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT (SELECT COUNT(*) FROM users WHERE username = $1)
|
||||
+ (SELECT COUNT(*) FROM agents WHERE agent_name = $1)
|
||||
`, name).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// AgentNameAvailable 供 Agent 注册前校验(不与人类用户重名)
|
||||
func AgentNameAvailable(ctx context.Context, agentName string) (bool, error) {
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE username = $1`, agentName).Scan(&n)
|
||||
return n == 0, err
|
||||
}
|
||||
|
||||
func validUsername(name string) bool {
|
||||
if len(name) < 2 || len(name) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range name {
|
||||
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-'
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 保留字:human 是兼容别名,不能被真实用户占用
|
||||
return name != "human"
|
||||
}
|
||||
|
||||
// ---------- User CRUD ----------
|
||||
|
||||
func scanUser(row *sql.Row) (*models.User, error) {
|
||||
var u models.User
|
||||
var agentsJSON, pathsJSON []byte
|
||||
err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash,
|
||||
&u.Role, &u.Status, &u.CreatedAt, &u.LastLogin, &agentsJSON, &pathsJSON)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
u.AllowedAgents = decodeStrList(agentsJSON)
|
||||
u.AllowedPaths = decodeStrList(pathsJSON)
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func decodeStrList(raw []byte) []string {
|
||||
out := []string{}
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
}
|
||||
if out == nil {
|
||||
out = []string{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func CreateUser(ctx context.Context, username, password, displayName, role string,
|
||||
allowedAgents, allowedPaths []string) (*models.User, error) {
|
||||
username = strings.ToLower(strings.TrimSpace(username))
|
||||
if !validUsername(username) {
|
||||
return nil, ErrInvalidUsername
|
||||
}
|
||||
if role != "admin" {
|
||||
role = "user"
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = username
|
||||
}
|
||||
|
||||
taken, err := nameTaken(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taken {
|
||||
return nil, ErrNameTaken
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
agentsJSON, _ := json.Marshal(normalizeList(allowedAgents))
|
||||
pathsJSON, _ := json.Marshal(normalizeList(allowedPaths))
|
||||
|
||||
row := db.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO users (username, display_name, password_hash, role, allowed_agents, allowed_paths)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING `+userSelectCols,
|
||||
username, displayName, string(hash), role, agentsJSON, pathsJSON)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func GetUserByName(ctx context.Context, username string) (*models.User, error) {
|
||||
return scanUser(db.DB.QueryRowContext(ctx,
|
||||
`SELECT `+userSelectCols+` FROM users WHERE username = $1`,
|
||||
strings.ToLower(strings.TrimSpace(username))))
|
||||
}
|
||||
|
||||
func GetUserByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
|
||||
return scanUser(db.DB.QueryRowContext(ctx,
|
||||
`SELECT `+userSelectCols+` FROM users WHERE user_id = $1`, id))
|
||||
}
|
||||
|
||||
func ListUsers(ctx context.Context) ([]models.User, error) {
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT `+userSelectCols+` FROM users ORDER BY created_at ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := []models.User{}
|
||||
for rows.Next() {
|
||||
var u models.User
|
||||
var agentsJSON, pathsJSON []byte
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash,
|
||||
&u.Role, &u.Status, &u.CreatedAt, &u.LastLogin, &agentsJSON, &pathsJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.AllowedAgents = decodeStrList(agentsJSON)
|
||||
u.AllowedPaths = decodeStrList(pathsJSON)
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// UserUpdate 描述一次用户更新;nil 字段表示不改
|
||||
type UserUpdate struct {
|
||||
DisplayName *string
|
||||
Role *string
|
||||
Status *string
|
||||
AllowedAgents *[]string
|
||||
AllowedPaths *[]string
|
||||
}
|
||||
|
||||
func UpdateUser(ctx context.Context, id uuid.UUID, up UserUpdate) (*models.User, error) {
|
||||
var agentsJSON, pathsJSON *string
|
||||
if up.AllowedAgents != nil {
|
||||
b, _ := json.Marshal(normalizeList(*up.AllowedAgents))
|
||||
s := string(b)
|
||||
agentsJSON = &s
|
||||
}
|
||||
if up.AllowedPaths != nil {
|
||||
b, _ := json.Marshal(normalizeList(*up.AllowedPaths))
|
||||
s := string(b)
|
||||
pathsJSON = &s
|
||||
}
|
||||
|
||||
row := db.DB.QueryRowContext(ctx, `
|
||||
UPDATE users SET
|
||||
display_name = COALESCE($2, display_name),
|
||||
role = COALESCE($3, role),
|
||||
status = COALESCE($4, status),
|
||||
allowed_agents = COALESCE($5`+db.JSONCast()+`, allowed_agents),
|
||||
allowed_paths = COALESCE($6`+db.JSONCast()+`, allowed_paths)
|
||||
WHERE user_id = $1
|
||||
RETURNING `+userSelectCols,
|
||||
id, up.DisplayName, up.Role, up.Status, agentsJSON, pathsJSON)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
// normalizeList 去空白、去空项、去重,保持顺序
|
||||
func normalizeList(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
seen := map[string]bool{}
|
||||
for _, s := range in {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func SetPassword(ctx context.Context, id uuid.UUID, newPassword string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE users SET password_hash = $2 WHERE user_id = $1`, id, string(hash))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
// 改密后踢掉该用户所有会话
|
||||
_, _ = db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE user_id = $1`, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func DisableUser(ctx context.Context, id uuid.UUID) error {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE users SET status = 'disabled' WHERE user_id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
_, _ = db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE user_id = $1`, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func CountAdmins(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE role = 'admin' AND status = 'active'`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// EnsureAdminUser 首次启动时创建默认管理员(幂等)
|
||||
func EnsureAdminUser(ctx context.Context, username, password string) (*models.User, bool, error) {
|
||||
if n, err := CountAdmins(ctx); err != nil {
|
||||
return nil, false, err
|
||||
} else if n > 0 {
|
||||
u, err := GetUserByName(ctx, username)
|
||||
if err != nil && !errors.Is(err, ErrUserNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
return u, false, nil
|
||||
}
|
||||
u, err := CreateUser(ctx, username, password, "管理员", "admin", nil, nil)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return u, true, nil
|
||||
}
|
||||
|
||||
// ---------- 登录 / 会话令牌 ----------
|
||||
|
||||
func Authenticate(ctx context.Context, username, password string) (*models.User, error) {
|
||||
u, err := GetUserByName(ctx, username)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
// 统一错误,避免暴露用户是否存在
|
||||
return nil, ErrBadCredentials
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if u.Status != "active" {
|
||||
return nil, ErrUserDisabled
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
|
||||
return nil, ErrBadCredentials
|
||||
}
|
||||
_, _ = db.DB.ExecContext(ctx, `UPDATE users SET last_login = NOW() WHERE user_id = $1`, u.ID)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func newToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func CreateUserSession(ctx context.Context, userID uuid.UUID, userAgent string) (string, time.Time, error) {
|
||||
token, err := newToken()
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
expires := time.Now().Add(sessionTTL)
|
||||
if len(userAgent) > 256 {
|
||||
userAgent = userAgent[:256]
|
||||
}
|
||||
_, err = db.DB.ExecContext(ctx, `
|
||||
INSERT INTO user_sessions (token, user_id, expires_at, user_agent)
|
||||
VALUES ($1, $2, $3, $4)`, token, userID, expires, userAgent)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
// 顺手清理过期令牌
|
||||
_, _ = db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE expires_at < NOW()`)
|
||||
return token, expires, nil
|
||||
}
|
||||
|
||||
// ResolveUserSession 校验令牌并滑动续期
|
||||
func ResolveUserSession(ctx context.Context, token string) (*models.User, error) {
|
||||
if token == "" {
|
||||
return nil, ErrSessionInvalid
|
||||
}
|
||||
var userID uuid.UUID
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT user_id FROM user_sessions
|
||||
WHERE token = $1 AND expires_at > NOW()`, token).Scan(&userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSessionInvalid
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u, err := GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Status != "active" {
|
||||
return nil, ErrUserDisabled
|
||||
}
|
||||
|
||||
_, _ = db.DB.ExecContext(ctx,
|
||||
`UPDATE user_sessions SET expires_at = $2 WHERE token = $1`,
|
||||
token, time.Now().Add(sessionTTL))
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func DeleteUserSession(ctx context.Context, token string) error {
|
||||
_, err := db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE token = $1`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- 人类用户候选(供地址补全) ----------
|
||||
|
||||
func ListActiveUsernames(ctx context.Context) ([]string, error) {
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT username FROM users WHERE status = 'active' ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []string{}
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err == nil {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- 会话归属 ----------
|
||||
|
||||
func SetSessionOwner(ctx context.Context, sessionID, userID uuid.UUID) error {
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET owner_user_id = $2 WHERE session_id = $1 AND owner_user_id IS NULL`,
|
||||
sessionID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SessionOwnerUsername 返回会话归属人类用户名;无归属时返回空串
|
||||
func SessionOwnerUsername(ctx context.Context, sessionID uuid.UUID) (string, error) {
|
||||
var name *string
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT u.username
|
||||
FROM sessions s LEFT JOIN users u ON u.user_id = s.owner_user_id
|
||||
WHERE s.session_id = $1`, sessionID).Scan(&name)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fmt.Errorf("session %s not found", sessionID)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if name == nil {
|
||||
return "", nil
|
||||
}
|
||||
return *name, nil
|
||||
}
|
||||
|
||||
// UserCanAccessSession 判断用户能否访问该会话:owner、或在邮件收发/抄送中出现,或 admin
|
||||
func UserCanAccessSession(ctx context.Context, u *models.User, sessionID uuid.UUID) (bool, error) {
|
||||
if u.IsAdmin() {
|
||||
return true, nil
|
||||
}
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM sessions s
|
||||
WHERE s.session_id = $1
|
||||
AND (s.owner_user_id = $2
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM mails m
|
||||
WHERE m.session_id = s.session_id
|
||||
AND (m.from_name = $3 OR m.to_name = $3
|
||||
OR `+db.CCHas("m.cc_list", 3)+`)
|
||||
))
|
||||
`, sessionID, u.ID, u.Username).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// RandomPassword 生成一个随机初始密码(首次启动无 ADMIN_PASSWORD 时使用)
|
||||
func RandomPassword(n int) string {
|
||||
const charset = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "ChangeMe" + fmt.Sprint(time.Now().Unix())
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = charset[int(b[i])%len(charset)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ---------- Setup(首次初始化管理员) ----------
|
||||
|
||||
// NeedsSetup 返回系统是否尚未初始化(没有任何用户)
|
||||
func NeedsSetup(ctx context.Context) (bool, error) {
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
|
||||
return n == 0, err
|
||||
}
|
||||
|
||||
// SetupFirstAdmin 在系统尚无任何用户时创建首个管理员。
|
||||
// 已初始化时返回 ErrAlreadySetup,避免被用作后门。
|
||||
func SetupFirstAdmin(ctx context.Context, username, password, displayName string) (*models.User, error) {
|
||||
empty, err := NeedsSetup(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !empty {
|
||||
return nil, ErrAlreadySetup
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = username
|
||||
}
|
||||
return CreateUser(ctx, username, password, displayName, "admin", nil, nil)
|
||||
}
|
||||
|
||||
// ---------- 可选目录候选(供权限设置界面) ----------
|
||||
|
||||
// AllWorkspaceNames 汇总所有 Agent 注册过的工作区名,供管理员挑选可访问目录
|
||||
func AllWorkspaceNames(ctx context.Context) ([]string, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT DISTINCT ws->>'name' AS name
|
||||
FROM agents, jsonb_array_elements(workspaces) AS ws
|
||||
WHERE COALESCE(ws->>'name', '') <> ''
|
||||
ORDER BY name`)
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []string{}
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err == nil {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// IsHumanUser 判断某个三维地址 name 位是否为人类用户
|
||||
func IsHumanUser(ctx context.Context, name string) (bool, error) {
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE username = $1`, name).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// NearestHumanInThread 找出该会话上「最该为它点头的人」。
|
||||
//
|
||||
// 用途:权限询问的决策者是 Agent 时的救援路径。Agent 收不到 SendToUser
|
||||
// (那是人类的 SSE 通道),桥的 await 也就永不 resolve —— 会话永久阻塞。
|
||||
// 生产事故:pi 把任务派给自己的另一条会话,那条会话要跑 bash,
|
||||
// 权限邮件发给了 "pi" 自己,整条线索卡死,只能改数据库救回来。
|
||||
//
|
||||
// 三级查找,按「谁最了解这件事」排序:
|
||||
//
|
||||
// 1. 会话 owner —— 人在界面上开的会话,归属明确
|
||||
// 2. 最近一个往这条线索里**发过信**的人类 —— 派活的人
|
||||
// 3. 最近一个作为**收件人或抄送**出现的人类 —— 至少他知道这件事在进行
|
||||
//
|
||||
// 找不到时返回空串(不是错误):调用方据此拒绝请求。这比转给一个对上下文
|
||||
// 一无所知的管理员好 —— 他既不知道这个 bash 命令在做什么,
|
||||
// 也不知道拒绝之后 Agent 该怎么绕过去。
|
||||
//
|
||||
// skipSelf 是发起询问的 Agent 名,永不作为决策者返回:它正是被卡住的那一方。
|
||||
// 名字与人类用户名共用命名空间,所以这里也顺手挡住「Agent 名恰好等于某人类名」
|
||||
// 这种配置错误。
|
||||
func NearestHumanInThread(ctx context.Context, sessionID uuid.UUID, skipSelf string) (string, error) {
|
||||
// 1. 会话 owner
|
||||
if owner, err := SessionOwnerUsername(ctx, sessionID); err == nil && owner != "" && owner != skipSelf {
|
||||
return owner, nil
|
||||
}
|
||||
|
||||
// 2/3. 扫这条会话的邮件。发件人优先于收件人/抄送方:
|
||||
// 发过信的人是主动参与者,被抄送的人可能只是旁观。
|
||||
//
|
||||
// 不用递归 CTE 沿 parent_mail_id 上溯:调用方只持有 session_id,
|
||||
// 没有触发询问的那封锚点邮件,所谓「链」的起点本来就得靠猜。
|
||||
// 而按会话扫还能覆盖分叉分支与断链(父邮件被删)的情形。
|
||||
//
|
||||
// **必须先把行读完再判定人类身份**:SQLite 连接池在测试与单文件库下
|
||||
// 常常只有一条连接,rows 未关闭时再发一条查询会自我死锁(实测挂死 60s)。
|
||||
type participants struct {
|
||||
from string
|
||||
others []string
|
||||
}
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT m.from_name, m.to_name, m.cc_list
|
||||
FROM mails m
|
||||
WHERE m.session_id = $1
|
||||
ORDER BY m.created_at DESC, m.mail_id DESC
|
||||
`, sessionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var scanned []participants
|
||||
for rows.Next() {
|
||||
var from, to string
|
||||
var ccRaw []byte
|
||||
if err := rows.Scan(&from, &to, &ccRaw); err != nil {
|
||||
rows.Close()
|
||||
return "", err
|
||||
}
|
||||
scanned = append(scanned, participants{
|
||||
from: from,
|
||||
others: append([]string{to}, ccNames(ccRaw)...),
|
||||
})
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 人类身份查询结果缓存:一条会话里同一个名字会出现很多次
|
||||
human := map[string]bool{}
|
||||
isHuman := func(name string) (bool, error) {
|
||||
if v, ok := human[name]; ok {
|
||||
return v, nil
|
||||
}
|
||||
v, err := IsHumanUser(ctx, name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
human[name] = v
|
||||
return v, nil
|
||||
}
|
||||
|
||||
var fallback string // 收件人/抄送里的人类,仅在没有人类发件人时使用
|
||||
for _, p := range scanned {
|
||||
if p.from != skipSelf {
|
||||
if ok, err := isHuman(p.from); err != nil {
|
||||
return "", err
|
||||
} else if ok {
|
||||
return p.from, nil // 最近的人类发件人,直接定案
|
||||
}
|
||||
}
|
||||
if fallback != "" {
|
||||
continue
|
||||
}
|
||||
for _, cand := range p.others {
|
||||
if cand == "" || cand == skipSelf {
|
||||
continue
|
||||
}
|
||||
if ok, err := isHuman(cand); err != nil {
|
||||
return "", err
|
||||
} else if ok {
|
||||
fallback = cand
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
// ccNames 从 cc_list 的 JSON 里取出 name 位。
|
||||
//
|
||||
// 解析失败返回空切片而不是报错:抄送列表读不出来只该让这一封少几个候选,
|
||||
// 不该让整个决策者查找失败 —— 那会把「会话卡死」换成「权限请求 500」。
|
||||
func ccNames(raw []byte) []string {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var list []models.Address
|
||||
if err := json.Unmarshal(raw, &list); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for _, a := range list {
|
||||
if a.Name != "" {
|
||||
out = append(out, a.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
195
server/internal/repo/usersession_time_test.go
Normal file
195
server/internal/repo/usersession_time_test.go
Normal file
@ -0,0 +1,195 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
/**
|
||||
* 会话令牌的时间比较。
|
||||
*
|
||||
* 这些用例锁的是一个真实的安全 bug:SQLite 驱动默认把 time.Time 写成
|
||||
* Go 的 t.String()("2026-09-10 15:10:36.12 +0800 HKT m=+607182.88"),
|
||||
* 而 NOW() 返回 "2006-01-02 15:04:05.000000" 的 UTC 串。
|
||||
* 两者做字符串比较时 'T'/'+' 与数字的字典序、以及 +0800 与 UTC 的偏移
|
||||
* 双重错位,结果是:
|
||||
* - expires_at > NOW() 恒为真 → 令牌永不过期
|
||||
* - DELETE WHERE expires_at < NOW() 删 0 行 → 过期令牌永久堆积
|
||||
* 生产库实测两条 user_sessions 都是这个状态。
|
||||
*
|
||||
* 修法在 db.sqliteDSN 的 _time_format=sqlite&_timezone=UTC,
|
||||
* 所以这里必须用真实的 db.Connect 路径来验(setupTestDB 就是)。
|
||||
*/
|
||||
|
||||
func seedUserForSession(t *testing.T, ctx context.Context, username string) uuid.UUID {
|
||||
t.Helper()
|
||||
id := uuid.New()
|
||||
_, err := db.DB.ExecContext(ctx, `
|
||||
INSERT INTO users (user_id, username, password_hash, display_name, role, status)
|
||||
VALUES ($1, $2, 'x', $2, 'user', 'active')`, id, username)
|
||||
if err != nil {
|
||||
t.Fatalf("seed user %s: %v", username, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// 存进去的时间必须能被 SQLite 的时间函数解析。
|
||||
// datetime() 返回 NULL 意味着所有 SQL 侧时间运算(过期判断、日历到点判断)全废。
|
||||
func TestTimeBindingIsParsableBySQLite(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
uid := seedUserForSession(t, ctx, "alice")
|
||||
|
||||
if _, _, err := CreateUserSession(ctx, uid, "probe"); err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
|
||||
var bad int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM user_sessions WHERE datetime(expires_at) IS NULL`).Scan(&bad)
|
||||
if err != nil {
|
||||
t.Fatalf("probe: %v", err)
|
||||
}
|
||||
if bad != 0 {
|
||||
var raw string
|
||||
db.DB.QueryRowContext(ctx,
|
||||
`SELECT CAST(expires_at AS TEXT) FROM user_sessions LIMIT 1`).Scan(&raw)
|
||||
t.Fatalf("expires_at 无法被 datetime() 解析(存储为 %q)——"+
|
||||
"所有 SQL 侧时间比较都会静默失效", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// 新令牌必须被认作有效,且滑动续期后仍然有效。
|
||||
func TestFreshSessionResolves(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
uid := seedUserForSession(t, ctx, "bob")
|
||||
|
||||
token, expires, err := CreateUserSession(ctx, uid, "ua")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if !expires.After(time.Now()) {
|
||||
t.Fatalf("新令牌的过期时间应在将来,得到 %v", expires)
|
||||
}
|
||||
|
||||
u, err := ResolveUserSession(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if u.Username != "bob" {
|
||||
t.Fatalf("解析出的用户应为 bob,得到 %q", u.Username)
|
||||
}
|
||||
|
||||
// 滑动续期写回的也是 time.Time,格式同样必须正确
|
||||
var bad int
|
||||
db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM user_sessions WHERE datetime(expires_at) IS NULL`).Scan(&bad)
|
||||
if bad != 0 {
|
||||
t.Fatal("滑动续期写回的 expires_at 格式不可解析")
|
||||
}
|
||||
}
|
||||
|
||||
// 过期令牌必须被拒。这是 bug 的核心症状:格式错位时 expires_at > NOW()
|
||||
// 恒为真,一个 2020 年就过期的令牌照样能登录。
|
||||
func TestExpiredSessionRejected(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
uid := seedUserForSession(t, ctx, "carol")
|
||||
|
||||
token, _, err := CreateUserSession(ctx, uid, "ua")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// 手工把过期时间推到过去(走同一条 time.Time 绑定路径)
|
||||
past := time.Now().Add(-1 * time.Hour)
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE user_sessions SET expires_at = $2 WHERE token = $1`, token, past); err != nil {
|
||||
t.Fatalf("backdate: %v", err)
|
||||
}
|
||||
|
||||
if _, err := ResolveUserSession(ctx, token); err == nil {
|
||||
var raw string
|
||||
db.DB.QueryRowContext(ctx,
|
||||
`SELECT CAST(expires_at AS TEXT) FROM user_sessions WHERE token=$1`, token).Scan(&raw)
|
||||
t.Fatalf("过期令牌竟然解析成功(expires_at=%q)—— 会话永不过期", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// 顺手清理必须真的删掉过期行。删 0 行意味着 user_sessions 无限增长,
|
||||
// 且被盗令牌永远有效。
|
||||
func TestExpiredSessionsGetPurged(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
uid := seedUserForSession(t, ctx, "dave")
|
||||
|
||||
stale, _, err := CreateUserSession(ctx, uid, "ua")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
past := time.Now().Add(-48 * time.Hour)
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE user_sessions SET expires_at = $2 WHERE token = $1`, stale, past); err != nil {
|
||||
t.Fatalf("backdate: %v", err)
|
||||
}
|
||||
|
||||
// 再建一个会话,CreateUserSession 内部会顺手清理过期令牌
|
||||
if _, _, err := CreateUserSession(ctx, uid, "ua2"); err != nil {
|
||||
t.Fatalf("second create: %v", err)
|
||||
}
|
||||
|
||||
var n int
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM user_sessions WHERE token = $1`, stale).Scan(&n); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatal("过期令牌没被清理 —— DELETE ... WHERE expires_at < NOW() 匹配不到行")
|
||||
}
|
||||
|
||||
// 没过期的那条不能被误删
|
||||
var alive int
|
||||
db.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_sessions`).Scan(&alive)
|
||||
if alive != 1 {
|
||||
t.Fatalf("应剩 1 条有效会话,得到 %d", alive)
|
||||
}
|
||||
}
|
||||
|
||||
// 时间能原样读回,且亚秒精度不丢。
|
||||
//
|
||||
// 精度是有代价地保住的:_time_format=datetime 也能让 datetime() 正常工作,
|
||||
// 但它把时间截断到秒 —— 同秒插入的多封邮件排序就不确定,
|
||||
// 「会话里最早那封」(决定联系人身份)会取错行。
|
||||
func TestTimeRoundTripKeepsSubSecond(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`INSERT INTO agents (agent_name, secret, platform) VALUES ('probe','x','test')`)
|
||||
if err != nil {
|
||||
t.Fatalf("seed agent: %v", err)
|
||||
}
|
||||
|
||||
want := time.Date(2026, 9, 3, 8, 45, 38, 123456000, time.UTC)
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agents SET last_seen = $1 WHERE agent_name = 'probe'`, want); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
|
||||
var got time.Time
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT last_seen FROM agents WHERE agent_name = 'probe'`).Scan(&got); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
if !got.UTC().Equal(want) {
|
||||
t.Fatalf("时间往返不一致:写入 %v,读回 %v", want, got.UTC())
|
||||
}
|
||||
if got.UTC().Nanosecond() == 0 {
|
||||
t.Fatal("亚秒精度被截断 —— 同秒插入的多行排序会不确定")
|
||||
}
|
||||
}
|
||||
394
server/internal/scheduler/calendar.go
Normal file
394
server/internal/scheduler/calendar.go
Normal file
@ -0,0 +1,394 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/notify"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CalendarScheduler 定时扫描日历事件,触发到期提醒。
|
||||
//
|
||||
// 设计参照 Outlook 的 Exchange 提醒器:
|
||||
// - 每 30 秒扫描一次(精度到分钟够用,不需要秒级)
|
||||
// - 到期事件 → 生成一封提醒邮件 → 投递
|
||||
// - 重复事件自动推进到下一次
|
||||
// - 幂等:last_fired_at 保证同一分钟不触发两次
|
||||
//
|
||||
// 为什么用进程内 goroutine 而不是 cron:调度器与 Gateway 同生命周期,
|
||||
// 不需要外部依赖,也不需要第二套「谁在跑」的信任模型。
|
||||
var (
|
||||
schedMu sync.Mutex
|
||||
schedStop chan struct{}
|
||||
schedWg sync.WaitGroup
|
||||
)
|
||||
|
||||
// Start 启动日历调度器。重复调用安全(先停旧的)。
|
||||
func Start() {
|
||||
Stop()
|
||||
|
||||
schedMu.Lock()
|
||||
defer schedMu.Unlock()
|
||||
|
||||
schedStop = make(chan struct{})
|
||||
stop := schedStop
|
||||
schedWg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer schedWg.Done()
|
||||
runLoop(stop)
|
||||
}()
|
||||
|
||||
log.Printf("[scheduler] 日历调度器已启动(30s 周期)")
|
||||
}
|
||||
|
||||
// Stop 停止调度器,等待当前扫描完成。
|
||||
func Stop() {
|
||||
schedMu.Lock()
|
||||
defer schedMu.Unlock()
|
||||
|
||||
if schedStop != nil {
|
||||
close(schedStop)
|
||||
schedWg.Wait()
|
||||
schedStop = nil
|
||||
log.Printf("[scheduler] 日历调度器已停止")
|
||||
}
|
||||
}
|
||||
|
||||
func runLoop(stop chan struct{}) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// 启动时先扫一次:进程重启期间到期的事件不该被跳过
|
||||
scanAndFire()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
scanAndFire()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scanAndFire 扫一轮到期事件。
|
||||
//
|
||||
// **全程不得 panic 出去**:它跑在后台 goroutine 里,而 Go 的 goroutine panic
|
||||
// 会直接结束整个进程 —— 一条写坏的提醒把邮件网关整个带倒是荒谬的代价。
|
||||
func scanAndFire() {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
log.Printf("[scheduler] 扫描崩溃(已捕获,下一轮重试): %v", rec)
|
||||
}
|
||||
}()
|
||||
|
||||
// DB 未就绪就什么都不做。
|
||||
//
|
||||
// 调度器的启动时机比它看起来脆弱:main 里它排在 migrate 之后,
|
||||
// 但 Start() 是个导出函数,测试与将来的调用方都可能在建库前调到它。
|
||||
// 而 nil *sql.DB 上调 QueryContext 是空指针解引用,不是一个 error。
|
||||
if db.DB == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
events, err := repo.DueEvents(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] 查询到期事件失败: %v", err)
|
||||
return
|
||||
}
|
||||
for _, e := range events {
|
||||
fireEvent(ctx, e)
|
||||
}
|
||||
}
|
||||
|
||||
// RenderReminder 把提醒模板里的变量替换成事件的实际内容。
|
||||
//
|
||||
// 单独提出来是为了可测:模板渲染错了不会立刻报错,
|
||||
// 只会让 Agent 收到一封写着 `{title}` 的邮件。
|
||||
//
|
||||
// **{time} 必须转本地时区再格式化。**
|
||||
// DSN 带 `_timezone=UTC`,从库里读回的 EventTime 是 UTC;直接 Format
|
||||
// 会把人在 +0800 输入的 14:30 写成 06:30,而前端预览用的是本地时间
|
||||
// —— 于是预览显示 14:30、Agent 收到 06:30,两边差 8 小时且两边都不报错。
|
||||
// 日历是给人看的,人说「下午两点半」指的就是自己时区的那个时刻。
|
||||
func RenderReminder(tmpl string, e models.CalendarEvent) string {
|
||||
body := tmpl
|
||||
body = strings.ReplaceAll(body, "{title}", e.Title)
|
||||
body = strings.ReplaceAll(body, "{time}", e.EventTime.Local().Format("2006-01-02 15:04"))
|
||||
body = strings.ReplaceAll(body, "{description}", e.Description)
|
||||
return body
|
||||
}
|
||||
|
||||
// fireEvent 触发一条事件:渲染提醒文本、投递邮件、推进重复。
|
||||
//
|
||||
// 顺序很重要:**先标记已触发再发信**。
|
||||
// 反过来的话,发信成功但标记失败会让下一轮再发一遍 ——
|
||||
// 提醒邮件重复比漏发更糟(Agent 会把同一件事做两次)。
|
||||
func fireEvent(ctx context.Context, e models.CalendarEvent) {
|
||||
// 单条事件崩溃不得让同一轮里剩下的提醒全部落空。
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
log.Printf("[scheduler] 事件 %s 触发崩溃(已捕获): %v", short(e.EventID), rec)
|
||||
}
|
||||
}()
|
||||
|
||||
// 收件人:Recipients 优先,为空时退回 to_address / agent_name(旧数据)
|
||||
recipients := e.EffectiveRecipients()
|
||||
if len(recipients) == 0 {
|
||||
log.Printf("[scheduler] 事件 %s 没有收件人,跳过(title=%q)", short(e.EventID), e.Title)
|
||||
// 没有收件人的事件永远发不出去,标记已触发免得每 30 秒重试一次
|
||||
_ = repo.MarkEventFired(ctx, e.EventID)
|
||||
return
|
||||
}
|
||||
|
||||
if err := repo.MarkEventFired(ctx, e.EventID); err != nil {
|
||||
log.Printf("[scheduler] 标记事件 %s 已触发失败,本轮不发信: %v", short(e.EventID), err)
|
||||
return
|
||||
}
|
||||
|
||||
body := RenderReminder(e.ReminderText, e)
|
||||
subject := "日程提醒:" + e.Title
|
||||
|
||||
switch e.EffectiveDeliveryMode() {
|
||||
case models.DeliverTogether:
|
||||
// 一起发:首个是主收件人,其余进 cc_list —— 所有人共享同一条线索,
|
||||
// 能看到彼此的回复。适合「pi 主办、dsh 知情」这种有主次的协作。
|
||||
primary, cc := recipients[0], recipients[1:]
|
||||
if err := SendCalendarMail(ctx, e.EventID, primary, subject, body, e.CreatedBy, e.PermissionMode, cc...); err != nil {
|
||||
log.Printf("[scheduler] 投递提醒失败(%s → %s +%d抄送): %v",
|
||||
e.Title, primary, len(cc), err)
|
||||
} else {
|
||||
log.Printf("[scheduler] 已触发日历提醒: %s → %s(抄送 %d 人,同一线索)",
|
||||
e.Title, primary, len(cc))
|
||||
}
|
||||
|
||||
default:
|
||||
// 各发一封:每人落在自己的会话里,互相看不到。
|
||||
// 适合「让三个 Agent 各自独立汇报」—— 用 together 会让他们互相
|
||||
// 看到回复而趋同,那种上下文污染事后无法分离。
|
||||
//
|
||||
// 一个失败不影响其余:三个 Agent 里有一个离线时,
|
||||
// 另外两个仍该收到提醒。
|
||||
ok, failed := 0, 0
|
||||
for _, addr := range recipients {
|
||||
if err := SendCalendarMail(ctx, e.EventID, addr, subject, body, e.CreatedBy, e.PermissionMode); err != nil {
|
||||
failed++
|
||||
log.Printf("[scheduler] 投递提醒失败(%s → %s): %v", e.Title, addr, err)
|
||||
continue
|
||||
}
|
||||
ok++
|
||||
}
|
||||
if failed == 0 {
|
||||
log.Printf("[scheduler] 已触发日历提醒: %s → %d 个收件人(各自独立会话)",
|
||||
e.Title, ok)
|
||||
} else {
|
||||
log.Printf("[scheduler] 日历提醒 %s:%d 成功 / %d 失败", e.Title, ok, failed)
|
||||
}
|
||||
}
|
||||
|
||||
// 重复事件推进到下一次;一次性事件到此结束
|
||||
if e.Recurrence != models.RecurNone {
|
||||
advanced, err := repo.AdvanceRecurrence(ctx, e.EventID)
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] 推进重复事件 %s 失败: %v", short(e.EventID), err)
|
||||
} else if !advanced {
|
||||
log.Printf("[scheduler] 重复事件 %s 已过 recurrence_end 或无法推进,已置为 cancelled",
|
||||
short(e.EventID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func short(id string) string {
|
||||
if len(id) > 8 {
|
||||
return id[:8]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// SendCalendarMail 把一条提醒投成邮件。
|
||||
//
|
||||
// 直接走 repo + sse 而不是自己发一个 HTTP 请求到 /mail/send:
|
||||
// 调度器是进程内 goroutine,绕出去再进来只是多一次鉴权与序列化。
|
||||
//
|
||||
// **日历提醒不扣会话预算**:预算的语义是「这件事值得模型自主发多少封信」,
|
||||
// 而提醒是人预先设定的定时任务,不是模型的自主行为。让它扣预算会出现
|
||||
// 「每天 9 点的日报提醒把当天的预算吃掉一格」这种反直觉结果。
|
||||
//
|
||||
// 收件地址支持完整三维寻址:
|
||||
// - `agent` → 该 Agent 的默认会话(长期提醒应该用这个,
|
||||
// 所有提醒落在同一条线索上,模型看得到历史)
|
||||
// - `agent@/path` → 指定工作目录的默认会话
|
||||
// - `agent@/path.alias` → 指定已存在的会话(不存在则报错,不静默新建)
|
||||
// - `agent@/path.new` → 每次提醒开一条新会话(适合互不相关的一次性任务)
|
||||
func SendCalendarMail(ctx context.Context, eventID, toAddr, subject, body, createdBy, permMode string, ccAddrs ...string) error {
|
||||
addr, err := models.ParseAddress(toAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("收件地址 %q 无法解析: %w", toAddr, err)
|
||||
}
|
||||
|
||||
// 抄送方逐个解析。单个解析失败只跳过它,不让整封信发不出去 ——
|
||||
// 主收件人能收到提醒比「抄送名单必须完整」重要。
|
||||
ccList := make([]models.Address, 0, len(ccAddrs))
|
||||
for _, raw := range ccAddrs {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
ca, cErr := models.ParseAddress(raw)
|
||||
if cErr != nil {
|
||||
log.Printf("[scheduler] 抄送地址 %q 无法解析,已跳过: %v", raw, cErr)
|
||||
continue
|
||||
}
|
||||
// 抄送方的 session 位不参与会话定位(那是主收件人的事),
|
||||
// 但必须保留在地址里:cc_list 的 name/path 决定谁能看到这条线索。
|
||||
ccList = append(ccList, ca)
|
||||
}
|
||||
|
||||
sessionID, err := resolveCalendarSession(ctx, addr, subject, permMode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 人建的日程 → 把他设为会话 owner。
|
||||
//
|
||||
// 为什么必须设:权限询问的决策人解析是「会话 owner → 线索里最近的人类 → 409」。
|
||||
// 日历提醒的发件人是 `calendar`(不是人也不是 Agent),所以一旦 Agent 在
|
||||
// 这条会话里要跑需要授权的命令,线索上根本找不到人类 —— 而那个日程
|
||||
// 就是人自己在界面上设的,他当然是合理的决策人。
|
||||
//
|
||||
// 不设的后果(删掉管理员兜底之后暴露):人建的提醒触发后,Agent 的权限询问
|
||||
// 直接得 409「这条链上没有人类」。
|
||||
//
|
||||
// created_by 是 Agent(Agent 自己建的日程)时 owner 保持为空 ——
|
||||
// 那条链上确实没有人类,409 是对的。
|
||||
if u, uErr := repo.GetUserByName(ctx, createdBy); uErr == nil && u != nil {
|
||||
_ = repo.SetSessionOwner(ctx, sessionID, u.ID)
|
||||
}
|
||||
|
||||
// 发件人固定为 calendar:它不是任何一个 Agent,也不是人。
|
||||
// 用创建者的名字会让 Agent 以为人在实时找它,而人此刻可能在睡觉 ——
|
||||
// 模型据此判断「要不要马上追问」,来源写错会让它问一个不在线的人。
|
||||
mailID, err := repo.CreateMail(ctx, sessionID, nil,
|
||||
calendarSender, "", addr.Name, addr.Path, subject, body, ccList)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 事件附件复制成邮件附件。失败不阻断投递:提醒本身(正文)比附件重要得多,
|
||||
// 少一个附件也比整条提醒发不出去好 —— 后者会让人以为定时任务坏了。
|
||||
if eventID != "" {
|
||||
if n, aErr := repo.AttachCalendarFilesToMail(ctx, eventID, mailID, calendarSender); aErr != nil {
|
||||
log.Printf("[scheduler] 事件 %s 的附件挂载失败(提醒仍已发出): %v", short(eventID), aErr)
|
||||
} else if n > 0 {
|
||||
log.Printf("[scheduler] 事件 %s 随提醒带了 %d 个附件", short(eventID), n)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 推送 SSE ───
|
||||
//
|
||||
// 这是整个系统里**唯一**的推送实现(handler 那条路径也是 notify.Recipients)。
|
||||
// 原来这里自己拼了一整份 payload(handler 里是另一份),
|
||||
// 加 `platform_session_id` 时只改了那边 → 日历提醒投进接管会话时
|
||||
// 插件不知道是接管、另开了一条新 pi 会话 → 命名同步把接管会话的别名冲掉
|
||||
// → 人在补全里选的「项目定位」变成了「日程提醒:…」→ 选哪条都落进同一条。
|
||||
// 根因只是「同一件事写了两遍」。
|
||||
//
|
||||
// ReplyToName = addr.Name:日历提醒的回信要落回那条线索,不是回给 calendar
|
||||
// Origin = "calendar":让插件与 UI 能区分「这封是定时提醒」
|
||||
notify.Recipients(ctx, notify.Mail{
|
||||
SessionID: sessionID,
|
||||
MailID: mailID,
|
||||
From: calendarSender,
|
||||
To: addr,
|
||||
CC: ccList,
|
||||
Subject: subject,
|
||||
MailType: "normal",
|
||||
Origin: "calendar",
|
||||
ReplyToName: addr.Name,
|
||||
})
|
||||
|
||||
// 创建者不是收件方,不在 Recipients 的参与方去重里 —— 他不会收到
|
||||
// new_mail(他不该被「有人在找你」打扰),但他应该看到会话活跃起来:
|
||||
// 「我设的提醒到底触发了没有」不应该只能去翻 journalctl。
|
||||
notify.SessionActive(createdBy, sessionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// calendarSender 是提醒邮件的发件人名。
|
||||
//
|
||||
// 刻意不是人类用户名也不是 Agent 名:这样收件方一眼能看出这封信来自定时任务,
|
||||
// 而 `from_name` 又不会与命名空间里任何真实账号冲突。
|
||||
const calendarSender = "calendar"
|
||||
|
||||
// resolveCalendarSession 按地址的 session 位定位会话。
|
||||
//
|
||||
// 与 handler.resolveTarget 同一套三态语义,但**不受新建会话速率限制**:
|
||||
// 那条限制是防 Agent 暴开线索的,而日历事件的数量由人在界面上决定。
|
||||
//
|
||||
// 档位规则(见 PLAN 7.11 P1):
|
||||
// - 新建会话 → 用事件档位定死(permMode,已规范化)
|
||||
// - 复用已有会话 → ModeAtMost(会话现档, 事件档),取更严,
|
||||
// 不允许因复用而提权(plan 档 Agent 建的日程触发时拿 workspace 就绕开了 plan)
|
||||
func resolveCalendarSession(ctx context.Context, addr models.Address, subject, permMode string) (uuid.UUID, error) {
|
||||
// permMode 由调用方已规范化过,这里再保一次(直接调用本函数的路径上该一样)
|
||||
eventMode := models.NormalizePermissionMode(permMode)
|
||||
apply := func(sessionID uuid.UUID, created bool) {
|
||||
if created {
|
||||
_, _ = repo.SetSessionPermissionMode(ctx, sessionID, eventMode)
|
||||
_ = repo.SetSessionEnforcement(ctx, sessionID, repo.AgentModeEnforcement(ctx, addr.Name))
|
||||
return
|
||||
}
|
||||
// 复用已有会话:取更严。ModeAtMost 已判过会话现档与事件档,取严的那个。
|
||||
cur := repo.SessionPermissionMode(ctx, sessionID)
|
||||
merged := models.ModeAtMost(cur, eventMode)
|
||||
if merged != cur {
|
||||
_, _ = repo.SetSessionPermissionMode(ctx, sessionID, merged)
|
||||
}
|
||||
}
|
||||
switch addr.Mode() {
|
||||
case models.SessionNew:
|
||||
id, err := repo.CreateSession(ctx, nil, calendarSender, subject, addr.Path)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
// 与发信路径一致:`.new` 建完必须立刻有别名,否则这条会话
|
||||
// 除了回复那一封之外再也无法寻址(未命名会话查不到也补全不出来)。
|
||||
_, _ = repo.EnsureSessionAlias(ctx, id, repo.AutoAliasFor(addr.Name, subject))
|
||||
apply(id, true)
|
||||
return id, nil
|
||||
|
||||
case models.SessionNamed:
|
||||
id, err := repo.FindNamedSessionFor(ctx, addr.Name, addr.Path, addr.Session)
|
||||
if errors.Is(err, repo.ErrSessionNotFound) {
|
||||
// 不静默新建:别名指向不存在的会话时报错,与人发信时的语义一致。
|
||||
// 静默新建会让「提醒发到哪去了」变成一个查不清的问题。
|
||||
return uuid.Nil, fmt.Errorf("会话 %q 不存在于 %s@%s", addr.Session, addr.Name, addr.Path)
|
||||
}
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
repo.TouchSession(ctx, id)
|
||||
apply(id, false)
|
||||
return id, nil
|
||||
|
||||
default: // SessionDefault
|
||||
id, created, err := repo.FindOrCreateDefaultSessionCreated(ctx, addr.Name, addr.Path, calendarSender, subject)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
_, _ = repo.EnsureSessionAlias(ctx, id, repo.AutoAliasFor(addr.Name, subject))
|
||||
apply(id, created)
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
111
server/internal/scheduler/calendar_test.go
Normal file
111
server/internal/scheduler/calendar_test.go
Normal file
@ -0,0 +1,111 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
)
|
||||
|
||||
func TestRenderReminder(t *testing.T) {
|
||||
at := time.Date(2026, 9, 4, 9, 30, 0, 0, time.Local)
|
||||
e := models.CalendarEvent{
|
||||
Title: "每日站会",
|
||||
Description: "同步昨天进展与今天计划",
|
||||
EventTime: at,
|
||||
}
|
||||
|
||||
t.Run("三个变量都替换", func(t *testing.T) {
|
||||
got := RenderReminder("日程提醒:{title}\n时间:{time}\n{description}", e)
|
||||
for _, want := range []string{"每日站会", "2026-09-04 09:30", "同步昨天进展与今天计划"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("渲染结果缺 %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "{") {
|
||||
t.Errorf("仍有未替换的占位符:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("同一变量出现多次全部替换", func(t *testing.T) {
|
||||
// ReplaceAll 而非 Replace:模板里写两遍 {title} 时
|
||||
// 只替换第一处会让 Agent 收到一封半成品邮件。
|
||||
got := RenderReminder("{title} —— 请开始 {title}", e)
|
||||
if strings.Contains(got, "{title}") {
|
||||
t.Errorf("第二处 {title} 未替换:%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空模板不产生占位符残留", func(t *testing.T) {
|
||||
if got := RenderReminder("", e); got != "" {
|
||||
t.Errorf("空模板应渲染成空串,得到 %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("没有变量的模板原样返回", func(t *testing.T) {
|
||||
const plain = "该跑测试了"
|
||||
if got := RenderReminder(plain, e); got != plain {
|
||||
t.Errorf("纯文本模板应原样返回,得到 %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("描述为空时不留下空行以外的痕迹", func(t *testing.T) {
|
||||
e2 := e
|
||||
e2.Description = ""
|
||||
got := RenderReminder("{title}|{description}|", e2)
|
||||
if got != "每日站会||" {
|
||||
t.Errorf("空描述应替换成空串,得到 %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
// {time} 必须是本地时间。DSN 带 _timezone=UTC,从库里读回的 EventTime
|
||||
// 是 UTC;不转本地就会把人在 +0800 输入的 14:30 写成 06:30,
|
||||
// 而前端预览用的是本地时间 —— 两边差 8 小时且都不报错。
|
||||
t.Run("time 用本地时区而非 UTC", func(t *testing.T) {
|
||||
// 刻意构造一个 UTC 时刻(模拟从库里 Scan 出来的样子)
|
||||
utcEvent := models.CalendarEvent{
|
||||
Title: "跨时区检查",
|
||||
EventTime: time.Date(2026, 9, 3, 6, 30, 0, 0, time.UTC),
|
||||
}
|
||||
got := RenderReminder("{time}", utcEvent)
|
||||
want := time.Date(2026, 9, 3, 6, 30, 0, 0, time.UTC).Local().Format("2006-01-02 15:04")
|
||||
if got != want {
|
||||
t.Errorf("{time} = %q,期望本地时间 %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
// 同一时刻无论以哪个时区的 Location 传进来,渲染结果必须一致 ——
|
||||
// 它代表的是「墙上时钟的那一刻」,与 Location 的表示方式无关。
|
||||
t.Run("同一时刻不同 Location 渲染一致", func(t *testing.T) {
|
||||
base := time.Date(2026, 9, 3, 6, 30, 0, 0, time.UTC)
|
||||
a := RenderReminder("{time}", models.CalendarEvent{EventTime: base})
|
||||
b := RenderReminder("{time}", models.CalendarEvent{EventTime: base.Local()})
|
||||
if a != b {
|
||||
t.Errorf("UTC 与 Local 表示同一时刻却渲染出不同结果:%q vs %q", a, b)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestShort(t *testing.T) {
|
||||
// 日志里截前 8 位;短 id(测试里可能出现)不能 panic
|
||||
if got := short("0123456789abcdef"); got != "01234567" {
|
||||
t.Errorf("长 id 应截断成 8 位,得到 %q", got)
|
||||
}
|
||||
if got := short("abc"); got != "abc" {
|
||||
t.Errorf("短 id 应原样返回,得到 %q", got)
|
||||
}
|
||||
if got := short(""); got != "" {
|
||||
t.Errorf("空串应原样返回,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartStopIdempotent(t *testing.T) {
|
||||
// Stop 在没启动时被调(defer 里必然发生)不该 panic;
|
||||
// Start 两次也不该泄漏 goroutine(第二次先停旧的)。
|
||||
Stop()
|
||||
Start()
|
||||
Start()
|
||||
Stop()
|
||||
Stop()
|
||||
}
|
||||
108
server/internal/sse/e2e_test.go
Normal file
108
server/internal/sse/e2e_test.go
Normal file
@ -0,0 +1,108 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 一个可 Flush 的 ResponseWriter,供集成测试用。
|
||||
type flushWriter struct {
|
||||
*httptest.ResponseRecorder
|
||||
flushed chan bool
|
||||
}
|
||||
|
||||
func (f *flushWriter) Flush() {
|
||||
select {
|
||||
case f.flushed <- true:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端验证:Manager 完整走一遍「事件入缓冲区 → 新连接带 Last-Event-ID 重连 → 补投」。
|
||||
// 这是生产里最关键的可靠性路径 —— 断线期间收的邮件,重连后必须能看到。
|
||||
func TestManagerReplayOnReconnect(t *testing.T) {
|
||||
m := &Manager{
|
||||
clients: make(map[string]*Client),
|
||||
eventBuffer: make(map[string]*eventRing),
|
||||
}
|
||||
|
||||
// 1) 用户 alice 发来一封信(无人连接也入缓冲区)
|
||||
m.SendToUser("alice", "new_mail", map[string]string{"mail_id": "m1"})
|
||||
m.SendToUser("alice", "new_mail", map[string]string{"mail_id": "m2"})
|
||||
m.SendToUser("alice", "new_mail", map[string]string{"mail_id": "m3"})
|
||||
|
||||
ring := m.eventBuffer["u:alice"]
|
||||
if ring == nil {
|
||||
t.Fatal("alice 的缓冲区应该已创建")
|
||||
}
|
||||
|
||||
// 2) 带 Last-Event-ID=1 重连,应补投 m2、m3(跳过 m1)
|
||||
req := httptest.NewRequest("GET", "/events/stream", nil)
|
||||
req.Header.Set("Last-Event-ID", "1")
|
||||
w := &flushWriter{httptest.NewRecorder(), make(chan bool, 10)}
|
||||
|
||||
client := m.AddClient(w, req, "", "alice")
|
||||
if client == nil {
|
||||
t.Fatal("AddClient 返回 nil(flushWriter 应支持 Flush)")
|
||||
}
|
||||
defer m.RemoveClient(client.ID)
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `"mail_id":"m2"`) {
|
||||
t.Error("重连后应补投 m2,实际 body:", body)
|
||||
}
|
||||
if !strings.Contains(body, `"mail_id":"m3"`) {
|
||||
t.Error("重连后应补投 m3,实际 body:", body)
|
||||
}
|
||||
if strings.Contains(body, `"mail_id":"m1"`) {
|
||||
t.Error("已确认的 m1 不应重放(Last-Event-ID=1),实际 body:", body)
|
||||
}
|
||||
|
||||
// 3) 连接期间新来一封信,实时推送
|
||||
m.SendToUser("alice", "new_mail", map[string]string{"mail_id": "m4"})
|
||||
body = w.Body.String()
|
||||
if !strings.Contains(body, `"mail_id":"m4"`) {
|
||||
t.Error("在线连接应实时收到 m4,实际 body:", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 序列号全局递增,两条不同事件不同 ID。
|
||||
func TestEventIDMonotonic(t *testing.T) {
|
||||
m := &Manager{
|
||||
clients: make(map[string]*Client),
|
||||
eventBuffer: make(map[string]*eventRing),
|
||||
}
|
||||
a := m.nextEventID()
|
||||
b := m.nextEventID()
|
||||
if a == b {
|
||||
t.Fatalf("两个连续事件 ID 相同: %q", a)
|
||||
}
|
||||
if a > b {
|
||||
t.Fatalf("事件 ID 应递增: %q > %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// 确保事件 ID 写进了 SSE 帧(EventSource 靠 id: 行记住位置)
|
||||
func TestSendWritesIDField(t *testing.T) {
|
||||
fw := &flushWriter{httptest.NewRecorder(), make(chan bool, 5)}
|
||||
c := &Client{
|
||||
ID: "c1",
|
||||
UserName: "alice",
|
||||
Res: fw,
|
||||
Flusher: fw,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
c.SendWithID("42", "new_mail", map[string]string{"x": "y"})
|
||||
|
||||
body := fw.Body.String()
|
||||
if !strings.Contains(body, "id: 42\n") {
|
||||
t.Error("帧里应有 id: 42 行,实际:", body)
|
||||
}
|
||||
if !strings.Contains(body, "event: new_mail") {
|
||||
t.Error("帧里应有 event: new_mail,实际:", body)
|
||||
}
|
||||
if !strings.Contains(body, `data: {"x":"y"}`) {
|
||||
t.Error("帧里应有 data,实际:", body)
|
||||
}
|
||||
}
|
||||
369
server/internal/sse/manager.go
Normal file
369
server/internal/sse/manager.go
Normal file
@ -0,0 +1,369 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// eventRing 是单用户事件的有界环形缓冲区。
|
||||
//
|
||||
// EventSource 断线重连时自带 Last-Event-ID 头:服务端据此回放断线期间的事件。
|
||||
// 没有它,重连后永远看不到断线期间收到的邮件 —— 而这正是实时协作的体验核心。
|
||||
//
|
||||
// 缓冲区大小 500 条:一条事件约 200B(typical),500 条 ≈ 100KB/用户。
|
||||
// 20 个在线用户 ≈ 2MB,远低于 OOM 风险。
|
||||
type eventRing struct {
|
||||
mu sync.Mutex
|
||||
events []StoredEvent
|
||||
cap int
|
||||
head int // 下一次写入的位置
|
||||
full bool
|
||||
}
|
||||
|
||||
// StoredEvent 是缓冲区中的单条事件。
|
||||
type StoredEvent struct {
|
||||
ID string // 自增序列号,EventSource 的 Last-Event-ID 值
|
||||
EventType string
|
||||
Data []byte
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
func newEventRing(cap int) *eventRing {
|
||||
return &eventRing{events: make([]StoredEvent, cap), cap: cap}
|
||||
}
|
||||
|
||||
// push 追加一条事件到缓冲区。满了就覆盖最旧的。
|
||||
func (r *eventRing) push(evt StoredEvent) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.events[r.head] = evt
|
||||
r.head = (r.head + 1) % r.cap
|
||||
if r.head == 0 && !r.full {
|
||||
r.full = true
|
||||
}
|
||||
}
|
||||
|
||||
// replay 从 afterID 之后的所有事件回放给 ResponseWriter。
|
||||
// afterID 为空时:缓冲区未满不回放(首次连接无历史);满了也不回放
|
||||
// (首次连接的 EventSource 不传 Last-Event-ID)。
|
||||
// afterID 非空时:找到该 ID 的位置,从下一条开始回放。
|
||||
func (r *eventRing) replay(afterID string, flush http.Flusher, res http.ResponseWriter) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if afterID == "" {
|
||||
return // 首次连接,不回放
|
||||
}
|
||||
|
||||
start := -1
|
||||
total := r.cap
|
||||
for i := 0; i < r.cap; i++ {
|
||||
idx := (r.head + i) % r.cap
|
||||
if r.events[idx].ID == afterID {
|
||||
start = (idx + 1) % r.cap
|
||||
break
|
||||
}
|
||||
}
|
||||
if start == -1 {
|
||||
// afterID 不在缓冲区里(已被覆盖或从未存在),
|
||||
// 回放缓冲区里所有事件 —— 宁可重复也不丢失
|
||||
start = 0
|
||||
if !r.full {
|
||||
total = r.head
|
||||
}
|
||||
} else {
|
||||
// 从 start 开始到 head 结束
|
||||
total = r.head - start
|
||||
if total < 0 {
|
||||
total += r.cap
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < total; i++ {
|
||||
idx := (start + i) % r.cap
|
||||
evt := &r.events[idx]
|
||||
if evt.ID == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(res, "id: %s\nevent: %s\ndata: %s\n\n", evt.ID, evt.EventType, evt.Data)
|
||||
}
|
||||
flush.Flush()
|
||||
}
|
||||
|
||||
// Client 是一个 SSE 连接客户端
|
||||
type Client struct {
|
||||
ID string
|
||||
AgentName string // 非空 = Agent 侧连接
|
||||
UserName string // 非空 = 已登录人类用户的前端连接
|
||||
Res http.ResponseWriter
|
||||
Flusher http.Flusher
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Manager 管理所有 SSE 客户端连接
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
clients map[string]*Client
|
||||
|
||||
// eventBuffer:per-user/agent 的事件环形缓冲区,供 Last-Event-ID 回放。
|
||||
// Key 是 userName(人类)或 agentName(Agent),二者共享一个 map。
|
||||
// 不是连接级别的 —— 同一用户断线重连后仍能从同一个缓冲区拿到断线期间的事件。
|
||||
eventBuffer map[string]*eventRing
|
||||
bufMu sync.RWMutex
|
||||
seqCounter uint64 // 全局递增序列号,用作事件 ID
|
||||
seqMu sync.Mutex
|
||||
}
|
||||
|
||||
// Default 是全局 SSE 管理器
|
||||
var Default = &Manager{
|
||||
clients: make(map[string]*Client),
|
||||
eventBuffer: make(map[string]*eventRing),
|
||||
}
|
||||
|
||||
const eventBufferCap = 500 // 每用户最多保留 500 条事件
|
||||
|
||||
// nextEventID 生成下一个全局递增的事件 ID
|
||||
func (m *Manager) nextEventID() string {
|
||||
m.seqMu.Lock()
|
||||
defer m.seqMu.Unlock()
|
||||
m.seqCounter++
|
||||
return fmt.Sprintf("%d", m.seqCounter)
|
||||
}
|
||||
|
||||
// getOrCreateRing 获取或创建用户的环形缓冲区
|
||||
func (m *Manager) getOrCreateRing(key string) *eventRing {
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
m.bufMu.RLock()
|
||||
ring, ok := m.eventBuffer[key]
|
||||
m.bufMu.RUnlock()
|
||||
if ok {
|
||||
return ring
|
||||
}
|
||||
m.bufMu.Lock()
|
||||
defer m.bufMu.Unlock()
|
||||
// double-check
|
||||
if ring, ok = m.eventBuffer[key]; ok {
|
||||
return ring
|
||||
}
|
||||
ring = newEventRing(eventBufferCap)
|
||||
m.eventBuffer[key] = ring
|
||||
return ring
|
||||
}
|
||||
|
||||
// AddClient 注册一个新 SSE 客户端(agentName 与 userName 二者恰其一)
|
||||
func (m *Manager) AddClient(res http.ResponseWriter, r *http.Request, agentName, userName string) *Client {
|
||||
flusher, ok := res.(http.Flusher)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
id := uuid.New().String()[:8]
|
||||
client := &Client{
|
||||
ID: id,
|
||||
AgentName: agentName,
|
||||
UserName: userName,
|
||||
Res: res,
|
||||
Flusher: flusher,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.Header().Set("Content-Type", "text/event-stream")
|
||||
res.Header().Set("Cache-Control", "no-cache")
|
||||
res.Header().Set("Connection", "keep-alive")
|
||||
res.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
// Last-Event-ID 回放:EventSource 断线重连时自带这个头,
|
||||
// 服务端据此把断线期间的事件补上 —— 否则重连后永远看不到那段时间的邮件。
|
||||
lastID := r.Header.Get("Last-Event-ID")
|
||||
key := m.bufferKey(userName, agentName)
|
||||
if ring := m.getOrCreateRing(key); ring != nil && lastID != "" {
|
||||
ring.replay(lastID, flusher, res)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.clients[id] = client
|
||||
m.mu.Unlock()
|
||||
|
||||
// 发送连接确认(带 id 让客户端知道自己的 ID)
|
||||
evtID := m.nextEventID()
|
||||
client.SendWithID(evtID, "connected", map[string]string{"id": id})
|
||||
|
||||
// 启动心跳
|
||||
go m.heartbeat(client)
|
||||
|
||||
fmt.Printf("[SSE] Client connected: %s (agent=%q user=%q) lastID=%q\n", id, agentName, userName, lastID)
|
||||
return client
|
||||
}
|
||||
|
||||
// bufferKey 返回缓冲区 key:优先 userName(人类),其次 agentName(Agent)
|
||||
func (m *Manager) bufferKey(userName, agentName string) string {
|
||||
if userName != "" {
|
||||
return "u:" + userName
|
||||
}
|
||||
if agentName != "" {
|
||||
return "a:" + agentName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RemoveClient 移除一个客户端
|
||||
func (m *Manager) RemoveClient(id string) {
|
||||
m.mu.Lock()
|
||||
if c, ok := m.clients[id]; ok {
|
||||
close(c.done)
|
||||
delete(m.clients, id)
|
||||
fmt.Printf("[SSE] Client disconnected: %s\n", id)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// SendToAgent 向指定 Agent 名的所有客户端推送事件
|
||||
func (m *Manager) SendToAgent(agentName, eventType string, data interface{}) {
|
||||
if agentName == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// 写入缓冲区
|
||||
evtID := m.nextEventID()
|
||||
raw, _ := json.Marshal(data)
|
||||
if ring := m.getOrCreateRing(m.bufferKey("", agentName)); ring != nil {
|
||||
ring.push(StoredEvent{ID: evtID, EventType: eventType, Data: raw, Timestamp: time.Now()})
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, c := range m.clients {
|
||||
if c.AgentName == agentName {
|
||||
c.SendWithID(evtID, eventType, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendToUser 向指定人类用户的所有前端连接推送事件
|
||||
func (m *Manager) SendToUser(userName, eventType string, data interface{}) {
|
||||
if userName == "" {
|
||||
return
|
||||
}
|
||||
|
||||
evtID := m.nextEventID()
|
||||
raw, _ := json.Marshal(data)
|
||||
if ring := m.getOrCreateRing(m.bufferKey(userName, "")); ring != nil {
|
||||
ring.push(StoredEvent{ID: evtID, EventType: eventType, Data: raw, Timestamp: time.Now()})
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, c := range m.clients {
|
||||
if c.UserName == userName {
|
||||
c.SendWithID(evtID, eventType, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendToRecipient 根据收件人名同时尝试 Agent 通道与人类用户通道
|
||||
func (m *Manager) SendToRecipient(name, eventType string, data interface{}) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
|
||||
evtID := m.nextEventID()
|
||||
raw, _ := json.Marshal(data)
|
||||
|
||||
// 同时写两个缓冲区(人类或 Agent,或两者都有)
|
||||
if ring := m.getOrCreateRing(m.bufferKey(name, "")); ring != nil {
|
||||
ring.push(StoredEvent{ID: evtID, EventType: eventType, Data: raw, Timestamp: time.Now()})
|
||||
}
|
||||
if ring := m.getOrCreateRing(m.bufferKey("", name)); ring != nil {
|
||||
ring.push(StoredEvent{ID: evtID, EventType: eventType, Data: raw, Timestamp: time.Now()})
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, c := range m.clients {
|
||||
if c.AgentName == name || c.UserName == name {
|
||||
c.SendWithID(evtID, eventType, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast 向所有客户端广播事件(心跳、系统通知等)
|
||||
func (m *Manager) Broadcast(eventType string, data interface{}) {
|
||||
evtID := m.nextEventID()
|
||||
raw, _ := json.Marshal(data)
|
||||
|
||||
// 广播写入所有用户的缓冲区(确保任何用户重连都能回放)
|
||||
m.bufMu.RLock()
|
||||
for key, ring := range m.eventBuffer {
|
||||
ring.push(StoredEvent{ID: evtID, EventType: eventType, Data: raw, Timestamp: time.Now()})
|
||||
_ = key // key 仅用于日志,此处不需
|
||||
}
|
||||
m.bufMu.RUnlock()
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, c := range m.clients {
|
||||
c.SendWithID(evtID, eventType, data)
|
||||
}
|
||||
}
|
||||
|
||||
// ClientCount 返回当前连接数
|
||||
func (m *Manager) ClientCount() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.clients)
|
||||
}
|
||||
|
||||
// Send 向单个客户端发送事件(无 ID)
|
||||
func (c *Client) Send(eventType string, data interface{}) {
|
||||
defer func() { recover() }()
|
||||
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(c.Res, "event: %s\ndata: %s\n\n", eventType, jsonData)
|
||||
c.Flusher.Flush()
|
||||
}
|
||||
|
||||
// SendWithID 向单个客户端发送带 ID 的事件
|
||||
func (c *Client) SendWithID(id, eventType string, data interface{}) {
|
||||
defer func() { recover() }()
|
||||
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(c.Res, "id: %s\nevent: %s\ndata: %s\n\n", id, eventType, jsonData)
|
||||
c.Flusher.Flush()
|
||||
}
|
||||
|
||||
// heartbeat 定期发送心跳保活
|
||||
func (m *Manager) heartbeat(client *Client) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-client.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
defer func() { recover() }()
|
||||
fmt.Fprintf(client.Res, ": heartbeat\n\n")
|
||||
client.Flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
100
server/internal/sse/manager_test.go
Normal file
100
server/internal/sse/manager_test.go
Normal file
@ -0,0 +1,100 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEventRingPushReplay(t *testing.T) {
|
||||
ring := newEventRing(5)
|
||||
|
||||
// 推 3 条
|
||||
for i := 1; i <= 3; i++ {
|
||||
ring.push(StoredEvent{
|
||||
ID: string(rune('0' + i)),
|
||||
EventType: "test",
|
||||
Data: []byte(`{"n":` + string(rune('0'+i)) + `}`),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// 空 afterID → 首次连接,不回放(缓冲区未满)
|
||||
rec := httptest.NewRecorder()
|
||||
ring.replay("", rec, rec)
|
||||
if rec.Body.Len() > 0 {
|
||||
t.Error("首次连接不应回放事件,实际:", rec.Body.String())
|
||||
}
|
||||
|
||||
// 有 afterID → 从下一条开始回放
|
||||
rec2 := httptest.NewRecorder()
|
||||
ring.replay("1", rec2, rec2)
|
||||
body := rec2.Body.String()
|
||||
if !strings.Contains(body, "id: 2") {
|
||||
t.Error("afterID=1 应该回放 id:2,实际:", body)
|
||||
}
|
||||
if !strings.Contains(body, "id: 3") {
|
||||
t.Error("afterID=1 应该回放 id:3,实际:", body)
|
||||
}
|
||||
if strings.Contains(body, "id: 1") {
|
||||
t.Error("afterID=1 不应回放 id:1,实际:", body)
|
||||
}
|
||||
|
||||
// 不存在的 afterID → 从头回放全部
|
||||
rec3 := httptest.NewRecorder()
|
||||
ring.replay("999", rec3, rec3)
|
||||
body3 := rec3.Body.String()
|
||||
if !strings.Contains(body3, "id: 1") {
|
||||
t.Error("不存在的 afterID 应从头回放,实际:", body3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventRingOverflow(t *testing.T) {
|
||||
ring := newEventRing(3)
|
||||
|
||||
// 推 5 条(超过容量 3,最旧的 2 条被覆盖)
|
||||
for i := 1; i <= 5; i++ {
|
||||
ring.push(StoredEvent{
|
||||
ID: string(rune('0' + i)),
|
||||
EventType: "test",
|
||||
Data: []byte(`{}`),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
if !ring.full {
|
||||
t.Fatal("推了 5 条进容量 3 的缓冲区,应该已满")
|
||||
}
|
||||
|
||||
// afterID=2 已被覆盖 → 找不到位置,从头回放全部
|
||||
rec := httptest.NewRecorder()
|
||||
ring.replay("2", rec, rec)
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "id: 3") || !strings.Contains(body, "id: 5") {
|
||||
t.Error("缓冲区溢出后应能回放可用范围,实际:", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventRingConcurrent(t *testing.T) {
|
||||
ring := newEventRing(100)
|
||||
|
||||
done := make(chan bool, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
for j := 0; j < 200; j++ {
|
||||
ring.push(StoredEvent{
|
||||
ID: "evt",
|
||||
EventType: "test",
|
||||
Data: []byte(`{}`),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
// 只验证不 panic,不验证内容(并发下顺序无意义)
|
||||
}
|
||||
37
server/internal/static/static.go
Normal file
37
server/internal/static/static.go
Normal file
@ -0,0 +1,37 @@
|
||||
package static
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
var (
|
||||
indexHTML []byte
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// GetIndex 返回入口页。
|
||||
//
|
||||
// index.html 缺失时回退到 placeholder.html:前端产物不进版本库,
|
||||
// 新克隆里只有占位页。回退而不是报错是故意的 ——
|
||||
// 只改后端的人应当能直接 go run 起来调 API,而不必先装 node。
|
||||
func GetIndex() []byte {
|
||||
once.Do(func() {
|
||||
if data, err := fs.ReadFile(staticFS, "static/index.html"); err == nil {
|
||||
indexHTML = data
|
||||
return
|
||||
}
|
||||
indexHTML, _ = fs.ReadFile(staticFS, "static/placeholder.html")
|
||||
})
|
||||
return indexHTML
|
||||
}
|
||||
|
||||
func Handler() http.Handler {
|
||||
sub, _ := fs.Sub(staticFS, "static")
|
||||
return http.FileServer(http.FS(sub))
|
||||
}
|
||||
70
server/internal/static/static/placeholder.html
Normal file
70
server/internal/static/static/placeholder.html
Normal file
@ -0,0 +1,70 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
前端未构建时的占位页。
|
||||
|
||||
为什么文件名不是 index.html:那正是 Vite 构建产物的名字。用 index.html 当占位,
|
||||
每次构建后真实产物都会盖掉它并被 git 视为改动;提交进去的 index.html 引用着
|
||||
被忽略的 assets/,新克隆打开就是白屏而不是这张提示页。
|
||||
|
||||
改叫 placeholder.html:构建不会产生这个名字,因此永不被覆盖。
|
||||
static.go 在 index.html 缺失时回退到它。
|
||||
它同时让 go:embed 有文件可嵌 —— 否则新克隆连 go build 都过不去。
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>AgentMail — 前端未构建</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
font: 14px/1.7 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
main { max-width: 34rem; padding: 2rem; }
|
||||
h1 { font-size: 1rem; margin: 0 0 0.75rem; }
|
||||
code {
|
||||
background: #e2e8f0;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
pre {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow-x: auto;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
p { margin: 0 0 0.75rem; }
|
||||
.dim { color: #64748b; font-size: 0.9em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>AgentMail 前端未构建</h1>
|
||||
<p>
|
||||
当前二进制里没有前端产物。后端 API 仍然可用(<code>/api/v1</code>、
|
||||
<code>/health</code>)。
|
||||
</p>
|
||||
<p>构建并嵌入前端:</p>
|
||||
<pre>sudo ./deploy/install.sh</pre>
|
||||
<p>或者手工:</p>
|
||||
<pre>cd client/electron && npm ci && npm run build
|
||||
cd ../..
|
||||
rm -rf server/internal/static/static/assets
|
||||
cp -r client/electron/dist/. server/internal/static/static/
|
||||
cd server && go build ./cmd/server</pre>
|
||||
<p class="dim">
|
||||
构建产物不进版本库;这个占位文件的存在只是为了让
|
||||
<code>go:embed</code> 在新克隆里能编译通过。
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user