Files
MailUI4Agents/gateway/internal/blob/store.go
JianFeeeee 0e754617a4 feat: AgentMail —— 以邮件为统一范式的多智能体协作平台
Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是
「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制,
数据库默认内置 SQLite,systemd 托管。

核心设计
- 三维寻址 name@path.session,按最后一个 . 切分;session 位三态:
  省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达)
- 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的
  标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖
- 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构,
  再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载
- 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重,
  且路径与用户 filename 无关,杜绝 ../ 穿越
- 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与
  最终总结走免配额通道,靠上游消息 id 做幂等键而非计数
- 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过

后端 gateway/
- models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL)
  共用一份 repo 层 SQL,差异集中在 internal/db
- 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界)
- 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文
  只从客户端流向服务器一次
- 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、
  附件挂载),并发下不会刷穿

前端 web/
- 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件
- 全站纯 SVG 图标,不使用 emoji
- api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts

插件 plugins/opencode-mail-bridge/
- 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、
  session.idle 时转发本轮总结)
2026-09-02 10:29:26 +08:00

150 lines
4.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

// Package blob 提供附件文件的内容寻址存储。
//
// 设计取舍:文件内容存磁盘、数据库只存元数据。
// 不把附件塞进 SQLite 的 BLOB —— 附件是「写一次读多次」的冷数据,
// 塞进库会让 .db 膨胀、WAL 变大、备份变慢,而这些代价换不来任何好处。
//
// 路径由内容的 sha256 派生ab/cdef...),因此:
// - 相同内容天然去重,重复上传不占额外空间
// - 路径与用户提供的 filename 完全无关,杜绝 ../ 穿越
// - 两级目录前缀避免单目录塞进十万个文件
package blob
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
)
// 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
}
// 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
}