起因:传个东西太烦了

你有没有这种时刻:

  • 手机上看到一段文字,想发到电脑上——打开微信文件传输助手,等它加载,粘贴,发送,电脑端再等接收
  • 想把 WiFi 密码从电脑发到手机——又是一轮微信登录
  • 传个截图——微信压缩了,清晰度没了
  • 想回头找三天前传的那个密码——聊天记录翻半天

这些场景的共同点是:我就是想把一条信息从 A 设备弄到 B 设备,为什么这么费劲?

市面上有 AirDrop,但只限苹果生态且要双方确认;有 Snapdrop/PairDrop,但不留历史记录;有 PrivateBin,但它是便签不是聊天流;有 Syncthing,但它是文件同步不是”发一条”。

没有一个工具正好干这件事:单人、跨设备、能存能查、像聊天框一样简单。

所以我造了一个。

速传是什么

一句话:你的私人跨设备中转站,界面是一个聊天框,在自己手机/电脑/平板之间传文字、密码、文件、截图。

它长这样:

  • 多房间隔离:不再是「一个密码管全站」。开多个房间,每个房间独立时间流,可加密码可不加。默认有个 BUG反馈 房间不加密,留给外部接口往里塞数据;自己日常用的房间可以加密
  • 侧边栏房间列表:房间再多也不挤——桌面端左侧常驻侧边栏,移动端抽屉式滑出。切换房间、新建房间、管理工具都在侧边栏
  • 删除房间:不要的房间连同全部消息一键删除(硬删,不进回收站)。需要输入服务端配置的 ADMIN_PASSWORD 验证,错误密码有延迟 + 限速防护,默认房间不可删
  • 消息置顶:常用密码、常用链接钉在房间顶部,不用翻历史。每个房间一条置顶,消息被删时自动解除
  • 暗色模式:亮 / 暗 / 跟随系统三态切换,跟随系统时实时响应系统主题变化
  • 回收站:删错的消息先进回收站,3 天内可恢复;3 天后自动彻底删除并清理对应文件。误删不再是不可逆操作
  • BUG反馈 webhookBUG反馈 房间有一个固定的 Bearer Token,外部脚本/服务可以直接通过 HTTP 把错误日志、截图、崩溃文件推到这个房间,相当于一个自托管的轻量级错误收集器
  • 时间流单栏:所有消息按天分组,统一方向排列。不是聊天气泡那种左右分边——因为只有你一个人,没有对话对象,单栏扫读效率最高
  • 长文字自动折叠:超过约 8 行折叠显示「展开全文」,不会一条长消息占满整个屏幕
  • 图片直接预览:截图显示缩略图,点击全屏查看、可下载
  • 粘贴/拖拽上传:桌面端 Ctrl+V 粘贴截图直接发送,拖文件进窗口即上传
  • 历史搜索:顶部搜索框,多关键词模糊匹配(顺序无关),”admin 密码” 能命中 “密码是 admin”
  • 复制按钮:每条文字消息带一键复制,传密码就是为了粘贴用
  • 容量统计 + 清理工具:顶栏实时显示消息数和占用空间,支持按时间批量清理(7/30/90 天前)
  • 版本号:侧边栏底部显示当前版本(v1.2.0),点击可看构建时间

核心设计决策是房间隔离模型——不需要账号体系,用房间名 + 可选密码把不同场景的内容隔开:日常传输房间加密、BUG反馈房间不加密但只接受带 Token 的外部推送、临时房间用完即弃。这让权限边界清晰,又比多用户系统简单一个数量级。

技术栈:为什么是 Go + Vue3

选型时我考虑过 Node、Python、Go,最终选了 Go 单二进制。原因很实际:

Go 的单二进制交付是杀手锏。 编译出来一个 15MB 的可执行文件,前端已经嵌在里面了。服务器上不需要装 Node、不需要装 Python、不需要装运行时——一个文件 + 一个数据目录,完事。升级就是替换二进制重启,迁移就是把数据目录拷走。

SQLite 做存储。 单用户量级,消息撑死几万条,SQLite 绰绰有余。用的 modernc.org/sqlite 纯 Go 驱动,不依赖 CGO,交叉编译零障碍。WAL 模式 + SetMaxOpenConns(1) 彻底避免 “database is locked”。

Vue3 + Vite 做前端。 构建产物只有 ~34KB(gzip),通过 Go 1.16+ 的 embed 包直接嵌进二进制。访问网址就是完整站点,没有前后端分离部署的麻烦,没有跨域问题。

Docker 三阶段构建。 第一阶段 Node 构建前端,第二阶段 Go 编译后端(把前端产物 embed 进去),第三阶段 Alpine 运行时——最终镜像只有二进制 + 基础系统,约 20MB。

整个技术栈的选择标准只有一个:能少一个依赖就少一个依赖。 个人工具不需要微服务、不需要 Redis、不需要消息队列。越简单越不容易坏。

安全模型:先想清楚再写代码

这个工具会存我所有的密码,安全设计不能含糊。核心决策:

房间级密码,而非全站密码。 每个房间可选是否加密。加密房间的密码不存明文——存的是 SHA-256 摘要,验证时比对摘要。登录成功后下发一个 room_session cookie(10 年有效)标记当前房间,下次进同房间免密。不加密房间(如 BUG反馈)直接进,但它的写入只接受两类来源:浏览器里的你,或带正确 Token 的 webhook 调用。

Webhook 用恒定时间比较。 BUG反馈 房间的 Token 用 subtle.ConstantTimeCompare 校验,避免时序攻击。Token 在部署时通过 WEBHOOK_TOKEN 环境变量注入,不写进数据库、不出现在前端。

高危操作独立管理密码。 删除房间是全站最高危的操作——连消息带文件一起硬删。它不走房间密码,而是要求输入服务端环境变量里的 ADMIN_PASSWORD(同样恒定时间比较)。这个密码只存在你的密码管理器里,不进数据库、不进前端;不配置这个变量,删除房间功能整体禁用。错误密码加 200ms 延迟 + 每分钟限 10 次。

必须走 HTTPS。 密码和消息明文存在 SQLite——这意味着服务器本身的安全就是密码库的安全。HTTP 明文传输会让密码在网络上裸奔,所以部署时必须配 nginx + Let’s Encrypt。

登录限速。 同一 IP 每分钟最多 10 次房间登录尝试,错误密码加 300ms 延迟拖慢暴力破解。

端口不直接暴露。 服务只绑 127.0.0.1:8787,公网通过 nginx 反代访问。即使有人扫到 8788 端口也连不上。

每日自动备份。 每天凌晨 4 点用 VACUUM INTO 生成数据库一致性快照,和上传文件一起打成 tar.gz,保留最近 7 份。运行中备份不影响服务。

没有做端到端加密——那是 PrivateBin 的路线。我选了明文存 + 服务器安全这条务实路线,因为端到端加密的代价是换设备前必须输对密码才能解密历史,实现复杂度明显上升。这个权衡你可以不认同,但它是我深思熟虑后的选择。

多房间 + 回收站:为什么这么设计

第一版只有一个全局密码、一条时间流。用了一阵发现两个真实痛点:

痛点一:内容混在一起。 BUG反馈、日常传密码、临时贴段代码,全挤在一个流里。想找三天前的密码得先翻过一堆报错日志。多房间就是解这个——BUG反馈 房间专门收外部推送,日常房间加密只自己看,内容物理隔离,搜索也按房间作用域。

痛点二:误删即丢失。 手机上手指一滑删错一条,没了就是没了。回收站是必须的。

实现上几个关键点:

  • 单表 + room_id:没有给每个房间建一张表,那太重。所有消息都在 messages 表,加 room_id 外键隔离。查询带 WHERE room_id = ? 即可,索引覆盖,几万条量级毫秒级返回
  • 软删除 + 自动清理:删消息不是 DELETE,是 UPDATE ... SET deleted_at = ?。回收站查 WHERE deleted_at IS NOT NULL,恢复就是清空 deleted_at。后台 goroutine 每小时跑一次 PurgeExpired,把 deleted_at 超过 3 天的消息彻底删掉,连带磁盘上的上传文件一起清掉——避免回收站变成只增不减的垃圾堆
  • 房间密码存摘要password 列存 SHA-256 hex,不存明文。即使数据库泄露,攻击者也拿不到原始密码(只能爆破)
  • BUG反馈 房间写死 ID=1:webhook 接口固定往 room ID=1 推,前端不显示这个房间的删除按钮(外部推送的数据由系统管理),避免误删外部上报的 BUG

BUG反馈 Webhook:把外部报错收进同一个流

BUG反馈 房间最有意思的地方:它不只是你在浏览器里看,还能被外部调用

设想场景:你有个跑在服务器上的脚本、一个 webhook 钩子、或者一个监控告警——出了问题想留个痕。以前是发邮件、发钉钉、写日志文件。现在直接 POST 一条消息到速传的 BUG反馈 房间,和你手动贴的报错截图在同一个时间流里。

接口规范:

文本消息 POST /api/webhook/message
文件上传 POST /api/webhook/upload
鉴权 Authorization: Bearer <WEBHOOK_TOKEN>
内容类型 application/json(文本)/ multipart/form-data(文件)
落点 固定写入 BUG反馈 房间(room ID=1)

文本消息示例:

1
2
3
4
curl -X POST https://你的域名/api/webhook/message \
-H "Authorization: Bearer <WEBHOOK_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"content":"凌晨3点备份任务失败,磁盘空间不足","type":"text"}'

字段说明:

  • content(必填):消息文本
  • type(可选):text(默认)或 codecode 类型前端会按等宽字体渲染,适合贴错误堆栈

文件上传示例:

1
2
3
4
curl -X POST https://你的域名/api/webhook/upload \
-H "Authorization: Bearer <WEBHOOK_TOKEN>" \
-F "file=@/var/log/app/crash-2026-08-24.log" \
-F "content=服务崩溃日志,见附件"

字段说明:

  • file(必填):要上传的文件,支持任意类型。图片自动识别为图片消息(前端预览),其他文件作为附件下载
  • content(可选):附带的文字说明,会作为独立文本消息插在文件之前

鉴权失败返回 401:

1
{"error": "token 无效"}

成功返回 200:

1
{"ok": true, "id": 42}

WEBHOOK_TOKENdocker-compose.yml 的环境变量里设,部署后即用。想换 Token 改环境变量重启容器即可,老 Token 立刻失效。

我用它接了个 cron 脚本,每晚备份失败就推一条到 BUG反馈 房间——第二天打开速传,红色报错就在时间流最顶上,比翻邮件快多了。

踩过的坑

写这个的过程中踩了几个有意思的 bug,记下来:

1. SQLite NULL 列扫描失败

Go 的 database/sql 默认不能把 NULL 扫进 string。文本消息的 file_idfile_name 等列允许 NULL(因为文字消息没有文件),直接 Scan(&msg.FileID) 就报错。

修复:用 sql.Null[string] 接收,再取 .V 字段。或者更简单——查询时用 COALESCE(file_id, '') 把 NULL 转空字符串。

2. 空切片序列化成 null 导致前端崩溃

Go 里 var msgs []Message 是 nil 切片,json.Marshal 会把它序列化成 null 而不是 []。前端拿到 data.messages.length 就报 Cannot read properties of null

修复:初始化为空切片 msgs := []Message{},保证序列化为 []。这是 Go 的经典坑。

3. 备份保留策略切片越界

1
for _, n := range names[m.keep:] { ... }

当文件数少于 keep 时,names[7:] 在长度只有 3 时直接 panic(slice bounds out of range)。经典 Go 切片越界。修复:先判断 len(names) > m.keep

4. 手机键盘弹出把顶栏推走

这是最难的一个。最初用 scrollIntoView({ block: 'center' }) 想把输入框拉回键盘上方,结果在 overflow: hidden + flex 布局的页面上,浏览器找不到正确的滚动容器,把整个文档往上推——顶栏跑出屏幕、消息列表被挤到上面。

最终方案是不用任何 JS 滚动,纯靠布局自适应:

  • bodyposition: fixed; inset: 0,把页面钉死在视口里
  • --app-height CSS 变量跟随 visualViewport.resize 更新
  • 键盘弹出 → 视口缩小 → #app 变矮 → flex 列布局重算 → .list 缩高 + 输入区钉底

全程零 JS 滚动,浏览器推不动页面,输入框自然在键盘正上方。

5. 中文输入法回车误发送

Vue 的 @keydown.enter 在中文输入法选词确认时也会触发,导致还没选完词消息就发出去了。修复:检查 e.isComposing || e.keyCode === 229,合成状态下不发送。

部署:三步上线

1
2
3
4
5
6
7
8
9
10
# 1. 拿代码
git clone https://github.com/7852604/transfer.git
cd transfer

# 2. 设 Token 和管理密码
cp .env.example .env
vi .env # 设置 WEBHOOK_TOKEN(webhook 调用)和 ADMIN_PASSWORD(删除房间用,可留空禁用)

# 3. 启动
docker compose up -d --build

启动后默认会自动创建 BUG反馈 房间(不加密,room ID=1),用 WEBHOOK_TOKEN 就能往里推消息。其他房间在网页里手动新建,可选是否加密码。

如果要用域名 + HTTPS,把 nginx.conf.example 里的域名改成你的,放到 nginx 配置目录,然后 certbot --nginx -d 你的域名 签证书。强烈建议走 HTTPS,否则房间密码和消息在网络上明文传输。

手机上打开网址,Safari 选「添加到主屏幕」,之后点图标即用,体验接近原生 App。

开源

代码已开源:github.com/7852604/transfer

MIT 协议,随便用。目录结构:

1
2
3
4
5
6
7
8
9
10
11
transfer/
├── main.go # 入口:配置、启动 HTTP 服务
├── internal/
│ ├── store/ # SQLite 数据层
│ ├── api/ # HTTP 路由 + 鉴权 + 文件上传
│ ├── backup/ # 每日自动备份
│ └── webui/ # 嵌入的前端构建产物
├── web/ # Vue3 + Vite 前端源码
├── Dockerfile # 三阶段构建
├── docker-compose.yml # 一键部署
└── nginx.conf.example # 反代配置示例

整个项目约 2000 行代码(Go ~1200 行 + Vue/CSS ~800 行),没有第三方前端 UI 库,没有后端框架——标准库路由 + 原生 SQL + 原生 Vue 组件。少即是多。

写在最后

这个工具我每天都在用。传密码、传截图、传一段代码片段、传一个文档——打开网址,粘贴,发送,另一台设备已经在了。再也不用等微信同步、不用担心文件被压缩、不用翻聊天记录找三天前的东西。

后来又加了多房间和回收站:BUG反馈和日常传输分房间隔开,删错的东西进回收站 3 天内能捞回来。最意外好用的是 BUG反馈 的 webhook——把服务器的报错直接推到同一个时间流里,第二天打开速传红色报错就在最顶上,比翻邮件、翻日志都快。

最近这轮打磨了三个体验细节:侧边栏房间列表(房间多了不挤)、消息置顶(常用密码钉在顶部不用翻)、暗色模式(晚上看不刺眼)。删除房间则配了独立的管理密码——高危操作值得一把单独的钥匙。

它不复杂,但它正好解决我的问题。有时候最好的工具就是你自己造的那个——因为它的每一个设计决策,都是为你的使用场景量身定做的。

如果你也有跨设备传输的痛点,试试看。有问题欢迎在 GitHub 提 issue。