新增: SFTP直连+网站预览+OSS区域嗅探+热键+BGM播放
This commit is contained in:
177
docs/04-功能迭代/GO-DESK-10.SFTP直连支持/README.md
Normal file
177
docs/04-功能迭代/GO-DESK-10.SFTP直连支持/README.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# GO-DESK-10: SFTP 直连支持
|
||||
|
||||
> 版本: v0.5.0 | 状态: 开发中 | 分支: fs-only-v3 → u-desk-sftp
|
||||
|
||||
## 概述
|
||||
|
||||
为 U-Desk 新增 **SFTP(SSH File Transfer Protocol)直连模式**,作为第三种文件系统传输方式,与现有的本地模式(Wails IPC)和远程 HTTP Agent 模式并列。
|
||||
|
||||
用户无需在目标机器部署任何 Agent 服务,仅需 SSH 账号即可直接浏览和操作远程 Linux 服务器的文件系统。
|
||||
|
||||
## 架构设计
|
||||
|
||||
```
|
||||
FsTransport (interface)
|
||||
├── WailsTransport → Wails IPC → FileSystemService (本地 os)
|
||||
├── HttpTransport → HTTP REST → u-fs-agent (远程部署)
|
||||
└── SftpTransport [NEW]→ Wails IPC → SftpService → pkg/sftp (SSH/SFTP)
|
||||
```
|
||||
|
||||
**核心原则**:复用现有 `FsTransport` 接口抽象,前端组件无感知。
|
||||
|
||||
## 技术方案
|
||||
|
||||
### 后端(Go)
|
||||
|
||||
**新增依赖**:
|
||||
- `github.com/pkg/sftp` — SFTP 客户端库
|
||||
- `golang.org/x/crypto/ssh` — SSH 协议(已在 indirect 中,提升为 direct)
|
||||
|
||||
**新增包 `internal/sftp/`**(4 个文件):
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `config.go` | `Config` 结构体:Host/Port/Username/Password/KeyPath/Timeout |
|
||||
| `client.go` | `Manager`(sync.Map 连接池,以 host:port 为 key)+ `Client`(单连接封装:SSH 握手、健康检查、自动重连、双认证) |
|
||||
| `service.go` | `Service`(对齐 FileSystemService 返回格式的文件操作方法) |
|
||||
| `errors.go` | `ConnectionError` + `ToUserMessage()` 中文友好错误映射 |
|
||||
|
||||
**连接管理**:
|
||||
- 以 `host:port` 为 key 的 `sync.Map` 连接池
|
||||
- 支持密码认证 / 私钥文件认证(二选一)
|
||||
- `WithRetry(fn)` — 操作前检查健康度,断线自动重连(3 次,指数退避)
|
||||
- `IsHealthy()` — 通过 `Stat("/")` 探测
|
||||
- 切换 profile 时复用已有连接(避免重复 SSH 握手)
|
||||
|
||||
**App 层新增绑定方法**(12 个):
|
||||
|
||||
```
|
||||
SftpConnect(req) → connID 建立连接
|
||||
SftpDisconnect(connID) 断开连接
|
||||
SftpListDir(connID, path) 列目录
|
||||
SftpReadFile(connID, path) 读文件
|
||||
SftpWriteFile(req) 写文件
|
||||
SftpGetFileInfo(connID, path) 文件信息
|
||||
SftpCreateDir(connID, path) 创建目录
|
||||
SftpCreateFile(connID, path) 创建文件
|
||||
SftpDeletePath(connID, path) 删除
|
||||
SftpRenamePath(req) 重命名
|
||||
SftpDownloadToTemp(connID, path) 下载到临时目录(预览用)
|
||||
SftpGetCommonPaths(connID) 远程主机常用路径
|
||||
```
|
||||
|
||||
### 前端(TypeScript/Vue)
|
||||
|
||||
**新建 `frontend/src/api/sftp-transport.ts`**:
|
||||
- 实现 `FsTransport` 接口的完整 23 个方法
|
||||
- `connect()/disconnect()/requireConn()` 管理 SFTP 会话生命周期
|
||||
- `downloadForPreview(remotePath)` 下载远程文件到本地临时目录(带缓存)
|
||||
- ZIP/回收站/openPath 等不适用方法抛出 "暂未实现"
|
||||
|
||||
**修改 `connection-manager.ts`**:
|
||||
- `ConnectionType` 扩展:`'local' | 'remote' | 'sftp'`
|
||||
- `ConnectionProfile` 新增字段:`username?`, `password?`, `keyPath?`
|
||||
- `applyActive()` 增加 sftp 分支(创建 SftpTransport → connect() → 连通性检查)
|
||||
- 新增 `isSftp()` 方法
|
||||
- `isRemote()` 扩展为包含 `'sftp'`
|
||||
- `getFileServerBaseURL()` sftp 模式返回 `'http://localhost:8073'`
|
||||
- 切换/删除 profile 时显式断开 SFTP 连接
|
||||
|
||||
**修改 `ConnectionDialog.vue`**:
|
||||
- 新增连接类型选择器(RadioGroup: HTTP Agent / SFTP)
|
||||
- SFTP 类型显示额外字段:用户名(默认 root)、密码、私钥路径
|
||||
- HTTP Agent 类型保持原有 Token 字段
|
||||
- 默认端口根据类型切换(SFTP=22,HTTP=9876)
|
||||
- `addProfile` 时 type 使用表单选择的值
|
||||
|
||||
**修改 `ConnectionIndicator.vue`**:
|
||||
- `.dot.sftp` 样式(紫色 `#7c3aed`)
|
||||
- `dotClass(p)` 函数返回 local/remote/sftp
|
||||
- "更多操作"按钮条件从 `type === 'remote'` 改为 `type !== 'local'`
|
||||
|
||||
**修改 `Sidebar.vue`**:
|
||||
- 模式标签区分显示:本地(绿)/远程(蓝)/SFTP(紫)
|
||||
- 新增 `isSftp` 响应式变量
|
||||
|
||||
**修改 `useFilePreview.ts`**:
|
||||
- SFTP 模式下 `updatePreviewUrl()` 先调用 `downloadForPreview()` 下载到本地临时目录
|
||||
- 下载完成后使用 `http://localhost:8073/localfs/{temp_path}` 预览(复用现有 LocalFileServer)
|
||||
- 已下载文件缓存避免重复下载
|
||||
|
||||
### 文件预览方案
|
||||
|
||||
**策略:下载到本地临时目录 + 复用 LocalFileServer**
|
||||
|
||||
```
|
||||
用户点击 SFTP 远程文件
|
||||
→ useFilePreview 检测 isSftp()
|
||||
→ SftpTransport.downloadForPreview(remotePath)
|
||||
→ Go 后端 SFTP 下载到 %TEMP%/udesk-sftp-preview-{filename}
|
||||
→ 返回本地绝对路径
|
||||
→ 预览 URL = http://localhost:8073/localfs/{temp_path}
|
||||
→ 现有 LocalFileServer 直接提供服务
|
||||
```
|
||||
|
||||
**临时文件管理**:
|
||||
- 启动时清理上次遗留的 `udesk-sftp-preview-*` 文件(`ServiceStartup` 中调用 `CleanupTempFiles()`)
|
||||
- 关闭时清理本次创建的临时文件(`ServiceShutdown` 中调用)
|
||||
- SftpTransport 内部维护 remotePath → localTempPath 缓存
|
||||
|
||||
## 文件变更清单
|
||||
|
||||
### 新增(5 个)
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `internal/sftp/config.go` | SFTP 配置结构体 |
|
||||
| `internal/sftp/client.go` | SSH/SFTP 连接管理器 |
|
||||
| `internal/sftp/service.go` | SFTP 文件操作服务 |
|
||||
| `internal/sftp/errors.go` | 错误处理 + 用户友好消息 |
|
||||
| `frontend/src/api/sftp-transport.ts` | 前端 FsTransport 实现 |
|
||||
|
||||
### 修改(8 个)
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `go.mod` | 添加 `github.com/pkg/sftp` 依赖 |
|
||||
| `internal/filesystem/fs.go` | `formatBytes` 导出为 `FormatBytes` |
|
||||
| `app.go` | +sftpService 字段 + 12 个绑定方法 + Shutdown 扩展 + 清理临时文件 |
|
||||
| `frontend/src/api/connection-manager.ts` | 类型扩展 + sftp 分支 + isSftp() |
|
||||
| `frontend/src/.../ConnectionDialog.vue` | 类型选择器 + SFTP 表单 |
|
||||
| `frontend/src/.../ConnectionIndicator.vue` | sftp 样式 + dotClass |
|
||||
| `frontend/src/.../Sidebar.vue` | SFTP 模式标签 |
|
||||
| `frontend/src/.../useFilePreview.ts` | SFTP 下载预览 |
|
||||
|
||||
### 自动生成
|
||||
|
||||
| 文件 | 触发方式 |
|
||||
|------|---------|
|
||||
| `frontend/src/wailsjs/v3-bindings/u-desk/app.ts` | `wails dev` 启动时重新生成 |
|
||||
|
||||
## 与现有模式的对比
|
||||
|
||||
| 特性 | 本地 (WailsTransport) | 远程 (HttpTransport) | SFTP (SftpTransport) |
|
||||
|------|----------------------|---------------------|---------------------|
|
||||
| 协议 | Wails IPC | HTTP REST | SSH/SFTP |
|
||||
| 目标要求 | 本地桌面 | 部署 u-fs-agent | SSH 服务 |
|
||||
| 认证 | 无 | Bearer Token | 密码 / 私钥 |
|
||||
| 文件预览 | LocalFileServer | Agent 反向代理 | 下载到临时目录 |
|
||||
| ZIP 支持 | ✅ | ❌ | ❌ |
|
||||
| 回收站 | ✅ | ❌ | ❌ |
|
||||
| 延迟 | < 10ms | 取决于网络 | 取决于网络(首次握手 ~2s) |
|
||||
| 连接复用 | N/A | 每次请求 HTTP | sync.Map 连接池 |
|
||||
|
||||
## 后续迭代方向
|
||||
|
||||
### Phase 2(体验完善)
|
||||
- 密钥文件选择器(Wails OpenFileDialog)
|
||||
- 断线重连 UI 提示 + 手动重连按钮
|
||||
- 大文件传输进度显示
|
||||
- saveBase64File 二进制上传支持
|
||||
|
||||
### Phase 3(高级功能)
|
||||
- SSH known_hosts 安全验证(替换 InsecureIgnoreHostKey)
|
||||
- TCP KeepAlive + 应用层心跳防空闲断开
|
||||
- 端口转发(SOCKS5/本地转发)
|
||||
- 符号链接处理选项
|
||||
- 并发传输队列 + 带宽限制
|
||||
114
docs/04-功能迭代/GO-DESK-10.SFTP直连支持/开发经验.md
Normal file
114
docs/04-功能迭代/GO-DESK-10.SFTP直连支持/开发经验.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# SFTP 直连 + autoConnect 开发经验
|
||||
|
||||
> 日期:2026-05-04 | 对应分支:fs-only-v3
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
### 1. 连接池模式替代单连接
|
||||
|
||||
**背景**: 原方案切换 profile 时断开旧连接再建新连接,切换慢且丢失状态。
|
||||
|
||||
**决策**: `Map<profileId, FsTransport>` 连接池。所有 profile 可同时在线,切换为 O(1)。
|
||||
|
||||
**关键实现**:
|
||||
- `buildAndPool()`: 创建 transport 并入池
|
||||
- `connect()`: 池中已有则直接复用,否则新建
|
||||
- `disconnectProfile(id)`: 断开指定 profile,从池移除
|
||||
- `disconnectAll()`: 清空池,保留 local
|
||||
|
||||
### 2. 文件服务器 URL 集中管理
|
||||
|
||||
**背景**: 前端 8+ 处硬编码 `localhost:2652`,端口冲突时全部失效。
|
||||
|
||||
**决策**: 新建 `file-server.ts`,从后端动态获取 URL。
|
||||
|
||||
**原则**: **单一数据源**。`connectionManager` 不再缓存 URL,所有模块从 `file-server.ts` 读取。
|
||||
|
||||
### 3. 端口自动回退
|
||||
|
||||
**背景**: 端口被占用时应用崩溃,用户必须手动杀进程。
|
||||
|
||||
**决策**: `listenWithFallback(basePort, handler)` 尝试 basePort + 0..9,直接 `srv.Serve(l)` 消除 TOCTOU。
|
||||
|
||||
**关键**: 不用 `Listen → Close → ListenAndServe`(有竞态),改为把 listener 传给 `Serve`。
|
||||
|
||||
---
|
||||
|
||||
## 踩坑记录
|
||||
|
||||
### 踩坑 1: autoConnect 不工作 — 三层嵌套根因
|
||||
|
||||
**现象**: 开启"启动时自动连接",重启后服务器不连接。手动点击则正常。
|
||||
|
||||
**排查过程**:
|
||||
1. 加诊断日志 → 发现 `loadFromDB` 正常执行,DB 返回 4 条记录
|
||||
2. 日志显示 `lastConnected: null`(所有 profile)→ `lc: false`
|
||||
3. 原因链:
|
||||
- Go JSON tag 是 `last_connected`,前端读 `p.lastConnected` → **字段名不匹配**
|
||||
- `SaveProfileRequest` 没有 `LastConnected` 字段 → **从未持久化**
|
||||
- autoConnect 守卫 `p.lastConnected` → 即使修了前两层,旧数据仍是 null → **守卫逻辑有误**
|
||||
|
||||
**修复**:
|
||||
```ts
|
||||
// 1. 字段名 fallback
|
||||
lastConnected: p.lastConnected || p.last_connected ? ... : undefined
|
||||
// 2. persistProfile 传递 lastConnected
|
||||
lastConnected: profile.lastConnected ? Math.floor(profile.lastConnected / 1000) : undefined
|
||||
// 3. 去掉 lastConnected 守卫(核心修复)
|
||||
if (p.type !== 'local') { // 原来是 p.type !== 'local' && p.lastConnected
|
||||
```
|
||||
|
||||
**教训**: 数据链路问题要追踪完整链路:前端写入 → Go 接收 → DB 存储 → DB 读取 → Go 返回 → 前端读取。每一步都可能有字段名/类型/单位不匹配。
|
||||
|
||||
### 踩坑 2: Settings 弹窗被 overflow:hidden 裁剪
|
||||
|
||||
**现象**: 点击表头 `···` 按钮,弹窗不出现。
|
||||
|
||||
**根因**: `settings-panel` 在 `server-content`(`overflow: hidden`)内部。
|
||||
|
||||
**修复**: DOM 上移到 `server-content` 外部,改用 `position: absolute` 相对 `sidebar-section`。
|
||||
|
||||
### 踩坑 3: More-menu 被收藏夹遮挡
|
||||
|
||||
**现象**: 最后一个服务器行的 `···` 菜单被收藏夹区块盖住。
|
||||
|
||||
**根因**: `.section-content` 有 `overflow: hidden`(用于折叠动画),裁剪了 `.more-menu`。同时服务器 section 的 z-index 低于收藏夹。
|
||||
|
||||
**修复**:
|
||||
- `.server-content` 加 `overflow: visible` 覆盖通用规则
|
||||
- 服务器 section 在菜单打开时加 `z-index: 30`(`.section-on-top` 类)
|
||||
|
||||
### 踩坑 4: require() 在 Vite ESM 构建中不可靠
|
||||
|
||||
**现象**: `const { getFileServerBaseURL } = require('./file-server')` 在 dev 模式正常,production build 失败。
|
||||
|
||||
**修复**: 全部改为 ES 静态 `import`。
|
||||
|
||||
### 踩坑 5: Go time.Unix 返回值不能直接赋给 *time.Time
|
||||
|
||||
```go
|
||||
// ❌ 编译错误
|
||||
p.LastConnected = time.Unix(*req.LastConnected, 0)
|
||||
|
||||
// ✅ 中间变量
|
||||
t := time.Unix(*req.LastConnected, 0)
|
||||
p.LastConnected = &t
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### SFTP 二进制文件写入
|
||||
前端剪贴板图片 → `canvas.toDataURL()` 得到 base64 → `SftpWriteBase64File` Go binding → base64 解码 → SFTP Create + Write。
|
||||
|
||||
### CSS overflow 与弹窗
|
||||
`overflow: hidden` 用于折叠动画时,会裁剪子元素的 `position: absolute` 弹窗。解法:弹窗放在 overflow 容器外部,用 `position: absolute` 相对最近的 `position: relative` 祖先定位。
|
||||
|
||||
### z-index 层级管理
|
||||
- 基础层: 1-10
|
||||
- 弹出菜单/面板: 20-30
|
||||
- 模态/对话框: 40-50
|
||||
- 确保弹出元素的父容器也有足够的 z-index
|
||||
Reference in New Issue
Block a user