Compare commits
27 Commits
v0.3.0
...
feature/fs
| Author | SHA1 | Date | |
|---|---|---|---|
| 44847e0d40 | |||
| 3d5a1e5892 | |||
| 4f1d5f885f | |||
| 742581c5d6 | |||
| 4ffac72999 | |||
| 72fef3e56f | |||
| 691e38604f | |||
| 756028af0f | |||
| 7dbd57a8b6 | |||
| efc042fcd3 | |||
| fb12ec48e8 | |||
| e5dbe89a6f | |||
| 5f94ccf13b | |||
| 1eaf61cf41 | |||
| c5e6ff3ba6 | |||
| a6f99e0c49 | |||
| e198fd4ee1 | |||
| bfe5226bfe | |||
| ded8989fe3 | |||
| 22f5862f15 | |||
| 4a1f0213df | |||
| d62b9ca7bd | |||
| 0229cab550 | |||
| 9eb39fbb8f | |||
| f7d648ea52 | |||
| ce2698f245 | |||
| edd5b7c869 |
4
.gitignore
vendored
@@ -4,8 +4,12 @@ web/src/wailsjs/
|
||||
|
||||
# 构建产物
|
||||
build/bin/
|
||||
build/*.log
|
||||
web/dist/
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
|
||||
# 依赖目录
|
||||
web/node_modules/
|
||||
web/bun.lock
|
||||
|
||||
@@ -2,6 +2,392 @@
|
||||
|
||||
> 本文档记录所有技术细节,包括代码重构、构建优化等内部改动
|
||||
|
||||
## [0.3.3] - 2026-04-13
|
||||
|
||||
### 架构新增 🏗️
|
||||
|
||||
#### PDF 导出模块
|
||||
新增 `internal/api/pdf_api.go`,提供两种导出方式:
|
||||
- **chromedp**: 无头浏览器渲染 HTML → PDF,支持完整 CSS 样式
|
||||
- **gofpdf** (`app.go:ExportMarkdownToPDF`): 纯 Go 实现,解析 Markdown 标题/列表/代码块写入 PDF
|
||||
- 前端 `PdfExportButton.vue` 使用 `window.open` + `print()` 浏览器打印方式
|
||||
|
||||
#### Markdown 编辑器
|
||||
新增 `web/src/components/MarkdownEditor.vue` 组件:
|
||||
- textarea 编辑 + MarkdownPreview 实时预览(左右分栏)
|
||||
- 字符/行数统计、Ctrl+S 保存、5 秒防抖自动保存
|
||||
- 支持 `content` prop 和 `v-model:content` 双向绑定
|
||||
- 独立页面 `web/src/views/markdown-editor/index.vue` 和 `web/src/views/MarkdownViewer.vue`
|
||||
|
||||
---
|
||||
|
||||
### 数据库层重构 🗄️
|
||||
|
||||
#### MySQL 连接池 (`internal/dbclient/pool.go`, `pool_config.go`)
|
||||
- 动态扩缩容: `adaptiveScaling()` 基于使用率自动 scaleUp/scaleDown
|
||||
- 健康检查: `enhancedHealthCheck()` 定期 Ping,使用中连接带 100ms 超时
|
||||
- 性能权重: `adaptiveWeights` 基于 Ping 延迟计算,`getOptimalConnection()` 优选
|
||||
- **注意**: `warmUp()` 为空壳实现,未被调用;`OptimizeQuery` 等方法未接入 `sql_exec_service.go` 业务调用
|
||||
|
||||
#### 查询优化器 (`internal/dbclient/query_optimizer.go`, `cache.go`)
|
||||
- 查询缓存: SHA-256 key hash + LRU/LFU 混合驱逐,100MB 内存限制,RLock 读锁优化
|
||||
- 慢查询日志: 超过 100ms 自动记录,最多 1000 条,维护协程定期分析
|
||||
- 正则预编译: 5 个正则从方法内移到包级别 `var` 声明
|
||||
- **注意**: 索引建议框架在但 `analyzeQueryForIndexes` 分析逻辑为占位实现;`extractIndexUsed` 始终返回 `"unknown"`
|
||||
|
||||
#### Redis Pipeline (`internal/dbclient/redis_pipeline.go`)
|
||||
- `RedisPipeline`: 批量命令,使用 go-redis 原生 `Pipeline()` 一次性发送
|
||||
- `RedisTransaction`: 事务支持,使用 `TxPipeline()` 自动 MULTI/EXEC
|
||||
- **注意**: 未被业务代码调用,仅 `pool.go` 中定义了桥接方法
|
||||
|
||||
---
|
||||
|
||||
### 前端变更 🖥️
|
||||
|
||||
#### App.vue
|
||||
- 新增窗口置顶按钮,调用 `WindowToggleAlwaysOnTop` Wails runtime API
|
||||
- 新增 Markdown 编辑器 tab
|
||||
- 禁止 Ctrl+滚轮缩放(`wheel` 事件 passive: false)
|
||||
- 移除 `preloadCommonLanguages()` 预加载(改按需加载)
|
||||
- `lang="ts"` 迁移
|
||||
|
||||
#### 文件系统
|
||||
- `ContextMenu.vue`: 新增新建文件/文件夹菜单项
|
||||
- `FileEditorPanel.vue`: 集成 PDF 导出按钮、Markdown 预览/编辑模式切换
|
||||
- `useFavorites.ts`: 收藏夹置顶功能(`togglePin`/`isPinned`/排序)
|
||||
- `useFilePreview.ts`: Office/CSV 改用本地文件服务器 `fetch` 获取内容
|
||||
- HTML 预览改用 `iframe src` 替代 `srcdoc`(`f28fd70`, `7004c6e`)
|
||||
|
||||
#### 安全修复
|
||||
- `PdfExportButton.vue`: `escapeHtml()` 转义标题、`stripScripts()` 清除 script/iframe/事件处理器
|
||||
- `MarkdownPreview.vue`: `sanitizeHtml()` 清除 script/iframe/form/事件处理器/javascript: 协议
|
||||
- `pdf_api.go`: `filepath.Base()` 防路径穿越、`html.EscapeString()` 防标题 HTML 注入
|
||||
|
||||
#### 配置层
|
||||
- `config.ts`: Wails 绑定加载增加超时保护(最多 30 次重试,约 30 秒)
|
||||
- `config_service.go`: `TestConnection` 简化为直接传 id
|
||||
- `connection_api.go`: 依赖从 `storage` 改为 `service` 包
|
||||
|
||||
#### 样式
|
||||
- `style.css`: 新增 GitHub 风格 `.markdown-body` 样式、Highlight.js 代码高亮样式、`@media print` 打印优化
|
||||
- Tooltip 全局样式覆盖
|
||||
|
||||
---
|
||||
|
||||
### 后端变更 ⚙️
|
||||
|
||||
#### app.go
|
||||
- 新增 `pdfAPI`、`isAlwaysOnTop` 字段
|
||||
- 新增 PDF 导出方法: `ExportPDF`、`ExportMarkdownToPDF`、`SelectPDFSaveDirectory`
|
||||
- `startAutoUpdateCheck` 修复 `config["success"].(bool)` 类型断言,改为 ok 检查
|
||||
- `WindowToggleAlwaysOnTop`: Wails runtime 置顶切换
|
||||
|
||||
#### 其他
|
||||
- `aes.go`: AES 加密模块扩展
|
||||
- `pool.go`: 桥接查询优化器和缓存方法
|
||||
- `connection_service.go`: 增强 `GetConnection` 和 `TestConnection`
|
||||
|
||||
---
|
||||
|
||||
### 依赖变更 📦
|
||||
|
||||
```diff
|
||||
+ github.com/chromedp/cdproto
|
||||
+ github.com/chromedp/chromedp v0.14.2
|
||||
+ github.com/jung-kurt/gofpdf v1.16.2
|
||||
+ github.com/yuin/goldmark v1.8.2
|
||||
+ (间接) chromedp/sysutil, go-json-experiment/json, gobwas/ws, gobwas/pool, gobwas/httphead
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 删除文件 🗑️
|
||||
|
||||
- `claude-progress.txt`, `project-status-analysis.md` — 临时文件
|
||||
- `docs/代码审查/README.md` — 过期文档
|
||||
- `web/src/composables/useLocalStorage.ts` — 未使用
|
||||
- `web/src/utils/fileHelpers.js` — 合并到 fileUtils.js
|
||||
- `web/src/utils/pathHelpers.js` — 合并到 fileUtils.js
|
||||
|
||||
---
|
||||
|
||||
### 死代码清理 🧹
|
||||
|
||||
- `cache.go`: 移除 `CacheStrategy` 枚举、`warmupQueries`/`warmupEnabled` 字段
|
||||
- `redis_pipeline.go`: 移除 `RedisError` 冗余类型
|
||||
- `query_optimizer.go`: 移除 `go analyzeQuery()` 空操作 goroutine、清空 `generateJoinSuggestions`/`generateGroupSuggestions`/`generateInsertSuggestions` 硬编码
|
||||
- `openclaw/api.go`: 清理空 `import ()`
|
||||
- `openclaw/manager.go`: `*context.Context` 指针存储改为空结构体
|
||||
- `markdown-editor/index.vue`: 移除 `console.log('Content changed:', content)`
|
||||
|
||||
---
|
||||
|
||||
### 核心文件变更
|
||||
|
||||
| 文件 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `app.go` | 重构 | +208 行,新增 PDF/OpenClaw/置顶 API |
|
||||
| `internal/api/pdf_api.go` | 新增 | chromedp PDF 导出 |
|
||||
| `internal/dbclient/pool_config.go` | 重构 | +395 行,动态连接池 |
|
||||
| `internal/dbclient/query_optimizer.go` | 新增 | 查询优化器 |
|
||||
| `internal/dbclient/cache.go` | 新增 | 查询缓存 |
|
||||
| `internal/dbclient/redis_pipeline.go` | 新增 | Redis Pipeline/事务 |
|
||||
| `web/src/components/MarkdownEditor.vue` | 新增 | Markdown 编辑器组件 |
|
||||
| `web/src/components/PdfExportButton.vue` | 新增 | PDF 导出按钮 |
|
||||
| `web/src/components/MarkdownPreview.vue` | 新增 | Markdown 预览组件 |
|
||||
| `web/src/views/markdown-editor/` | 新增 | Markdown 编辑器页面 |
|
||||
| `web/src/style.css` | 扩展 | +316 行,Markdown/打印样式 |
|
||||
|
||||
---
|
||||
|
||||
## [0.3.2] - 2026-02-05
|
||||
|
||||
### 核心架构重构 🏗️
|
||||
|
||||
#### CodeMirror 统一导出机制
|
||||
**问题**: 多处直接从 `@codemirror/*` 导入导致多实例问题,影响状态共享和主题切换
|
||||
|
||||
**解决方案**:
|
||||
- 新增 `web/src/utils/codemirrorExports.js` 统一导出层
|
||||
- 所有 CodeMirror 模块通过此文件导出,确保单实例
|
||||
- 包括核心、语言包、主题等 27+ 个模块
|
||||
|
||||
```javascript
|
||||
// 核心模块
|
||||
export { EditorView, lineNumbers, ... } from '@codemirror/view'
|
||||
export { EditorState, Compartment, Facet, ... } from '@codemirror/state'
|
||||
|
||||
// 语言包
|
||||
export { javascript } from '@codemirror/lang-javascript'
|
||||
export { sql } from '@codemirror/lang-sql'
|
||||
// ... 13 个语言包
|
||||
```
|
||||
|
||||
**影响组件**:
|
||||
- `web/src/components/CodeEditor.vue`
|
||||
- `web/src/views/db-cli/components/SqlEditor.vue`
|
||||
- `web/src/views/db-cli/components/SqlPreviewDialog.vue`
|
||||
|
||||
#### 语言加载器简化
|
||||
**优化前** - 异步动态导入:
|
||||
```javascript
|
||||
export async function loadLanguageExtension(language) {
|
||||
const [path, method] = modernLangs[language]
|
||||
const mod = await import(path) // 异步加载
|
||||
return mod[method]()
|
||||
}
|
||||
```
|
||||
|
||||
**优化后** - 同步静态导入:
|
||||
```javascript
|
||||
import { javascript, json, sql, ... } from './codemirrorExports'
|
||||
|
||||
export function loadLanguageExtension(language) {
|
||||
switch (language) {
|
||||
case 'javascript': return javascript({ jsx: true })
|
||||
case 'sql': return sql()
|
||||
// ... 同步返回
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**收益**:
|
||||
- ✅ 消除异步加载失败风险
|
||||
- ✅ 代码逻辑简化 70%
|
||||
- ✅ 类型提示更完善
|
||||
- ✅ 移除 13 种 legacy 语言支持(ruby, shell, kotlin 等)
|
||||
|
||||
---
|
||||
|
||||
### 动态主题切换优化 ⚡
|
||||
|
||||
#### 使用 Compartment 实现无损切换
|
||||
**优化前** - 销毁重建方式:
|
||||
```javascript
|
||||
watch([isDark, fileExtension], async () => {
|
||||
await nextTick()
|
||||
const currentDoc = view.state.doc.toString()
|
||||
view.destroy()
|
||||
await createEditor(currentDoc) // 丢失光标、选择、历史
|
||||
})
|
||||
```
|
||||
|
||||
**优化后** - Compartment 动态重配置:
|
||||
```javascript
|
||||
const themeCompartment = new Compartment()
|
||||
const languageCompartment = new Compartment()
|
||||
|
||||
// 主题切换
|
||||
watch(() => themeStore.isDark, () => {
|
||||
view.dispatch({
|
||||
effects: themeCompartment.reconfigure(getThemeExtension())
|
||||
})
|
||||
})
|
||||
|
||||
// 语言切换
|
||||
watch(() => props.fileExtension, () => {
|
||||
initLanguage() // 使用 languageCompartment.reconfigure
|
||||
})
|
||||
```
|
||||
|
||||
**保留状态**:
|
||||
- ✅ 光标位置
|
||||
- ✅ 选择内容
|
||||
- ✅ 撤销/重做历史
|
||||
- ✅ 滚动位置
|
||||
|
||||
**性能提升**:
|
||||
- 切换耗时: 150ms → 15ms(90% 提升)
|
||||
- 无需重新解析文档
|
||||
|
||||
#### 亮色主题改进
|
||||
**新增专用亮色主题定义**:
|
||||
```javascript
|
||||
const lightTheme = EditorView.theme({
|
||||
'&': { backgroundColor: '#ffffff' },
|
||||
'.cm-gutters': { backgroundColor: '#f7f7f7', color: '#999', border: 'none' },
|
||||
'.cm-activeLineGutter': { backgroundColor: '#e8e8e8', color: '#333' },
|
||||
'.cm-line': { caretColor: '#000' },
|
||||
'.cm-selection': { backgroundColor: '#d9d9d9' },
|
||||
'.cm-cursor': { borderLeftColor: '#000' }
|
||||
})
|
||||
```
|
||||
|
||||
结合 `defaultHighlightStyle` 提供完整语法高亮
|
||||
|
||||
---
|
||||
|
||||
### 性能优化 🚀
|
||||
|
||||
#### 内容更新防抖
|
||||
**问题**: 每次按键都触发 `emit('update:modelValue')`,导致频繁的 Vue 响应式更新
|
||||
|
||||
**解决方案**:
|
||||
```javascript
|
||||
let emitTimeout = null
|
||||
const debouncedEmit = (value) => {
|
||||
if (emitTimeout) clearTimeout(emitTimeout)
|
||||
emitTimeout = setTimeout(() => {
|
||||
emit('update:modelValue', value)
|
||||
}, 150)
|
||||
}
|
||||
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
debouncedEmit(update.state.doc.toString())
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**收益**:
|
||||
- ✅ 减少 85% 的 emit 调用
|
||||
- ✅ 输入流畅度显著提升
|
||||
- ✅ 组件更新压力降低
|
||||
|
||||
---
|
||||
|
||||
### 依赖和构建优化 📦
|
||||
|
||||
#### 移除废弃依赖
|
||||
```diff
|
||||
- "@codemirror/highlight": "^0.19.8" // 已废弃
|
||||
- "@codemirror/legacy-modes": "^6.5.2" // 不需要
|
||||
```
|
||||
|
||||
**原因**:
|
||||
- `@codemirror/highlight` v0.19.8 已废弃,功能整合到 `@codemirror/language@6.12.1`
|
||||
- `@codemirror/legacy-modes` 支持的语言项目不需要
|
||||
|
||||
#### Vite 配置简化
|
||||
**移除 manualChunks 配置**:
|
||||
```diff
|
||||
- rollupOptions: {
|
||||
- output: {
|
||||
- manualChunks: (id) => {
|
||||
- if (id.includes('@codemirror')) return 'vendor-codemirror'
|
||||
- if (id.includes('@arco-design')) return 'vendor-arco'
|
||||
- ...
|
||||
- }
|
||||
- }
|
||||
- }
|
||||
```
|
||||
|
||||
**简化 optimizeDeps 配置**:
|
||||
```diff
|
||||
- optimizeDeps: {
|
||||
- include: [
|
||||
- 'vue', 'pinia', '@arco-design/web-vue',
|
||||
- '@codemirror/view', '@codemirror/state',
|
||||
- '@codemirror/language', '@codemirror/commands',
|
||||
- ... 20+ 个 CodeMirror 包
|
||||
- ]
|
||||
- }
|
||||
+ optimizeDeps: {
|
||||
+ include: ['vue', 'pinia', '@arco-design/web-vue', 'marked', 'highlight.js']
|
||||
+ }
|
||||
```
|
||||
|
||||
**收益**:
|
||||
- ✅ 配置行数减少 40+
|
||||
- ✅ Vite 自动依赖预构建更高效
|
||||
- ✅ 构建速度提升 15%
|
||||
|
||||
---
|
||||
|
||||
### 代码清理 🧹
|
||||
|
||||
#### 删除过期文档
|
||||
移除 9 个代码审查相关文档(2026-01-29/30 时期的临时文档)
|
||||
|
||||
#### 删除冗余代码
|
||||
- `web/src/components/FileSystem/components/FileEditor/CodeEditor.vue` - 旧编辑器实现
|
||||
- `web/src/components/FileSystem/components/FileEditorPanel.new.vue` - 未使用的原型文件
|
||||
|
||||
---
|
||||
|
||||
### 技术细节
|
||||
|
||||
#### 核心文件变更
|
||||
|
||||
| 文件 | 类型 | 行数变化 | 说明 |
|
||||
|------|------|----------|------|
|
||||
| `web/src/utils/codemirrorExports.js` | 新增 | +27 | 统一导出 |
|
||||
| `web/src/utils/codeMirrorLoader.js` | 重构 | -50 | 简化语言加载 |
|
||||
| `web/src/components/CodeEditor.vue` | 重构 | +80/-40 | Compartment + 防抖 |
|
||||
| `web/package.json` | 优化 | -2 | 移除废弃包 |
|
||||
| `web/vite.config.js` | 优化 | -40 | 简化配置 |
|
||||
| `internal/service/version.go` | 更新 | ±1 | 版本号 0.3.0 → 0.3.2 |
|
||||
|
||||
#### 依赖变化
|
||||
```diff
|
||||
dependencies:
|
||||
- @codemirror/highlight: ^0.19.8
|
||||
- @codemirror/legacy-modes: ^6.5.2
|
||||
|
||||
(共移除 2 个包,减少约 80KB 打包体积)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 构建验证
|
||||
|
||||
```bash
|
||||
✓ 依赖安装: npm install (无警告)
|
||||
✓ 开发构建: npm run dev (正常启动)
|
||||
✓ 生产构建: npm run build (10.2s)
|
||||
✓ 类型检查: 无错误
|
||||
✓ 运行测试: 编辑器功能正常,主题切换流畅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 相关文档
|
||||
- [详细 changelog](docs/项目管理/版本管理/changelog-2026-02-05.md)
|
||||
- [CodeMirror 配置优化总结](docs/CodeMirror-配置优化总结.md)
|
||||
- [CodeEditor 优化报告](docs/CodeEditor-优化报告.md)
|
||||
|
||||
---
|
||||
|
||||
## [0.3.0] - 2026-02-04
|
||||
|
||||
### 新增功能 ✨
|
||||
|
||||
117
CHANGELOG.md
@@ -1,5 +1,120 @@
|
||||
# 更新日志
|
||||
|
||||
## [0.4.0] - 2026-04-25
|
||||
|
||||
### 重构 🔧
|
||||
- **移除数据库客户端模块**: 删除全部 MySQL/Redis/MongoDB 相关代码(-17,885 行),应用专注文件管理
|
||||
- **清理依赖**: 移除 go-sql-driver/mysql、go-redis/v9、mongo-driver/v2、gorm.io/driver/mysql 等驱动依赖
|
||||
- **构建体积优化**: 原始 exe 从 36MB 降至 26MB,UPX 压缩后仅 7.5MB(压缩率 28.8%)
|
||||
|
||||
### 变更说明
|
||||
- 顶部 Tab 仅保留「文件管理」,移除数据库入口
|
||||
- Markdown 编辑器、版本历史、系统信息、更新检查等模块不受影响
|
||||
- 本地 SQLite 配置存储(AppConfig)保留不变
|
||||
|
||||
---
|
||||
|
||||
## [0.3.4] - 2026-04-22
|
||||
|
||||
### 新增 ✨
|
||||
- **CodeMirror 搜索功能**: Ctrl+F / Ctrl+H 全局查找替换,`@codemirror/search` 集成
|
||||
- **编辑器滚动位置恢复**: LRU 缓存(最多5份/3分钟TTL),切换文件不丢位置
|
||||
- **文件列表列排序**: 图标/名称/时间/大小四列可排序,升序降序切换
|
||||
- **文件搜索过滤**: 工具栏实时搜索框,按文件名过滤列表
|
||||
- **Toolbar UI 重排**: 快捷访问内嵌面包屑左侧、历史记录改为图标+tooltip、Ctrl+H 快捷键
|
||||
- **更新面板 Markdown 渲染**: changelog 用 `marked.parse()` 结构化渲染,替代纯文本
|
||||
- **重命名零闪烁**: `updateFilePath()` 仅迁移路径引用+草稿key,不重新加载内容
|
||||
|
||||
### 优化 🚀
|
||||
- **路径安全重构**: `validateFilePath()` 提取统一函数,消除两处重复校验代码
|
||||
- **requireUpdateAPI 模式**: 7 处重复 nil 检查收敛为 guard 方法
|
||||
- **端口统一**: 文件服务器端口 18765→8073,全局一致消除魔法数字分散
|
||||
- **文件服务器 URL 动态获取**: 前端从后端 API 获取,不再硬编码
|
||||
- **Tab 配置迁移扩展**: MigrateTabConfig 改为 map 驱动,覆盖 openclaw-manager→version 迁移
|
||||
- **updateContent 简化**: 去掉时间窗口双重检查,仅保留版本号机制
|
||||
|
||||
### 安全修复 🔒
|
||||
- **sentinel error 替代字符串匹配**: validateFilePath 错误用 `errors.Is()` 判断,消息变更不再静默失效
|
||||
- **sanitizeHtml 防御远程 Markdown XSS**: 过滤 script/iframe/embed/on* 事件属性
|
||||
|
||||
### 修复 🐛
|
||||
- **showHeader 默认值修正**: localStorage 无值时默认显示表头(兼容旧行为)
|
||||
- **外层容器双重 scroll reset 移除**: 避免 CodeEditor 内部滚动恢复与外层 reset 冲突闪烁
|
||||
|
||||
---
|
||||
|
||||
## [0.3.3] - 2026-04-13
|
||||
|
||||
### 新增 ✨
|
||||
- **Markdown 编辑器**: 独立编辑页面、实时预览、字符/行数统计、Ctrl+S 保存、自动保存
|
||||
- **Markdown 文件页面**: 独立的 Markdown 文件查看与编辑界面 (`views/markdown-editor/`)
|
||||
- **PDF 导出**: 浏览器打印 + 后端 gofpdf/chromedp 多种导出方式
|
||||
- **窗口置顶**: 支持窗口始终置顶
|
||||
- **收藏夹置顶**: 收藏项支持置顶排序
|
||||
- **文件预览**: Excel/Word 文件预览支持
|
||||
- **数据库 UI 大幅改进**: 查询历史面板、查询模板面板、SQL 工具栏、结果导出(CSV)、SQL 格式化器
|
||||
- **数据库可见性过滤**: 连接管理增强、ConnectionForm 重写、统一错误处理模块 (`database-error.ts`)
|
||||
|
||||
### 优化 🚀
|
||||
- MySQL 动态连接池重构 — 健康检查、性能权重、自适应扩缩容
|
||||
- SQL 查询优化器 — 查询缓存、慢查询日志
|
||||
- Redis Pipeline — 批量命令、事务 MULTI/EXEC 支持
|
||||
- Office/CSV 预览增强 — 本地文件服务器获取文件
|
||||
- Markdown 增强 — 本地文件链接支持、Shell 语法高亮
|
||||
- HTML 预览 — 改用 iframe src 替代 srcdoc
|
||||
- Wails 框架升级 + Mermaid 主题切换 + 代码高亮修复
|
||||
- 文件列表 UI 重构 — 统一渲染逻辑,提升滚动性能
|
||||
- CSV 编辑模式优化 + PDF 导出重构
|
||||
- 拷贝功能优化
|
||||
|
||||
### 修复 🐛
|
||||
- Office 文件预览:修复类型检测与二进制误判
|
||||
- 本地文件服务器 CORS 跨域问题
|
||||
- 大文件点击卡死问题
|
||||
- 收藏夹 bug 修复
|
||||
- FileEditorPanel 语法错误
|
||||
|
||||
### 安全修复 🔒
|
||||
- XSS 防护(PdfExportButton、MarkdownPreview HTML 消毒)
|
||||
- PDF 导出路径穿越防护
|
||||
- PDF 导出标题 HTML 注入防护
|
||||
|
||||
### 重构 🔧
|
||||
- CodeMirror 架构优化 — 统一导出避免多实例问题
|
||||
- 消除代码重复 — storage/connection_service 重构、useVisibleDatabases 抽取
|
||||
- 大规模死代码清理,显著减小包体积
|
||||
- 配置加载超时保护(最多重试 30 次)
|
||||
- 正则表达式预编译、缓存读锁优化
|
||||
- 禁止 Ctrl+滚轮缩放
|
||||
- Dockerfile 语法高亮支持
|
||||
- 滚动条样式修复
|
||||
|
||||
### 文件系统 📁
|
||||
- 右键菜单新增新建文件/文件夹
|
||||
- FileEditorPanel 集成 PDF 导出按钮
|
||||
- Markdown 文件自动预览与编辑/预览模式切换
|
||||
- 面包屑导航组件
|
||||
|
||||
---
|
||||
|
||||
## [0.3.2] - 2026-02-05
|
||||
|
||||
### 重构 🔧
|
||||
- **CodeMirror 架构优化** - 统一导出避免多实例问题
|
||||
- **语言加载器优化** - 从动态 import 改为静态导入,提升稳定性
|
||||
- **动态主题切换** - 使用 Compartment 实现无损切换
|
||||
|
||||
### 优化 🚀
|
||||
- **编辑器性能** - 添加内容更新防抖,减少不必要的渲染
|
||||
- **亮色主题** - 改进代码编辑器亮色模式样式
|
||||
- **构建配置** - 简化 Vite 配置,优化打包效率
|
||||
|
||||
### 依赖清理 🧹
|
||||
- 移除废弃的 `@codemirror/highlight` 包
|
||||
- 移除不再使用的 `@codemirror/legacy-modes` 包
|
||||
|
||||
---
|
||||
|
||||
## [0.3.0] - 2026-02-04
|
||||
|
||||
### 新增 ✨
|
||||
@@ -45,5 +160,3 @@
|
||||
- **主版本号** - 不兼容的 API 修改
|
||||
- **次版本号** - 向下兼容的功能性新增
|
||||
- **修订号** - 向下兼容的问题修复
|
||||
|
||||
|
||||
|
||||
159
README.md
@@ -1,155 +1,22 @@
|
||||
# U-Desk
|
||||
# U-Desk v0.3.4
|
||||
|
||||
基于 Wails 的桌面应用程序,集成数据库客户端、文件管理、设备测试等功能。
|
||||
## 功能
|
||||
- **文件管理** — 本地文件浏览、编辑(CodeMirror 语法高亮+搜索)、预览(图片/视频/PDF/HTML/Markdown/Excel/Word/CSV)
|
||||
- **数据库客户端** — 多数据库连接管理、SQL 执行、查询历史、表结构管理
|
||||
- **Markdown 编辑器** — 独立编辑页面、实时预览、PDF 导出
|
||||
- **版本更新** — 自动检查更新、下载安装、changelog 渲染
|
||||
- **系统信息** — CPU/内存/磁盘硬件信息查询
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**:Go 1.25+、Wails v2
|
||||
- **前端**:Vue 3、Arco Design Vue、Vite
|
||||
- **存储**:SQLite、MySQL、Redis、MongoDB
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 1. 数据库客户端
|
||||
- 支持 MySQL、Redis、MongoDB 多种数据库连接
|
||||
- 连接管理(保存、编辑、删除连接配置)
|
||||
- SQL 执行与结果展示
|
||||
- 数据表结构查看
|
||||
|
||||
### 2. 文件管理
|
||||
- 本地文件系统浏览(支持多盘符)
|
||||
- 文件预览(图片、文本、代码)
|
||||
- 文件操作(复制、移动、删除、重命名)
|
||||
- 常用路径快捷访问(桌面、文档、下载等)
|
||||
- 搜索与筛选功能
|
||||
|
||||
### 3. 设备测试
|
||||
- 系统设备信息查询
|
||||
- 硬件状态检测
|
||||
|
||||
### 4. 更新管理
|
||||
- 应用版本检查与自动更新
|
||||
- 更新日志展示
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
go-desk/
|
||||
├── app.go # 应用入口,API 方法绑定
|
||||
├── main.go # 程序启动
|
||||
├── wails.json # Wails 配置
|
||||
├── go.mod # Go 模块依赖
|
||||
├── internal/
|
||||
│ ├── api/ # API 层(数据库、标签页、更新等)
|
||||
│ ├── common/ # 通用工具(超时、工具函数)
|
||||
│ ├── dbclient/ # 数据库客户端(MySQL、Redis、MongoDB)
|
||||
│ ├── filesystem/ # 文件系统管理(模块化架构)
|
||||
│ ├── service/ # 服务层(SQL 执行等)
|
||||
│ ├── storage/ # 本地存储(SQLite)
|
||||
│ └── system/ # 系统信息获取
|
||||
└── web/ # 前端代码
|
||||
├── package.json
|
||||
├── vite.config.js
|
||||
├── index.html
|
||||
└── src/
|
||||
├── components/ # Vue 组件
|
||||
│ ├── FileSystem.vue # 文件管理
|
||||
│ ├── DeviceTest.vue # 设备测试
|
||||
│ ├── UpdatePanel.vue # 更新面板
|
||||
│ └── CodeEditor.vue # 代码编辑器
|
||||
├── composables/ # 组合式函数
|
||||
│ ├── useFileOperations.js
|
||||
│ ├── useFavoriteFiles.js
|
||||
│ └── useLocalStorage.js
|
||||
├── utils/ # 工具函数
|
||||
├── api/ # API 调用
|
||||
└── App.vue # 主应用
|
||||
```
|
||||
- **后端**: Go + Wails v2 (桌面应用框架)
|
||||
- **前端**: Vue 3 + Arco Design + CodeMirror 6 + Pinia
|
||||
- **存储**: SQLite (GORM)
|
||||
- **本地文件服务器**: `localhost:8073`(CSS/JS 路径转换、HTML 预览)
|
||||
|
||||
## 开发
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
# Go 依赖
|
||||
go mod tidy
|
||||
|
||||
# 前端依赖
|
||||
cd web
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 构建前端(必须)
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm run build
|
||||
```
|
||||
|
||||
**重要**:每次修改前端代码后都需要重新构建,Wails 使用 `web/dist` 目录中的构建产物。
|
||||
|
||||
### 3. 开发模式运行
|
||||
|
||||
```bash
|
||||
# 在项目根目录
|
||||
wails dev
|
||||
```
|
||||
|
||||
**注意**:如果 `wails` 命令找不到,使用完整路径:
|
||||
```bash
|
||||
# 获取 GOPATH
|
||||
go env GOPATH
|
||||
|
||||
# 使用完整路径(根据你的 GOPATH 调整)
|
||||
D:\Go\go-workspace\bin\wails.exe dev
|
||||
```
|
||||
|
||||
### 4. 构建应用
|
||||
|
||||
```bash
|
||||
# 确保前端已构建
|
||||
cd web
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
# 构建当前平台
|
||||
wails build
|
||||
|
||||
# 构建 Windows(明确指定平台)
|
||||
wails build -platform windows/amd64
|
||||
```
|
||||
|
||||
**构建产物位置**:`build/bin/go-desk.exe`
|
||||
|
||||
**注意**:
|
||||
- 构建前确保前端已构建(`web/dist` 目录存在)
|
||||
- 构建产物是独立的可执行文件,包含前端资源
|
||||
|
||||
## 数据库配置
|
||||
|
||||
应用使用 SQLite 本地存储连接配置和用户数据。
|
||||
|
||||
可选连接外部数据库:
|
||||
- **MySQL**:支持连接、查询、表结构查看
|
||||
- **Redis**:支持连接、基础操作
|
||||
- **MongoDB**:支持连接、基础操作
|
||||
|
||||
## 架构特点
|
||||
|
||||
- **模块化文件系统**:文件管理功能采用模块化设计,职责分离
|
||||
- **异步启动优化**:应用启动流程优化,核心功能快速初始化
|
||||
- **本地文件服务器**:支持本地文件预览和访问
|
||||
- **SQLite 持久化**:连接配置和用户数据本地存储
|
||||
|
||||
## 文档
|
||||
|
||||
详细文档请查看 `docs/` 目录:
|
||||
- 架构设计文档
|
||||
- 功能迭代记录
|
||||
- 技术决策记录(ADR)
|
||||
- 测试用例和检查报告
|
||||
|
||||
## 许可
|
||||
|
||||
本项目用于学习和测试目的。
|
||||
|
||||
## 更新
|
||||
- ✅ 文件服务器安全重构+编辑器增强+搜索排序+更新面板渲染
|
||||
|
||||
429
app.go
@@ -1,19 +1,22 @@
|
||||
// [fs-only] 数据库客户端模块已移除(feature/fs-only 分支)
|
||||
// 保留模块:文件系统 | Markdown编辑器 | 版本历史(抽屉) | 系统信息 | 更新检查 | PDF导出
|
||||
// 顶部Tab仅:file-system(数据库 db-cli 已删除)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
stdruntime "runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
"u-desk/internal/api"
|
||||
"u-desk/internal/common"
|
||||
"u-desk/internal/database"
|
||||
"u-desk/internal/filesystem"
|
||||
"u-desk/internal/service"
|
||||
"u-desk/internal/storage"
|
||||
"u-desk/internal/system"
|
||||
|
||||
@@ -23,16 +26,17 @@ import (
|
||||
// App 应用结构体
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
db *database.DB
|
||||
connectionAPI *api.ConnectionAPI
|
||||
sqlAPI *api.SqlAPI
|
||||
tabAPI *api.TabAPI
|
||||
updateAPI *api.UpdateAPI
|
||||
configAPI *api.ConfigAPI
|
||||
fileServer *http.Server
|
||||
pdfAPI *api.PdfAPI
|
||||
filesystem *filesystem.FileSystemService
|
||||
isAlwaysOnTop bool
|
||||
}
|
||||
|
||||
// App 方法命名约定:
|
||||
// - 多参数操作 → XxxRequest 结构体(Wails 自动生成 TS 类型)
|
||||
// - 单参数查询/简单操作 → 直接参数
|
||||
|
||||
// NewApp 创建新的应用实例
|
||||
func NewApp() *App {
|
||||
return &App{}
|
||||
@@ -59,7 +63,22 @@ func (a *App) Startup(ctx context.Context) {
|
||||
// 2.5. 迁移旧配置
|
||||
_ = a.configAPI.MigrateTabConfig()
|
||||
|
||||
// 3. 读取配置,获取可见的 Tabs
|
||||
// 2.6. 初始化PDF导出API
|
||||
fmt.Println("[启动] 初始化PDF导出模块...")
|
||||
pdfAPI, err := api.NewPdfAPI()
|
||||
if err != nil {
|
||||
fmt.Printf("[启动] PDF导出API初始化失败: %v\n", err)
|
||||
// PDF导出失败不应影响应用启动,所以只警告不panic
|
||||
} else {
|
||||
a.pdfAPI = pdfAPI
|
||||
fmt.Println("[启动] PDF导出模块初始化完成")
|
||||
}
|
||||
|
||||
// 3. 初始化版本号(提前触发缓存,避免后续重复计算)
|
||||
version := service.GetCurrentVersion()
|
||||
fmt.Printf("[启动] 当前版本: %s\n", version)
|
||||
|
||||
// 4. 读取配置,获取可见的 Tabs
|
||||
visibleTabs := a.getVisibleTabs()
|
||||
fmt.Printf("[启动] 可用的模块: %v\n", visibleTabs)
|
||||
|
||||
@@ -70,7 +89,7 @@ func (a *App) Startup(ctx context.Context) {
|
||||
|
||||
// 5. 异步初始化:UpdateAPI(涉及网络请求,完全异步)
|
||||
go func() {
|
||||
if updateAPI, err := api.NewUpdateAPI("https://img.1216.top/u-desk/last-version.json"); err == nil {
|
||||
if updateAPI, err := api.NewUpdateAPI("https://c.1216.top/last-version.json"); err == nil {
|
||||
a.updateAPI = updateAPI
|
||||
a.updateAPI.SetContext(ctx)
|
||||
a.startAutoUpdateCheck()
|
||||
@@ -117,31 +136,6 @@ func (a *App) getVisibleTabs() []string {
|
||||
|
||||
// initModulesByConfig 根据配置初始化模块
|
||||
func (a *App) initModulesByConfig(visibleTabs []string) error {
|
||||
// 检查是否启用数据库模块
|
||||
if common.Contains(visibleTabs, common.TabDatabase) {
|
||||
fmt.Println("[启动] 初始化数据库模块...")
|
||||
var err error
|
||||
|
||||
// 初始化 ConnectionAPI
|
||||
if a.connectionAPI, err = api.NewConnectionAPI(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 初始化 SqlAPI
|
||||
if a.sqlAPI, err = api.NewSqlAPI(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 初始化 TabAPI
|
||||
if a.tabAPI, err = api.NewTabAPI(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("[启动] 数据库模块初始化完成")
|
||||
} else {
|
||||
fmt.Println("[启动] 跳过数据库模块(未启用)")
|
||||
}
|
||||
|
||||
// 检查是否启用文件系统模块
|
||||
if common.Contains(visibleTabs, common.TabFileSystem) {
|
||||
fmt.Println("[启动] 初始化文件系统模块...")
|
||||
@@ -173,61 +167,34 @@ func (a *App) startFileServer() {
|
||||
return
|
||||
}
|
||||
|
||||
// 创建一个占位服务器用于保持引用(实际服务器由 StartLocalFileServer 管理)
|
||||
a.fileServer = &http.Server{
|
||||
Addr: "localhost:18765",
|
||||
}
|
||||
|
||||
fmt.Println("[文件服务器] 启动在 http://localhost:18765")
|
||||
fmt.Println("[文件服务器] 启动在 http://localhost:8073")
|
||||
}
|
||||
|
||||
// Shutdown 应用关闭时调用
|
||||
func (a *App) Shutdown(ctx context.Context) {
|
||||
// 关闭文件系统服务(优雅关闭,释放资源)
|
||||
// 创建带超时的上下文(5秒超时)
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 1. 关闭文件系统服务(优雅关闭,释放资源)
|
||||
if a.filesystem != nil {
|
||||
fmt.Println("[文件系统服务] 正在关闭...")
|
||||
if err := a.filesystem.Close(ctx); err != nil {
|
||||
if err := a.filesystem.Close(shutdownCtx); err != nil {
|
||||
fmt.Printf("[文件系统服务] 关闭失败: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("[文件系统服务] 已关闭")
|
||||
}
|
||||
}
|
||||
|
||||
// 停止文件服务器
|
||||
if a.fileServer != nil {
|
||||
// 2. 停止文件服务器(使用全局服务器的关闭方法)
|
||||
fmt.Println("[文件服务器] 正在关闭...")
|
||||
a.fileServer.Shutdown(ctx)
|
||||
if err := filesystem.ShutdownLocalFileServer(); err != nil {
|
||||
fmt.Printf("[文件服务器] 关闭失败: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("[文件服务器] 已关闭")
|
||||
}
|
||||
}
|
||||
|
||||
// QueryUsers 查询用户列表
|
||||
func (a *App) QueryUsers(keyword string, status int, role int, organid int, page int, pageSize int, sortField string, sortOrder string) (map[string]interface{}, error) {
|
||||
db, err := a.getDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db.QueryUsers(keyword, status, role, organid, page, pageSize, sortField, sortOrder)
|
||||
}
|
||||
|
||||
// getDB 获取数据库连接(延迟加载,按需初始化)
|
||||
func (a *App) getDB() (*database.DB, error) {
|
||||
if a.db != nil {
|
||||
return a.db, nil
|
||||
}
|
||||
|
||||
// 首次调用时才连接数据库
|
||||
db, err := database.Init()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("数据库连接失败: %v", err)
|
||||
}
|
||||
|
||||
a.db = db
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Greet 测试方法
|
||||
func (a *App) Greet(name string) string {
|
||||
return "Hello " + name + ", It's show time!"
|
||||
}
|
||||
|
||||
// GetSystemInfo 获取系统信息
|
||||
func (a *App) GetSystemInfo() (map[string]interface{}, error) {
|
||||
return system.GetSystemInfo()
|
||||
@@ -264,23 +231,34 @@ func (a *App) WriteFile(req WriteFileRequest) error {
|
||||
return a.filesystem.WriteFile(req.Path, req.Content)
|
||||
}
|
||||
|
||||
// SaveBase64FileRequest 保存 Base64 编码的二进制文件
|
||||
type SaveBase64FileRequest struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"` // base64 编码的文件内容
|
||||
}
|
||||
|
||||
// SaveBase64File 将 base64 内容解码后写入文件(用于图片等二进制数据)
|
||||
func (a *App) SaveBase64File(req SaveBase64FileRequest) error {
|
||||
return a.filesystem.SaveBase64File(req.Path, req.Content)
|
||||
}
|
||||
|
||||
// ListDir 列出目录
|
||||
func (a *App) ListDir(path string) ([]map[string]interface{}, error) {
|
||||
return a.filesystem.ListDir(path)
|
||||
}
|
||||
|
||||
// CreateDir 创建目录
|
||||
func (a *App) CreateDir(path string) error {
|
||||
func (a *App) CreateDir(path string) (*filesystem.FileOperationResult, error) {
|
||||
return a.filesystem.CreateDir(path)
|
||||
}
|
||||
|
||||
// CreateFile 创建文件
|
||||
func (a *App) CreateFile(path string) error {
|
||||
func (a *App) CreateFile(path string) (*filesystem.FileOperationResult, error) {
|
||||
return a.filesystem.CreateFile(path)
|
||||
}
|
||||
|
||||
// DeletePath 删除文件或目录
|
||||
func (a *App) DeletePath(path string) error {
|
||||
func (a *App) DeletePath(path string) (*filesystem.FileOperationResult, error) {
|
||||
return a.filesystem.DeletePath(path)
|
||||
}
|
||||
|
||||
@@ -291,7 +269,7 @@ type RenamePathRequest struct {
|
||||
}
|
||||
|
||||
// RenamePath 重命名文件或目录
|
||||
func (a *App) RenamePath(req RenamePathRequest) error {
|
||||
func (a *App) RenamePath(req RenamePathRequest) (*filesystem.FileOperationResult, error) {
|
||||
return a.filesystem.RenamePath(req.OldPath, req.NewPath)
|
||||
}
|
||||
|
||||
@@ -371,6 +349,31 @@ func (a *App) ResolveShortcut(lnkPath string) (map[string]interface{}, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getWindowsSpecialFolder 从注册表读取 Windows 特殊文件夹的真实路径
|
||||
// 用户可通过系统设置修改下载/桌面/文档等目录位置,注册表记录实际路径
|
||||
func getWindowsSpecialFolder(guid string, fallbackName string) string {
|
||||
key, err := registry.OpenKey(registry.CURRENT_USER,
|
||||
`Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders`,
|
||||
registry.READ)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
val, _, err := key.GetStringValue(guid)
|
||||
if err != nil || val == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 展开 %USERPROFILE% 等环境变量
|
||||
path := os.ExpandEnv(val)
|
||||
// 验证路径存在
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return ""
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// GetCommonPaths 获取常用系统路径
|
||||
func (a *App) GetCommonPaths() (map[string]string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
@@ -380,9 +383,21 @@ func (a *App) GetCommonPaths() (map[string]string, error) {
|
||||
|
||||
paths := map[string]string{
|
||||
"home": homeDir,
|
||||
"desktop": filepath.Join(homeDir, "Desktop"),
|
||||
"documents": filepath.Join(homeDir, "Documents"),
|
||||
"downloads": filepath.Join(homeDir, "Downloads"),
|
||||
}
|
||||
|
||||
// Windows: 从注册表读取特殊文件夹真实路径(用户可能已修改位置)
|
||||
folderGUIDs := map[string]string{
|
||||
"desktop": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}",
|
||||
"documents": "{D20B4C7F-5EA7-424C-B25E-039F6F1FCC8A}",
|
||||
"downloads": "{374DE290-123F-4565-9164-39C4925E467B}",
|
||||
}
|
||||
for name, guid := range folderGUIDs {
|
||||
if p := getWindowsSpecialFolder(guid, name); p != "" {
|
||||
paths[name] = p
|
||||
} else {
|
||||
// folderGUIDs 的 key 均为 ASCII,无需 Unicode 处理
|
||||
paths[name] = filepath.Join(homeDir, strings.ToUpper(name[:1])+name[1:])
|
||||
}
|
||||
}
|
||||
|
||||
// Windows: 动态添加所有盘符
|
||||
@@ -399,89 +414,6 @@ func (a *App) GetCommonPaths() (map[string]string, error) {
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// ========== 数据库连接管理接口 ==========
|
||||
|
||||
// SaveDbConnection 保存数据库连接配置
|
||||
func (a *App) SaveDbConnection(req api.SaveConnectionRequest) error {
|
||||
return a.connectionAPI.SaveDbConnection(req)
|
||||
}
|
||||
|
||||
// ListDbConnections 获取连接列表
|
||||
func (a *App) ListDbConnections() ([]map[string]interface{}, error) {
|
||||
return a.connectionAPI.ListDbConnections()
|
||||
}
|
||||
|
||||
// DeleteDbConnection 删除连接配置
|
||||
func (a *App) DeleteDbConnection(id uint) error {
|
||||
return a.connectionAPI.DeleteDbConnection(id)
|
||||
}
|
||||
|
||||
// TestDbConnection 测试连接(通过已保存的连接ID)
|
||||
func (a *App) TestDbConnection(id uint) error {
|
||||
return a.connectionAPI.TestDbConnection(id)
|
||||
}
|
||||
|
||||
// TestDbConnectionWithParams 测试数据库连接(直接传入参数,不保存数据)
|
||||
func (a *App) TestDbConnectionWithParams(req api.TestConnectionRequest) error {
|
||||
return a.connectionAPI.TestDbConnectionWithParams(req)
|
||||
}
|
||||
|
||||
// ExecuteSQL 执行 SQL 语句
|
||||
// 注意:SQL 语句应该已经包含分页信息(LIMIT 和 OFFSET),由客户端添加
|
||||
func (a *App) ExecuteSQL(connectionId uint, sqlStr string, database string) (map[string]interface{}, error) {
|
||||
return a.sqlAPI.ExecuteSQL(connectionId, sqlStr, database)
|
||||
}
|
||||
|
||||
// GetDatabases 获取数据库列表
|
||||
func (a *App) GetDatabases(connectionId uint) ([]string, error) {
|
||||
return a.sqlAPI.GetDatabases(connectionId)
|
||||
}
|
||||
|
||||
// GetTables 获取表列表
|
||||
func (a *App) GetTables(connectionId uint, database string) ([]string, error) {
|
||||
return a.sqlAPI.GetTables(connectionId, database)
|
||||
}
|
||||
|
||||
// GetTableStructure 获取表结构
|
||||
func (a *App) GetTableStructure(connectionId uint, database, tableName string) (map[string]interface{}, error) {
|
||||
return a.sqlAPI.GetTableStructure(connectionId, database, tableName)
|
||||
}
|
||||
|
||||
// GetIndexes 获取索引列表
|
||||
func (a *App) GetIndexes(connectionId uint, database, tableName string) ([]map[string]interface{}, error) {
|
||||
return a.sqlAPI.GetIndexes(connectionId, database, tableName)
|
||||
}
|
||||
|
||||
// PreviewTableStructure 预览表结构变更
|
||||
func (a *App) PreviewTableStructure(connectionId uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
||||
return a.sqlAPI.PreviewTableStructure(connectionId, database, tableName, structure)
|
||||
}
|
||||
|
||||
// UpdateTableStructure 更新表结构
|
||||
func (a *App) UpdateTableStructure(connectionId uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
||||
return a.sqlAPI.UpdateTableStructure(connectionId, database, tableName, structure)
|
||||
}
|
||||
|
||||
// SaveResult 手动保存执行结果
|
||||
func (a *App) SaveResult(connectionId uint, database, sql string, resultType string, data interface{}, columns []string, rowsAffected int, executionTime int64) (map[string]interface{}, error) {
|
||||
return a.sqlAPI.SaveResult(connectionId, database, sql, resultType, data, columns, rowsAffected, executionTime)
|
||||
}
|
||||
|
||||
// GetResultHistory 获取结果历史
|
||||
func (a *App) GetResultHistory(connectionId *uint, keyword string, limit, offset int) (map[string]interface{}, error) {
|
||||
return a.sqlAPI.GetResultHistory(connectionId, keyword, limit, offset)
|
||||
}
|
||||
|
||||
// GetResultHistoryByID 根据ID获取结果历史
|
||||
func (a *App) GetResultHistoryByID(id uint) (map[string]interface{}, error) {
|
||||
return a.sqlAPI.GetResultHistoryByID(id)
|
||||
}
|
||||
|
||||
// DeleteResultHistory 删除结果历史
|
||||
func (a *App) DeleteResultHistory(id uint) error {
|
||||
return a.sqlAPI.DeleteResultHistory(id)
|
||||
}
|
||||
|
||||
// Reload 重新加载窗口(用于菜单项)
|
||||
func (a *App) Reload() {
|
||||
if a.ctx != nil {
|
||||
@@ -532,82 +464,96 @@ func (a *App) WindowIsMaximized() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ========== SQL 标签页管理接口 ==========
|
||||
|
||||
// SaveSqlTabs 保存 SQL 标签页列表
|
||||
func (a *App) SaveSqlTabs(tabs []map[string]interface{}) error {
|
||||
return a.tabAPI.SaveSqlTabs(tabs)
|
||||
// WindowToggleAlwaysOnTop 切换窗口置顶
|
||||
func (a *App) WindowToggleAlwaysOnTop() bool {
|
||||
if a.ctx == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// ListSqlTabs 获取 SQL 标签页列表
|
||||
func (a *App) ListSqlTabs() ([]map[string]interface{}, error) {
|
||||
return a.tabAPI.ListSqlTabs()
|
||||
a.isAlwaysOnTop = !a.isAlwaysOnTop
|
||||
runtime.WindowSetAlwaysOnTop(a.ctx, a.isAlwaysOnTop)
|
||||
return a.isAlwaysOnTop
|
||||
}
|
||||
|
||||
// ========== 版本更新管理接口 ==========
|
||||
|
||||
// CheckUpdate 检查更新(UpdateAPI 可能尚未初始化完成)
|
||||
func (a *App) CheckUpdate() (map[string]interface{}, error) {
|
||||
// requireUpdateAPI 检查 updateAPI 是否已初始化,未初始化返回统一错误
|
||||
func (a *App) requireUpdateAPI() (*api.UpdateAPI, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
}
|
||||
return a.updateAPI.CheckUpdate()
|
||||
return a.updateAPI, nil
|
||||
}
|
||||
|
||||
// CheckUpdate 检查更新(UpdateAPI 可能尚未初始化完成)
|
||||
func (a *App) CheckUpdate() (map[string]interface{}, error) {
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return api.CheckUpdate()
|
||||
}
|
||||
|
||||
// GetCurrentVersion 获取当前版本号
|
||||
func (a *App) GetCurrentVersion() (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.GetCurrentVersion()
|
||||
return api.GetCurrentVersion()
|
||||
}
|
||||
|
||||
// GetUpdateConfig 获取更新配置
|
||||
func (a *App) GetUpdateConfig() (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.GetUpdateConfig()
|
||||
return api.GetUpdateConfig()
|
||||
}
|
||||
|
||||
// SetUpdateConfig 设置更新配置
|
||||
func (a *App) SetUpdateConfig(autoCheckEnabled bool, checkIntervalMinutes int, checkURL string) (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL)
|
||||
return api.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL)
|
||||
}
|
||||
|
||||
// DownloadUpdate 下载更新包
|
||||
func (a *App) DownloadUpdate(downloadURL string) (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.DownloadUpdate(downloadURL)
|
||||
return api.DownloadUpdate(downloadURL)
|
||||
}
|
||||
|
||||
// InstallUpdate 安装更新包
|
||||
func (a *App) InstallUpdate(installerPath string, autoRestart bool) (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.InstallUpdate(installerPath, autoRestart)
|
||||
return api.InstallUpdate(installerPath, autoRestart)
|
||||
}
|
||||
|
||||
// InstallUpdateWithHash 安装更新包(带哈希验证)
|
||||
func (a *App) InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash string, hashType string) (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType)
|
||||
return api.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType)
|
||||
}
|
||||
|
||||
// VerifyUpdateFile 验证更新文件哈希值
|
||||
func (a *App) VerifyUpdateFile(filePath string, expectedHash string, hashType string) (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.VerifyUpdateFile(filePath, expectedHash, hashType)
|
||||
return api.VerifyUpdateFile(filePath, expectedHash, hashType)
|
||||
}
|
||||
|
||||
// startAutoUpdateCheck 启动自动更新检查
|
||||
@@ -617,7 +563,11 @@ func (a *App) startAutoUpdateCheck() {
|
||||
}
|
||||
|
||||
config, err := a.updateAPI.GetUpdateConfig()
|
||||
if err != nil || !config["success"].(bool) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
success, ok := config["success"].(bool)
|
||||
if !ok || !success {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -692,7 +642,7 @@ func (a *App) GetAuditLogs(limit int) ([]map[string]interface{}, error) {
|
||||
|
||||
// GetFileServerURL 获取本地文件服务器的URL
|
||||
func (a *App) GetFileServerURL() string {
|
||||
return "http://localhost:18765"
|
||||
return "http://localhost:8073"
|
||||
}
|
||||
|
||||
// DetectFileTypeByContent 通过文件内容检测文件类型(用于小文件)
|
||||
@@ -787,8 +737,6 @@ func (a *App) handleNewlyEnabledModules(oldTabs, newTabs []string) {
|
||||
|
||||
for _, tab := range newlyEnabled {
|
||||
switch tab {
|
||||
case common.TabDatabase:
|
||||
a.initDatabaseModule()
|
||||
case common.TabFileSystem:
|
||||
a.initFilesystemModule()
|
||||
case common.TabDevice:
|
||||
@@ -797,37 +745,6 @@ func (a *App) handleNewlyEnabledModules(oldTabs, newTabs []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// initDatabaseModule 延迟初始化数据库模块
|
||||
func (a *App) initDatabaseModule() {
|
||||
if a.connectionAPI != nil {
|
||||
fmt.Println("[模块] 数据库模块已初始化,跳过")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("[模块] 延迟初始化数据库模块...")
|
||||
var err error
|
||||
|
||||
// 初始化 ConnectionAPI
|
||||
if a.connectionAPI, err = api.NewConnectionAPI(); err != nil {
|
||||
fmt.Printf("[模块] 数据库模块初始化失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化 SqlAPI
|
||||
if a.sqlAPI, err = api.NewSqlAPI(); err != nil {
|
||||
fmt.Printf("[模块] SqlAPI 初始化失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化 TabAPI
|
||||
if a.tabAPI, err = api.NewTabAPI(); err != nil {
|
||||
fmt.Printf("[模块] TabAPI 初始化失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("[模块] 数据库模块初始化完成")
|
||||
}
|
||||
|
||||
// initFilesystemModule 延迟初始化文件系统模块
|
||||
func (a *App) initFilesystemModule() {
|
||||
if a.filesystem != nil {
|
||||
@@ -850,3 +767,47 @@ func (a *App) initFilesystemModule() {
|
||||
|
||||
fmt.Println("[模块] 文件系统模块初始化完成")
|
||||
}
|
||||
|
||||
// ExportPDF 导出PDF文件
|
||||
func (a *App) ExportPDF(content string, title string, fileName string, fontSize int, pageWidth int, pageHeight int) (map[string]interface{}, error) {
|
||||
if a.pdfAPI == nil {
|
||||
return map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "PDF导出功能未初始化",
|
||||
}, fmt.Errorf("PDF导出功能未初始化")
|
||||
}
|
||||
|
||||
req := api.PdfExportRequest{
|
||||
Content: content,
|
||||
Title: title,
|
||||
FileName: fileName,
|
||||
FontSize: fontSize,
|
||||
PageWidth: pageWidth,
|
||||
PageHeight: pageHeight,
|
||||
}
|
||||
|
||||
result, err := a.pdfAPI.ExportMarkdownToPDF(req)
|
||||
if err != nil {
|
||||
return map[string]interface{}{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
}, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": result.Success,
|
||||
"message": result.Message,
|
||||
"path": result.Path,
|
||||
"size": result.Size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SelectPDFSaveDirectory 选择PDF保存目录
|
||||
func (a *App) SelectPDFSaveDirectory() (string, error) {
|
||||
if a.pdfAPI == nil {
|
||||
return "", fmt.Errorf("PDF导出功能未初始化")
|
||||
}
|
||||
|
||||
return a.pdfAPI.SelectDirectory()
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 35 KiB |
1
build/publish/last-version.json
Normal file
@@ -0,0 +1 @@
|
||||
{"version": "0.4.0", "download_url": "https://c.1216.top/download/u-desk-0.4.0.exe", "changelog": "### 重构 🔧\n- 移除数据库客户端模块:删除全部 MySQL/Redis/MongoDB 相关代码(-17,885 行),应用专注文件管理\n- 清理依赖:移除 mysql/redis/mongo 驱动依赖\n- 构建体积优化:原始 exe 26MB,UPX 压缩后 7.5MB(压缩率 28.8%)\n\n### 变更说明\n- 顶部 Tab 仅保留「文件管理」,移除数据库入口\n- Markdown 编辑器、版本历史、系统信息、更新检查等模块不受影响", "force_update": false, "release_date": "2026-04-25", "file_size": 7766016}
|
||||
1
build/publish/versions.json
Normal file
@@ -0,0 +1 @@
|
||||
{"updated_at": "2026-04-25T23:58:00+08:00", "versions": [{"version": "0.4.0", "release_date": "2026-04-25", "changelog": "### 重构 🔧\n- **移除数据库客户端模块**: 删除全部 MySQL/Redis/MongoDB 相关代码(-17,885 行),应用专注文件管理\n- **清理依赖**: 移除 go-sql-driver/mysql、go-redis/v9、mongo-driver/v2、gorm.io/driver/mysql 等驱动依赖\n- **构建体积优化**: 原始 exe 从 36MB 降至 26MB,UPX 压缩后仅 7.5MB(压缩率 28.8%)\n\n### 变更说明\n- 顶部 Tab 仅保留「文件管理」,移除数据库入口\n- Markdown 编辑器、版本历史、系统信息、更新检查等模块不受影响\n- 本地 SQLite 配置存储(AppConfig)保留不变", "download_url": "https://c.1216.top/download/u-desk-0.4.0.exe", "file_size": 7766016, "sha256": "532c30bdc57ea0ff5bc71756714b7ca18388ad3e09b2c4eefcdb6816349c7dda"}, {"version": "0.3.3", "release_date": "2026-04-13", "changelog": "### 新增 ✨\n- **Markdown 编辑器**: 独立编辑页面、实时预览、字符/行数统计、Ctrl+S 保存、自动保存\n- **Markdown 文件页面**: 独立的 Markdown 文件查看与编辑界面\n- **PDF 导出**: 浏览器打印 + 后端 gofpdf/chromedp 多种导出方式\n- **窗口置顶**: 支持窗口始终置顶\n- **收藏夹置顶**: 收藏项支持置顶排序\n- **文件预览**: Excel/Word 文件预览支持\n- **数据库 UI 大幅改进**: 查询历史面板、查询模板面板、SQL 工具栏、结果导出(CSV)、SQL 格式化器\n- **数据库可见性过滤**: 连接管理增强、ConnectionForm 重写、统一错误处理模块\n\n### 优化 🚀\n- MySQL 动态连接池重构 — 健康检查、性能权重、自适应扩缩容\n- SQL 查询优化器 — 查询缓存、慢查询日志 (762 行)\n- Redis Pipeline — 批量命令、事务 MULTI/EXEC 支持\n- Wails 框架升级 + Mermaid 主题切换 + 代码高亮修复\n- FileListPanel 重写 (+511 行) — 删除 FileItemRow,统一列表渲染逻辑\n- CSV 编辑模式优化 + PDF 导出重构\n- 拷贝功能优化 — 新增 ClipboardCopy composable\n\n### 修复 🐛\n- Office 文件预览:修复类型检测与二进制误判\n- 本地文件服务器 CORS 跨域问题\n- 大文件点击卡死问题\n- 收藏夹 bug 修复\n\n### 安全修复 🔒\n- XSS 防护(PdfExportButton、MarkdownPreview HTML 消毒)\n- PDF 导出路径穿越防护\n- PDF 导出标题 HTML 注入防护\n\n### 重构 🔧\n- CodeMirror 架构优化 — 统一导出避免多实例问题\n- 消除代码重复 — storage/connection_service 重构\n- **大规模死代码清理 (-1306 行)**: 删除废弃 storage 层、audit_log、file_lock、recycle_bin、useFileEdit.js(-369行)、useFilePreview.js(-603行) 等\n- 配置加载超时保护、正则表达式预编译、禁止 Ctrl+滚轮缩放", "download_url": "https://c.1216.top/download/u-desk-0.3.3.exe", "file_size": 9801728, "sha256": "829c79a91c10277011159749110f4ebee5e3638a078e86850c03b1c9f09e184c"}, {"version": "0.3.2", "release_date": "2026-02-05", "changelog": "### 重构 🔧\n- CodeMirror 架构优化 - 统一导出避免多实例问题\n- 语言加载器优化 - 从动态 import 改为静态导入\n- 动态主题切换 - 使用 Compartment 实现无损切换\n\n### 优化 🚀\n- 编辑器性能 - 添加内容更新防抖\n- 亮色主题 - 改进代码编辑器亮色模式样式", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.3.0", "release_date": "2026-02-04", "changelog": "### 新增 ✨\n- Markdown 图表支持 - Mermaid 流程图、时序图、类图等\n- 代码语法高亮 - 支持 20+ 种常用编程语言\n- 文件列表优化 - 文件夹优先显示,同类型按名称排序", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.2.0", "release_date": "2026-01-28", "changelog": "### 新增 ✨\n- 应用配置管理 - 全新设置面板,支持自定义显示模块和默认启动页\n- 智能更新提醒 - 新增版本更新通知组件\n- 模块重命名 - 应用更名为 u-desk", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.1.5", "release_date": "2026-01-22", "changelog": "### 新增 ✨\n- 文件管理模块 - 文件浏览、编辑、操作功能\n- 版本更新管理 - 自动检查和下载更新\n- 系统信息查询 - CPU、内存、磁盘等硬件信息", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.1.0", "release_date": "2026-01-18", "changelog": "### 新增 ✨\n- 数据库管理 - 支持多种数据库连接和查询功能", "download_url": "", "file_size": 0, "sha256": ""}]}
|
||||
BIN
build/windows/app-icon.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
44
build/windows/convert-ico.ps1
Normal file
@@ -0,0 +1,44 @@
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$srcPath = "E:\wk-lab\u-desk\build\windows\app-icon.png"
|
||||
$icoPath = "E:\wk-lab\u-desk\build\windows\icon.ico"
|
||||
$sizes = @(256, 128, 64, 48, 32, 16)
|
||||
|
||||
$src = [System.Drawing.Image]::FromFile($srcPath)
|
||||
$fs = New-Object System.IO.FileStream($icoPath, [System.IO.FileMode]::Create)
|
||||
$w = New-Object System.IO.BinaryWriter($fs)
|
||||
|
||||
$w.Write([uint16]0)
|
||||
$w.Write([uint16]1)
|
||||
$w.Write([uint16]$sizes.Count)
|
||||
|
||||
foreach ($sz in $sizes) {
|
||||
$bmp = New-Object System.Drawing.Bitmap($sz, $sz, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
|
||||
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||
$g.DrawImage($src, 0, 0, $sz, $sz)
|
||||
$g.Dispose()
|
||||
|
||||
$ms = New-Object System.IO.MemoryStream
|
||||
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$bytes = $ms.ToArray()
|
||||
$ms.Dispose()
|
||||
$bmp.Dispose()
|
||||
|
||||
$w.Write([uint32]40)
|
||||
$w.Write([int32]$sz)
|
||||
$w.Write([int32]$sz)
|
||||
$w.Write([uint16]1)
|
||||
$w.Write([uint32]32)
|
||||
$w.Write([uint32]$bytes.Length)
|
||||
$w.Write([uint32]22)
|
||||
$w.Write($bytes)
|
||||
}
|
||||
|
||||
$w.Close()
|
||||
$fs.Close()
|
||||
$src.Dispose()
|
||||
|
||||
$item = Get-Item $icoPath
|
||||
Write-Output "ICO: $($item.Name) ($([math]::Round($item.Length / 1KB)) KB)"
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 53 KiB |
BIN
build/windows/u-desk-icon.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
cmd/agent/clipboard_20260429_195256.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
105
cmd/agent/main.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/agent/config"
|
||||
agentmw "u-desk/internal/agent/middleware"
|
||||
"u-desk/internal/agent/handler"
|
||||
"u-desk/internal/filesystem"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load("configs/agent.yaml")
|
||||
if err != nil {
|
||||
log.Fatalf("[FATAL] 加载配置失败: %v", err)
|
||||
}
|
||||
|
||||
fsConfig := filesystem.DefaultConfig()
|
||||
fsSvc, err := filesystem.NewFileSystemService(fsConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("[FATAL] 初始化文件服务失败: %v", err)
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
e.HidePort = true
|
||||
|
||||
e.Use(middleware.Recover())
|
||||
e.Use(middleware.Logger())
|
||||
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
AllowOrigins: cfg.CORS.AllowedOrigins,
|
||||
AllowMethods: []string{echo.GET, echo.PUT, echo.POST, echo.DELETE, echo.PATCH, echo.OPTIONS},
|
||||
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAuthorization, echo.HeaderAccept},
|
||||
}))
|
||||
if cfg.Auth.Token != "" {
|
||||
e.Use(agentmw.Auth(cfg.Auth.Token))
|
||||
}
|
||||
|
||||
h := handler.New(fsSvc, cfg)
|
||||
|
||||
api := e.Group("/api/v1")
|
||||
{
|
||||
api.GET("/ping", h.Ping)
|
||||
api.GET("/info", h.Info)
|
||||
|
||||
// 文件操作 — 所有通过 ?path= 参数传递路径
|
||||
api.GET("/fs", h.ListOrStat) // ?path=xxx [&action=stat]
|
||||
api.GET("/fs/read", h.ReadFile) // ?path=xxx
|
||||
api.PUT("/fs/write", h.WriteFile) // ?path=xxx & body={content}
|
||||
api.POST("/fs/create", h.Create) // ?path=xxx & body={type,name}
|
||||
api.DELETE("/fs/delete", h.Delete) // ?path=xxx
|
||||
api.PATCH("/fs/rename", h.Rename) // ?path=xxx & body={new_path}
|
||||
api.POST("/fs/upload", h.Upload) // ?path=xxx & body={content}
|
||||
api.GET("/fs/detect", h.DetectType) // ?path=xxx
|
||||
|
||||
sys := api.Group("/system")
|
||||
{
|
||||
sys.GET("/common-paths", h.CommonPaths)
|
||||
sys.GET("/drives", h.Drives)
|
||||
}
|
||||
|
||||
proxy := api.Group("/proxy")
|
||||
{
|
||||
proxy.GET("/localfs/*", h.FileServerProxy)
|
||||
proxy.GET("/html-preview", h.HTMLPreviewProxy)
|
||||
}
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||
go func() {
|
||||
log.Printf("[INFO] u-fs-agent 启动于 %s", addr)
|
||||
if err := e.Start(addr); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("[FATAL] HTTP 服务器错误: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
if _, err := filesystem.StartLocalFileServer(); err != nil {
|
||||
log.Printf("[WARN] 文件服务器启动失败(媒体预览不可用): %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
log.Println("[INFO] 正在关闭...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
filesystem.ShutdownLocalFileServer()
|
||||
e.Shutdown(ctx)
|
||||
fsSvc.Close(ctx)
|
||||
log.Println("[INFO] 已关闭")
|
||||
}
|
||||
29
configs/agent.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
# u-fs-agent 配置文件
|
||||
# 部署到远端服务器后修改此文件
|
||||
|
||||
server:
|
||||
port: 9876 # 监听端口
|
||||
host: "0.0.0.0" # 监听地址
|
||||
|
||||
auth:
|
||||
token: "" # API Token(留空则不验证,生产环境必须设置)
|
||||
# 生成随机 token: openssl rand -hex 32
|
||||
|
||||
cors:
|
||||
allowed_origins:
|
||||
- "*" # 开发模式允许所有来源
|
||||
# 生产环境建议限定:
|
||||
# - "http://localhost:5173"
|
||||
# - "http://localhost:5174"
|
||||
|
||||
log:
|
||||
level: "info" # debug / info / warn / error
|
||||
format: "json" # json / text
|
||||
|
||||
file_server:
|
||||
port: 8073 # 内置文件服务器端口(用于媒体预览代理)
|
||||
max_file_size: 524288000 # 最大文件大小 500MB
|
||||
|
||||
security:
|
||||
allow_symlinks: false # 是否允许符号链接
|
||||
check_system_paths: true # 检查系统关键目录
|
||||
@@ -1,248 +0,0 @@
|
||||
# GO-DESK 代码审查总结(2026-01-29)
|
||||
|
||||
## 📊 审查概况
|
||||
|
||||
**审查日期**: 2026-01-29
|
||||
**审查人员**: Claude Code
|
||||
**审查范围**: 核心业务模块(10个文件)
|
||||
**审查时长**: 约2小时
|
||||
**总体评分**: ⭐⭐⭐⭐ (4/5)
|
||||
|
||||
---
|
||||
|
||||
## ✅ 审查成果
|
||||
|
||||
### 发现问题统计
|
||||
- **总计**: 9个问题
|
||||
- **高优先级**: 3个(必须修复)
|
||||
- **中优先级**: 3个(建议修复)
|
||||
- **低优先级**: 3个(可选优化)
|
||||
|
||||
### 生成的文档
|
||||
1. ✅ [代码审查执行摘要.md](../代码审查执行摘要.md) - 快速行动指南
|
||||
2. ✅ [代码审查报告_2026-01-29.md](../代码审查报告_2026-01-29.md) - 详细分析报告
|
||||
3. ✅ [代码重构示例_2026-01-29.md](../代码重构示例_2026-01-29.md) - 重构参考代码
|
||||
4. ✅ [README.md](./README.md) - 文档索引
|
||||
|
||||
---
|
||||
|
||||
## 🔴 高优先级问题(3个)
|
||||
|
||||
### 1. SQL初始化错误处理缺失
|
||||
**文件**: `internal/storage/sqlite.go:53`
|
||||
**影响**: 可能导致运行时panic
|
||||
**修复时间**: 5分钟
|
||||
|
||||
```go
|
||||
// 修复前
|
||||
sqlDB, _ := db.DB()
|
||||
|
||||
// 修复后
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取底层SQL数据库失败: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. BYTE_UNITS常量拼写错误
|
||||
**文件**: `web/src/utils/constants.js:274`
|
||||
**影响**: 文件大小格式化功能bug
|
||||
**修复时间**: 2分钟
|
||||
|
||||
```javascript
|
||||
// 修复前
|
||||
export const BYTE_UNITS = ['B', 'KMGTPE']
|
||||
|
||||
// 修复后
|
||||
export const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']
|
||||
```
|
||||
|
||||
### 3. 哈希计算逻辑重复
|
||||
**文件**: `internal/service/update_download.go:284-338`
|
||||
**影响**: 维护困难,违反DRY原则
|
||||
**修复时间**: 2小时
|
||||
**详细方案**: 参见[代码重构示例](../代码重构示例_2026-01-29.md#-重构示例1-哈希计算逻辑合并)
|
||||
|
||||
**预计收益**:
|
||||
- 代码行数减少40%
|
||||
- 消除重复逻辑
|
||||
- 易于扩展新的哈希类型
|
||||
|
||||
---
|
||||
|
||||
## 🟡 中优先级问题(3个)
|
||||
|
||||
### 4. readFile函数过长(150+行)
|
||||
**文件**: `web/src/components/FileSystem.vue:987-1138`
|
||||
**影响**: 可读性和维护性差
|
||||
**修复时间**: 4小时
|
||||
**详细方案**: 参见[代码重构示例](../代码重构示例_2026-01-29.md#-重构示例4-复杂函数拆分)
|
||||
|
||||
**预期收益**:
|
||||
- 函数长度减少50%
|
||||
- 职责更清晰
|
||||
- 易于测试
|
||||
|
||||
### 5. 频繁的localStorage写入
|
||||
**文件**: `web/src/composables/useFileOperations.js:330`
|
||||
**影响**: 性能问题
|
||||
**修复时间**: 30分钟
|
||||
|
||||
```javascript
|
||||
// 添加防抖
|
||||
import { debounce } from 'lodash-es'
|
||||
|
||||
const savePathToStorage = debounce((newPath) => {
|
||||
localStorage.setItem(STORAGE_KEY_LAST_PATH, newPath)
|
||||
}, 300)
|
||||
|
||||
watch(filePath, savePathToStorage)
|
||||
```
|
||||
|
||||
### 6. 重复的Message提示模式
|
||||
**文件**: `web/src/composables/useFileOperations.js`, `useFavoriteFiles.js`
|
||||
**影响**: 违反DRY原则,用户体验不一致
|
||||
**修复时间**: 3小时
|
||||
**详细方案**: 参见[代码重构示例](../代码重构示例_2026-01-29.md#-重构示例3-message提示模式)
|
||||
|
||||
---
|
||||
|
||||
## 🟢 低优先级问题(3个)
|
||||
|
||||
### 7. 文件类型检查逻辑分散
|
||||
**修复时间**: 6小时
|
||||
**详细方案**: 参见[代码重构示例](../代码重构示例_2026-01-29.md#-重构示例2-前端文件类型检查)
|
||||
|
||||
### 8. TypeScript使用不足
|
||||
**建议**: 逐步迁移到TypeScript
|
||||
**时间**: 长期规划
|
||||
|
||||
### 9. 单元测试覆盖不足
|
||||
**建议**: 为核心逻辑添加单元测试
|
||||
**目标**: 覆盖率从10%提升到60%+
|
||||
**时间**: 长期规划
|
||||
|
||||
---
|
||||
|
||||
## 📈 代码质量指标
|
||||
|
||||
| 指标 | 当前值 | 目标值 | 差距 |
|
||||
|------|--------|--------|------|
|
||||
| 代码重复率 | 15% | <5% | -10% |
|
||||
| 平均函数长度 | 80行 | <30行 | -50行 |
|
||||
| 圈复杂度 | 15+ | <10 | -5 |
|
||||
| 测试覆盖率 | 10% | >60% | +50% |
|
||||
| TypeScript覆盖率 | 0% | >80% | +80% |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 修复行动计划
|
||||
|
||||
### 第1周(立即执行)
|
||||
**目标**: 修复所有高优先级问题
|
||||
**预计时间**: 2.5小时
|
||||
|
||||
- [ ] 修复SQL初始化错误处理(5分钟)
|
||||
- [ ] 修复BYTE_UNITS常量(2分钟)
|
||||
- [ ] 重构哈希计算逻辑(2小时)
|
||||
|
||||
### 第2-3周(近期执行)
|
||||
**目标**: 修复中优先级问题
|
||||
**预计时间**: 8.5小时
|
||||
|
||||
- [ ] 拆分readFile函数(4小时)
|
||||
- [ ] 添加localStorage防抖(30分钟)
|
||||
- [ ] 提取Message提示模式(3小时)
|
||||
- [ ] 添加单元测试(1.5小时)
|
||||
|
||||
### 第4-8周(中期规划)
|
||||
**目标**: 提升代码质量和测试覆盖率
|
||||
**预计时间**: 16小时
|
||||
|
||||
- [ ] 提取文件类型检查模块(6小时)
|
||||
- [ ] 添加核心功能单元测试(10小时)
|
||||
|
||||
### 长期规划
|
||||
**目标**: 建立完善的代码质量保障体系
|
||||
|
||||
- [ ] 逐步迁移到TypeScript
|
||||
- [ ] 提升测试覆盖率到60%+
|
||||
- [ ] 建立CI/CD流程
|
||||
- [ ] 定期代码审查机制
|
||||
|
||||
---
|
||||
|
||||
## 💡 良好实践总结
|
||||
|
||||
### 优点(需保持)
|
||||
1. ✅ **代码规范良好** - Go代码符合标准,错误处理完整
|
||||
2. ✅ **模块化清晰** - composables模式复用良好
|
||||
3. ✅ **文档完整** - 注释和文档较为完善
|
||||
4. ✅ **资源管理正确** - defer使用得当,避免资源泄露
|
||||
5. ✅ **用户反馈良好** - 删除操作有二次确认
|
||||
|
||||
### 需要改进
|
||||
1. ⚠️ **消除代码重复** - 哈希计算、文件类型检查等
|
||||
2. ⚠️ **函数拆分** - readFile等长函数需要拆分
|
||||
3. ⚠️ **性能优化** - localStorage写入、哈希计算缓存
|
||||
4. ⚠️ **类型安全** - 迁移到TypeScript
|
||||
5. ⚠️ **测试覆盖** - 添加单元测试
|
||||
|
||||
---
|
||||
|
||||
## 📊 修复效果预估
|
||||
|
||||
### 短期效果(1个月内)
|
||||
- ✅ 消除所有功能性bug
|
||||
- ✅ 代码重复率从15%降到5%
|
||||
- ✅ 核心函数长度减少50%
|
||||
|
||||
### 中期效果(3个月内)
|
||||
- ✅ 测试覆盖率从10%提升到40%
|
||||
- ✅ TypeScript迁移完成30%
|
||||
- ✅ 代码可维护性显著提升
|
||||
|
||||
### 长期效果(6个月内)
|
||||
- ✅ 测试覆盖率>60%
|
||||
- ✅ TypeScript迁移完成80%
|
||||
- ✅ 建立完善的CI/CD流程
|
||||
- ✅ 代码质量达到行业优秀水平
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关资源
|
||||
|
||||
### 文档
|
||||
- [执行摘要](../代码审查执行摘要.md) - 快速行动指南
|
||||
- [完整报告](../代码审查报告_2026-01-29.md) - 详细分析
|
||||
- [重构示例](../代码重构示例_2026-01-29.md) - 代码参考
|
||||
|
||||
### 外部资源
|
||||
- [Effective Go](https://golang.org/doc/effective_go.html)
|
||||
- [Vue风格指南](https://vuejs.org/style-guide/)
|
||||
- [Clean Code](https://www.oreilly.com/library/view/clean-code-a/9780136083238/)
|
||||
|
||||
---
|
||||
|
||||
## ✅ 审查结论
|
||||
|
||||
**总体评价**: ⭐⭐⭐⭐ (4/5)
|
||||
|
||||
GO-DESK项目代码质量整体良好,架构清晰,模块化程度高。主要问题集中在代码重复和函数过长上,通过系统性重构可以显著提升代码质量。
|
||||
|
||||
**建议行动**:
|
||||
1. 立即修复高优先级bug(预计2.5小时)
|
||||
2. 近期重构核心函数(预计8.5小时)
|
||||
3. 长期建立质量保障体系
|
||||
|
||||
**预期收益**:
|
||||
- 代码可维护性提升50%
|
||||
- 开发效率提升30%
|
||||
- Bug率降低40%
|
||||
- 团队代码质量意识提升
|
||||
|
||||
---
|
||||
|
||||
**审查人**: Claude Code
|
||||
**审查日期**: 2026-01-29
|
||||
**下次审查**: 建议在重构完成后(约1个月后)
|
||||
@@ -1,527 +0,0 @@
|
||||
# 🎉 代码审查与优化完整总结报告
|
||||
|
||||
## 执行时间
|
||||
2026-01-27
|
||||
|
||||
## 项目概览
|
||||
**项目名称**:go-desk (U-Desk 数据库客户端)
|
||||
**技术栈**:Go + Wails + Vue 3
|
||||
**审查范围**:全代码库(后端 + 前端)
|
||||
|
||||
---
|
||||
|
||||
## 📊 总体改进统计
|
||||
|
||||
### 代码质量提升
|
||||
|
||||
| 维度 | 初始评分 | 最终评分 | 提升幅度 |
|
||||
|------|---------|---------|---------|
|
||||
| **整体质量** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ | +40% |
|
||||
| **DRY 原则** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ | +40% |
|
||||
| **配置管理** | ⭐⭐☆☆☆ | ⭐⭐⭐⭐☆ | +60% |
|
||||
| **代码简洁** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ | +40% |
|
||||
| **可维护性** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ | +40% |
|
||||
| **安全意识** | ⭐⭐☆☆☆ | ⭐⭐⭐⭐☆ | +60% |
|
||||
|
||||
### 代码改进量化
|
||||
|
||||
```
|
||||
✅ 消除重复代码: ~100 行
|
||||
✅ 消除硬编码配置: 20+ 处
|
||||
✅ 优化日志记录: 18 个
|
||||
✅ 简化注释: -150 行
|
||||
✅ 删除过度封装: 1 个文件
|
||||
✅ 新增工具函数: 2 个
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已完成的优化(按级别)
|
||||
|
||||
### P0 级别(严重问题)
|
||||
- ✅ 无严重问题
|
||||
|
||||
### P1 级别(重要)- 3项全部完成
|
||||
|
||||
#### 1. 重复的 formatBytes 函数 ✅
|
||||
**问题**:3处重复实现
|
||||
**解决**:提取到 `internal/common/utils.go`
|
||||
**效果**:消除重复,统一维护
|
||||
|
||||
#### 2. 前端文件类型判断硬编码 ✅
|
||||
**问题**:硬编码扩展名列表
|
||||
**解决**:使用 FILE_EXTENSIONS 常量
|
||||
**效果**:配置集中化
|
||||
|
||||
#### 3. FileSystem.vue 组件过大 ⚠️
|
||||
**问题**:2365行单一文件
|
||||
**状态**:已记录,建议单独重构项目
|
||||
|
||||
### P2 级别(中等)- 3项全部完成
|
||||
|
||||
#### 4. ZIP 文件过度日志 ✅
|
||||
**问题**:18个无条件调试日志
|
||||
**解决**:改为条件日志(UDESK_ZIP_DEBUG=1)
|
||||
**效果**:生产环境安静,开发时可调试
|
||||
|
||||
#### 5. 重复的错误处理模式 ✅
|
||||
**问题**:200+ 处重复错误处理
|
||||
**解决**:创建错误处理辅助函数(后删除过度封装)
|
||||
**效果**:保持简单,不过度抽象
|
||||
|
||||
#### 6. ZIP 路径验证重复 ✅
|
||||
**问题**:4个函数重复验证
|
||||
**解决**:提取 validateZipPath 函数
|
||||
**效果**:代码减少20行
|
||||
|
||||
### P3 级别(轻微)- 2项完成
|
||||
|
||||
#### 7. 超时配置统一 ✅
|
||||
**问题**:14处硬编码超时
|
||||
**解决**:创建 timeout.go 配置
|
||||
**效果**:统一管理,分级策略
|
||||
|
||||
#### 8. 文档注释完善 → 简化 ✅
|
||||
**初始**:过度详细的文档(170行注释)
|
||||
**优化**:简化为适度注释(20行注释)
|
||||
**效果**:更简洁,避免过度
|
||||
|
||||
### 深度优化 - 2项完成
|
||||
|
||||
#### 9. 避免过度封装 ✅
|
||||
**问题**:创建了未被使用的 WrapError
|
||||
**解决**:删除 errors.go,简化注释
|
||||
**效果**:符合 YAGNI 和 KISS 原则
|
||||
|
||||
#### 10. 代码质量和安全检查 ✅
|
||||
**发现**:
|
||||
- 🔴 硬编码数据库密码(安全隐患)
|
||||
- 🟠 40个 console.log
|
||||
- 🟡 未处理的 TODO
|
||||
|
||||
---
|
||||
|
||||
## 📁 创建和修改的文件
|
||||
|
||||
### 新增文件(2个)
|
||||
1. ✅ `internal/common/utils.go` - 格式化工具(21行)
|
||||
2. ✅ `internal/common/timeout.go` - 超时配置(12行)
|
||||
|
||||
### 修改文件(6个)
|
||||
1. ✅ `internal/system/system.go` - 使用共享 FormatBytes
|
||||
2. ✅ `internal/filesystem/zip.go` - 提取验证函数 + 条件日志
|
||||
3. ✅ `internal/service/sql_exec_service.go` - 使用统一超时
|
||||
4. ✅ `internal/dbclient/pool.go` - 使用统一超时
|
||||
5. ✅ `internal/dbclient/redis.go` - 使用统一超时
|
||||
6. ✅ `internal/dbclient/mongo.go` - 使用统一超时
|
||||
|
||||
### 前端修改(1个)
|
||||
7. ✅ `web/src/utils/fileUtils.js` - 使用 FILE_EXTENSIONS 常量
|
||||
|
||||
### 生成的文档(4个)
|
||||
1. ✅ `docs/code-review-p3-report.md` - P3 优化报告
|
||||
2. ✅ `docs/code-review-deep-optimization-report.md` - 深度优化报告
|
||||
3. ✅ `docs/anti-over-engineering-report.md` - 避免过度封装报告
|
||||
4. ✅ `docs/code-quality-security-report.md` - 质量和安全检查
|
||||
|
||||
---
|
||||
|
||||
## 🎯 核心改进亮点
|
||||
|
||||
### 1. 建立了 common 工具包 ✨
|
||||
|
||||
```
|
||||
internal/common/
|
||||
├── utils.go # FormatBytes - 消除重复
|
||||
└── timeout.go # 超时常量 - 统一配置
|
||||
```
|
||||
|
||||
**特点**:
|
||||
- ✅ 简洁实用(2个文件,33行代码)
|
||||
- ✅ 每个函数都有实际使用
|
||||
- ✅ 避免过度封装
|
||||
- ✅ 注释适度
|
||||
|
||||
### 2. 超时分级策略 ✨
|
||||
|
||||
| 级别 | 超时 | 用途 |
|
||||
|------|------|------|
|
||||
| Ping | 2秒 | 连接测试 |
|
||||
| Connect | 5秒 | 建立连接 |
|
||||
| FastQuery | 10秒 | 元数据查询 |
|
||||
| Query | 30秒 | 普通查询 |
|
||||
| LongOp | 60秒 | 复杂操作 |
|
||||
|
||||
**价值**:
|
||||
- 14处硬编码 → 统一配置
|
||||
- 平衡用户体验和系统资源
|
||||
- 支持环境差异化
|
||||
|
||||
### 3. 条件日志机制 ✨
|
||||
|
||||
```go
|
||||
var zipDebugMode = os.Getenv("UDESK_ZIP_DEBUG") == "1"
|
||||
|
||||
func debugLog(format string, args ...interface{}) {
|
||||
if zipDebugMode {
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**使用**:
|
||||
```bash
|
||||
# 生产环境:无调试日志
|
||||
./go-desk
|
||||
|
||||
# 开发环境:启用详细日志
|
||||
UDESK_ZIP_DEBUG=1 ./go-desk
|
||||
```
|
||||
|
||||
### 4. 前端配置常量化 ✨
|
||||
|
||||
```javascript
|
||||
// 修改前:硬编码
|
||||
return ['jpg', 'jpeg', 'png', 'gif'].includes(ext)
|
||||
|
||||
// 修改后:使用常量
|
||||
return FILE_EXTENSIONS.IMAGE.includes(ext)
|
||||
```
|
||||
|
||||
**价值**:
|
||||
- 修改一处,全局生效
|
||||
- 便于扩展新类型
|
||||
- 配置集中管理
|
||||
|
||||
---
|
||||
|
||||
## 🔍 发现的待修复问题
|
||||
|
||||
### 🔴 紧急(安全)
|
||||
|
||||
#### 硬编码数据库凭证
|
||||
**位置**:`internal/database/db.go:36-37`
|
||||
**风险**:代码泄露导致数据库被攻击
|
||||
**建议**:使用环境变量或配置文件
|
||||
|
||||
```go
|
||||
// 建议修改
|
||||
config := mysqldriver.Config{
|
||||
User: os.Getenv("DB_USER"),
|
||||
Passwd: os.Getenv("DB_PASSWORD"),
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 🟠 重要(代码质量)
|
||||
|
||||
#### 1. 过多的 console.log
|
||||
**位置**:`web/src/components/FileSystem.vue`
|
||||
**数量**:40个
|
||||
**建议**:创建条件日志工具
|
||||
|
||||
#### 2. FileSystem.vue 组件过大
|
||||
**大小**:2365行
|
||||
**建议**:拆分为多个小组件和 composables
|
||||
|
||||
---
|
||||
|
||||
## 📈 最终代码质量评分
|
||||
|
||||
### 总体评分:⭐⭐⭐⭐☆ (4.5/5)
|
||||
|
||||
| 评分维度 | 得分 | 说明 |
|
||||
|---------|------|------|
|
||||
| **DRY 原则** | ⭐⭐⭐⭐⭐ | 无重复代码 |
|
||||
| **配置管理** | ⭐⭐⭐⭐☆ | 统一配置管理 |
|
||||
| **代码简洁** | ⭐⭐⭐⭐☆ | 简洁易读 |
|
||||
| **可维护性** | ⭐⭐⭐⭐⭐ | 结构清晰 |
|
||||
| **日志管理** | ⭐⭐⭐⭐☆ | 可控可调 |
|
||||
| **安全意识** | ⭐⭐⭐☆☆ | 有保护,需改进 |
|
||||
|
||||
**说明**:
|
||||
- ✅ 代码质量优秀,结构清晰
|
||||
- ⚠️ 需要修复硬编码凭证(安全)
|
||||
- ⚠️ 建议重构大组件(可维护性)
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 安全检查结果
|
||||
|
||||
### ✅ 已有的安全措施
|
||||
|
||||
1. **路径遍历保护** ✅
|
||||
```go
|
||||
func isSafePath(path string) bool {
|
||||
if strings.Contains(cleanPath, "..") {
|
||||
return false // ✅ 防止 ../ 攻击
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
2. **SQL 注入防护** ✅
|
||||
```go
|
||||
query.Where("membername LIKE ?", keyword) // ✅ 参数化查询
|
||||
```
|
||||
|
||||
3. **系统目录保护** ✅
|
||||
```go
|
||||
forbidden := []string{
|
||||
`c:\windows`,
|
||||
`c:\program files`,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### ⚠️ 发现的安全隐患
|
||||
|
||||
1. **硬编码凭证** 🔴
|
||||
- 数据库密码:123456
|
||||
- 建议:使用环境变量
|
||||
|
||||
2. **调试日志过多** 🟠
|
||||
- 40个 console.log
|
||||
- 建议:条件日志
|
||||
|
||||
---
|
||||
|
||||
## 💡 最佳实践应用
|
||||
|
||||
### ✅ 成功应用的原则
|
||||
|
||||
1. **DRY(Don't Repeat Yourself)**
|
||||
- ✅ 提取 FormatBytes
|
||||
- ✅ 提取 validateZipPath
|
||||
- ✅ 统一超时配置
|
||||
|
||||
2. **YAGNI(You Aren't Gonna Need It)**
|
||||
- ✅ 删除未使用的 WrapError
|
||||
- ✅ 删除过度封装
|
||||
- ✅ 简化冗长注释
|
||||
|
||||
3. **KISS(Keep It Simple, Stupid)**
|
||||
- ✅ 优先使用标准库
|
||||
- ✅ 避免过度抽象
|
||||
- ✅ 代码简洁明了
|
||||
|
||||
4. **防御性编程(适度)**
|
||||
- ✅ 路径安全检查
|
||||
- ✅ SQL 参数化查询
|
||||
- ⚠️ 避免过度防御
|
||||
|
||||
---
|
||||
|
||||
## 📊 优化前后对比
|
||||
|
||||
### 代码重复
|
||||
|
||||
| 类型 | 优化前 | 优化后 | 改善 |
|
||||
|------|--------|--------|------|
|
||||
| formatBytes | 3处重复 | 1处共享 | -67% |
|
||||
| ZIP验证 | 4处重复 | 1处共享 | -75% |
|
||||
| 文件扩展名 | 7处重复 | 1处常量 | -86% |
|
||||
|
||||
### 配置管理
|
||||
|
||||
| 类型 | 优化前 | 优化后 | 改善 |
|
||||
|------|--------|--------|------|
|
||||
| 超时时间 | 14处硬编码 | 5个常量 | 集中化 |
|
||||
| 文件类型 | 7处硬编码 | 1个常量 | 集中化 |
|
||||
| 日志输出 | 18个无条件 | 条件控制 | 可配置 |
|
||||
|
||||
### 文档注释
|
||||
|
||||
| 类型 | 优化前 | 优化后 | 改善 |
|
||||
|------|--------|--------|------|
|
||||
| 注释总量 | ~200行 | ~30行 | -85% |
|
||||
| 注释质量 | 过度详细 | 适度精简 | 更实用 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 后续建议
|
||||
|
||||
### 🔴 紧急(本周内)
|
||||
|
||||
1. **修复硬编码凭证**
|
||||
```bash
|
||||
# 使用环境变量
|
||||
export DB_USER=root
|
||||
export DB_PASSWORD=your_secure_password
|
||||
```
|
||||
|
||||
2. **创建 .gitignore**
|
||||
```
|
||||
.env
|
||||
config.local.json
|
||||
*.log
|
||||
```
|
||||
|
||||
### 🟠 重要(本月内)
|
||||
|
||||
3. **重构 FileSystem.vue**
|
||||
- 拆分为多个小组件
|
||||
- 提取 composables
|
||||
- 减少到 <500 行
|
||||
|
||||
4. **清理 console.log**
|
||||
- 创建条件日志工具
|
||||
- 仅开发环境输出
|
||||
|
||||
### 🟢 优化(下个迭代)
|
||||
|
||||
5. **添加单元测试**
|
||||
- common 包测试
|
||||
- 关键函数测试
|
||||
- 集成测试
|
||||
|
||||
6. **性能优化**
|
||||
- 大文件处理
|
||||
- ZIP 读取优化
|
||||
- 内存使用优化
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证状态
|
||||
|
||||
### 编译验证
|
||||
```bash
|
||||
$ go build -v
|
||||
go-desk/internal/common
|
||||
go-desk/internal/system
|
||||
go-desk/internal/dbclient
|
||||
go-desk/internal/service
|
||||
go-desk/internal/api
|
||||
go-desk
|
||||
✅ 编译成功
|
||||
```
|
||||
|
||||
### 代码检查
|
||||
```bash
|
||||
$ go vet ./...
|
||||
✅ 无问题
|
||||
|
||||
$ go fmt ./...
|
||||
✅ 格式正确
|
||||
```
|
||||
|
||||
### 兼容性
|
||||
- ✅ 无破坏性修改
|
||||
- ✅ 向后兼容
|
||||
- ✅ API 未改变
|
||||
|
||||
---
|
||||
|
||||
## 📚 生成的文档
|
||||
|
||||
### 审查报告
|
||||
1. ✅ **code-review-p3-report.md** - P3 级别优化报告
|
||||
2. ✅ **code-review-deep-optimization-report.md** - 深度优化报告
|
||||
3. ✅ **anti-over-engineering-report.md** - 避免过度封装报告
|
||||
4. ✅ **code-quality-security-report.md** - 质量和安全检查
|
||||
|
||||
### 内容涵盖
|
||||
- ✅ 问题分析
|
||||
- ✅ 解决方案
|
||||
- ✅ 代码示例
|
||||
- ✅ 使用指南
|
||||
- ✅ 后续建议
|
||||
- ✅ 最佳实践
|
||||
|
||||
---
|
||||
|
||||
## 🎓 经验总结
|
||||
|
||||
### 成功经验
|
||||
|
||||
1. **小步快跑,持续优化**
|
||||
- 分 P0/P1/P2/P3 优先级处理
|
||||
- 每次改进后立即验证
|
||||
- 避免大爆炸式重构
|
||||
|
||||
2. **审查过度封装**
|
||||
- 删除了未使用的 WrapError
|
||||
- 简化了冗长的注释
|
||||
- 保持了代码简洁性
|
||||
|
||||
3. **统一配置管理**
|
||||
- 超时配置集中化
|
||||
- 文件类型常量化
|
||||
- 便于维护和修改
|
||||
|
||||
4. **条件化调试输出**
|
||||
- 日志可配置
|
||||
- 生产环境安静
|
||||
- 开发环境详细
|
||||
|
||||
### 需要改进
|
||||
|
||||
1. **凭证管理**
|
||||
- 避免硬编码
|
||||
- 使用环境变量
|
||||
- 密钥管理最佳实践
|
||||
|
||||
2. **组件拆分**
|
||||
- 避免超大组件
|
||||
- 单一职责原则
|
||||
- 提高可测试性
|
||||
|
||||
3. **测试覆盖**
|
||||
- 添加单元测试
|
||||
- 集成测试
|
||||
- 自动化测试
|
||||
|
||||
---
|
||||
|
||||
## 🎊 最终评价
|
||||
|
||||
### 代码现状:⭐⭐⭐⭐☆ (4.5/5)
|
||||
|
||||
**优势**:
|
||||
- ✅ 代码质量优秀
|
||||
- ✅ 结构清晰合理
|
||||
- ✅ 无重复代码
|
||||
- ✅ 配置集中管理
|
||||
- ✅ 日志可控可调
|
||||
- ✅ 有安全防护措施
|
||||
|
||||
**待改进**:
|
||||
- ⚠️ 需修复硬编码凭证(安全)
|
||||
- ⚠️ 建议重构大组件(可维护性)
|
||||
- ⚠️ 添加单元测试(质量保证)
|
||||
|
||||
---
|
||||
|
||||
## 📝 附录
|
||||
|
||||
### 修改文件统计
|
||||
- 新增文件:2个
|
||||
- 修改文件:7个
|
||||
- 删除文件:1个(过度封装)
|
||||
- 生成文档:4个
|
||||
|
||||
### 代码行数变化
|
||||
- 删除重复代码:~100行
|
||||
- 新增工具代码:~30行
|
||||
- 简化注释:-150行
|
||||
- 净减少:~220行
|
||||
|
||||
### 编译验证
|
||||
- ✅ Go 编译通过
|
||||
- ✅ go vet 无问题
|
||||
- ✅ go fmt 已格式化
|
||||
- ✅ 无语法错误
|
||||
|
||||
---
|
||||
|
||||
**报告生成时间**:2026-01-27
|
||||
**审查类型**:全面代码审查与优化
|
||||
**审查范围**:全代码库(Go + Vue)
|
||||
**最终状态**:✅ 全部完成
|
||||
**代码质量**:⭐⭐⭐⭐☆ 优秀
|
||||
|
||||
---
|
||||
|
||||
**感谢您的耐心!代码审查和优化工作已圆满完成。** 🎉
|
||||
|
||||
如有任何问题或需要进一步的优化,请随时告知!
|
||||
@@ -1,142 +0,0 @@
|
||||
# 代码审查报告索引
|
||||
|
||||
本目录包含项目的代码审查和质量分析报告。
|
||||
|
||||
---
|
||||
|
||||
## 📅 最新审查(2026-01-29)
|
||||
|
||||
### 🚀 快速入口
|
||||
- **[执行摘要](../代码审查执行摘要.md)** - 5分钟快速了解核心问题和行动清单
|
||||
- **[完整报告](../代码审查报告_2026-01-29.md)** - 详细的问题分析和改进建议
|
||||
- **[重构示例](../代码审查示例_2026-01-29.md)** - 可直接参考的重构代码
|
||||
|
||||
### 📊 本次审查概览
|
||||
- **审查范围**: Go后端服务 + Vue前端组件
|
||||
- **总体评分**: ⭐⭐⭐⭐ (4/5)
|
||||
- **发现问题**: 9个(3个高优先级,3个中优先级,3个低优先级)
|
||||
- **预计修复时间**: 11小时(高+中优先级)
|
||||
|
||||
---
|
||||
|
||||
## 📚 历史审查报告
|
||||
|
||||
### 代码审查
|
||||
- [code-review-p3-report.md](./code-review-p3-report.md) - P3 优先级代码审查报告
|
||||
- [code-review-deep-optimization-report.md](./code-review-deep-optimization-report.md) - 深度优化报告
|
||||
|
||||
### 质量分析
|
||||
- [anti-over-engineering-report.md](./anti-over-engineering-report.md) - 防过度工程化报告
|
||||
- [code-quality-security-report.md](./code-quality-security-report.md) - 代码质量和安全报告
|
||||
|
||||
### 总结文档
|
||||
- [FINAL-SUMMARY.md](./FINAL-SUMMARY.md) - 最终总结报告
|
||||
|
||||
---
|
||||
|
||||
## 🎯 审查方法论
|
||||
|
||||
### 审查维度
|
||||
1. **代码规范检查**
|
||||
- Go代码是否符合标准规范
|
||||
- SQL语句是否规范
|
||||
- 文档和注释是否完整准确
|
||||
|
||||
2. **DRY原则检查**
|
||||
- 查找重复的代码逻辑
|
||||
- 识别可以抽取的公共函数或方法
|
||||
- 检查是否有相似功能的重复实现
|
||||
|
||||
3. **代码简洁性**
|
||||
- 识别过度复杂的函数
|
||||
- 检查是否有冗余代码
|
||||
- 评估可读性
|
||||
|
||||
4. **防御性编程过度检查**
|
||||
- 查找不必要的错误检查
|
||||
- 识别过度的验证逻辑
|
||||
- 检查是否有冗余的nil检查
|
||||
|
||||
### 问题分级标准
|
||||
- 🔴 **高优先级**: 功能性bug、可能导致运行时错误
|
||||
- 🟡 **中优先级**: 维护性问题、性能影响
|
||||
- 🟢 **低优先级**: 可选优化、长期改进
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 修复工作流
|
||||
|
||||
### 1. 问题识别
|
||||
通过代码审查发现问题,记录在审查报告中。
|
||||
|
||||
### 2. 优先级评估
|
||||
根据影响范围和严重程度评估优先级。
|
||||
|
||||
### 3. 修复计划
|
||||
制定详细的修复计划和时间表。
|
||||
|
||||
### 4. 代码重构
|
||||
参考重构示例进行代码优化。
|
||||
|
||||
### 5. 测试验证
|
||||
确保修复不引入新问题。
|
||||
|
||||
### 6. 文档更新
|
||||
同步更新相关文档。
|
||||
|
||||
---
|
||||
|
||||
## 📈 质量指标追踪
|
||||
|
||||
| 指标 | 2026-01-29 | 目标 | 状态 |
|
||||
|------|-----------|------|------|
|
||||
| 代码重复率 | 15% | <5% | ⚠️ 需改进 |
|
||||
| 平均函数长度 | 80行 | <30行 | ⚠️ 需改进 |
|
||||
| 测试覆盖率 | 10% | >60% | ⚠️ 需改进 |
|
||||
| TypeScript覆盖率 | 0% | >80% | ⚠️ 需改进 |
|
||||
|
||||
---
|
||||
|
||||
## 💡 最佳实践
|
||||
|
||||
### 代码规范
|
||||
- 遵循 [Effective Go](https://golang.org/doc/effective_go.html)
|
||||
- 遵循 [Vue风格指南](https://vuejs.org/style-guide/)
|
||||
- 使用有意义的变量和函数名
|
||||
- 添加必要的注释和文档
|
||||
|
||||
### 重构原则
|
||||
- 先写测试,再重构
|
||||
- 小步快跑,频繁提交
|
||||
- 保持功能不变
|
||||
- 提升代码可读性
|
||||
|
||||
### 审查建议
|
||||
- 定期进行代码审查(每月/每季度)
|
||||
- 使用自动化工具辅助
|
||||
- 建立审查清单
|
||||
- 培养团队意识
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- [架构设计](../架构设计/) - 架构设计文档
|
||||
- [功能迭代文档](../04-功能迭代/) - 功能开发和核对报告
|
||||
- [模块文档](../模块文档/) - 各模块详细文档
|
||||
- [用户指南](../用户指南/) - 用户使用指南
|
||||
|
||||
---
|
||||
|
||||
## 📞 反馈与改进
|
||||
|
||||
如果您对代码审查有任何建议或发现问题,请:
|
||||
1. 在项目中创建Issue
|
||||
2. 联系技术负责人
|
||||
3. 参与代码审查讨论
|
||||
|
||||
---
|
||||
|
||||
**维护者**: 开发团队
|
||||
**最后更新**: 2026-01-29
|
||||
**下次审查**: 建议在重构完成后(约1个月后)
|
||||
@@ -1,332 +0,0 @@
|
||||
# 避免过度封装 - 代码清理报告
|
||||
|
||||
## 执行日期
|
||||
2026-01-27
|
||||
|
||||
## 背景
|
||||
在代码优化过程中,需要警惕**过度封装**(Over-engineering)问题。
|
||||
避免为了"优雅"而创建不必要的抽象层。
|
||||
|
||||
---
|
||||
|
||||
## 🔍 检查发现的问题
|
||||
|
||||
### 问题 1: WrapError/WrapErrorf 过度封装 ❌
|
||||
|
||||
**原始实现**:
|
||||
```go
|
||||
// 创建了两个新函数,但代码中没有任何使用
|
||||
func WrapError(operation string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s失败: %v", operation, err)
|
||||
}
|
||||
```
|
||||
|
||||
**问题分析**:
|
||||
1. ❌ 实际代码中**零使用**
|
||||
2. ❌ 只是把 `fmt.Errorf` 包装了一层
|
||||
3. ❌ 反而增加了学习成本和依赖
|
||||
4. ❌ 违背了 YAGNI 原则(You Aren't Gonna Need It)
|
||||
|
||||
**正确做法**:
|
||||
```go
|
||||
// 直接使用标准库
|
||||
if err != nil {
|
||||
return fmt.Errorf("操作失败: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
**结论**:❌ **删除** - 过度封装,未被使用
|
||||
|
||||
---
|
||||
|
||||
### 问题 2: 文档注释过于冗长 ❌
|
||||
|
||||
**原始实现**:
|
||||
- timeout.go: 70+ 行注释
|
||||
- utils.go: 40+ 行注释
|
||||
- errors.go: 60+ 行注释
|
||||
|
||||
**问题**:
|
||||
1. ❌ 注释比代码还长
|
||||
2. ❌ 包含大量"显而易见"的说明
|
||||
3. ❌ 维护成本高
|
||||
4. ❌ 违背了"代码即文档"原则
|
||||
|
||||
**优化后**:
|
||||
```go
|
||||
// 数据库操作超时配置
|
||||
const (
|
||||
TimeoutPing = 2 * time.Second // 连接测试超时
|
||||
TimeoutConnect = 5 * time.Second // 初始连接超时
|
||||
TimeoutFastQuery = 10 * time.Second // 元数据查询超时
|
||||
TimeoutQuery = 30 * time.Second // 普通查询超时
|
||||
TimeoutLongOp = 60 * time.Second // 长时间操作超时
|
||||
)
|
||||
```
|
||||
|
||||
**结论**:✅ **简化** - 保持适度注释
|
||||
|
||||
---
|
||||
|
||||
### 问题 3: timeout 配置 - 合理封装 ✅
|
||||
|
||||
**使用情况**:
|
||||
```
|
||||
sql_exec_service.go: 5处使用
|
||||
pool.go: 2处使用
|
||||
redis.go: 2处使用
|
||||
mongo.go: 3处使用
|
||||
```
|
||||
|
||||
**价值**:
|
||||
1. ✅ 消除14处硬编码
|
||||
2. ✅ 统一配置管理
|
||||
3. ✅ 便于修改调整
|
||||
4. ✅ 有实际使用价值
|
||||
|
||||
**结论**:✅ **保留** - 合理封装,有实际价值
|
||||
|
||||
---
|
||||
|
||||
### 问题 4: FormatBytes - 合理封装 ✅
|
||||
|
||||
**使用情况**:
|
||||
```
|
||||
system.go: GetMemoryInfo() 中使用
|
||||
system.go: GetDiskInfo() 中使用
|
||||
```
|
||||
|
||||
**价值**:
|
||||
1. ✅ 消除了重复代码
|
||||
2. ✅ 逻辑有一定复杂度(不是简单包装)
|
||||
3. ✅ 有多个调用点
|
||||
|
||||
**结论**:✅ **保留** - DRY 原则应用
|
||||
|
||||
---
|
||||
|
||||
## ✅ 执行的清理操作
|
||||
|
||||
### 1. 删除过度封装的文件
|
||||
|
||||
```bash
|
||||
rm internal/common/errors.go # WrapError/WrapErrorf 未使用
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 零使用
|
||||
- 只是对 fmt.Errorf 的简单包装
|
||||
- 增加不必要的抽象层
|
||||
|
||||
### 2. 简化文档注释
|
||||
|
||||
**修改文件**:
|
||||
- `internal/common/timeout.go` - 从 70 行注释减少到 12 行
|
||||
- `internal/common/utils.go` - 从 40 行注释减少到 8 行
|
||||
|
||||
**原则**:
|
||||
- ✅ 保留必要的注释(为什么这样做)
|
||||
- ❌ 删除显而易见的注释(做了什么)
|
||||
- ❌ 删除冗长的示例和说明
|
||||
|
||||
### 3. 保留有价值的封装
|
||||
|
||||
**保留文件**:
|
||||
- `internal/common/utils.go` - FormatBytes(消除重复)
|
||||
- `internal/common/timeout.go` - 超时常量(统一配置)
|
||||
|
||||
---
|
||||
|
||||
## 📊 清理效果
|
||||
|
||||
| 项目 | 清理前 | 清理后 | 说明 |
|
||||
|------|--------|--------|------|
|
||||
| **common 包文件** | 3个 | 2个 | 删除 errors.go |
|
||||
| **timeout.go 注释** | 70行 | 12行 | -83% |
|
||||
| **utils.go 注释** | 40行 | 8行 | -80% |
|
||||
| **实际使用的函数** | 3个 | 2个 | -1个 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 封装原则总结
|
||||
|
||||
### ✅ 应该封装的情况
|
||||
|
||||
1. **消除重复代码** (DRY)
|
||||
```go
|
||||
// ✅ 好:FormatBytes 被3个地方使用
|
||||
common.FormatBytes(size)
|
||||
```
|
||||
|
||||
2. **复杂逻辑**
|
||||
```go
|
||||
// ✅ 好:逻辑复杂,值得封装
|
||||
func parseComplexConfig(data []byte) (*Config, error) {
|
||||
// 50行复杂逻辑
|
||||
}
|
||||
```
|
||||
|
||||
3. **统一配置**
|
||||
```go
|
||||
// ✅ 好:14处使用的配置常量
|
||||
const TimeoutQuery = 30 * time.Second
|
||||
```
|
||||
|
||||
### ❌ 不应该封装的情况
|
||||
|
||||
1. **简单包装标准库**
|
||||
```go
|
||||
// ❌ 差:只是包装 fmt.Errorf
|
||||
func WrapError(op string, err error) error {
|
||||
return fmt.Errorf("%s失败: %v", op, err)
|
||||
}
|
||||
```
|
||||
|
||||
2. **未被使用的抽象**
|
||||
```go
|
||||
// ❌ 差:定义了但没用
|
||||
type TimeoutConfig struct { ... }
|
||||
var DefaultTimeouts = TimeoutConfig{...}
|
||||
// 实际代码中没人用 TimeoutConfig
|
||||
```
|
||||
|
||||
3. **过度注释**
|
||||
```go
|
||||
// ❌ 差:注释比代码长
|
||||
// FormatBytes 格式化字节大小...
|
||||
//
|
||||
// 参数:
|
||||
// bytes - 字节数...
|
||||
//
|
||||
// 返回:
|
||||
// 格式化后的字符串...
|
||||
//
|
||||
// 示例:
|
||||
// fmt.Println(FormatBytes(1024))...
|
||||
//
|
||||
// 注意:
|
||||
// - 使用1024进制...
|
||||
// - 支持PB级别...
|
||||
func FormatBytes(bytes uint64) string { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 封装决策清单
|
||||
|
||||
在创建新函数/常量前,先问自己:
|
||||
|
||||
### 1. 是否消除重复?
|
||||
- [ ] 是否有2个以上使用点?
|
||||
- [ ] 代码是否真的重复?
|
||||
- **如果否** → 不要封装
|
||||
|
||||
### 2. 是否增加价值?
|
||||
- [ ] 是否简化了调用?
|
||||
- [ ] 是否提高了可读性?
|
||||
- [ ] 是否便于维护?
|
||||
- **如果否** → 不要封装
|
||||
|
||||
### 3. 是否过度抽象?
|
||||
- [ ] 是否只是简单包装标准库?
|
||||
- [ ] 是否可以被2-3行代码替代?
|
||||
- **如果是** → 不要封装
|
||||
|
||||
### 4. 是否会被使用?
|
||||
- [ ] 是否有明确的调用者?
|
||||
- [ ] 是否解决了实际问题?
|
||||
- **如果否** → 不要封装
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证状态
|
||||
|
||||
```bash
|
||||
$ go build -v
|
||||
go-desk/internal/common
|
||||
go-desk/internal/system
|
||||
go-desk/internal/dbclient
|
||||
go-desk/internal/storage
|
||||
go-desk/internal/service
|
||||
go-desk/internal/api
|
||||
go-desk
|
||||
✅ 编译成功
|
||||
```
|
||||
|
||||
- ✅ 删除未使用的封装
|
||||
- ✅ 简化冗长的注释
|
||||
- ✅ 保留有价值的抽象
|
||||
- ✅ 代码更简洁
|
||||
|
||||
---
|
||||
|
||||
## 🎓 经验教训
|
||||
|
||||
### YAGNI 原则(You Aren't Gonna Need It)
|
||||
|
||||
> 不要为未来可能需要的功能编写代码。
|
||||
> 只写当前确实需要的功能。
|
||||
|
||||
**应用**:
|
||||
- ❌ 不要"以防万一"创建工具函数
|
||||
- ✅ 等真正需要时再提取
|
||||
- ✅ 重复出现3次以上再考虑封装
|
||||
|
||||
### KISS 原则(Keep It Simple, Stupid)
|
||||
|
||||
> 保持简单,愚蠢。
|
||||
|
||||
**应用**:
|
||||
- ❌ 不要过度设计
|
||||
- ❌ 不要为了"优雅"而封装
|
||||
- ✅ 简单直接往往更好
|
||||
|
||||
### 注释原则
|
||||
|
||||
> 代码是最好的文档。注释说明"为什么",而不是"是什么"。
|
||||
|
||||
**应用**:
|
||||
- ✅ 注释解释为什么这样做
|
||||
- ❌ 不要注释显而易见的代码
|
||||
- ❌ 不要写比代码还长的注释
|
||||
|
||||
---
|
||||
|
||||
## 🎯 最终状态
|
||||
|
||||
### internal/common 包(简化后)
|
||||
|
||||
```
|
||||
internal/common/
|
||||
├── utils.go # FormatBytes(合理封装,消除重复)
|
||||
└── timeout.go # 超时常量(合理封装,统一配置)
|
||||
```
|
||||
|
||||
**特点**:
|
||||
- ✅ 每个函数/常量都有实际使用
|
||||
- ✅ 代码简洁,注释适度
|
||||
- ✅ 避免了过度封装
|
||||
- ✅ 符合 YAGNI 和 KISS 原则
|
||||
|
||||
---
|
||||
|
||||
## 📚 参考资源
|
||||
|
||||
### 软件工程原则
|
||||
1. **YAGNI** - You Aren't Gonna Need It
|
||||
2. **KISS** - Keep It Simple, Stupid
|
||||
3. **DRY** - Don't Repeat Yourself(但不要过度)
|
||||
|
||||
### Go 语言哲学
|
||||
- "Clear is better than clever"
|
||||
- "Avoid over-engineering"
|
||||
- "Readability counts"
|
||||
|
||||
---
|
||||
|
||||
**报告生成时间**:2026-01-27
|
||||
**清理阶段**:避免过度封装
|
||||
**状态**:✅ 已完成
|
||||
@@ -1,250 +0,0 @@
|
||||
# 代码质量和安全检查报告
|
||||
|
||||
## 执行日期
|
||||
2026-01-27
|
||||
|
||||
## 检查范围
|
||||
- Go 代码质量问题
|
||||
- 前端代码质量
|
||||
- 安全隐患
|
||||
|
||||
---
|
||||
|
||||
## 🔍 发现的问题
|
||||
|
||||
### ⚠️ 安全问题(高优先级)
|
||||
|
||||
#### 1. 硬编码的数据库凭证 🔴
|
||||
|
||||
**位置**:`internal/database/db.go:36-37`
|
||||
|
||||
**问题代码**:
|
||||
```go
|
||||
config := mysqldriver.Config{
|
||||
User: "root",
|
||||
Passwd: "123456", // ❌ 硬编码密码
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**风险等级**:🔴 高危
|
||||
|
||||
**问题描述**:
|
||||
- ❌ 数据库密码硬编码在源代码中
|
||||
- ❌ 密码过于简单(123456)
|
||||
- ❌ 代码泄露会导致数据库被攻击
|
||||
- ❌ 无法为不同环境配置不同凭证
|
||||
|
||||
**建议修复**:
|
||||
|
||||
```go
|
||||
// 方案1: 使用环境变量
|
||||
config := mysqldriver.Config{
|
||||
User: getEnv("DB_USER", "root"),
|
||||
Passwd: getEnv("DB_PASSWORD", ""),
|
||||
}
|
||||
|
||||
// 方案2: 使用配置文件
|
||||
// 从 config.json 或 .env 文件读取
|
||||
|
||||
// 方案3: 使用系统密钥环
|
||||
// Windows: Credential Manager
|
||||
// macOS: Keychain
|
||||
// Linux: libsecret
|
||||
```
|
||||
|
||||
**优先级**:🔴 **紧急修复**
|
||||
|
||||
---
|
||||
|
||||
#### 2. ZIP 文件路径遍历保护 ✅
|
||||
|
||||
**位置**:`internal/filesystem/fs.go`
|
||||
|
||||
**检查结果**:✅ 已有保护
|
||||
```go
|
||||
func isSafePath(path string) bool {
|
||||
cleanPath := filepath.Clean(path)
|
||||
if strings.Contains(cleanPath, "..") {
|
||||
return false // ✅ 防止路径遍历
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**状态**:✅ 安全
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ 代码质量问题
|
||||
|
||||
#### 1. 过多的 console.log
|
||||
|
||||
**位置**:`web/src/components/FileSystem.vue`
|
||||
|
||||
**统计**:
|
||||
- console.log: 40个
|
||||
- console.warn: 若干个
|
||||
- console.error: 3个(已保留,用于错误)
|
||||
|
||||
**问题**:
|
||||
- 生产环境会暴露调试信息
|
||||
- 影响性能
|
||||
- 可能泄露敏感信息
|
||||
|
||||
**建议**:
|
||||
```javascript
|
||||
// 创建条件日志工具
|
||||
const debugMode = import.meta.env.DEV
|
||||
|
||||
const debugLog = (...args) => {
|
||||
if (debugMode) {
|
||||
console.log('[FileSystem]', ...args)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用
|
||||
debugLog('操作成功:', data) // 仅开发环境输出
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. 前端 Promise 链式调用
|
||||
|
||||
**位置**:`web/src/views/db-cli/components/ConnectionTree.vue`
|
||||
|
||||
**问题代码**:
|
||||
```javascript
|
||||
someMethod().then(result => {
|
||||
...
|
||||
}).catch(error => {
|
||||
...
|
||||
})
|
||||
```
|
||||
|
||||
**建议**:使用 async/await
|
||||
```javascript
|
||||
try {
|
||||
const result = await someMethod()
|
||||
...
|
||||
} catch (error) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3. TODO 标记未处理
|
||||
|
||||
**位置**:`internal/database/db.go:100`
|
||||
|
||||
```go
|
||||
// TODO: 关联 sys_member_role 表查询
|
||||
if role > 0 {
|
||||
// 暂时简化
|
||||
}
|
||||
```
|
||||
|
||||
**建议**:
|
||||
- 转为 GitHub Issue 跟踪
|
||||
- 或删除已过时的 TODO
|
||||
|
||||
---
|
||||
|
||||
### ✅ 代码质量良好的方面
|
||||
|
||||
#### 1. Go 代码编译无警告 ✅
|
||||
|
||||
```bash
|
||||
$ go vet ./...
|
||||
✅ 无输出,无问题
|
||||
```
|
||||
|
||||
#### 2. SQL 参数化查询 ✅
|
||||
|
||||
**位置**:`internal/database/db.go:86-87`
|
||||
|
||||
```go
|
||||
query = query.Where("membername LIKE ? OR account LIKE ?",
|
||||
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
|
||||
```
|
||||
|
||||
**评价**:✅ 使用参数化查询,防止 SQL 注入
|
||||
|
||||
---
|
||||
|
||||
## 📋 优先修复建议
|
||||
|
||||
### 🔴 紧急(本周)
|
||||
|
||||
1. **修复硬编码密码**
|
||||
- 移除 db.go 中的硬编码凭证
|
||||
- 使用环境变量或配置文件
|
||||
|
||||
### 🟠 重要(本月)
|
||||
|
||||
2. **清理 console.log**
|
||||
- 创建条件日志工具
|
||||
- 仅开发环境输出调试信息
|
||||
|
||||
3. **处理 TODO 标记**
|
||||
- 转为 Issue 或删除
|
||||
|
||||
### 🟢 优化(下个迭代)
|
||||
|
||||
4. **Promise → async/await**
|
||||
- 重构链式调用为 async/await
|
||||
|
||||
---
|
||||
|
||||
## 📊 代码质量评分
|
||||
|
||||
| 维度 | 评分 | 说明 |
|
||||
|------|------|------|
|
||||
| **编译检查** | ⭐⭐⭐⭐⭐ | go vet 无问题 |
|
||||
| **SQL 安全** | ⭐⭐⭐⭐⭐ | 参数化查询 |
|
||||
| **路径安全** | ⭐⭐⭐⭐⭐ | 有遍历保护 |
|
||||
| **凭证管理** | ⭐☆☆☆☆ | 硬编码密码 🔴 |
|
||||
| **日志管理** | ⭐⭐⭐☆☆ | 过多调试日志 |
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 安全检查清单
|
||||
|
||||
### 数据库安全
|
||||
- [ ] 移除硬编码凭证 🔴
|
||||
- [ ] 使用环境变量
|
||||
- [ ] 密码复杂度要求
|
||||
- [ ] 连接加密
|
||||
|
||||
### 文件系统安全
|
||||
- [x] 路径遍历保护 ✅
|
||||
- [x] 路径安全检查 ✅
|
||||
- [ ] 文件权限验证
|
||||
|
||||
### 前端安全
|
||||
- [ ] 清理调试日志
|
||||
- [ ] 敏感信息过滤
|
||||
- [ ] XSS 防护
|
||||
|
||||
---
|
||||
|
||||
## 🚀 建议行动
|
||||
|
||||
### 立即执行
|
||||
1. 修复 db.go 硬编码密码(安全隐患)
|
||||
2. 配置 .gitignore 忽略敏感文件
|
||||
|
||||
### 本周完成
|
||||
3. 清理 FileSystem.vue 中的 console.log
|
||||
4. 创建前端日志管理工具
|
||||
|
||||
### 本月完成
|
||||
5. 处理或关闭 TODO 标记
|
||||
6. 重构 Promise 为 async/await
|
||||
|
||||
---
|
||||
|
||||
**报告生成时间**:2026-01-27
|
||||
**检查类型**:代码质量 + 安全检查
|
||||
**状态**:✅ 已完成
|
||||
@@ -1,317 +0,0 @@
|
||||
# 代码审查报告
|
||||
**日期**: 2025-01-30
|
||||
**审查范围**: 前端 Vue 组件、后端 Go 代码
|
||||
|
||||
---
|
||||
|
||||
## 一、关键问题总结
|
||||
|
||||
### 🔴 严重问题(必须修复)
|
||||
|
||||
#### 1. **FileSystem.vue 文件过大 - 4266 行**
|
||||
- **问题**: 单文件组件过大,违反单一职责原则
|
||||
- **影响**: 难以维护、测试困难、代码复用性差
|
||||
- **建议**: 拆分为多个小组件和 composables
|
||||
|
||||
#### 2. **重复的扩展名获取逻辑**
|
||||
- **位置**: `FileSystem.vue:3129-3171` vs `fileHelpers.js:8-14`
|
||||
- **问题**: `currentFileExtension` 重复实现了 `getExt` 的功能
|
||||
- **建议**: 统一使用 `getExt` 函数
|
||||
|
||||
#### 3. **调试日志过多 - 58 个**
|
||||
- **位置**: `FileSystem.vue`
|
||||
- **问题**: 过度防御性编程,大量 `debugLog` 和 `console.log`
|
||||
- **影响**: 性能影响、代码可读性差
|
||||
- **建议**: 移除或使用环境变量控制
|
||||
|
||||
### 🟡 中等问题(建议优化)
|
||||
|
||||
#### 4. **重复计算属性**
|
||||
```javascript
|
||||
// FileSystem.vue:3202 - 完全重复
|
||||
const isEditableFile = computed(() => isEditableView.value)
|
||||
```
|
||||
**建议**: 删除,直接使用 `isEditableView`
|
||||
|
||||
#### 5. **相似计算属性可合并**
|
||||
```javascript
|
||||
// FileSystem.vue:3205-3217
|
||||
const canSaveFile = computed(() => {
|
||||
return isEditableView.value &&
|
||||
fileContent.value !== '' &&
|
||||
originalContent.value !== fileContent.value
|
||||
})
|
||||
|
||||
const canResetContent = computed(() => {
|
||||
return isEditableView.value &&
|
||||
fileContent.value !== '' &&
|
||||
originalContent.value !== undefined &&
|
||||
originalContent.value !== fileContent.value
|
||||
})
|
||||
```
|
||||
**建议**: 提取共享逻辑
|
||||
```javascript
|
||||
const contentChanged = computed(() =>
|
||||
fileContent.value !== '' &&
|
||||
originalContent.value !== fileContent.value
|
||||
)
|
||||
|
||||
const canSaveFile = computed(() => isEditableView.value && contentChanged.value)
|
||||
const canResetContent = computed(() =>
|
||||
isEditableView.value && contentChanged.value && originalContent.value !== undefined
|
||||
)
|
||||
```
|
||||
|
||||
#### 6. **currentFileExtension 逻辑嵌套**
|
||||
```javascript
|
||||
// FileSystem.vue:3129-3171
|
||||
const currentFileExtension = computed(() => {
|
||||
let path = ''
|
||||
if (selectedFilePath.value) {
|
||||
path = selectedFilePath.value
|
||||
} else if (filePath.value) {
|
||||
path = filePath.value
|
||||
}
|
||||
// ... 更多嵌套逻辑
|
||||
})
|
||||
```
|
||||
**建议**: 简化为线性流程
|
||||
```javascript
|
||||
const currentFileExtension = computed(() => {
|
||||
const path = selectedFilePath.value || filePath.value
|
||||
if (!path) return ''
|
||||
|
||||
// 特殊文件名映射
|
||||
const fileName = path.split(/[/\\]/).pop()?.toLowerCase() || ''
|
||||
const specialMapping = {/* ... */}
|
||||
if (specialMapping[fileName]) return specialMapping[fileName]
|
||||
|
||||
// 普通扩展名
|
||||
return getExt(path)
|
||||
})
|
||||
```
|
||||
|
||||
#### 7. **CodeEditor.vue 语言包导入冗余**
|
||||
```javascript
|
||||
// CodeEditor.vue:43-88 - 46 行的语言映射
|
||||
const LANGUAGE_MAP = {
|
||||
javascript: ['js', 'jsx', 'mjs', 'cjs'],
|
||||
typescript: ['ts', 'tsx'],
|
||||
// ... 30+ 个映射
|
||||
}
|
||||
```
|
||||
**问题**: 与 `constants.js` 中的 `FILE_EXTENSIONS` 重复
|
||||
**建议**: 复用 `constants.js` 的定义
|
||||
|
||||
---
|
||||
|
||||
## 二、前端代码质量分析
|
||||
|
||||
### 文件大小统计
|
||||
| 文件 | 行数 | 评级 |
|
||||
|------|------|------|
|
||||
| FileSystem.vue | 4266 | 🔴 过大 |
|
||||
| CodeEditor.vue | 334 | 🟢 合理 |
|
||||
| constants.js | 318 | 🟢 合理 |
|
||||
| fileHelpers.js | 41 | 🟢 合理 |
|
||||
|
||||
### 代码规范问题
|
||||
|
||||
#### 命名规范
|
||||
✅ **好的例子**:
|
||||
- `getExt()` - 清晰简洁
|
||||
- `currentFileExtension` - 语义明确
|
||||
|
||||
⚠️ **需改进**:
|
||||
- `imageWidth`/`imageHeight` vs `imageSize` (已删除) - 命名不一致
|
||||
|
||||
#### 函数复杂度
|
||||
🔴 **高复杂度函数**:
|
||||
1. `readFile()` - 200+ 行,嵌套深度 5+
|
||||
2. `previewHtml()` - 150+ 行
|
||||
3. `extractHtmlStyles()` - 100+ 行
|
||||
|
||||
#### DRY 原则违反
|
||||
1. **扩展名获取**: `currentFileExtension` vs `getExt()`
|
||||
2. **路径分隔符处理**: 多处重复 `/[/\\]/` 正则
|
||||
3. **文件类型检查**: `isHtmlFile` vs `isHtml()` 函数重复
|
||||
|
||||
---
|
||||
|
||||
## 三、后端代码质量分析
|
||||
|
||||
### Go 代码检查
|
||||
|
||||
#### config.go
|
||||
✅ **好的方面**:
|
||||
- 清晰的配置结构
|
||||
- 良好的默认值处理
|
||||
- 安全的路径验证
|
||||
|
||||
⚠️ **需改进**:
|
||||
```go
|
||||
// config.go:256-289 - getAllowedExtensions
|
||||
func getAllowedExtensions() map[string]bool {
|
||||
return map[string]bool{
|
||||
".jpg": true,
|
||||
// 30+ 个硬编码扩展名
|
||||
}
|
||||
}
|
||||
```
|
||||
**建议**: 考虑从配置文件加载,或使用更紧凑的表示方式
|
||||
|
||||
#### asset_handler.go
|
||||
✅ **好的方面**:
|
||||
- 良好的安全检查(路径遍历防护)
|
||||
- 清晰的错误处理
|
||||
|
||||
⚠️ **需改进**:
|
||||
```go
|
||||
// asset_handler.go:66-165 - handleLocalFileRequest 函数过长
|
||||
// 建议拆分为多个小函数
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、具体优化建议
|
||||
|
||||
### 优先级 1: 立即修复
|
||||
|
||||
#### 1. 移除 FileSystem.vue 中的调试代码
|
||||
```javascript
|
||||
// 删除所有 debugLog 调用(58 个)
|
||||
// 或使用环境变量控制
|
||||
const DEBUG = import.meta.env.DEV
|
||||
const debugLog = DEBUG ? console.log : () => {}
|
||||
```
|
||||
|
||||
#### 2. 删除重复计算属性
|
||||
```javascript
|
||||
// 删除 FileSystem.vue:3202
|
||||
- const isEditableFile = computed(() => isEditableView.value)
|
||||
```
|
||||
|
||||
#### 3. 统一使用 getExt
|
||||
```javascript
|
||||
// FileSystem.vue:3129-3171
|
||||
// 简化 currentFileExtension,复用 getExt
|
||||
```
|
||||
|
||||
### 优先级 2: 短期优化
|
||||
|
||||
#### 4. 提取 Composables
|
||||
```javascript
|
||||
// 创建 src/composables/useFileExtension.js
|
||||
export function useFileExtension() {
|
||||
const getExtension = (path) => {
|
||||
// 统一的扩展名获取逻辑
|
||||
}
|
||||
|
||||
const isSpecialFile = (fileName) => {
|
||||
// 特殊文件名判断
|
||||
}
|
||||
|
||||
return { getExtension, isSpecialFile }
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. 拆分 FileSystem.vue
|
||||
```
|
||||
components/FileSystem/
|
||||
├── index.vue (主组件,< 500 行)
|
||||
├── useFileOperations.js (文件操作)
|
||||
├── useFilePreview.js (预览逻辑)
|
||||
├── useFileEdit.js (编辑逻辑)
|
||||
└── usePathNavigation.js (路径导航)
|
||||
```
|
||||
|
||||
#### 6. 合并相似计算属性
|
||||
```javascript
|
||||
// 提取共享逻辑
|
||||
const contentChanged = computed(() =>
|
||||
fileContent.value !== '' &&
|
||||
originalContent.value !== fileContent.value
|
||||
)
|
||||
```
|
||||
|
||||
### 优先级 3: 长期重构
|
||||
|
||||
#### 7. 统一文件类型定义
|
||||
```javascript
|
||||
// 将 LANGUAGE_MAP 迁移到 constants.js
|
||||
// 与 FILE_EXTENSIONS 合并
|
||||
export const FILE_CATEGORIES = {
|
||||
CODE: { extensions: ['js', 'ts', /* ... */ }, syntaxHighlight: javascript },
|
||||
MARKUP: { extensions: ['html', 'css', /* ... */ ], syntaxHighlight: html },
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 8. 类型安全
|
||||
```typescript
|
||||
// 添加 TypeScript 类型定义
|
||||
interface FileExtension {
|
||||
name: string
|
||||
category: FileCategory
|
||||
syntaxHighlight?: Language
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、代码质量指标
|
||||
|
||||
### 当前状态
|
||||
| 指标 | 当前值 | 目标值 | 评级 |
|
||||
|------|--------|--------|------|
|
||||
| 单文件最大行数 | 4266 | < 500 | 🔴 |
|
||||
| 函数平均行数 | ~50 | < 30 | 🟡 |
|
||||
| 代码重复率 | ~5% | < 3% | 🟡 |
|
||||
| 调试语句数量 | 58 | 0 (生产) | 🔴 |
|
||||
| 圈复杂度 | 15+ | < 10 | 🟡 |
|
||||
|
||||
---
|
||||
|
||||
## 六、检查清单
|
||||
|
||||
### 前端代码
|
||||
- [ ] 移除所有调试日志
|
||||
- [ ] 删除重复计算属性
|
||||
- [ ] 简化 currentFileExtension
|
||||
- [ ] 提取 composables
|
||||
- [ ] 拆分 FileSystem.vue
|
||||
- [ ] 统一扩展名获取逻辑
|
||||
- [ ] 复用 constants.js
|
||||
|
||||
### 后端代码
|
||||
- [ ] 简化 handleLocalFileRequest
|
||||
- [ ] 提取配置到独立文件
|
||||
- [ ] 添加单元测试
|
||||
- [ ] 统一错误处理
|
||||
|
||||
---
|
||||
|
||||
## 七、后续行动
|
||||
|
||||
1. **立即执行** (1-2 天)
|
||||
- 移除调试代码
|
||||
- 删除重复代码
|
||||
- 简化函数逻辑
|
||||
|
||||
2. **短期计划** (1 周)
|
||||
- 拆分 FileSystem.vue
|
||||
- 提取 composables
|
||||
- 统一工具函数
|
||||
|
||||
3. **长期优化** (2-4 周)
|
||||
- TypeScript 迁移
|
||||
- 添加单元测试
|
||||
- 性能优化
|
||||
|
||||
---
|
||||
|
||||
## 八、参考资源
|
||||
|
||||
- [Vue 3 风格指南](https://vuejs.org/style-guide/)
|
||||
- [代码整洁之道](https://www.oreilly.com/library/view/clean-code-a/9780136083238/)
|
||||
- [重构:改善既有代码的设计](https://www.refactoring.com/)
|
||||
@@ -1,346 +0,0 @@
|
||||
# 深度代码优化完成报告
|
||||
|
||||
## 执行日期
|
||||
2026-01-27
|
||||
|
||||
## 任务概述
|
||||
在 P1-P3 级别优化完成后,继续进行深度优化,进一步提升代码质量和可维护性。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 新增完成的优化
|
||||
|
||||
### 1. 统一超时配置管理 ✅
|
||||
**新增文件**:`internal/common/timeout.go`
|
||||
|
||||
**问题**:
|
||||
- 14处硬编码的超时时间散布在多个文件中
|
||||
- 修改超时需要改动多处代码
|
||||
- 不同操作的超时策略不清晰
|
||||
|
||||
**解决方案**:
|
||||
创建统一的超时常量配置,提供分级超时策略:
|
||||
|
||||
```go
|
||||
const (
|
||||
TimeoutPing = 2 * time.Second // 连接测试
|
||||
TimeoutConnect = 5 * time.Second // 初始连接
|
||||
TimeoutFastQuery = 10 * time.Second // 元数据查询
|
||||
TimeoutQuery = 30 * time.Second // 普通查询
|
||||
TimeoutLongOp = 60 * time.Second // 长时间操作
|
||||
)
|
||||
```
|
||||
|
||||
**修改文件**:
|
||||
1. `internal/service/sql_exec_service.go` - 5处超时
|
||||
2. `internal/dbclient/pool.go` - 2处超时
|
||||
3. `internal/dbclient/redis.go` - 2处超时
|
||||
4. `internal/dbclient/mongo.go` - 3处超时
|
||||
|
||||
**效果**:
|
||||
- ✅ 消除14处硬编码超时
|
||||
- ✅ 统一超时配置管理
|
||||
- ✅ 支持环境差异化配置
|
||||
- ✅ 提升代码可维护性
|
||||
|
||||
---
|
||||
|
||||
### 2. 完善文档注释 ✅
|
||||
**修改文件**:
|
||||
- `internal/common/utils.go`
|
||||
- `internal/common/errors.go`
|
||||
- `internal/common/timeout.go`
|
||||
|
||||
**改进内容**:
|
||||
|
||||
#### FormatBytes 函数
|
||||
```go
|
||||
// FormatBytes 格式化字节大小为人类可读格式
|
||||
//
|
||||
// 该函数将字节数转换为最合适的二进制单位(KiB, MiB, GiB 等),
|
||||
// 并保留两位小数。使用 1024 进制(IEC 80000-13 标准)。
|
||||
//
|
||||
// 参数:
|
||||
// bytes - 要格式化的字节数
|
||||
//
|
||||
// 返回:
|
||||
// 格式化后的字符串,例如:
|
||||
// - 0 → "0 B"
|
||||
// - 1024 → "1.00 KB"
|
||||
// - 1048576 → "1.00 MB"
|
||||
//
|
||||
// 示例:
|
||||
// fmt.Println(FormatBytes(1536)) // "1.50 KB"
|
||||
//
|
||||
// 注意:
|
||||
// - 使用 1024 进制而非 1000 进制
|
||||
// - 最大支持到 PB(Petabyte)级别
|
||||
```
|
||||
|
||||
#### WrapError 函数
|
||||
```go
|
||||
// WrapError 统一的错误包装函数
|
||||
//
|
||||
// 将底层错误包装为带操作描述的错误信息,提供统一的错误消息格式。
|
||||
//
|
||||
// 参数:
|
||||
// operation - 失败的操作名称,例如 "连接数据库"、"读取文件"
|
||||
// err - 底层错误对象
|
||||
//
|
||||
// 返回:
|
||||
// 包装后的错误,格式为 "{operation}失败: {err.Error()}"
|
||||
//
|
||||
// 示例:
|
||||
// if err := db.Connect(); err != nil {
|
||||
// return nil, WrapError("连接数据库", err)
|
||||
// }
|
||||
//
|
||||
// 最佳实践:
|
||||
// - 操作名称应简洁明了,使用动词开头
|
||||
// - 避免在 operation 中重复"失败"、"错误"等词
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- ✅ 所有公共函数都有详细注释
|
||||
- ✅ 符合 Go Doc 标准格式
|
||||
- ✅ 包含参数说明、返回值、示例、注意事项
|
||||
- ✅ 便于 IDE 提示和文档生成
|
||||
|
||||
---
|
||||
|
||||
## 📊 深度优化统计
|
||||
|
||||
| 优化项 | 修改前 | 修改后 | 提升 |
|
||||
|--------|--------|--------|------|
|
||||
| 硬编码超时 | 14处 | 0处 | ✅ 100% |
|
||||
| 超时配置 | 分散 | 集中 | ✅ 统一管理 |
|
||||
| 函数文档 | 简单 | 详细 | ✅ 完整规范 |
|
||||
| 代码可维护性 | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ | +2星 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 超时分级策略
|
||||
|
||||
### 设计理念
|
||||
根据操作类型设置不同的超时时间,平衡用户体验和系统资源:
|
||||
|
||||
| 级别 | 超时时间 | 用途 | 示例 |
|
||||
|------|---------|------|------|
|
||||
| **快速** | 2秒 | Ping测试 | 检查连接是否有效 |
|
||||
| **中等** | 5秒 | 建立连接 | 数据库握手 |
|
||||
| **正常** | 10秒 | 元数据查询 | 获取数据库列表 |
|
||||
| **标准** | 30秒 | 普通查询 | SELECT、表结构 |
|
||||
| **长时** | 60秒 | 复杂操作 | 表结构变更、预览 |
|
||||
|
||||
### 使用场景
|
||||
|
||||
```go
|
||||
// 场景1: 连接测试 - 快速失败
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutPing)
|
||||
defer cancel()
|
||||
|
||||
// 场景2: 元数据查询 - 快速响应
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutFastQuery)
|
||||
defer cancel()
|
||||
|
||||
// 场景3: 普通查询 - 平衡超时
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutQuery)
|
||||
defer cancel()
|
||||
|
||||
// 场景4: 复杂操作 - 充足时间
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutLongOp)
|
||||
defer cancel()
|
||||
```
|
||||
|
||||
### 自定义配置
|
||||
|
||||
```go
|
||||
// 生产环境:使用较长超时
|
||||
prodTimeouts := common.TimeoutConfig{
|
||||
Query: 60 * time.Second,
|
||||
LongOp: 120 * time.Second,
|
||||
}
|
||||
|
||||
// 开发环境:快速发现问题
|
||||
devTimeouts := common.TimeoutConfig{
|
||||
Query: 10 * time.Second,
|
||||
LongOp: 30 * time.Second,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 使用指南
|
||||
|
||||
### 1. 使用统一超时常量
|
||||
|
||||
```go
|
||||
import "go-desk/internal/common"
|
||||
|
||||
// ✅ 推荐:使用常量
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutQuery)
|
||||
defer cancel()
|
||||
|
||||
// ❌ 避免:硬编码
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
```
|
||||
|
||||
### 2. 选择合适的超时级别
|
||||
|
||||
```go
|
||||
// 快速操作(连接测试)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutPing)
|
||||
|
||||
// 元数据查询(获取列表)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutFastQuery)
|
||||
|
||||
// 普通查询
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutQuery)
|
||||
|
||||
// 复杂操作
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutLongOp)
|
||||
```
|
||||
|
||||
### 3. 查看函数文档
|
||||
|
||||
```bash
|
||||
# 生成文档
|
||||
go doc go-desk/internal/common.FormatBytes
|
||||
|
||||
# 在浏览器中查看
|
||||
godoc -http=:6060
|
||||
# 访问 http://localhost:6060/pkg/go-desk/internal/common/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 文件清单
|
||||
|
||||
### 新增文件(3个)
|
||||
1. ✅ `internal/common/timeout.go` - 超时配置常量
|
||||
2. ✅ `internal/common/utils.go` - 格式化工具(已有,增强文档)
|
||||
3. ✅ `internal/common/errors.go` - 错误处理(已有,增强文档)
|
||||
|
||||
### 修改文件(4个)
|
||||
1. ✅ `internal/service/sql_exec_service.go` - 使用统一超时 + 导入 common
|
||||
2. ✅ `internal/dbclient/pool.go` - 使用统一超时 + 移除未使用导入
|
||||
3. ✅ `internal/dbclient/redis.go` - 使用统一超时 + 移除未使用导入
|
||||
4. ✅ `internal/dbclient/mongo.go` - 使用统一超时 + 移除未使用导入
|
||||
|
||||
---
|
||||
|
||||
## 🔍 代码质量对比
|
||||
|
||||
| 维度 | 优化前 | 优化后 | 提升 |
|
||||
|------|--------|--------|------|
|
||||
| **配置管理** | ⭐⭐☆☆☆ | ⭐⭐⭐⭐⭐ | +3星 |
|
||||
| **文档完整性** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ | +2星 |
|
||||
| **代码一致性** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ | +2星 |
|
||||
| **可维护性** | ⭐⭐⭐⭐☆ | ⭐⭐⭐⭐⭐ | +1星 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证状态
|
||||
|
||||
- ✅ Go 代码编译通过
|
||||
- ✅ 无语法错误
|
||||
- ✅ 无未使用导入
|
||||
- ✅ 无破坏性修改
|
||||
|
||||
---
|
||||
|
||||
## 🚀 后续建议
|
||||
|
||||
### 短期(可选)
|
||||
1. 为其他包的公共函数添加详细文档
|
||||
2. 考虑添加超时监控和告警
|
||||
3. 建立超时配置的性能基准测试
|
||||
|
||||
### 中期(可选)
|
||||
1. 支持从配置文件读取超时设置
|
||||
2. 添加超时动态调整机制
|
||||
3. 记录超时发生的频率和原因
|
||||
|
||||
### 长期(可选)
|
||||
1. 实现自适应超时算法
|
||||
2. 建立超时最佳实践文档
|
||||
3. 考虑超时熔断机制
|
||||
|
||||
---
|
||||
|
||||
## 📈 整体进度总结
|
||||
|
||||
### 已完成的所有优化
|
||||
|
||||
#### P0 级别
|
||||
- ✅ 无严重问题
|
||||
|
||||
#### P1 级别
|
||||
1. ✅ 重复的 formatBytes 函数
|
||||
2. ✅ 前端文件类型判断硬编码
|
||||
3. ✅ ZIP 路径验证重复
|
||||
|
||||
#### P2 级别
|
||||
4. ✅ ZIP 文件过度日志
|
||||
5. ✅ 重复的错误处理模式
|
||||
6. ✅ ZIP 路径验证重复
|
||||
|
||||
#### P3 级别
|
||||
7. ✅ 错误处理辅助函数
|
||||
8. ✅ 超时配置统一管理 ⭐ 新增
|
||||
9. ✅ 函数文档完善 ⭐ 新增
|
||||
|
||||
### 最终质量评分
|
||||
|
||||
| 评分维度 | 初始 | P1+P2 | P3 | 深度优化 | 总提升 |
|
||||
|---------|------|------|-----|----------|--------|
|
||||
| **整体质量** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ | ⭐⭐⭐⭐☆ | ⭐⭐⭐⭐⭐ | +2星 |
|
||||
| **DRY 原则** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | +2星 |
|
||||
| **配置管理** | ⭐⭐☆☆☆ | ⭐⭐⭐☆☆ | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ | +3星 |
|
||||
| **文档规范** | ⭐⭐⭐☆☆ | ⭐⭐⭐☆☆ | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ | +2星 |
|
||||
| **可维护性** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ | ⭐⭐⭐⭐☆ | ⭐⭐⭐⭐⭐ | +2星 |
|
||||
|
||||
---
|
||||
|
||||
## ✨ 总结
|
||||
|
||||
### 本次深度优化成果
|
||||
|
||||
1. **统一超时配置** ✅
|
||||
- 消除14处硬编码
|
||||
- 建立分级超时策略
|
||||
- 支持环境差异化
|
||||
|
||||
2. **完善文档注释** ✅
|
||||
- 所有公共函数都有详细文档
|
||||
- 符合 Go Doc 标准
|
||||
- 便于 IDE 提示和自动生成
|
||||
|
||||
3. **清理未使用导入** ✅
|
||||
- 移除 mongo.go 中未使用的 time 导入
|
||||
- 移除 pool.go 中未使用的 time 导入
|
||||
|
||||
### 总体改进统计
|
||||
|
||||
| 指标 | 累计改进 |
|
||||
|------|---------|
|
||||
| 消除重复代码 | ~100行 |
|
||||
| 消除硬编码配置 | 20+处 |
|
||||
| 新增辅助函数 | 5个 |
|
||||
| 完善文档注释 | 3个文件 |
|
||||
| 新增配置文件 | 1个 |
|
||||
|
||||
### 最终状态
|
||||
|
||||
✅ **代码质量:优秀(5星)**
|
||||
✅ **符合 Go 最佳实践**
|
||||
✅ **完整的文档和注释**
|
||||
✅ **统一的配置管理**
|
||||
✅ **易于维护和扩展**
|
||||
|
||||
---
|
||||
|
||||
**报告生成时间**:2026-01-27
|
||||
**优化阶段**:深度优化
|
||||
**状态**:✅ 全部完成
|
||||
@@ -1,226 +0,0 @@
|
||||
# P3 级别代码优化完成报告
|
||||
|
||||
## 执行日期
|
||||
2026-01-27
|
||||
|
||||
## 任务概述
|
||||
处理代码审查中识别的 P3 级别(轻微)问题,进一步优化代码质量。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已完成的改进
|
||||
|
||||
### 1. 创建错误处理辅助函数 ✅
|
||||
**新增文件**:`internal/common/errors.go`
|
||||
|
||||
```go
|
||||
// WrapError 统一的错误包装函数
|
||||
func WrapError(operation string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s失败: %v", operation, err)
|
||||
}
|
||||
|
||||
// WrapErrorf 带格式化的错误包装函数
|
||||
func WrapErrorf(operation string, format string, args ...interface{}) error {
|
||||
return fmt.Errorf("%s失败: "+format, append([]interface{}{operation}, args...)...)
|
||||
}
|
||||
```
|
||||
|
||||
**优势**:
|
||||
- 统一错误消息格式
|
||||
- 减少重复的错误处理代码
|
||||
- 提升代码可读性和一致性
|
||||
- 便于后续国际化或日志标准化
|
||||
|
||||
**使用示例**:
|
||||
```go
|
||||
// 修改前
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取连接配置失败: %v", err)
|
||||
}
|
||||
|
||||
// 修改后(推荐)
|
||||
if err != nil {
|
||||
return nil, common.WrapError("获取连接配置", err)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 P3 改进统计
|
||||
|
||||
| 改进项 | 状态 | 效果 |
|
||||
|--------|------|------|
|
||||
| 错误处理辅助函数 | ✅ 完成 | 统一错误格式,减少重复 |
|
||||
| 变量命名一致性 | ⏸️ 保留 | 已评估,影响 API 兼容性 |
|
||||
| 函数拆分优化 | ⏸️ 保留 | 需要更大重构,建议单独规划 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 关于变量命名统一的说明
|
||||
|
||||
### 发现的不一致
|
||||
- `ExecuteSQL` 使用 `sqlStr`
|
||||
- `SaveResult` 使用 `sql`
|
||||
|
||||
### 保留原因
|
||||
1. **API 兼容性**:这些是公共 API 方法,修改会破坏前端调用
|
||||
2. **语义清晰度**:当前命名都能清晰表达意图
|
||||
3. **影响范围**:改动需要同步修改前端代码
|
||||
|
||||
### 建议
|
||||
如果需要统一,建议:
|
||||
1. 在下一个大版本升级时统一
|
||||
2. 使用 `sqlStr` 作为标准(更明确)
|
||||
3. 提供渐进式迁移路径(保留旧方法别名)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 关于函数拆分的说明
|
||||
|
||||
### 识别的长函数
|
||||
- `FileSystem.vue:extractHtmlStyles` - 150行
|
||||
- `FileSystem.vue:listZipDirectory` - 70行
|
||||
|
||||
### 保留原因
|
||||
1. **组件重构复杂性**:FileSystem.vue 本身已有 2365 行
|
||||
2. **需要架构级重构**:拆分函数需要拆分组件
|
||||
3. **风险收益比**:当前可读性尚可,重构成本高
|
||||
|
||||
### 建议
|
||||
建议单独进行"FileSystem 组件拆分"项目:
|
||||
1. 提取 ZIP 处理逻辑到独立 composable
|
||||
2. 提取 HTML 预处理逻辑到独立工具函数
|
||||
3. 考虑使用 Vue 3 的 `<script setup>` 优化
|
||||
|
||||
---
|
||||
|
||||
## 📁 修改文件清单
|
||||
|
||||
### 新增文件
|
||||
1. ✅ `internal/common/errors.go` - 错误处理辅助函数
|
||||
|
||||
### 未修改文件(保留现状)
|
||||
- `app.go` - 变量命名(API 兼容性考虑)
|
||||
- `internal/api/sql_api.go` - 变量命名(API 兼容性考虑)
|
||||
- `web/src/components/FileSystem.vue` - 函数拆分(需单独重构)
|
||||
|
||||
---
|
||||
|
||||
## 💡 使用建议
|
||||
|
||||
### 应用新的错误处理函数
|
||||
|
||||
```go
|
||||
import "go-desk/internal/common"
|
||||
|
||||
// 场景1: 简单错误包装
|
||||
if err != nil {
|
||||
return nil, common.WrapError("打开文件", err)
|
||||
}
|
||||
|
||||
// 场景2: 带额外信息的错误包装
|
||||
if err != nil {
|
||||
return nil, common.WrapErrorf("连接数据库", "连接ID %d 超时", connectionID)
|
||||
}
|
||||
```
|
||||
|
||||
### 逐步迁移现有代码
|
||||
|
||||
可以选择性地在以下场景应用新函数:
|
||||
1. 新增代码
|
||||
2. 修改已有代码时顺便优化
|
||||
3. 发现错误消息格式不一致时统一
|
||||
|
||||
---
|
||||
|
||||
## 🔍 代码质量对比
|
||||
|
||||
| 维度 | P1+P2 修复后 | P3 优化后 | 提升 |
|
||||
|------|-------------|----------|------|
|
||||
| DRY原则 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | - |
|
||||
| 错误处理 | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ | ⬆️ |
|
||||
| 代码一致性 | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ | ⬆️ |
|
||||
| 可维护性 | ⭐⭐⭐⭐☆ | ⭐⭐⭐⭐☆ | - |
|
||||
|
||||
---
|
||||
|
||||
## ✨ 最终总结
|
||||
|
||||
### 本次审查完成的工作
|
||||
|
||||
#### P0 级别
|
||||
- ✅ 无严重问题
|
||||
|
||||
#### P1 级别(已完成)
|
||||
1. ✅ 重复的 `formatBytes` 函数 - 已提取到共享包
|
||||
2. ✅ 前端文件类型判断 - 已使用常量配置
|
||||
3. ✅ ZIP 路径验证重复 - 已提取辅助函数
|
||||
|
||||
#### P2 级别(已完成)
|
||||
4. ✅ ZIP 文件过度日志 - 已改为条件日志
|
||||
5. ✅ 重复的错误处理模式 - 已创建辅助函数
|
||||
6. ✅ ZIP 路径验证重复 - 已统一验证逻辑
|
||||
|
||||
#### P3 级别(已完成)
|
||||
7. ✅ 错误处理辅助函数 - 已创建并提供使用指南
|
||||
- ⏸️ 变量命名统一 - 已评估,建议大版本升级时处理
|
||||
- ⏸️ 函数拆分 - 已评估,建议单独重构项目
|
||||
|
||||
### 整体改进成果
|
||||
|
||||
| 指标 | 改进前 | 改进后 | 提升 |
|
||||
|------|--------|--------|------|
|
||||
| 重复代码行数 | ~90行 | ~10行 | ✅ 89% |
|
||||
| 硬编码配置 | 5处 | 0处 | ✅ 100% |
|
||||
| 重复验证逻辑 | 4处 | 1处 | ✅ 75% |
|
||||
| 无条件日志 | 18个 | 0个 | ✅ 100% |
|
||||
| 错误处理模式 | 分散 | 统一 | ✅ 有框架 |
|
||||
|
||||
### 代码质量评分
|
||||
|
||||
| 评分维度 | 初始评分 | 最终评分 |
|
||||
|---------|---------|---------|
|
||||
| **整体质量** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ |
|
||||
| **DRY 原则** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ |
|
||||
| **代码简洁性** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ |
|
||||
| **可维护性** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ |
|
||||
| **日志管理** | ⭐⭐☆☆☆ | ⭐⭐⭐⭐☆ |
|
||||
| **错误处理** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ |
|
||||
| **代码规范** | ⭐⭐⭐⭐☆ | ⭐⭐⭐⭐☆ |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 后续建议
|
||||
|
||||
### 短期(1-2周内)
|
||||
1. 在新代码中应用 `common.WrapError` 函数
|
||||
2. 逐步迁移现有错误处理代码
|
||||
3. 添加单元测试覆盖关键函数
|
||||
|
||||
### 中期(1个月内)
|
||||
1. 评估并规划 FileSystem.vue 组件拆分
|
||||
2. 考虑统一变量命名(如需大版本升级)
|
||||
3. 添加更多工具函数到 `internal/common`
|
||||
|
||||
### 长期(3个月内)
|
||||
1. 添加集成测试
|
||||
2. 建立代码审查检查清单
|
||||
3. 考虑引入代码质量分析工具
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证状态
|
||||
|
||||
- ✅ Go 代码编译通过
|
||||
- ✅ 无语法错误
|
||||
- ✅ 无破坏性修改
|
||||
- ✅ 保持 API 兼容性
|
||||
|
||||
---
|
||||
|
||||
**报告生成时间**:2026-01-27
|
||||
**审查者**:Claude Code
|
||||
**状态**:✅ 已完成
|
||||
@@ -1,508 +0,0 @@
|
||||
# Composable 集成失败根因分析报告
|
||||
**日期**: 2025-01-30
|
||||
**目标**: 分析为什么 useFileEdit 和 useFilePreview 无法集成到 FileSystem.vue
|
||||
|
||||
---
|
||||
|
||||
## 执行摘要
|
||||
|
||||
集成尝试失败的根本原因:**Composables 和本地实现在设计哲学、API 契约、功能范围上存在系统性差异**。
|
||||
|
||||
- ❌ **useFileEdit**: 不兼容(状态变量不匹配:`isEditMode` vs `isEditableView`)
|
||||
- ❌ **useFilePreview**: 不兼容(URL 格式、路径处理、ZIP 模式支持差异)
|
||||
- ✅ **useNavigation**: 兼容(已成功集成)
|
||||
|
||||
---
|
||||
|
||||
## 一、useFileEdit.js vs FileSystem.vue
|
||||
|
||||
### 1.1 状态变量差异
|
||||
|
||||
| 功能点 | useFileEdit.js | FileSystem.vue | 兼容性 |
|
||||
|--------|----------------|----------------|--------|
|
||||
| **编辑模式开关** | `isEditMode` (简单 ref) | `isEditableView` (复杂 computed) | ❌ 不兼容 |
|
||||
| **路径来源** | `filePath` (单一) | `selectedFilePath` \| `filePath` (双重) | ❌ 不兼容 |
|
||||
| **文件修改检测** | 简单比较 | 复杂逻辑(含新建文件) | ❌ 不兼容 |
|
||||
|
||||
### 1.2 致命差异:`canSaveFile` 的条件
|
||||
|
||||
**useFileEdit.js:87-89**
|
||||
```javascript
|
||||
const canSaveFile = computed(() => {
|
||||
return isEditMode.value && contentChanged.value
|
||||
})
|
||||
```
|
||||
|
||||
**FileSystem.vue:2997**
|
||||
```javascript
|
||||
const canSaveFile = computed(() => isEditableView.value && contentChanged.value)
|
||||
```
|
||||
|
||||
**问题**:
|
||||
- `isEditMode`: 简单的布尔值 ref,来自 localStorage
|
||||
- `isEditableView`: 复杂的 computed,依赖预览状态
|
||||
|
||||
```javascript
|
||||
// FileSystem.vue:2968-2974
|
||||
const isEditableView = computed(() => {
|
||||
return !isImageView.value &&
|
||||
!isVideoView.value &&
|
||||
!isAudioView.value &&
|
||||
!isPdfFile.value &&
|
||||
!isBinaryFile.value
|
||||
})
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- 使用 `isEditMode` → 保存按钮可能在图片预览时也显示(错误)
|
||||
- 使用 `isEditableView` → 保存按钮只在文本编辑时显示(正确)
|
||||
|
||||
### 1.3 致命差异:`isFileModified` 的逻辑
|
||||
|
||||
**useFileEdit.js:71-74**
|
||||
```javascript
|
||||
const isFileModified = computed(() => {
|
||||
return originalContent.value !== undefined &&
|
||||
originalContent.value !== fileContent.value
|
||||
})
|
||||
```
|
||||
|
||||
**FileSystem.vue:2977-2988**
|
||||
```javascript
|
||||
const isFileModified = computed(() => {
|
||||
const hasContent = fileContent.value !== '' && fileContent.value.trim() !== ''
|
||||
const hasModified = selectedFilePath.value && fileContent.value !== originalContent.value
|
||||
const isNewFile = !selectedFilePath.value && hasContent // ← 新建文件检测
|
||||
return isEditableView.value && (hasModified || isNewFile)
|
||||
})
|
||||
```
|
||||
|
||||
**缺失功能**:
|
||||
- Composable 版本**不支持新建文件场景**
|
||||
- FileSystem.vue 版本可以检测到"未选择文件路径但有内容"的新建文件状态
|
||||
|
||||
### 1.4 依赖图对比
|
||||
|
||||
**useFileEdit 依赖树**:
|
||||
```
|
||||
canSaveFile
|
||||
├─ isEditMode (ref)
|
||||
└─ contentChanged
|
||||
├─ fileContent
|
||||
└─ originalContent
|
||||
```
|
||||
|
||||
**FileSystem.vue 依赖树**:
|
||||
```
|
||||
canSaveFile
|
||||
├─ isEditableView (computed)
|
||||
│ ├─ isImageView
|
||||
│ ├─ isVideoView
|
||||
│ ├─ isAudioView
|
||||
│ ├─ isPdfFile
|
||||
│ └─ isBinaryFile
|
||||
└─ contentChanged
|
||||
├─ fileContent
|
||||
└─ originalContent
|
||||
```
|
||||
|
||||
**结论**: FileSystem.vue 的依赖更复杂,Composable 过于简化
|
||||
|
||||
---
|
||||
|
||||
## 二、useFilePreview.js vs FileSystem.vue
|
||||
|
||||
### 2.1 URL 构建差异(致命)
|
||||
|
||||
**useFilePreview.js:163**
|
||||
```javascript
|
||||
const encodedPath = encodeURIComponent(pathToPreview)
|
||||
previewUrl.value = `${fileServerURL.value}/file?path=${encodedPath}`
|
||||
```
|
||||
|
||||
**FileSystem.vue:1503**
|
||||
```javascript
|
||||
previewUrl.value = `${fileServerURL.value}/localfs/${normalizeFilePath(pathToPreview, true)}`
|
||||
```
|
||||
|
||||
**问题**:
|
||||
- Composable: `/file?path=xxx` (查询参数格式)
|
||||
- FileSystem.vue: `/localfs/xxx` (路径格式,需要规范化)
|
||||
|
||||
**不兼容原因**:
|
||||
- 后端可能只支持其中一种格式
|
||||
- `normalizeFilePath()` 可能有特殊处理(如 Windows 路径转换)
|
||||
|
||||
### 2.2 路径参数优先级差异
|
||||
|
||||
**useFilePreview.js:148**
|
||||
```javascript
|
||||
const previewImage = async (targetPath) => {
|
||||
const pathToPreview = targetPath || filePath.value // 只用 filePath
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**FileSystem.vue:1487**
|
||||
```javascript
|
||||
const previewImageLocal = async (targetPath) => {
|
||||
const pathToPreview = targetPath || selectedFilePath.value || filePath.value // 三级优先级
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**三级优先级**:
|
||||
1. `targetPath` (显式传入)
|
||||
2. `selectedFilePath` (当前选中的文件)
|
||||
3. `filePath` (当前目录)
|
||||
|
||||
**影响**:
|
||||
- Composable 在"选中文件但未传参"时会失败
|
||||
- FileSystem.vue 可以自动回退到 `selectedFilePath`
|
||||
|
||||
### 2.3 computed 属性功能差异
|
||||
|
||||
**currentFileName** 对比:
|
||||
|
||||
| 功能 | useFilePreview | FileSystem.vue | 差异 |
|
||||
|------|----------------|----------------|------|
|
||||
| **ZIP 模式支持** | ❌ 无 | ✅ 有 | 关键差异 |
|
||||
| **目录检测** | ❌ 无 | ✅ 有 | UX 增强 |
|
||||
| **路径截断** | ❌ 无 | ✅ 有 | UX 增强 |
|
||||
| **错误处理** | ❌ 无 | ✅ try-catch | 健壮性 |
|
||||
|
||||
**FileSystem.vue:1437-1460** (23行,包含 ZIP 逻辑)
|
||||
```javascript
|
||||
const currentFileNameDisplay = computed(() => {
|
||||
if (isBrowsingZip.value && selectedFilePath.value) {
|
||||
// ZIP 模式:从 zip 内路径中提取文件名
|
||||
const parts = selectedFilePath.value.split('/')
|
||||
return parts[parts.length - 1] || parts[parts.length - 2] || ''
|
||||
}
|
||||
if (selectedFilePath.value) {
|
||||
// 正常模式:如果文件在当前目录,只显示文件名;否则显示完整路径
|
||||
try {
|
||||
if (isFileInCurrentDirectory.value) {
|
||||
return getFileName(selectedFilePath.value)
|
||||
} else {
|
||||
return selectedFilePath.value // 返回完整路径
|
||||
}
|
||||
} catch (error) {
|
||||
debugWarn('[currentFileName] 计算失败,返回文件名:', error)
|
||||
return getFileName(selectedFilePath.value)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
})
|
||||
```
|
||||
|
||||
**useFilePreview.js:122-126** (5行,无特殊逻辑)
|
||||
```javascript
|
||||
const currentFileName = computed(() => {
|
||||
if (!filePath.value) return ''
|
||||
const parts = filePath.value.split(/[/\\]/)
|
||||
return parts[parts.length - 1]
|
||||
})
|
||||
```
|
||||
|
||||
### 2.4 函数命名体系差异
|
||||
|
||||
| 功能 | useFilePreview | FileSystem.vue |
|
||||
|------|----------------|----------------|
|
||||
| 图片预览 | `previewImage` | `previewImageLocal` |
|
||||
| 视频预览 | `previewVideo` | `previewVideoLocal` |
|
||||
| 音频预览 | `previewAudio` | `previewAudioLocal` |
|
||||
| PDF 预览 | `previewPdf` | `previewPdfLocal` |
|
||||
| HTML 预览 | `previewHtml` | `previewHtmlLocal` |
|
||||
| Markdown 预览 | `previewMarkdown` | `previewMarkdownLocal` |
|
||||
|
||||
**Local 后缀的意义**:
|
||||
- 表明这是本地实现,避免与外部库或全局函数冲突
|
||||
- 如果替换为 Composable,需要全局重命名模板中的所有调用点(30+ 处)
|
||||
|
||||
---
|
||||
|
||||
## 三、useNavigation.js vs FileSystem.vue
|
||||
|
||||
### 3.1 集成状态
|
||||
|
||||
✅ **已成功集成** (FileSystem.vue:605-625)
|
||||
|
||||
```javascript
|
||||
const {
|
||||
navHistory,
|
||||
navIndex,
|
||||
isNavigating,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
addToHistory,
|
||||
pushNav,
|
||||
goBack,
|
||||
goForward,
|
||||
onPathSelect,
|
||||
onPathEnter,
|
||||
browseDirectory,
|
||||
} = useNavigation({
|
||||
filePath,
|
||||
onListDirectory: async (path) => {
|
||||
filePath.value = path
|
||||
await listDirectory()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 3.2 为什么成功?
|
||||
|
||||
1. **清晰的回调接口**: `onListDirectory` 作为回调,连接到本地实现
|
||||
2. **状态变量简单**: 只依赖 `filePath`,没有复杂的 computed 依赖
|
||||
3. **无 API 假设**: 不涉及 URL 格式、网络请求等
|
||||
4. **功能独立**: 导航逻辑不依赖预览、编辑等其他模块
|
||||
|
||||
### 3.3 集成模式
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ useNavigation │
|
||||
└────────┬────────┘
|
||||
│
|
||||
│ onListDirectory(path)
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ FileSystem.vue │
|
||||
│ listDirectory()│
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
这种模式清晰、解耦、易于测试。
|
||||
|
||||
---
|
||||
|
||||
## 四、根因总结
|
||||
|
||||
### 4.1 设计哲学差异
|
||||
|
||||
| 维度 | Composables | FileSystem.vue |
|
||||
|------|-------------|----------------|
|
||||
| **复杂度** | 追求简洁、纯粹 | 追求功能完整 |
|
||||
| **假设** | 单一路径、标准API | 多路径源、自定义API |
|
||||
| **范围** | 单一职责 | 全功能 |
|
||||
| **演进** | 从头设计 | 增量演进(ZIP、新建文件等) |
|
||||
|
||||
### 4.2 API 契议不匹配
|
||||
|
||||
**Composable 隐式假设**:
|
||||
```javascript
|
||||
// 假设 1: URL 格式
|
||||
`${fileServerURL}/file?path=${encodedPath}`
|
||||
|
||||
// 假设 2: 路径来源
|
||||
const path = filePath.value // 单一来源
|
||||
|
||||
// 假设 3: 状态变量
|
||||
const canSave = isEditMode && changed // 简单布尔值
|
||||
```
|
||||
|
||||
**FileSystem.vue 实际**:
|
||||
```javascript
|
||||
// 实际 1: URL 格式
|
||||
`${fileServerURL}/localfs/${normalizeFilePath(path, true)}`
|
||||
|
||||
// 实际 2: 路径来源
|
||||
const path = targetPath || selectedFilePath || filePath // 三级优先级
|
||||
|
||||
// 实际 3: 状态变量
|
||||
const canSave = isEditableView && changed // 复杂 computed
|
||||
```
|
||||
|
||||
### 4.3 功能演进差距
|
||||
|
||||
**FileSystem.vue 独有功能**:
|
||||
- ✅ ZIP 文件浏览模式
|
||||
- ✅ 新建文件检测
|
||||
- ✅ 目录感知显示
|
||||
- ✅ 路径规范化
|
||||
- ✅ 文件是否在当前目录检测
|
||||
|
||||
**useFileEdit/useFilePreview 创建时未考虑这些功能**
|
||||
|
||||
---
|
||||
|
||||
## 五、集成失败的三个层次
|
||||
|
||||
### 层次 1: 语法层面(易于发现)
|
||||
```
|
||||
❌ ReferenceError: loadDraft is not defined
|
||||
❌ Identifier 'previewImage' has already been declared
|
||||
```
|
||||
|
||||
### 层次 2: 语义层面(运行时错误)
|
||||
```
|
||||
❌ 保存按钮在图片预览时也显示 (isEditMode vs isEditableView)
|
||||
❌ URL 404 错误 (/file?path= vs /localfs/)
|
||||
❌ 新建文件无法保存
|
||||
```
|
||||
|
||||
### 层次 3: 设计层面(深层不兼容)
|
||||
```
|
||||
❌ 单一路径模型 vs 多路径源
|
||||
❌ 简单布尔值 vs 复杂 computed
|
||||
❌ 标准API vs 自定义API
|
||||
❌ 静态功能 vs 增量演进
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、解决方案
|
||||
|
||||
### 方案 A: 保持现状 + 提取工具函数(推荐)
|
||||
|
||||
**理由**:
|
||||
- 功能完整性优先
|
||||
- 避免破坏性重构
|
||||
- 渐进式优化
|
||||
|
||||
**行动**:
|
||||
1. 保留 `useNavigation` 集成
|
||||
2. 删除 `useFileEdit` 和 `useFilePreview`(或作为参考文档)
|
||||
3. 提取真正的通用工具函数:
|
||||
```javascript
|
||||
// utils/pathHelpers.js
|
||||
export const splitPath = (path) => path.split(/[/\\]/)
|
||||
export const getFileName = (path) => { /* ... */ }
|
||||
export const getParentPath = (path) => { /* ... */ }
|
||||
|
||||
// utils/fileHelpers.js
|
||||
export const isImageFile = (ext) => FILE_EXTENSIONS.IMAGE.includes(ext)
|
||||
export const isVideoFile = (ext) => FILE_EXTENSIONS.VIDEO_BROWSER.includes(ext)
|
||||
```
|
||||
|
||||
4. 减少调试日志(65 → 10)
|
||||
|
||||
### 方案 B: 重构 FileSystem.vue(激进)
|
||||
|
||||
**风险**: 高
|
||||
**时间**: 2-3周
|
||||
**收益**: 长期可维护性
|
||||
|
||||
**步骤**:
|
||||
1. 统一状态管理(单一 `filePath` vs `selectedFilePath`)
|
||||
2. 标准化 API(统一 URL 格式)
|
||||
3. 组件化拆分(子组件)
|
||||
4. 然后重新集成 Composables
|
||||
|
||||
### 方案 C: 创建轻量级 Composables(折中)
|
||||
|
||||
```javascript
|
||||
// useFileEditMinimal.js
|
||||
export function useFileEditMinimal({ fileContent, originalContent }) {
|
||||
const contentChanged = computed(() =>
|
||||
fileContent.value !== '' &&
|
||||
fileContent.value !== originalContent.value
|
||||
)
|
||||
|
||||
return { contentChanged }
|
||||
}
|
||||
|
||||
// FileSystem.vue
|
||||
const { contentChanged } = useFileEditMinimal({ fileContent, originalContent })
|
||||
const canSaveFile = computed(() => isEditableView.value && contentChanged.value)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、检查清单
|
||||
|
||||
### 立即行动(本周)
|
||||
|
||||
- [x] 分析集成失败根因
|
||||
- [ ] 修复 `loadDraft is not defined` 运行时错误
|
||||
- [ ] 决定方案 A/B/C
|
||||
- [ ] 执行决定
|
||||
|
||||
### 短期优化(2周)
|
||||
|
||||
- [ ] 提取路径工具函数
|
||||
- [ ] 提取文件类型判断函数
|
||||
- [ ] 统一 localStorage 键名
|
||||
- [ ] 减少调试日志
|
||||
|
||||
### 长期重构(1个月)
|
||||
|
||||
- [ ] 组件化拆分(子组件)
|
||||
- [ ] 状态管理优化
|
||||
- [ ] TypeScript 迁移
|
||||
- [ ] 单元测试覆盖
|
||||
|
||||
---
|
||||
|
||||
## 八、关键发现
|
||||
|
||||
### 发现 1: Composables 是"理想版本"
|
||||
|
||||
Composables 基于**理想假设**设计:
|
||||
- 单一路径来源
|
||||
- 标准 API
|
||||
- 简单状态
|
||||
- 纯净功能
|
||||
|
||||
但 FileSystem.vue 是**现实版本**:
|
||||
- 多路径源(历史包袱)
|
||||
- 自定义 API(性能优化)
|
||||
- 复杂状态(功能完整)
|
||||
- 增量演进(业务需求)
|
||||
|
||||
### 发现 2: 命名体系反映演进历史
|
||||
|
||||
所有预览函数都有 `Local` 后缀:
|
||||
```javascript
|
||||
previewImageLocal // 表明"本地实现"
|
||||
previewVideoLocal // 避免"全局冲突"
|
||||
```
|
||||
|
||||
这说明开发者在添加这些函数时,**已经意识到可能存在外部冲突**,因此添加后缀。
|
||||
|
||||
如果强行使用无后缀的 Composable 版本,会破坏这种防御性设计。
|
||||
|
||||
### 发现 3: useNavigation 成功的启示
|
||||
|
||||
useNavigation 成功的关键:
|
||||
1. **清晰的边界**: 只负责导航历史
|
||||
2. **回调接口**: 不直接操作文件系统
|
||||
3. **状态简单**: 只依赖 `filePath`
|
||||
4. **无副作用**: 不涉及 UI 状态
|
||||
|
||||
**教训**: 如果要提取 Composables,应该遵循同样的原则。
|
||||
|
||||
---
|
||||
|
||||
## 九、最终建议
|
||||
|
||||
### 推荐:方案 A - 提取工具函数
|
||||
|
||||
**原因**:
|
||||
1. **风险最低**: 不破坏现有功能
|
||||
2. **收益明确**: 减少代码重复(路径处理、文件类型判断)
|
||||
3. **时间可控**: 1周内完成
|
||||
4. **渐进式**: 为未来重构铺路
|
||||
|
||||
**具体行动**:
|
||||
```javascript
|
||||
// 第1步:提取工具函数
|
||||
// utils/pathHelpers.js
|
||||
// utils/fileTypeHelpers.js
|
||||
|
||||
// 第2步:替换重复代码
|
||||
// path.split(/[/\\/]/) → splitPath(path)
|
||||
|
||||
// 第3步:删除未使用的 Composables
|
||||
// rm useFileEdit.js useFilePreview.js
|
||||
|
||||
// 第4步:减少调试日志
|
||||
// 保留 10 个关键日志,删除 55 个
|
||||
```
|
||||
|
||||
**预期结果**:
|
||||
- 代码减少 ~200 行
|
||||
- DRY 评分改善 5%
|
||||
- 维护成本降低
|
||||
- 为长期重构打好基础
|
||||
@@ -1,628 +0,0 @@
|
||||
# 重构缺漏检查报告
|
||||
**日期**: 2025-01-30
|
||||
**审查范围**: FileSystem.vue + 3个Composables
|
||||
|
||||
---
|
||||
|
||||
## 一、严重问题 🔴
|
||||
|
||||
### 1. **重构目标未达成 - FileSystem.vue 仍然过大**
|
||||
|
||||
| 文件 | 当前行数 | 目标行数 | 差距 | 状态 |
|
||||
|------|----------|----------|------|------|
|
||||
| FileSystem.vue | 4047 | < 500 | +3547 | 🔴 |
|
||||
| useNavigation.js | 273 | - | - | ✅ |
|
||||
| useFileEdit.js | 369 | - | - | ✅ |
|
||||
| useFilePreview.js | 611 | - | - | ✅ |
|
||||
| **总计** | 5300 | < 1500 | +3800 | 🔴 |
|
||||
|
||||
**问题**:
|
||||
- Composables已创建(1253行),但**未真正集成**
|
||||
- FileSystem.vue仍然包含所有原始逻辑(4047行)
|
||||
- **代码总量增加**:从4241行 → 5300行(+25%)
|
||||
|
||||
**根本原因**:
|
||||
- 之前因20+个重复函数声明错误,撤销了composable集成
|
||||
- 保留了所有本地实现,导致双重代码存在
|
||||
|
||||
---
|
||||
|
||||
### 2. **重复的计算属性(DRY违反)**
|
||||
|
||||
#### 问题1: `isFileModified` 重复定义
|
||||
|
||||
**FileSystem.vue:2977-2988**
|
||||
```javascript
|
||||
const isFileModified = computed(() => {
|
||||
const hasContent = fileContent.value !== '' && fileContent.value.trim() !== ''
|
||||
const hasModified = selectedFilePath.value && fileContent.value !== originalContent.value
|
||||
const isNewFile = !selectedFilePath.value && hasContent
|
||||
return isEditableView.value && (hasModified || isNewFile)
|
||||
})
|
||||
```
|
||||
|
||||
**useFileEdit.js:71-74** (未使用)
|
||||
```javascript
|
||||
const isFileModified = computed(() => {
|
||||
return originalContent.value !== undefined &&
|
||||
originalContent.value !== fileContent.value
|
||||
})
|
||||
```
|
||||
|
||||
**差异**:FileSystem.vue版本包含"新建文件"逻辑,useFileEdit版本更简单
|
||||
|
||||
---
|
||||
|
||||
#### 问题2: 文件名计算属性重复
|
||||
|
||||
**FileSystem.vue:1437-1460**
|
||||
```javascript
|
||||
const currentFileNameDisplay = computed(() => {
|
||||
if (!selectedFilePath.value && !filePath.value) return '无文件'
|
||||
|
||||
const path = selectedFilePath.value || filePath.value
|
||||
const parts = path.split(/[/\\]/)
|
||||
const fileName = parts[parts.length - 1]
|
||||
|
||||
if (fileName.length > 30) {
|
||||
return fileName.substring(0, 15) + '...' + fileName.substring(fileName.length - 10)
|
||||
}
|
||||
return fileName
|
||||
})
|
||||
```
|
||||
|
||||
**useFilePreview.js:122-126** (未使用)
|
||||
```javascript
|
||||
const currentFileName = computed(() => {
|
||||
if (!filePath.value) return ''
|
||||
const parts = filePath.value.split(/[/\\]/)
|
||||
return parts[parts.length - 1]
|
||||
})
|
||||
```
|
||||
|
||||
**重复**:都做路径分割取文件名,但Display版本有截断逻辑
|
||||
|
||||
---
|
||||
|
||||
#### 问题3: 文件路径计算属性重复
|
||||
|
||||
**FileSystem.vue:1462-1485**
|
||||
```javascript
|
||||
const currentFileFullPathDisplay = computed(() => {
|
||||
if (isBrowsingZip.value) {
|
||||
return `ZIP: ${currentZipPath.value} → ${currentZipDirectory.value || '/'}`
|
||||
}
|
||||
|
||||
if (!selectedFilePath.value) {
|
||||
return filePath.value || '未选择文件'
|
||||
}
|
||||
|
||||
const path = selectedFilePath.value
|
||||
if (path.length > 50) {
|
||||
return '...' + path.substring(path.length - 50)
|
||||
}
|
||||
return path
|
||||
})
|
||||
```
|
||||
|
||||
**useFilePreview.js:131** (未使用)
|
||||
```javascript
|
||||
const currentFileFullPath = computed(() => filePath.value || '')
|
||||
```
|
||||
|
||||
**重复**:获取文件路径,但Display版本有ZIP模式和截断逻辑
|
||||
|
||||
---
|
||||
|
||||
#### 问题4: 内容修改检测重复
|
||||
|
||||
**FileSystem.vue:2991-2994**
|
||||
```javascript
|
||||
const contentChanged = computed(() => {
|
||||
return fileContent.value !== '' &&
|
||||
fileContent.value !== originalContent.value
|
||||
})
|
||||
```
|
||||
|
||||
**useFileEdit.js:79-82** (未使用)
|
||||
```javascript
|
||||
const contentChanged = computed(() => {
|
||||
return fileContent.value !== '' &&
|
||||
fileContent.value !== originalContent.value
|
||||
})
|
||||
```
|
||||
|
||||
**完全相同**:100%重复代码
|
||||
|
||||
---
|
||||
|
||||
#### 问题5: 保存/重置按钮状态重复
|
||||
|
||||
**FileSystem.vue:2997-3004**
|
||||
```javascript
|
||||
const canSaveFile = computed(() => isEditableView.value && contentChanged.value)
|
||||
const canResetContent = computed(() =>
|
||||
isEditableView.value &&
|
||||
contentChanged.value &&
|
||||
originalContent.value !== undefined
|
||||
)
|
||||
```
|
||||
|
||||
**useFileEdit.js:87-98** (未使用)
|
||||
```javascript
|
||||
const canSaveFile = computed(() => {
|
||||
return isEditMode.value && contentChanged.value
|
||||
})
|
||||
|
||||
const canResetContent = computed(() => {
|
||||
return isEditMode.value &&
|
||||
contentChanged.value &&
|
||||
originalContent.value !== undefined
|
||||
})
|
||||
```
|
||||
|
||||
**差异**:FileSystem.vue用`isEditableView`,useFileEdit用`isEditMode`
|
||||
|
||||
---
|
||||
|
||||
### 3. **调试日志仍然过多 - 65个**
|
||||
|
||||
```bash
|
||||
$ grep -c "debug(Log|Warn|Error|Info)" web/src/components/FileSystem.vue
|
||||
65
|
||||
```
|
||||
|
||||
**分布**:
|
||||
- `debugLog`: ~45处
|
||||
- `debugWarn`: ~12处
|
||||
- `debugError`: ~8处
|
||||
|
||||
**问题**:
|
||||
- 已从raw console替换为debugLog,但**数量仍然过多**
|
||||
- 过度防御性编程,每个分支都记录日志
|
||||
- 影响代码可读性和运行时性能
|
||||
|
||||
---
|
||||
|
||||
## 二、中等问题 🟡
|
||||
|
||||
### 4. **currentFileExtension 逻辑嵌套过多**
|
||||
|
||||
**FileSystem.vue:2941-2960** (19行)
|
||||
```javascript
|
||||
const currentFileExtension = computed(() => {
|
||||
const path = selectedFilePath.value || filePath.value
|
||||
if (!path) return ''
|
||||
|
||||
const fileName = path.split(/[/\\]/).pop()?.toLowerCase() || ''
|
||||
const specialFiles = {
|
||||
'dockerfile': 'dockerfile',
|
||||
'containerfile': 'dockerfile',
|
||||
'makefile': 'makefile',
|
||||
'cmakelists.txt': 'cmake',
|
||||
'.gitignore': 'gitignore',
|
||||
'.env': 'properties',
|
||||
}
|
||||
|
||||
if (specialFiles[fileName]) return specialFiles[fileName]
|
||||
return getExt(path)
|
||||
})
|
||||
```
|
||||
|
||||
**可以改进为**(使用fileHelpers.js中的函数):
|
||||
```javascript
|
||||
const currentFileExtension = computed(() => {
|
||||
const path = selectedFilePath.value || filePath.value
|
||||
return getExtensionForHighlight(path) // 复用现有工具函数
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. **函数命名不一致**
|
||||
|
||||
| FileSystem.vue | useFilePreview.js | 用途 |
|
||||
|----------------|-------------------|------|
|
||||
| `currentFileNameDisplay` | `currentFileName` | 获取文件名 |
|
||||
| `currentFileFullPathDisplay` | `currentFileFullPath` | 获取完整路径 |
|
||||
| `currentImageDimensionsLocal` | `currentImageDimensions` | 图片尺寸 |
|
||||
|
||||
**问题**:
|
||||
- 有的带`Display`后缀,有的不带
|
||||
- 有的带`Local`后缀,含义不明
|
||||
- 命名不一致导致维护困难
|
||||
|
||||
---
|
||||
|
||||
### 6. **Go代码配置函数重复**
|
||||
|
||||
**internal/filesystem/config.go:256-295**
|
||||
```go
|
||||
func getAllowedExtensions() map[string]bool {
|
||||
return map[string]bool{
|
||||
".jpg": true, ".jpeg": true, ".png": true,
|
||||
// ... 30+ 个硬编码扩展名
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**web/src/utils/constants.js:27-73** (重复定义)
|
||||
```javascript
|
||||
export const FILE_EXTENSIONS = {
|
||||
IMAGE: ['jpg', 'jpeg', 'png', /* ... */],
|
||||
VIDEO_BROWSER: ['mp4', 'webm', /* ... */],
|
||||
// ... 类似的30+个扩展名
|
||||
}
|
||||
```
|
||||
|
||||
**问题**:前后端用不同格式重复定义相同的数据
|
||||
|
||||
**建议**:后端从配置文件加载,或生成JSON供前端使用
|
||||
|
||||
---
|
||||
|
||||
## 三、代码规范问题 ⚠️
|
||||
|
||||
### 7. **路径分隔符正则重复**
|
||||
|
||||
**出现次数**: 15+
|
||||
|
||||
```javascript
|
||||
// FileSystem.vue 多处
|
||||
path.split(/[/\\]/) // 行 719, 798, 819, 833, 845, 2946, ...
|
||||
|
||||
// useFilePreview.js:124
|
||||
path.split(/[/\\/]/)
|
||||
|
||||
// useNavigation.js:304
|
||||
const parts = path.split(/[/\\]/)
|
||||
```
|
||||
|
||||
**建议**:提取为共享常量
|
||||
```javascript
|
||||
// utils/pathConstants.js
|
||||
export const PATH_SEPARATOR_REGEX = /[/\\]/
|
||||
export const splitPath = (path) => path.split(PATH_SEPARATOR_REGEX)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. **文件类型判断分散**
|
||||
|
||||
**FileSystem.vue:857-869**
|
||||
```javascript
|
||||
const previewableTypes = [
|
||||
...FILE_EXTENSIONS.IMAGE,
|
||||
...FILE_EXTENSIONS.VIDEO_BROWSER,
|
||||
...FILE_EXTENSIONS.AUDIO,
|
||||
'pdf', 'html', 'htm', 'md', 'markdown'
|
||||
]
|
||||
|
||||
const knownBinaryTypes = [
|
||||
'exe', 'dll', 'so', 'bin',
|
||||
'zip', 'rar', '7z', 'tar', 'gz', 'iso', 'img', 'dmg',
|
||||
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'
|
||||
]
|
||||
```
|
||||
|
||||
**问题**:
|
||||
- 内联定义在函数内部
|
||||
- 应该定义在constants.js中复用
|
||||
|
||||
---
|
||||
|
||||
### 9. **localStorage键名分散**
|
||||
|
||||
**多处重复定义**:
|
||||
- FileSystem.vue: 使用`STORAGE_KEYS.FILESYSTEM.*`
|
||||
- useFileEdit.js: 直接定义`DRAFT_STORAGE_KEY`
|
||||
- useNavigation.js: 直接定义`STORAGE_KEY_PATH_HISTORY`
|
||||
|
||||
**应该统一使用**:`STORAGE_KEYS`常量对象
|
||||
|
||||
---
|
||||
|
||||
## 四、DRY原则违反统计
|
||||
|
||||
### 重复代码统计
|
||||
|
||||
| 类型 | 重复次数 | 总行数 | 浪费 |
|
||||
|------|----------|--------|------|
|
||||
| 计算属性 | 5组 | ~80行 | 40行 |
|
||||
| 路径分割正则 | 15+次 | ~15行 | 14行 |
|
||||
| 文件类型判断 | 8+次 | ~50行 | 40行 |
|
||||
| localStorage键 | 6+处 | ~12行 | 8行 |
|
||||
| **总计** | **34+处** | **~157行** | **102行** |
|
||||
|
||||
---
|
||||
|
||||
## 五、优化建议
|
||||
|
||||
### 优先级1: 立即修复 🔴
|
||||
|
||||
#### 1.1 移除未使用的Composables
|
||||
```bash
|
||||
# 由于composables未被实际使用,应该删除或文档化
|
||||
rm web/src/composables/useNavigation.js
|
||||
rm web/src/composables/useFileEdit.js
|
||||
rm web/src/composables/useFilePreview.js
|
||||
```
|
||||
|
||||
**理由**:如果不用,就不应该存在,避免混淆
|
||||
|
||||
---
|
||||
|
||||
#### 1.2 删除重复计算属性
|
||||
|
||||
**FileSystem.vue - 保留更完整的版本,删除useFileEdit/useFilePreview中的**:
|
||||
|
||||
```javascript
|
||||
// 保留 FileSystem.vue:1437 - currentFileNameDisplay (有截断逻辑)
|
||||
// 保留 FileSystem.vue:1462 - currentFileFullPathDisplay (有ZIP模式)
|
||||
// 保留 FileSystem.vue:2977 - isFileModified (有新建文件逻辑)
|
||||
// 删除 useFileEdit.js:71, useFilePreview.js:122-126 等重复定义
|
||||
```
|
||||
|
||||
**或者相反**:如果决定使用composables,则删除FileSystem.vue中的重复定义
|
||||
|
||||
---
|
||||
|
||||
#### 1.3 大幅减少调试日志
|
||||
|
||||
**策略A: 环境变量控制**(已部分实现)
|
||||
```javascript
|
||||
// utils/debugLog.js
|
||||
const ENABLE_DEBUG = import.meta.env.DEV
|
||||
|
||||
export const debugLog = ENABLE_DEBUG ? console.log : () => {}
|
||||
export const debugWarn = ENABLE_DEBUG ? console.warn : () => {}
|
||||
export const debugError = console.error // 始终保留错误日志
|
||||
```
|
||||
|
||||
**策略B: 删除非关键日志**(推荐)
|
||||
```javascript
|
||||
// 删除这些类型的日志:
|
||||
debugLog('[readFile] 开始读取文件') // 显而易见的操作
|
||||
debugLog('[handleKeyDown] F2 pressed') // 用户操作
|
||||
debugLog('[startResizeHorizontal] 开始拖拽') // UI交互
|
||||
|
||||
// 保留这些:
|
||||
debugError('[readFile] 读取失败:', error) // 错误
|
||||
debugWarn('[loadCommonPaths] Wails API未就绪') // 降级场景
|
||||
```
|
||||
|
||||
**目标**: 从65个 → < 10个(只保留错误和关键警告)
|
||||
|
||||
---
|
||||
|
||||
### 优先级2: 短期优化 🟡
|
||||
|
||||
#### 2.1 提取共享工具函数
|
||||
|
||||
**创建 web/src/utils/pathHelpers.js**:
|
||||
```javascript
|
||||
export const PATH_SEPARATOR_REGEX = /[/\\]/
|
||||
|
||||
export const splitPath = (path) => path.split(PATH_SEPARATOR_REGEX)
|
||||
|
||||
export const getFileName = (path) => {
|
||||
if (!path) return ''
|
||||
const parts = splitPath(path)
|
||||
return parts[parts.length - 1] || path
|
||||
}
|
||||
|
||||
export const getParentPath = (path) => {
|
||||
if (!path) return ''
|
||||
const lastSep = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
return lastSep > 0 ? path.substring(0, lastSep) : path
|
||||
}
|
||||
```
|
||||
|
||||
**替换所有** `path.split(/[/\\]/)` 为 `splitPath(path)`
|
||||
|
||||
---
|
||||
|
||||
#### 2.2 统一文件类型常量
|
||||
|
||||
**创建 web/src/utils/fileTypeCategories.js**:
|
||||
```javascript
|
||||
import { FILE_EXTENSIONS } from './constants'
|
||||
|
||||
export const PREVIEWABLE_TYPES = [
|
||||
...FILE_EXTENSIONS.IMAGE,
|
||||
...FILE_EXTENSIONS.VIDEO_BROWSER,
|
||||
...FILE_EXTENSIONS.AUDIO,
|
||||
'pdf', 'html', 'htm', 'md', 'markdown'
|
||||
]
|
||||
|
||||
export const KNOWN_BINARY_TYPES = [
|
||||
'exe', 'dll', 'so', 'bin',
|
||||
'zip', 'rar', '7z', 'tar', 'gz', 'iso', 'img', 'dmg',
|
||||
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'
|
||||
]
|
||||
|
||||
export const TEXT_EDITABLE_TYPES = [
|
||||
...FILE_EXTENSIONS.TEXT,
|
||||
...FILE_EXTENSIONS.CODE
|
||||
]
|
||||
```
|
||||
|
||||
**替换所有内联定义**
|
||||
|
||||
---
|
||||
|
||||
#### 2.3 统一localStorage键名
|
||||
|
||||
**只在 constants.js 中定义一次**:
|
||||
```javascript
|
||||
export const STORAGE_KEYS = {
|
||||
FILESYSTEM: {
|
||||
PATH_HISTORY: 'app-filesystem-path-history',
|
||||
EDIT_MODE: 'app-filesystem-edit-mode',
|
||||
PANEL_WIDTH: 'app-filesystem-panel-width',
|
||||
DRAFT_CONTENT: 'filesystem-draft-content',
|
||||
DRAFT_TIME: 'filesystem-draft-time',
|
||||
FAVORITE_FILES: 'filesystem-favorite-files',
|
||||
}
|
||||
}
|
||||
|
||||
// 删除所有其他文件中的重复定义
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 优先级3: 长期重构 🔵
|
||||
|
||||
#### 3.1 真正拆分FileSystem.vue
|
||||
|
||||
**目标**: 从4047行 → < 500行
|
||||
|
||||
**策略**:
|
||||
1. **提取子组件** (~1500行)
|
||||
- `FileListPanel.vue` (文件列表, ~300行)
|
||||
- `CodeEditorPanel.vue` (编辑器面板, ~400行)
|
||||
- `PreviewPanel.vue` (预览面板, ~300行)
|
||||
- `FavoriteSidebar.vue` (收藏夹侧边栏, ~200行)
|
||||
- `Toolbar.vue` (顶部工具栏, ~150行)
|
||||
- `ContextMenu.vue` (右键菜单, ~150行)
|
||||
|
||||
2. **提取composables** (~1000行)
|
||||
- `useFileSystem.js` (核心文件系统操作, ~300行)
|
||||
- `useFileEditor.js` (编辑器逻辑, ~200行)
|
||||
- `useFilePreview.js` (预览逻辑, ~250行)
|
||||
- `useFavoriteFiles.js` (收藏夹管理, ~150行)
|
||||
- `useKeyboardShortcuts.js` (快捷键, ~100行)
|
||||
|
||||
3. **主组件保留** (~500行)
|
||||
- 布局和状态协调
|
||||
- 子组件通信
|
||||
- 生命周期管理
|
||||
|
||||
**时间估算**: 2-3周
|
||||
|
||||
---
|
||||
|
||||
#### 3.2 TypeScript迁移
|
||||
|
||||
**目标**: 添加类型安全,减少运行时错误
|
||||
|
||||
```typescript
|
||||
// types/file.ts
|
||||
export interface FileItem {
|
||||
path: string
|
||||
name: string
|
||||
is_dir: boolean
|
||||
size: number
|
||||
modified: string
|
||||
}
|
||||
|
||||
export interface PreviewState {
|
||||
isImageView: boolean
|
||||
isVideoView: boolean
|
||||
isAudioView: boolean
|
||||
isPdfFile: boolean
|
||||
isHtmlFile: boolean
|
||||
isMarkdownFile: boolean
|
||||
isBinaryFile: boolean
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3.3 统一前后端文件类型定义
|
||||
|
||||
**方案A: 后端生成JSON**
|
||||
```go
|
||||
// internal/filesystem/export_types.go
|
||||
func ExportFileTypes() string {
|
||||
types := map[string][]string{
|
||||
"image": getAllowedExtensions(),
|
||||
"binary": getForbiddenExtensions(),
|
||||
}
|
||||
json, _ := json.Marshal(types)
|
||||
return string(json)
|
||||
}
|
||||
```
|
||||
|
||||
**方案B: 独立配置文件**
|
||||
```yaml
|
||||
# config/file_types.yaml
|
||||
image:
|
||||
- jpg
|
||||
- jpeg
|
||||
- png
|
||||
binary:
|
||||
- exe
|
||||
- dll
|
||||
```
|
||||
|
||||
前后端都从同一配置读取
|
||||
|
||||
---
|
||||
|
||||
## 六、检查清单
|
||||
|
||||
### 立即执行(本周)
|
||||
|
||||
- [ ] **决定**: 删除还是使用composables
|
||||
- [ ] **删除重复**: 移除5组重复计算属性(102行)
|
||||
- [ ] **减少日志**: 从65个debugLog → < 10个
|
||||
- [ ] **提取工具**: 创建pathHelpers.js
|
||||
- [ ] **统一常量**: 合并文件类型定义
|
||||
- [ ] **统一键名**: 只使用STORAGE_KEYS
|
||||
|
||||
### 短期计划(2周)
|
||||
|
||||
- [ ] **提取子组件**: FileListPanel, Toolbar, ContextMenu
|
||||
- [ ] **提效composables**: 真正集成useFileSystem, useFilePreview
|
||||
- [ ] **优化函数**: 简化currentFileExtension逻辑
|
||||
- [ ] **命名统一**: 统一Display/Local后缀规则
|
||||
|
||||
### 长期优化(1个月)
|
||||
|
||||
- [ ] **组件化**: 完成所有子组件提取
|
||||
- [ ] **TypeScript**: 添加类型定义
|
||||
- [ ] **前后端统一**: 文件类型配置共享
|
||||
- [ ] **单元测试**: 覆盖核心逻辑
|
||||
|
||||
---
|
||||
|
||||
## 七、代码质量指标(更新后)
|
||||
|
||||
| 指标 | 当前值 | 目标值 | 评级 |
|
||||
|------|--------|--------|------|
|
||||
| 单文件最大行数 | 4047 | < 500 | 🔴 |
|
||||
| 函数平均行数 | ~50 | < 30 | 🟡 |
|
||||
| 代码重复率 | ~8% | < 3% | 🔴 |
|
||||
| 调试语句数量 | 65 | < 10 | 🔴 |
|
||||
| 圈复杂度 | 15+ | < 10 | 🟡 |
|
||||
| 未使用代码 | 1253行 | 0 | 🔴 |
|
||||
|
||||
---
|
||||
|
||||
## 八、总结
|
||||
|
||||
### 关键发现
|
||||
|
||||
1. **重构未完成**: Composables已创建但未使用,反而增加了总代码量
|
||||
2. **重复代码严重**: 5组计算属性重复,102行浪费
|
||||
3. **过度防御性编程**: 65个调试日志,远超必要数量
|
||||
4. **命名不一致**: Display/Local后缀混乱
|
||||
|
||||
### 下一步行动
|
||||
|
||||
**推荐方案A: 激进重构**
|
||||
- 删除3个未使用的composables
|
||||
- 立即开始拆分子组件
|
||||
- 1个月内完成组件化
|
||||
|
||||
**推荐方案B: 渐进优化(更稳妥)**
|
||||
- 先清理重复代码和日志
|
||||
- 提取共享工具函数
|
||||
- 逐步拆分子组件
|
||||
|
||||
### 风险提示
|
||||
|
||||
⚠️ **当前状态**: 代码库处于"半重构"状态,既有旧实现又有新参考,容易造成混淆
|
||||
|
||||
**建议**: 尽快决定方向(彻底重构 vs 回滚到重构前),避免技术债务累积
|
||||
30
go.mod
@@ -3,32 +3,35 @@ module u-desk
|
||||
go 1.25.6
|
||||
|
||||
require (
|
||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
|
||||
github.com/chromedp/chromedp v0.14.2
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/redis/go-redis/v9 v9.17.3
|
||||
github.com/labstack/echo/v4 v4.15.0
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
golang.org/x/sys v0.40.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.18.3 // indirect
|
||||
github.com/labstack/echo/v4 v4.15.0 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
@@ -52,17 +55,12 @@ require (
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
modernc.org/libc v1.67.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
79
go.sum
@@ -1,28 +1,32 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
|
||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
|
||||
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
|
||||
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -41,8 +45,6 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
|
||||
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
@@ -57,6 +59,8 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
@@ -68,6 +72,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -76,8 +82,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
|
||||
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
@@ -107,72 +111,45 @@ github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+
|
||||
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
|
||||
108
internal/agent/config/config.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
CORS CORSConfig `yaml:"cors"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
FileServer FileServerConfig `yaml:"file_server"`
|
||||
Security SecurityConfig `yaml:"security"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
Host string `yaml:"host"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
Token string `yaml:"token"`
|
||||
}
|
||||
|
||||
type CORSConfig struct {
|
||||
AllowedOrigins []string `yaml:"allowed_origins"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"`
|
||||
}
|
||||
|
||||
type FileServerConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
MaxFileSize int64 `yaml:"max_file_size"`
|
||||
}
|
||||
|
||||
type SecurityConfig struct {
|
||||
AllowSymlinks bool `yaml:"allow_symlinks"`
|
||||
CheckSystemPaths bool `yaml:"check_system_paths"`
|
||||
}
|
||||
|
||||
// FileServerAddr 返回文件服务器的完整地址
|
||||
func (c *Config) FileServerAddr() string {
|
||||
return fmt.Sprintf("http://localhost:%d", c.FileServer.Port)
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// 配置文件不存在时使用默认值
|
||||
if os.IsNotExist(err) {
|
||||
return Default(), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := Default()
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 清理 origins 中的空格并去重
|
||||
seen := make(map[string]bool, len(cfg.CORS.AllowedOrigins))
|
||||
uniques := cfg.CORS.AllowedOrigins[:0]
|
||||
for _, origin := range cfg.CORS.AllowedOrigins {
|
||||
o := strings.TrimSpace(origin)
|
||||
if o != "" && !seen[o] {
|
||||
seen[o] = true
|
||||
uniques = append(uniques, o)
|
||||
}
|
||||
}
|
||||
cfg.CORS.AllowedOrigins = uniques
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
Server: ServerConfig{
|
||||
Port: 9876,
|
||||
Host: "0.0.0.0",
|
||||
},
|
||||
Auth: AuthConfig{
|
||||
Token: "",
|
||||
},
|
||||
CORS: CORSConfig{
|
||||
AllowedOrigins: []string{"*"},
|
||||
},
|
||||
Log: LogConfig{
|
||||
Level: "info",
|
||||
Format: "json",
|
||||
},
|
||||
FileServer: FileServerConfig{
|
||||
Port: 8073,
|
||||
MaxFileSize: 500 * 1024 * 1024,
|
||||
},
|
||||
Security: SecurityConfig{
|
||||
AllowSymlinks: false,
|
||||
CheckSystemPaths: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
176
internal/agent/handler/file_handler.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"u-desk/internal/agent/model"
|
||||
"u-desk/internal/filesystem"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type writeFileReq struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type createReq struct {
|
||||
Type string `json:"type"` // "file" or "dir"
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type renameReq struct {
|
||||
NewPath string `json:"new_path"`
|
||||
}
|
||||
|
||||
type uploadReq struct {
|
||||
Content string `json:"content"` // base64 编码内容
|
||||
}
|
||||
|
||||
// ListOrStat 列出目录或获取文件信息(?get=stat 时返回单文件信息)
|
||||
func (h *Handler) ListOrStat(c echo.Context) error {
|
||||
path := getPath(c)
|
||||
action := c.QueryParam("get")
|
||||
|
||||
if action == "stat" {
|
||||
info, err := h.fsSvc.GetFileInfo(path)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusOK, model.NotFound(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.OK(info))
|
||||
}
|
||||
|
||||
files, err := h.fsSvc.ListDir(path)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusOK, model.InternalError(err.Error()))
|
||||
}
|
||||
// 限制返回数量,避免大目录导致前端卡顿
|
||||
limit := c.QueryParam("limit")
|
||||
if limit != "" {
|
||||
n := 0
|
||||
for i, f := range files {
|
||||
if n >= 500 { // 硬限制 500 条
|
||||
break
|
||||
}
|
||||
files[i] = f
|
||||
n++
|
||||
}
|
||||
files = files[:n]
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.OK(files))
|
||||
}
|
||||
|
||||
// ReadFile 读取文件文本内容
|
||||
func (h *Handler) ReadFile(c echo.Context) error {
|
||||
path := getPath(c)
|
||||
content, err := h.fsSvc.ReadFile(path)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusOK, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.OK(map[string]string{
|
||||
"content": content,
|
||||
}))
|
||||
}
|
||||
|
||||
// WriteFile 写入文件文本内容
|
||||
func (h *Handler) WriteFile(c echo.Context) error {
|
||||
path := getPath(c)
|
||||
var req writeFileReq
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
|
||||
}
|
||||
if err := h.fsSvc.WriteFile(path, req.Content); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.NoContent())
|
||||
}
|
||||
|
||||
// Create 创建文件或目录
|
||||
func (h *Handler) Create(c echo.Context) error {
|
||||
parentPath := getPath(c)
|
||||
var req createReq
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
|
||||
}
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("名称不能为空"))
|
||||
}
|
||||
|
||||
var result *filesystem.FileOperationResult
|
||||
var err error
|
||||
|
||||
fullPath := filepath.Join(parentPath, req.Name)
|
||||
|
||||
switch req.Type {
|
||||
case "dir":
|
||||
result, err = h.fsSvc.CreateDir(fullPath)
|
||||
default:
|
||||
result, err = h.fsSvc.CreateFile(fullPath)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusCreated, model.OK(result))
|
||||
}
|
||||
|
||||
// Delete 删除文件或目录
|
||||
func (h *Handler) Delete(c echo.Context) error {
|
||||
path := getPath(c)
|
||||
result, err := h.fsSvc.DeletePath(path)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.OK(result))
|
||||
}
|
||||
|
||||
// Rename 重命名文件或目录
|
||||
func (h *Handler) Rename(c echo.Context) error {
|
||||
oldPath := getPath(c)
|
||||
var req renameReq
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
|
||||
}
|
||||
req.NewPath = strings.TrimSpace(req.NewPath)
|
||||
if req.NewPath == "" {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("新路径不能为空"))
|
||||
}
|
||||
cleanNew := filepath.Clean(req.NewPath)
|
||||
if strings.Contains(cleanNew, "..") {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("新路径不允许包含 .."))
|
||||
}
|
||||
result, err := h.fsSvc.RenamePath(oldPath, cleanNew)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.OK(result))
|
||||
}
|
||||
|
||||
// Upload 上传 Base64 编码的二进制文件
|
||||
func (h *Handler) Upload(c echo.Context) error {
|
||||
path := getPath(c)
|
||||
var req uploadReq
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
|
||||
}
|
||||
if req.Content == "" {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("内容不能为空"))
|
||||
}
|
||||
if err := h.fsSvc.SaveBase64File(path, req.Content); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.NoContent())
|
||||
}
|
||||
|
||||
// DetectType 通过文件内容检测类型
|
||||
func (h *Handler) DetectType(c echo.Context) error {
|
||||
path := getPath(c)
|
||||
info, err := h.fsSvc.DetectFileTypeByContent(path)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusOK, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.OK(info))
|
||||
}
|
||||
37
internal/agent/handler/handler.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
|
||||
"u-desk/internal/agent/config"
|
||||
"u-desk/internal/filesystem"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
fsSvc *filesystem.FileSystemService
|
||||
cfg *config.Config
|
||||
fileProxy *httputil.ReverseProxy
|
||||
}
|
||||
|
||||
func New(fsSvc *filesystem.FileSystemService, cfg *config.Config) *Handler {
|
||||
fileTarget, _ := url.Parse(cfg.FileServerAddr() + "/localfs/")
|
||||
return &Handler{
|
||||
fsSvc: fsSvc,
|
||||
cfg: cfg,
|
||||
fileProxy: httputil.NewSingleHostReverseProxy(fileTarget),
|
||||
}
|
||||
}
|
||||
|
||||
// getPath 从 query 参数提取并规范化文件路径
|
||||
func getPath(c echo.Context) string {
|
||||
raw := c.QueryParam("path")
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
// URL 已被 Echo 自动 decode,只需转换路径分隔符
|
||||
return filepath.FromSlash(raw)
|
||||
}
|
||||
64
internal/agent/handler/server_handler.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// FileServerProxy 反向代理到内置文件服务器(用于媒体预览)
|
||||
func (h *Handler) FileServerProxy(c echo.Context) error {
|
||||
rawPath := c.Param("*")
|
||||
if rawPath == "" {
|
||||
return c.String(http.StatusBadRequest, "缺少文件路径")
|
||||
}
|
||||
|
||||
clean := filepath.Clean(rawPath)
|
||||
if strings.Contains(clean, "..") {
|
||||
return c.String(http.StatusForbidden, "路径不允许包含 ..")
|
||||
}
|
||||
|
||||
// 防止多重 /localfs/ 前缀(循环去除所有)
|
||||
targetPath := filepath.ToSlash(clean)
|
||||
for strings.HasPrefix(targetPath, "localfs/") || strings.HasPrefix(targetPath, "localfs\\") {
|
||||
targetPath = strings.TrimPrefix(targetPath, "localfs/")
|
||||
targetPath = strings.TrimPrefix(targetPath, "localfs\\")
|
||||
}
|
||||
c.Request().URL.Path = "/localfs/" + targetPath
|
||||
h.fileProxy.ServeHTTP(c.Response(), c.Request())
|
||||
return nil
|
||||
}
|
||||
|
||||
// HTMLPreviewProxy 代理 HTML 预览请求(直连内部服务器,避免 ReverseProxy 路径拼接问题)
|
||||
func (h *Handler) HTMLPreviewProxy(c echo.Context) error {
|
||||
rawPath := c.QueryParam("path")
|
||||
if rawPath == "" {
|
||||
return c.String(http.StatusBadRequest, "缺少 path 参数")
|
||||
}
|
||||
clean := filepath.Clean(rawPath)
|
||||
if strings.Contains(clean, "..") {
|
||||
return c.String(http.StatusForbidden, "路径不允许包含 ..")
|
||||
}
|
||||
theme := c.QueryParam("theme")
|
||||
|
||||
targetURL := fmt.Sprintf("http://localhost:8073/localfs/html-preview?path=%s&theme=%s",
|
||||
url.QueryEscape(clean), url.QueryEscape(theme))
|
||||
|
||||
resp, err := http.Get(targetURL)
|
||||
if err != nil {
|
||||
return c.String(http.StatusBadGateway, "内部服务器不可用")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
for k, v := range resp.Header {
|
||||
c.Response().Header()[k] = v
|
||||
}
|
||||
c.Response().WriteHeader(resp.StatusCode)
|
||||
io.Copy(c.Response(), resp.Body)
|
||||
return nil
|
||||
}
|
||||
113
internal/agent/handler/system_handler.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"u-desk/internal/agent/model"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// Ping 健康检查
|
||||
func (h *Handler) Ping(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, model.OK(map[string]string{
|
||||
"status": "ok",
|
||||
}))
|
||||
}
|
||||
|
||||
// Info 返回 Agent 信息
|
||||
func (h *Handler) Info(c echo.Context) error {
|
||||
hostname, _ := os.Hostname()
|
||||
return c.JSON(http.StatusOK, model.OK(map[string]interface{}{
|
||||
"version": "0.1.0",
|
||||
"os": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"hostname": hostname,
|
||||
}))
|
||||
}
|
||||
|
||||
// CommonPaths 返回常用系统路径
|
||||
func (h *Handler) CommonPaths(c echo.Context) error {
|
||||
paths := map[string]string{}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
if home != "" {
|
||||
paths["home"] = home
|
||||
paths["desktop"] = home + "/Desktop"
|
||||
paths["documents"] = home + "/Documents"
|
||||
paths["downloads"] = home + "/Downloads"
|
||||
}
|
||||
|
||||
// 根据平台添加盘符/根路径
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, drive := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
|
||||
_, err := os.Stat(string(drive) + ":\\")
|
||||
if err == nil {
|
||||
paths["drive_"+strings.ToLower(string(drive))] = string(drive) + ":\\"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
paths["root"] = "/"
|
||||
_, err := os.Stat("/home")
|
||||
if err == nil {
|
||||
paths["users"] = "/home"
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, model.OK(paths))
|
||||
}
|
||||
|
||||
// Drives 返回可用磁盘列表
|
||||
func (h *Handler) Drives(c echo.Context) error {
|
||||
type DriveInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
FsType string `json:"fs_type,omitempty"`
|
||||
Total uint64 `json:"total"`
|
||||
Free uint64 `json:"free"`
|
||||
}
|
||||
|
||||
var drives []DriveInfo
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, drive := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
|
||||
drivePath := string(drive) + ":\\"
|
||||
if _, err := os.Stat(drivePath); err != nil {
|
||||
continue
|
||||
}
|
||||
drives = append(drives, DriveInfo{
|
||||
Name: strings.ToLower(string(drive)),
|
||||
Path: drivePath,
|
||||
Total: 0,
|
||||
Free: 0,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
parts, err := os.ReadDir("/")
|
||||
if err == nil {
|
||||
for _, p := range parts {
|
||||
name := p.Name()
|
||||
if len(name) == 2 && name[0] != '.' && name[1] != '.' && p.IsDir() {
|
||||
// 可能是挂载点
|
||||
fullPath := "/" + name
|
||||
if stat, err := os.Stat(fullPath); err == nil && stat.IsDir() {
|
||||
drives = append(drives, DriveInfo{
|
||||
Name: name,
|
||||
Path: fullPath,
|
||||
})
|
||||
_ = stat
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 至少返回根目录
|
||||
if len(drives) == 0 {
|
||||
drives = append(drives, DriveInfo{Name: "/", Path: "/"})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, model.OK(drives))
|
||||
}
|
||||
61
internal/agent/middleware/auth.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
const cookieName = "fs_token"
|
||||
|
||||
func Auth(token string) echo.MiddlewareFunc {
|
||||
if token == "" {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// 1. Authorization header(API 调用,首选)
|
||||
auth := c.Request().Header.Get("Authorization")
|
||||
if len(auth) >= 7 && strings.HasPrefix(auth, "Bearer ") &&
|
||||
subtle.ConstantTimeCompare([]byte(auth[7:]), []byte(token)) == 1 {
|
||||
setAuthCookie(c, token)
|
||||
return next(c)
|
||||
}
|
||||
// 2. Cookie(<img>/<video> 等浏览器自动携带)
|
||||
if ck, err := c.Cookie(cookieName); err == nil &&
|
||||
subtle.ConstantTimeCompare([]byte(ck.Value), []byte(token)) == 1 {
|
||||
return next(c)
|
||||
}
|
||||
// 3. 查询参数(兼容旧版,可后续移除)
|
||||
if qt := c.QueryParam("token"); qt != "" &&
|
||||
subtle.ConstantTimeCompare([]byte(qt), []byte(token)) == 1 {
|
||||
setAuthCookie(c, token)
|
||||
return next(c)
|
||||
}
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"error": "unauthorized",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setAuthCookie 首次认证成功后设置 Cookie(供 <img> 等浏览器请求自动携带)
|
||||
func setAuthCookie(c echo.Context, token string) {
|
||||
c.SetCookie(&http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: int(24 * time.Hour / time.Second),
|
||||
HttpOnly: true,
|
||||
Secure: c.Request().TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
41
internal/agent/model/response.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package model
|
||||
|
||||
import "net/http"
|
||||
|
||||
type Response struct {
|
||||
Code int `json:"code"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func OK(data interface{}) Response {
|
||||
return Response{Code: http.StatusOK, Data: data}
|
||||
}
|
||||
|
||||
func Created(data interface{}) Response {
|
||||
return Response{Code: http.StatusCreated, Data: data}
|
||||
}
|
||||
|
||||
func NoContent() Response {
|
||||
return Response{Code: http.StatusNoContent}
|
||||
}
|
||||
|
||||
func BadRequest(msg string) Response {
|
||||
return Response{Code: http.StatusBadRequest, Message: msg}
|
||||
}
|
||||
|
||||
func Unauthorized(msg string) Response {
|
||||
return Response{Code: http.StatusUnauthorized, Message: msg}
|
||||
}
|
||||
|
||||
func Forbidden(msg string) Response {
|
||||
return Response{Code: http.StatusForbidden, Message: msg}
|
||||
}
|
||||
|
||||
func NotFound(msg string) Response {
|
||||
return Response{Code: http.StatusNotFound, Message: msg}
|
||||
}
|
||||
|
||||
func InternalError(msg string) Response {
|
||||
return Response{Code: http.StatusInternalServerError, Message: msg}
|
||||
}
|
||||
@@ -136,40 +136,66 @@ func (api *ConfigAPI) SaveAppConfig(req SaveAppConfigRequest) (map[string]interf
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MigrateTabConfig 迁移旧配置
|
||||
// MigrateTabConfig 迁移旧配置(device 移除 + openclaw-manager 重命名)
|
||||
func (api *ConfigAPI) MigrateTabConfig() error {
|
||||
config, _ := api.configService.GetTabConfig()
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查是否包含 device
|
||||
hasDevice := false
|
||||
needMigrate := false
|
||||
|
||||
// 检查是否包含需要迁移的旧 key
|
||||
for _, tab := range config.AvailableTabs {
|
||||
if tab.Key == "device" {
|
||||
hasDevice = true
|
||||
if tab.Key == "device" || tab.Key == "openclaw-manager" {
|
||||
needMigrate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDevice {
|
||||
if !needMigrate {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 过滤掉 device
|
||||
// 映射:旧 key → 新 key(不需要的移除)
|
||||
keyMap := map[string]string{
|
||||
"openclaw-manager": "version",
|
||||
// "device": "" // 直接过滤
|
||||
}
|
||||
|
||||
newTabs := make([]service.TabDefinition, 0, len(config.AvailableTabs))
|
||||
newVisible := make([]string, 0, len(config.VisibleTabs))
|
||||
seenKeys := map[string]bool{}
|
||||
|
||||
for _, tab := range config.AvailableTabs {
|
||||
if tab.Key != "device" {
|
||||
newKey, shouldRename := keyMap[tab.Key]
|
||||
if shouldRename {
|
||||
if newKey == "" {
|
||||
continue // 移除(如 device)
|
||||
}
|
||||
if seenKeys[newKey] {
|
||||
continue // 避免重复
|
||||
}
|
||||
seenKeys[newKey] = true
|
||||
newTabs = append(newTabs, service.TabDefinition{Key: newKey, Title: tab.Title, Enabled: tab.Enabled})
|
||||
} else {
|
||||
newTabs = append(newTabs, tab)
|
||||
}
|
||||
}
|
||||
for _, key := range config.VisibleTabs {
|
||||
if key != "device" {
|
||||
if newKey, ok := keyMap[key]; ok {
|
||||
if newKey != "" && !seenKeys[newKey] {
|
||||
newVisible = append(newVisible, newKey)
|
||||
}
|
||||
// newKey == "" 时跳过(如 device)
|
||||
} else {
|
||||
newVisible = append(newVisible, key)
|
||||
}
|
||||
}
|
||||
|
||||
defaultTab := config.DefaultTab
|
||||
if newKey, ok := keyMap[defaultTab]; ok && newKey != "" {
|
||||
defaultTab = newKey
|
||||
}
|
||||
if defaultTab == "device" {
|
||||
defaultTab = "file-system"
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"u-desk/internal/service"
|
||||
"u-desk/internal/storage/models"
|
||||
)
|
||||
|
||||
// ConnectionAPI 连接管理API
|
||||
type ConnectionAPI struct {
|
||||
connService *service.ConnectionService
|
||||
}
|
||||
|
||||
// NewConnectionAPI 创建连接管理API
|
||||
func NewConnectionAPI() (*ConnectionAPI, error) {
|
||||
connService, err := service.NewConnectionService()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ConnectionAPI{connService}, nil
|
||||
}
|
||||
|
||||
// SaveConnectionRequest 保存连接请求结构体
|
||||
type SaveConnectionRequest struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Database string `json:"database"`
|
||||
Options string `json:"options"`
|
||||
}
|
||||
|
||||
// SaveDbConnection 保存数据库连接配置
|
||||
func (api *ConnectionAPI) SaveDbConnection(req SaveConnectionRequest) error {
|
||||
conn := &models.DbConnection{
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Host: req.Host,
|
||||
Port: req.Port,
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
Database: req.Database,
|
||||
Options: req.Options,
|
||||
}
|
||||
return api.connService.SaveConnection(conn)
|
||||
}
|
||||
|
||||
// ListDbConnections 获取连接列表
|
||||
func (api *ConnectionAPI) ListDbConnections() ([]map[string]interface{}, error) {
|
||||
connections, err := api.connService.ListConnections()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]interface{}, len(connections))
|
||||
timeFormat := "2006-01-02 15:04:05"
|
||||
for i, conn := range connections {
|
||||
result[i] = map[string]interface{}{
|
||||
"id": conn.ID,
|
||||
"name": conn.Name,
|
||||
"type": conn.Type,
|
||||
"host": conn.Host,
|
||||
"port": conn.Port,
|
||||
"username": conn.Username,
|
||||
"database": conn.Database,
|
||||
"options": conn.Options,
|
||||
"created_at": conn.CreatedAt.Format(timeFormat),
|
||||
"updated_at": conn.UpdatedAt.Format(timeFormat),
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (api *ConnectionAPI) DeleteDbConnection(id uint) error {
|
||||
return api.connService.DeleteConnection(id)
|
||||
}
|
||||
|
||||
func (api *ConnectionAPI) TestDbConnection(id uint) error {
|
||||
return api.connService.TestConnection(id)
|
||||
}
|
||||
|
||||
// TestConnectionRequest 测试连接请求结构体(不保存数据)
|
||||
type TestConnectionRequest struct {
|
||||
ID uint `json:"id"` // 编辑模式下的连接ID(用于获取已保存的密码)
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Database string `json:"database"`
|
||||
Options string `json:"options"`
|
||||
}
|
||||
|
||||
// TestDbConnectionWithParams 测试数据库连接(直接传入参数,不保存数据)
|
||||
func (api *ConnectionAPI) TestDbConnectionWithParams(req TestConnectionRequest) error {
|
||||
return api.connService.TestConnectionWithParams(
|
||||
req.Type,
|
||||
req.Host,
|
||||
req.Port,
|
||||
req.Username,
|
||||
req.Password,
|
||||
req.Database,
|
||||
req.Options,
|
||||
req.ID,
|
||||
)
|
||||
}
|
||||
379
internal/api/pdf_api.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/yuin/goldmark"
|
||||
"u-desk/internal/common"
|
||||
)
|
||||
|
||||
// PdfExportRequest PDF导出请求结构体
|
||||
type PdfExportRequest struct {
|
||||
Content string `json:"content"` // Markdown/HTML内容
|
||||
Title string `json:"title"` // PDF标题
|
||||
FileName string `json:"fileName"` // 文件名(不含扩展名)
|
||||
FontSize int `json:"fontSize"` // 字体大小
|
||||
PageWidth int `json:"pageWidth"` // 页面宽度(mm)
|
||||
PageHeight int `json:"pageHeight"` // 页面高度(mm)
|
||||
}
|
||||
|
||||
// PdfExportResponse PDF导出响应结构体
|
||||
type PdfExportResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Path string `json:"path"` // PDF文件保存路径
|
||||
Size int64 `json:"size"` // 文件大小(字节)
|
||||
}
|
||||
|
||||
// PdfAPI PDF导出API
|
||||
type PdfAPI struct {
|
||||
// 可以在这里添加依赖,如文件系统服务等
|
||||
}
|
||||
|
||||
// NewPdfAPI 创建PDF导出API
|
||||
func NewPdfAPI() (*PdfAPI, error) {
|
||||
return &PdfAPI{}, nil
|
||||
}
|
||||
|
||||
// ExportMarkdownToPDF 将Markdown内容导出为PDF - 使用chromedp实现
|
||||
func (api *PdfAPI) ExportMarkdownToPDF(req PdfExportRequest) (*PdfExportResponse, error) {
|
||||
// 验证参数
|
||||
if strings.TrimSpace(req.Content) == "" {
|
||||
return nil, fmt.Errorf("内容不能为空")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.FileName) == "" {
|
||||
req.FileName = "document_" + time.Now().Format("20060102_150405")
|
||||
}
|
||||
|
||||
if req.FontSize <= 0 {
|
||||
req.FontSize = 12
|
||||
}
|
||||
|
||||
// 设置默认页面尺寸(A4)
|
||||
if req.PageWidth <= 0 {
|
||||
req.PageWidth = 210
|
||||
}
|
||||
if req.PageHeight <= 0 {
|
||||
req.PageHeight = 297
|
||||
}
|
||||
|
||||
// 将Markdown转换为HTML
|
||||
htmlContent := api.markdownToHTML(req.Content, req.Title, req.FontSize)
|
||||
|
||||
// 使用chromedp生成PDF
|
||||
pdfBuffer, err := api.generatePDFFromHTML(htmlContent, req.Title, req.PageWidth, req.PageHeight)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成PDF失败: %v", err)
|
||||
}
|
||||
|
||||
// 生成文件名
|
||||
if !strings.HasSuffix(strings.ToLower(req.FileName), ".pdf") {
|
||||
req.FileName += ".pdf"
|
||||
}
|
||||
|
||||
// 获取用户桌面目录作为默认保存位置
|
||||
saveDir := api.getDesktopDirectory()
|
||||
|
||||
// 确保目录存在
|
||||
if err := os.MkdirAll(saveDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建目录失败: %v", err)
|
||||
}
|
||||
|
||||
// 完整保存路径
|
||||
savePath := filepath.Join(saveDir, filepath.Base(req.FileName))
|
||||
|
||||
// 保存PDF文件
|
||||
err = os.WriteFile(savePath, pdfBuffer, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("保存PDF文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
fileInfo, err := os.Stat(savePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取文件信息失败: %v", err)
|
||||
}
|
||||
|
||||
// 返回成功响应
|
||||
return &PdfExportResponse{
|
||||
Success: true,
|
||||
Message: "PDF生成成功",
|
||||
Path: savePath,
|
||||
Size: fileInfo.Size(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// markdownToHTML 将Markdown转换为HTML
|
||||
func (api *PdfAPI) markdownToHTML(markdownContent string, title string, fontSize int) string {
|
||||
// 基础HTML模板
|
||||
htmlTemplate := `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
font-size: %dpx;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
h4 {
|
||||
font-size: 1em;
|
||||
}
|
||||
h5 {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
h6 {
|
||||
font-size: 0.85em;
|
||||
color: #6a737d;
|
||||
}
|
||||
p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
blockquote {
|
||||
margin: 0 0 16px;
|
||||
padding: 0 1em;
|
||||
color: #6a737d;
|
||||
border-left: 0.25em solid #dfe2e5;
|
||||
}
|
||||
ul, ol {
|
||||
padding-left: 2em;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
code {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
background-color: rgba(27,31,35,0.05);
|
||||
border-radius: 3px;
|
||||
font-size: 85%;
|
||||
margin: 0;
|
||||
padding: 0.2em 0.4em;
|
||||
}
|
||||
pre {
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 3px;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 100%;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #dfe2e5;
|
||||
}
|
||||
th, td {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #dfe2e5;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background-color: #f6f8fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 16px 0;
|
||||
}
|
||||
hr {
|
||||
height: 0.25em;
|
||||
padding: 0;
|
||||
margin: 24px 0;
|
||||
background-color: #e1e4e8;
|
||||
border: 0;
|
||||
}
|
||||
.title {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
font-size: 1.5em;
|
||||
color: #2c3e50;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="title">%s</div>
|
||||
%s
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// 标题处理
|
||||
docTitle := ""
|
||||
if title != "" {
|
||||
docTitle = html.EscapeString(title)
|
||||
} else {
|
||||
docTitle = "文档"
|
||||
}
|
||||
|
||||
// Markdown转HTML(使用goldmark)
|
||||
var htmlContent string
|
||||
var htmlBuf strings.Builder
|
||||
if err := goldmark.Convert([]byte(markdownContent), &htmlBuf); err != nil {
|
||||
htmlContent = "<p>Markdown 解析失败</p>"
|
||||
} else {
|
||||
htmlContent = htmlBuf.String()
|
||||
}
|
||||
|
||||
// 生成完整的HTML
|
||||
fullHTML := fmt.Sprintf(htmlTemplate, fontSize, docTitle, htmlContent)
|
||||
|
||||
return fullHTML
|
||||
}
|
||||
|
||||
// generatePDFFromHTML 使用chromedp从HTML生成PDF
|
||||
func (api *PdfAPI) generatePDFFromHTML(htmlContent, title string, pageWidth, pageHeight int) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
// 配置chromedp选项
|
||||
opts := []chromedp.ExecAllocatorOption{
|
||||
chromedp.Flag("headless", true),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
chromedp.Flag("no-sandbox", true),
|
||||
chromedp.Flag("disable-dev-shm-usage", true),
|
||||
chromedp.Flag("disable-software-rasterizer", true),
|
||||
chromedp.Flag("disable-extensions", true),
|
||||
chromedp.Flag("disable-notifications", true),
|
||||
}
|
||||
|
||||
// 在Windows上设置Chrome路径
|
||||
if common.IsWindows() {
|
||||
// 常见的Windows Chrome路径
|
||||
chromePaths := []string{
|
||||
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Users\\" + os.Getenv("USERNAME") + "\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe",
|
||||
}
|
||||
|
||||
for _, path := range chromePaths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
opts = append(opts, chromedp.ExecPath(path))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建执行分配器上下文
|
||||
allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...)
|
||||
defer allocCancel()
|
||||
|
||||
// 创建chromedp上下文
|
||||
chromeCtx, chromeCancel := chromedp.NewContext(allocCtx)
|
||||
defer chromeCancel()
|
||||
|
||||
// 创建一个临时的目录用于PDF生成
|
||||
tempDir, err := os.MkdirTemp("", "pdf_gen")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建临时目录失败: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// 将HTML写入临时文件
|
||||
htmlFile := filepath.Join(tempDir, "document.html")
|
||||
if err := os.WriteFile(htmlFile, []byte(htmlContent), 0644); err != nil {
|
||||
return nil, fmt.Errorf("写入HTML文件失败: %v", err)
|
||||
}
|
||||
|
||||
var buf []byte
|
||||
|
||||
// 使用 file URL 加载本地HTML文件
|
||||
err = chromedp.Run(chromeCtx,
|
||||
// 导航到HTML文件
|
||||
chromedp.Navigate("file://"+htmlFile),
|
||||
// 等待页面加载完成
|
||||
chromedp.WaitReady("body"),
|
||||
// 打印到PDF
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
// 设置页面打印参数
|
||||
printToPDF := page.PrintToPDF().
|
||||
WithPrintBackground(true).
|
||||
WithLandscape(false).
|
||||
WithMarginTop(0).
|
||||
WithMarginBottom(0).
|
||||
WithMarginLeft(0).
|
||||
WithMarginRight(0).
|
||||
WithPaperWidth(float64(pageWidth) / 25.4). // mm to inches
|
||||
WithPaperHeight(float64(pageHeight) / 25.4) // mm to inches
|
||||
|
||||
// 执行打印并获取PDF数据
|
||||
var err error
|
||||
buf, _, err = printToPDF.Do(ctx)
|
||||
return err
|
||||
}),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("chromedp执行失败: %v", err)
|
||||
}
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// getDesktopDirectory 获取用户桌面目录
|
||||
func (api *PdfAPI) getDesktopDirectory() string {
|
||||
// Windows系统
|
||||
if common.IsWindows() {
|
||||
home := os.Getenv("USERPROFILE")
|
||||
if home != "" {
|
||||
return filepath.Join(home, "Desktop")
|
||||
}
|
||||
}
|
||||
|
||||
// Linux/Mac系统
|
||||
home := os.Getenv("HOME")
|
||||
if home != "" {
|
||||
return filepath.Join(home, "Desktop")
|
||||
}
|
||||
|
||||
// 备用:当前目录
|
||||
return "."
|
||||
}
|
||||
|
||||
// SelectDirectory 选择保存目录(简化版,实际应该使用Wails runtime)
|
||||
func (api *PdfAPI) SelectDirectory() (string, error) {
|
||||
// 简化版:直接返回桌面目录
|
||||
desktop := api.getDesktopDirectory()
|
||||
if desktop == "." {
|
||||
return "", fmt.Errorf("无法确定默认目录")
|
||||
}
|
||||
return desktop, nil
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"u-desk/internal/service"
|
||||
"u-desk/internal/storage/models"
|
||||
"u-desk/internal/storage/repository"
|
||||
)
|
||||
|
||||
type SqlAPI struct {
|
||||
sqlService *service.SqlExecService
|
||||
resultRepo repository.ResultRepository
|
||||
}
|
||||
|
||||
func NewSqlAPI() (*SqlAPI, error) {
|
||||
sqlService, err := service.NewSqlExecService()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resultRepo, err := repository.NewResultRepository()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SqlAPI{sqlService, resultRepo}, nil
|
||||
}
|
||||
|
||||
// ExecuteSQL 执行SQL语句
|
||||
// 注意:SQL 语句应该已经包含分页信息(LIMIT 和 OFFSET),由客户端添加
|
||||
func (api *SqlAPI) ExecuteSQL(connectionID uint, sqlStr string, database string) (map[string]interface{}, error) {
|
||||
result, err := api.sqlService.ExecuteSQL(connectionID, sqlStr, database)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"type": result.Type,
|
||||
"data": result.Data,
|
||||
"rowsAffected": result.RowsAffected,
|
||||
"executionTime": result.ExecutionTime,
|
||||
}
|
||||
// 如果是查询,添加列顺序信息
|
||||
if result.Type == "query" && len(result.Columns) > 0 {
|
||||
response["columns"] = result.Columns
|
||||
}
|
||||
|
||||
// 自动保存结果到历史记录(异步执行)
|
||||
go func() {
|
||||
api.resultRepo.Save(connectionID, database, sqlStr, result.Type, result.Data, result.Columns, result.RowsAffected, result.ExecutionTime)
|
||||
}()
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *SqlAPI) GetDatabases(connectionID uint) ([]string, error) {
|
||||
return api.sqlService.GetDatabases(connectionID)
|
||||
}
|
||||
|
||||
func (api *SqlAPI) GetTables(connectionID uint, database string) ([]string, error) {
|
||||
return api.sqlService.GetTables(connectionID, database)
|
||||
}
|
||||
|
||||
func (api *SqlAPI) GetTableStructure(connectionID uint, database, tableName string) (map[string]interface{}, error) {
|
||||
return api.sqlService.GetTableStructure(connectionID, database, tableName)
|
||||
}
|
||||
|
||||
func (api *SqlAPI) GetIndexes(connectionID uint, database, tableName string) ([]map[string]interface{}, error) {
|
||||
return api.sqlService.GetIndexes(connectionID, database, tableName)
|
||||
}
|
||||
|
||||
func (api *SqlAPI) PreviewTableStructure(connectionID uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
||||
return api.sqlService.PreviewTableStructure(connectionID, database, tableName, structure)
|
||||
}
|
||||
|
||||
func (api *SqlAPI) UpdateTableStructure(connectionID uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
||||
return api.sqlService.UpdateTableStructure(connectionID, database, tableName, structure)
|
||||
}
|
||||
|
||||
func (api *SqlAPI) SaveResult(connectionID uint, database, sql string, resultType string, data interface{}, columns []string, rowsAffected int, executionTime int64) (map[string]interface{}, error) {
|
||||
history, err := api.resultRepo.Save(connectionID, database, sql, resultType, data, columns, rowsAffected, executionTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return historyToMap(history), nil
|
||||
}
|
||||
|
||||
func (api *SqlAPI) GetResultHistory(connectionID *uint, keyword string, limit, offset int) (map[string]interface{}, error) {
|
||||
histories, total, err := api.resultRepo.Search(connectionID, keyword, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]map[string]interface{}, len(histories))
|
||||
for i, h := range histories {
|
||||
items[i] = historyToMap(&h)
|
||||
}
|
||||
|
||||
return map[string]interface{}{"items": items, "total": total}, nil
|
||||
}
|
||||
|
||||
func (api *SqlAPI) GetResultHistoryByID(id uint) (map[string]interface{}, error) {
|
||||
history, err := api.resultRepo.FindByID(id)
|
||||
if err != nil || history == nil {
|
||||
return nil, err
|
||||
}
|
||||
return historyToMap(history), nil
|
||||
}
|
||||
|
||||
func (api *SqlAPI) DeleteResultHistory(id uint) error {
|
||||
return api.resultRepo.Delete(id)
|
||||
}
|
||||
|
||||
func historyToMap(history *models.SqlResultHistory) map[string]interface{} {
|
||||
result := map[string]interface{}{
|
||||
"id": history.ID,
|
||||
"connection_id": history.ConnectionID,
|
||||
"database": history.Database,
|
||||
"sql": history.Sql,
|
||||
"type": history.Type,
|
||||
"rows_affected": history.RowsAffected,
|
||||
"execution_time": history.ExecutionTime,
|
||||
"created_at": history.CreatedAt,
|
||||
}
|
||||
|
||||
if history.Data != "" {
|
||||
var data interface{}
|
||||
json.Unmarshal([]byte(history.Data), &data)
|
||||
result["data"] = data
|
||||
}
|
||||
|
||||
if history.Columns != "" {
|
||||
var columns []string
|
||||
json.Unmarshal([]byte(history.Columns), &columns)
|
||||
result["columns"] = columns
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"u-desk/internal/service"
|
||||
"u-desk/internal/storage/models"
|
||||
)
|
||||
|
||||
// TabAPI 标签页API
|
||||
type TabAPI struct {
|
||||
tabService *service.TabService
|
||||
}
|
||||
|
||||
// NewTabAPI 创建标签页API
|
||||
func NewTabAPI() (*TabAPI, error) {
|
||||
tabService, err := service.NewTabService()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TabAPI{tabService: tabService}, nil
|
||||
}
|
||||
|
||||
// SaveSqlTabs 保存SQL标签页列表(接收 map 格式,转换为模型)
|
||||
func (api *TabAPI) SaveSqlTabs(tabs []map[string]interface{}) error {
|
||||
sqlTabs := make([]models.SqlTab, len(tabs))
|
||||
for idx, tabData := range tabs {
|
||||
tab := models.SqlTab{
|
||||
Order: idx,
|
||||
}
|
||||
|
||||
// 处理 ID
|
||||
if id, ok := tabData["id"].(float64); ok && id > 0 {
|
||||
tab.ID = uint(id)
|
||||
}
|
||||
|
||||
// 处理标题
|
||||
if title, ok := tabData["title"].(string); ok {
|
||||
tab.Title = title
|
||||
} else {
|
||||
tab.Title = fmt.Sprintf("查询 %d", idx+1)
|
||||
}
|
||||
|
||||
// 处理内容
|
||||
if content, ok := tabData["content"].(string); ok {
|
||||
tab.Content = content
|
||||
}
|
||||
|
||||
// 处理连接ID
|
||||
if connId, ok := tabData["connectionId"].(float64); ok && connId > 0 {
|
||||
connID := uint(connId)
|
||||
tab.ConnectionID = &connID
|
||||
}
|
||||
|
||||
sqlTabs[idx] = tab
|
||||
}
|
||||
return api.tabService.SaveTabs(sqlTabs)
|
||||
}
|
||||
|
||||
// ListSqlTabs 获取SQL标签页列表(返回 map 格式)
|
||||
func (api *TabAPI) ListSqlTabs() ([]map[string]interface{}, error) {
|
||||
tabs, err := api.tabService.ListTabs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]interface{}, len(tabs))
|
||||
for i, tab := range tabs {
|
||||
result[i] = map[string]interface{}{
|
||||
"id": tab.ID,
|
||||
"title": tab.Title,
|
||||
"content": tab.Content,
|
||||
"connectionId": tab.ConnectionID,
|
||||
"order": tab.Order,
|
||||
"createdAt": tab.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"updatedAt": tab.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -50,13 +50,6 @@ func (api *UpdateAPI) CheckUpdate() (map[string]interface{}, error) {
|
||||
// GetCurrentVersion 获取当前版本号
|
||||
func (api *UpdateAPI) GetCurrentVersion() (map[string]interface{}, error) {
|
||||
version := service.GetCurrentVersion()
|
||||
|
||||
// 同步配置中的版本号
|
||||
if config, err := service.LoadUpdateConfig(); err == nil && config.CurrentVersion != version {
|
||||
config.CurrentVersion = version
|
||||
service.SaveUpdateConfig(config)
|
||||
}
|
||||
|
||||
return successResponse(map[string]interface{}{
|
||||
"version": version,
|
||||
}), nil
|
||||
@@ -69,13 +62,6 @@ func (api *UpdateAPI) GetUpdateConfig() (map[string]interface{}, error) {
|
||||
return errorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
// 同步最新版本号
|
||||
latestVersion := service.GetCurrentVersion()
|
||||
if config.CurrentVersion != latestVersion {
|
||||
config.CurrentVersion = latestVersion
|
||||
service.SaveUpdateConfig(config)
|
||||
}
|
||||
|
||||
return successResponse(map[string]interface{}{
|
||||
"current_version": config.CurrentVersion,
|
||||
"last_check_time": config.LastCheckTime.Format("2006-01-02 15:04:05"),
|
||||
|
||||
@@ -2,8 +2,6 @@ package common
|
||||
|
||||
// Default visible tabs configuration
|
||||
const (
|
||||
// TabDatabase 数据库管理 Tab
|
||||
TabDatabase = "db-cli"
|
||||
// TabFileSystem 文件系统 Tab
|
||||
TabFileSystem = "file-system"
|
||||
// TabDevice 设备测试 Tab
|
||||
@@ -11,7 +9,4 @@ const (
|
||||
)
|
||||
|
||||
// DefaultVisibleTabs 默认可见的 Tabs
|
||||
var DefaultVisibleTabs = []string{TabDatabase, TabFileSystem, TabDevice}
|
||||
|
||||
// DefaultTab 默认打开的 Tab
|
||||
const DefaultTab = TabDatabase
|
||||
var DefaultVisibleTabs = []string{TabFileSystem, TabDevice}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package common
|
||||
|
||||
import "time"
|
||||
|
||||
// 数据库操作超时配置
|
||||
const (
|
||||
TimeoutPing = 2 * time.Second // 连接测试超时
|
||||
TimeoutConnect = 5 * time.Second // 初始连接超时
|
||||
TimeoutFastQuery = 10 * time.Second // 元数据查询超时
|
||||
TimeoutQuery = 30 * time.Second // 普通查询超时
|
||||
TimeoutLongOp = 60 * time.Second // 长时间操作超时
|
||||
)
|
||||
@@ -2,6 +2,7 @@ package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// InterfaceSliceToStringSlice 将 []interface{} 安全转换为 []string
|
||||
@@ -54,3 +55,9 @@ func Difference[T comparable](a, b []T) []T {
|
||||
}
|
||||
return diff
|
||||
}
|
||||
|
||||
// IsWindows 判断是否为Windows系统
|
||||
func IsWindows() bool {
|
||||
return runtime.GOOS == "windows"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
var (
|
||||
// 默认密钥(实际应用中应该从配置文件或环境变量读取)
|
||||
// AES-256 需要 32 字节密钥
|
||||
// "go-desk-db-cli-key-32bytes123456" = 32 bytes
|
||||
defaultKey = []byte("go-desk-db-cli-key-32bytes123456") // 32 bytes for AES-256
|
||||
)
|
||||
|
||||
func init() {
|
||||
// 验证密钥长度
|
||||
if len(defaultKey) != 32 {
|
||||
panic(fmt.Sprintf("AES-256 密钥长度必须为 32 字节,当前为 %d 字节", len(defaultKey)))
|
||||
}
|
||||
}
|
||||
|
||||
// EncryptPassword 加密密码
|
||||
func EncryptPassword(password string) (string, error) {
|
||||
if password == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(defaultKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建加密器失败: %v", err)
|
||||
}
|
||||
|
||||
// 使用 GCM 模式
|
||||
aesGCM, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建 GCM 失败: %v", err)
|
||||
}
|
||||
|
||||
// 生成随机 nonce
|
||||
nonce := make([]byte, aesGCM.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", fmt.Errorf("生成 nonce 失败: %v", err)
|
||||
}
|
||||
|
||||
// 加密
|
||||
ciphertext := aesGCM.Seal(nonce, nonce, []byte(password), nil)
|
||||
|
||||
// Base64 编码
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// DecryptPassword 解密密码
|
||||
func DecryptPassword(encryptedPassword string) (string, error) {
|
||||
if encryptedPassword == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// 如果加密字符串为空或格式不正确,返回空字符串
|
||||
if len(encryptedPassword) < 10 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Base64 解码
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(encryptedPassword)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解码失败: %v", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(defaultKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建解密器失败: %v", err)
|
||||
}
|
||||
|
||||
// 使用 GCM 模式
|
||||
aesGCM, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建 GCM 失败: %v", err)
|
||||
}
|
||||
|
||||
// 提取 nonce
|
||||
nonceSize := aesGCM.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return "", fmt.Errorf("密文长度不足")
|
||||
}
|
||||
|
||||
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
|
||||
// 解密
|
||||
plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解密失败: %v", err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"u-desk/internal/model"
|
||||
"time"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotConnected = errors.New("数据库未连接")
|
||||
)
|
||||
|
||||
// DB 数据库连接封装
|
||||
type DB struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
var globalDB *DB
|
||||
|
||||
// Init 初始化数据库连接
|
||||
func Init() (*DB, error) {
|
||||
if globalDB != nil {
|
||||
return globalDB, nil
|
||||
}
|
||||
|
||||
// 数据库配置 - 测试服 lab_dev
|
||||
// 测试机外网IP: 39.99.243.191
|
||||
// 使用 mysqldriver.Config 结构体构建 DSN,自动处理密码中的特殊字符
|
||||
config := mysqldriver.Config{
|
||||
User: "root",
|
||||
Passwd: "123456",
|
||||
Net: "tcp",
|
||||
Addr: "127.0.0.1:3306",
|
||||
DBName: "lab_dev",
|
||||
Params: map[string]string{"charset": "utf8mb4", "parseTime": "True", "loc": "Local"},
|
||||
AllowNativePasswords: true,
|
||||
}
|
||||
dsn := config.FormatDSN()
|
||||
|
||||
// GORM 配置
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.Open(dsn), gormConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开数据库连接失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取底层 sql.DB 设置连接池参数
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取数据库实例失败: %v", err)
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("数据库连接测试失败: %v", err)
|
||||
}
|
||||
|
||||
// 设置连接池参数
|
||||
sqlDB.SetMaxOpenConns(25)
|
||||
sqlDB.SetMaxIdleConns(5)
|
||||
sqlDB.SetConnMaxLifetime(time.Duration(300) * time.Second)
|
||||
|
||||
globalDB = &DB{db: db}
|
||||
return globalDB, nil
|
||||
}
|
||||
|
||||
// QueryUsers 查询用户列表
|
||||
func (d *DB) QueryUsers(keyword string, status int, role int, organid int, page int, pageSize int, sortField string, sortOrder string) (map[string]interface{}, error) {
|
||||
if d.db == nil {
|
||||
return nil, ErrNotConnected
|
||||
}
|
||||
|
||||
query := d.db.Model(&model.MemberInfo{})
|
||||
|
||||
// 关键字搜索(姓名、账号、电话)
|
||||
if keyword != "" {
|
||||
query = query.Where("membername LIKE ? OR account LIKE ? OR contactphone LIKE ?",
|
||||
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if status > 0 {
|
||||
query = query.Where("status = ?", status)
|
||||
} else {
|
||||
// 默认过滤删除状态
|
||||
query = query.Where("status != ?", 3)
|
||||
}
|
||||
|
||||
// 角色筛选(需要关联查询,暂时简化)
|
||||
if role > 0 {
|
||||
// TODO: 关联 sys_member_role 表查询
|
||||
}
|
||||
|
||||
// 机构筛选
|
||||
if organid > 0 {
|
||||
query = query.Where("organid = ?", organid)
|
||||
}
|
||||
|
||||
// 排序
|
||||
if sortField != "" {
|
||||
if sortOrder == "descend" || sortOrder == "desc" {
|
||||
query = query.Order(sortField + " DESC")
|
||||
} else {
|
||||
query = query.Order(sortField + " ASC")
|
||||
}
|
||||
} else {
|
||||
// 默认按创建时间倒序
|
||||
query = query.Order("createtime DESC")
|
||||
}
|
||||
|
||||
// 总数
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
// 分页
|
||||
offset := (page - 1) * pageSize
|
||||
var users []model.MemberInfo
|
||||
if err := query.Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
|
||||
return nil, fmt.Errorf("查询用户失败: %v", err)
|
||||
}
|
||||
|
||||
// 返回结果
|
||||
result := map[string]interface{}{
|
||||
"rows": users,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,825 +0,0 @@
|
||||
package dbclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"u-desk/internal/common"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// MongoClient MongoDB 客户端
|
||||
type MongoClient struct {
|
||||
client *mongo.Client
|
||||
database *mongo.Database
|
||||
config *MongoConfig
|
||||
}
|
||||
|
||||
// MongoConfig MongoDB 配置
|
||||
type MongoConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
Database string
|
||||
AuthSource string // 认证数据库,默认为 "admin"
|
||||
AuthMechanism string // 认证机制,如 "SCRAM-SHA-1", "SCRAM-SHA-256" 等
|
||||
}
|
||||
|
||||
// NewMongoClient 创建 MongoDB 客户端
|
||||
func NewMongoClient(config *MongoConfig) (*MongoClient, error) {
|
||||
// 确定认证数据库,默认为 admin
|
||||
authSource := config.AuthSource
|
||||
if authSource == "" {
|
||||
authSource = "admin"
|
||||
}
|
||||
|
||||
// 如果指定了认证机制,直接使用;否则尝试自动检测
|
||||
authMechanisms := []string{}
|
||||
if config.AuthMechanism != "" {
|
||||
// 用户明确指定了认证机制,只使用该机制
|
||||
authMechanisms = []string{config.AuthMechanism}
|
||||
} else {
|
||||
// 未指定时,先尝试 SCRAM-SHA-256(更安全),失败则尝试 SCRAM-SHA-1
|
||||
authMechanisms = []string{"SCRAM-SHA-256", "SCRAM-SHA-1"}
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, authMechanism := range authMechanisms {
|
||||
client, err := tryConnectMongo(config, authSource, authMechanism)
|
||||
if err == nil {
|
||||
return client, nil
|
||||
}
|
||||
lastErr = err
|
||||
// 如果明确指定了认证机制,失败后不再尝试其他机制
|
||||
if config.AuthMechanism != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 所有认证机制都失败
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("MongoDB 连接测试失败: %v", lastErr)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("MongoDB 连接失败: 未知错误")
|
||||
}
|
||||
|
||||
// tryConnectMongo 尝试使用指定的认证机制连接 MongoDB
|
||||
func tryConnectMongo(config *MongoConfig, authSource, authMechanism string) (*MongoClient, error) {
|
||||
// 构建连接 URI
|
||||
var uri string
|
||||
|
||||
if config.Username != "" && config.Password != "" {
|
||||
// 使用 url.UserPassword 正确转义用户名和密码中的特殊字符
|
||||
// 这会正确处理 @、:、/ 等特殊字符
|
||||
userInfo := url.UserPassword(config.Username, config.Password)
|
||||
|
||||
// 构建基础 URI
|
||||
uri = fmt.Sprintf("mongodb://%s@%s:%d", userInfo.String(), config.Host, config.Port)
|
||||
|
||||
// 添加数据库和认证源参数
|
||||
params := url.Values{}
|
||||
params.Set("authSource", authSource)
|
||||
|
||||
// 添加认证机制参数
|
||||
if authMechanism != "" {
|
||||
params.Set("authMechanism", authMechanism)
|
||||
}
|
||||
|
||||
// 如果有业务数据库,添加到路径中
|
||||
if config.Database != "" {
|
||||
uri = fmt.Sprintf("%s/%s?%s", uri, config.Database, params.Encode())
|
||||
} else {
|
||||
// MongoDB URI 要求查询参数前必须有 /,即使没有数据库名
|
||||
uri = fmt.Sprintf("%s/?%s", uri, params.Encode())
|
||||
}
|
||||
} else if config.Database != "" {
|
||||
// 没有认证信息时,数据库部分用于指定默认数据库
|
||||
uri = fmt.Sprintf("mongodb://%s:%d/%s", config.Host, config.Port, config.Database)
|
||||
} else {
|
||||
uri = fmt.Sprintf("mongodb://%s:%d", config.Host, config.Port)
|
||||
}
|
||||
|
||||
// 客户端选项
|
||||
clientOptions := options.Client().
|
||||
ApplyURI(uri).
|
||||
SetConnectTimeout(common.TimeoutConnect).
|
||||
SetServerSelectionTimeout(common.TimeoutConnect)
|
||||
|
||||
// 创建客户端 (v2: 移除了 context 参数)
|
||||
client, err := mongo.Connect(clientOptions)
|
||||
|
||||
// 创建 context 用于其他操作
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutConnect)
|
||||
defer cancel()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("连接 MongoDB 失败: %v", err)
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
if err := client.Ping(ctx, nil); err != nil {
|
||||
client.Disconnect(ctx)
|
||||
return nil, fmt.Errorf("MongoDB 连接测试失败: %v", err)
|
||||
}
|
||||
|
||||
var database *mongo.Database
|
||||
if config.Database != "" {
|
||||
database = client.Database(config.Database)
|
||||
}
|
||||
|
||||
return &MongoClient{
|
||||
client: client,
|
||||
database: database,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestMongoConnection 测试连接
|
||||
func TestMongoConnection(host string, port int, username, password, database string) error {
|
||||
return TestMongoConnectionWithAuthSource(host, port, username, password, database, "")
|
||||
}
|
||||
|
||||
// TestMongoConnectionWithAuthSource 测试连接(支持指定认证数据库)
|
||||
func TestMongoConnectionWithAuthSource(host string, port int, username, password, database, authSource string) error {
|
||||
return TestMongoConnectionWithOptions(host, port, username, password, database, authSource, "")
|
||||
}
|
||||
|
||||
// TestMongoConnectionWithOptions 测试连接(支持指定认证数据库和认证机制)
|
||||
func TestMongoConnectionWithOptions(host string, port int, username, password, database, authSource, authMechanism string) error {
|
||||
config := &MongoConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Database: database,
|
||||
AuthSource: authSource,
|
||||
AuthMechanism: authMechanism,
|
||||
}
|
||||
client, err := NewMongoClient(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭连接
|
||||
func (c *MongoClient) Close() error {
|
||||
if c.client != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutConnect)
|
||||
defer cancel()
|
||||
return c.client.Disconnect(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListDatabases 获取数据库列表
|
||||
func (c *MongoClient) ListDatabases(ctx context.Context) ([]string, error) {
|
||||
databases, err := c.client.ListDatabaseNames(ctx, bson.M{})
|
||||
return databases, err
|
||||
}
|
||||
|
||||
// ListCollections 获取集合列表
|
||||
func (c *MongoClient) ListCollections(ctx context.Context, database string) ([]string, error) {
|
||||
db := c.client.Database(database)
|
||||
collections, err := db.ListCollectionNames(ctx, bson.M{})
|
||||
return collections, err
|
||||
}
|
||||
|
||||
// GetCollectionStructure 获取集合结构
|
||||
func (c *MongoClient) GetCollectionStructure(ctx context.Context, database, collectionName string) (map[string]interface{}, error) {
|
||||
coll := c.client.Database(database).Collection(collectionName)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"database": database,
|
||||
"collection": collectionName,
|
||||
"sampleDocs": []map[string]interface{}{},
|
||||
"fieldStats": map[string]int{},
|
||||
"indexes": []map[string]interface{}{},
|
||||
"documentCount": int64(0),
|
||||
}
|
||||
|
||||
// 获取文档示例(最多 5 个)
|
||||
opts := options.Find().SetLimit(5)
|
||||
cursor, err := coll.Find(ctx, bson.M{}, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取文档示例失败: %v", err)
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var docs []bson.M
|
||||
if err = cursor.All(ctx, &docs); err != nil {
|
||||
return nil, fmt.Errorf("解析文档失败: %v", err)
|
||||
}
|
||||
|
||||
// 转换为 map
|
||||
sampleDocs := make([]map[string]interface{}, 0, len(docs))
|
||||
for _, doc := range docs {
|
||||
docMap := make(map[string]interface{})
|
||||
for k, v := range doc {
|
||||
docMap[k] = v
|
||||
}
|
||||
sampleDocs = append(sampleDocs, docMap)
|
||||
}
|
||||
result["sampleDocs"] = sampleDocs
|
||||
|
||||
// 字段统计:使用 $sample 聚合管道随机采样10个文档进行统计
|
||||
// 这样可以获得更准确的字段分布,同时保持良好性能
|
||||
// 使用异步方式执行,避免阻塞主流程
|
||||
sampleSize := 10
|
||||
pipeline := []bson.M{
|
||||
{"$sample": bson.M{"size": sampleSize}},
|
||||
{"$project": bson.M{"keys": bson.M{"$objectToArray": "$$ROOT"}}},
|
||||
{"$unwind": "$keys"},
|
||||
{"$group": bson.M{
|
||||
"_id": "$keys.k",
|
||||
"count": bson.M{"$sum": 1},
|
||||
}},
|
||||
{"$sort": bson.M{"count": -1}}, // 按出现次数降序排序
|
||||
}
|
||||
|
||||
sampleCursor, err := coll.Aggregate(ctx, pipeline)
|
||||
if err != nil {
|
||||
// 如果采样失败,回退到基于文档示例的统计
|
||||
fieldCount := make(map[string]int)
|
||||
for _, doc := range docs {
|
||||
for key := range doc {
|
||||
fieldCount[key]++
|
||||
}
|
||||
}
|
||||
result["fieldStats"] = fieldCount
|
||||
result["fieldStatsSampleSize"] = len(docs) // 记录实际采样数量
|
||||
result["fieldStatsMethod"] = "sample-docs" // 标记统计方式
|
||||
} else {
|
||||
defer sampleCursor.Close(ctx)
|
||||
fieldCount := make(map[string]int)
|
||||
for sampleCursor.Next(ctx) {
|
||||
var statResult bson.M
|
||||
if err := sampleCursor.Decode(&statResult); err != nil {
|
||||
continue
|
||||
}
|
||||
fieldName, ok := statResult["_id"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var count int
|
||||
switch v := statResult["count"].(type) {
|
||||
case int32:
|
||||
count = int(v)
|
||||
case int64:
|
||||
count = int(v)
|
||||
case int:
|
||||
count = v
|
||||
case float64:
|
||||
count = int(v)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
fieldCount[fieldName] = count
|
||||
}
|
||||
result["fieldStats"] = fieldCount
|
||||
result["fieldStatsSampleSize"] = sampleSize // 记录采样数量
|
||||
result["fieldStatsMethod"] = "sample-aggregate" // 标记统计方式
|
||||
}
|
||||
|
||||
// 文档总数(使用估算值,性能更好)
|
||||
// 对于大数据集,estimatedDocumentCount 比 CountDocuments 快得多
|
||||
// 如果需要精确值,可以使用 CountDocuments,但性能较差
|
||||
count, err := coll.EstimatedDocumentCount(ctx)
|
||||
if err != nil {
|
||||
// 如果估算失败,尝试精确计数(可能较慢)
|
||||
count, err = coll.CountDocuments(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取文档数量失败: %v", err)
|
||||
}
|
||||
}
|
||||
result["documentCount"] = count
|
||||
|
||||
// 索引信息
|
||||
indexCursor, err := coll.Indexes().List(ctx)
|
||||
if err != nil {
|
||||
// 索引查询失败不影响主流程
|
||||
result["indexes"] = []map[string]interface{}{}
|
||||
} else {
|
||||
var indexes []map[string]interface{}
|
||||
for indexCursor.Next(ctx) {
|
||||
var indexSpec bson.M
|
||||
if err := indexCursor.Decode(&indexSpec); err != nil {
|
||||
continue
|
||||
}
|
||||
indexes = append(indexes, map[string]interface{}{
|
||||
"name": indexSpec["name"],
|
||||
"unique": indexSpec["unique"],
|
||||
"keys": indexSpec["key"],
|
||||
})
|
||||
}
|
||||
indexCursor.Close(ctx)
|
||||
result["indexes"] = indexes
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ExecuteQuery 执行查询
|
||||
func (c *MongoClient) ExecuteQuery(ctx context.Context, database, collection string, filter bson.M, limit int64) ([]map[string]interface{}, error) {
|
||||
db := c.client.Database(database)
|
||||
coll := db.Collection(collection)
|
||||
|
||||
opts := options.Find().SetLimit(limit)
|
||||
cursor, err := coll.Find(ctx, filter, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询失败: %v", err)
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var results []map[string]interface{}
|
||||
if err := cursor.All(ctx, &results); err != nil {
|
||||
return nil, fmt.Errorf("读取结果失败: %v", err)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// CountDocuments 获取文档数量
|
||||
func (c *MongoClient) CountDocuments(ctx context.Context, database, collection string, filter bson.M) (int64, error) {
|
||||
db := c.client.Database(database)
|
||||
coll := db.Collection(collection)
|
||||
return coll.CountDocuments(ctx, filter)
|
||||
}
|
||||
|
||||
// ExecuteCommand 执行 MongoDB 命令
|
||||
// command 可以是 JSON 格式的字符串,格式:{"op": "find", "database": "test", "collection": "users", "filter": {}, "limit": 100}
|
||||
// 支持的操作:find, count, insertOne, insertMany, updateOne, updateMany, deleteOne, deleteMany
|
||||
func (c *MongoClient) ExecuteCommand(ctx context.Context, database string, command map[string]interface{}) (interface{}, error) {
|
||||
op, ok := command["op"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("命令中缺少 'op' 字段或格式错误")
|
||||
}
|
||||
|
||||
collectionName, ok := command["collection"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("命令中缺少 'collection' 字段或格式错误")
|
||||
}
|
||||
|
||||
// 如果没有指定数据库,使用配置中的默认数据库
|
||||
if database == "" {
|
||||
if c.config != nil && c.config.Database != "" {
|
||||
database = c.config.Database
|
||||
} else {
|
||||
return nil, fmt.Errorf("需要指定数据库名称")
|
||||
}
|
||||
}
|
||||
|
||||
db := c.client.Database(database)
|
||||
coll := db.Collection(collectionName)
|
||||
|
||||
switch op {
|
||||
case "find":
|
||||
filter := bson.M{}
|
||||
if f, ok := command["filter"]; ok {
|
||||
if filterMap, ok := f.(map[string]interface{}); ok {
|
||||
filter = bson.M(filterMap)
|
||||
}
|
||||
}
|
||||
|
||||
limit := int64(100)
|
||||
if l, ok := command["limit"]; ok {
|
||||
if limitVal, ok := l.(float64); ok {
|
||||
limit = int64(limitVal)
|
||||
} else if limitVal, ok := l.(int64); ok {
|
||||
limit = limitVal
|
||||
}
|
||||
}
|
||||
|
||||
opts := options.Find().SetLimit(limit)
|
||||
cursor, err := coll.Find(ctx, filter, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询失败: %v", err)
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var results []map[string]interface{}
|
||||
if err := cursor.All(ctx, &results); err != nil {
|
||||
return nil, fmt.Errorf("读取结果失败: %v", err)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
|
||||
case "count":
|
||||
filter := bson.M{}
|
||||
if f, ok := command["filter"]; ok {
|
||||
if filterMap, ok := f.(map[string]interface{}); ok {
|
||||
filter = bson.M(filterMap)
|
||||
}
|
||||
}
|
||||
|
||||
count, err := coll.CountDocuments(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("统计失败: %v", err)
|
||||
}
|
||||
return count, nil
|
||||
|
||||
case "insertOne":
|
||||
document, ok := command["document"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("insertOne 操作需要 'document' 字段")
|
||||
}
|
||||
|
||||
doc := bson.M{}
|
||||
if docMap, ok := document.(map[string]interface{}); ok {
|
||||
doc = bson.M(docMap)
|
||||
} else {
|
||||
return nil, fmt.Errorf("document 必须是对象格式")
|
||||
}
|
||||
|
||||
result, err := coll.InsertOne(ctx, doc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("插入失败: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"insertedId": result.InsertedID,
|
||||
}, nil
|
||||
|
||||
case "insertMany":
|
||||
documents, ok := command["documents"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("insertMany 操作需要 'documents' 字段")
|
||||
}
|
||||
|
||||
docs := []interface{}{}
|
||||
if docsSlice, ok := documents.([]interface{}); ok {
|
||||
for _, d := range docsSlice {
|
||||
if docMap, ok := d.(map[string]interface{}); ok {
|
||||
docs = append(docs, bson.M(docMap))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("documents 必须是数组格式")
|
||||
}
|
||||
|
||||
result, err := coll.InsertMany(ctx, docs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("批量插入失败: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"insertedIds": result.InsertedIDs,
|
||||
"insertedCount": len(result.InsertedIDs),
|
||||
}, nil
|
||||
|
||||
case "updateOne":
|
||||
filter := bson.M{}
|
||||
if f, ok := command["filter"]; ok {
|
||||
if filterMap, ok := f.(map[string]interface{}); ok {
|
||||
filter = bson.M(filterMap)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("updateOne 操作需要 'filter' 字段")
|
||||
}
|
||||
|
||||
update, ok := command["update"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("updateOne 操作需要 'update' 字段")
|
||||
}
|
||||
|
||||
updateDoc := bson.M{}
|
||||
if updateMap, ok := update.(map[string]interface{}); ok {
|
||||
updateDoc = bson.M(updateMap)
|
||||
} else {
|
||||
return nil, fmt.Errorf("update 必须是对象格式")
|
||||
}
|
||||
|
||||
result, err := coll.UpdateOne(ctx, filter, bson.M{"$set": updateDoc})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("更新失败: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"matchedCount": result.MatchedCount,
|
||||
"modifiedCount": result.ModifiedCount,
|
||||
}, nil
|
||||
|
||||
case "updateMany":
|
||||
filter := bson.M{}
|
||||
if f, ok := command["filter"]; ok {
|
||||
if filterMap, ok := f.(map[string]interface{}); ok {
|
||||
filter = bson.M(filterMap)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("updateMany 操作需要 'filter' 字段")
|
||||
}
|
||||
|
||||
update, ok := command["update"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("updateMany 操作需要 'update' 字段")
|
||||
}
|
||||
|
||||
updateDoc := bson.M{}
|
||||
if updateMap, ok := update.(map[string]interface{}); ok {
|
||||
updateDoc = bson.M(updateMap)
|
||||
} else {
|
||||
return nil, fmt.Errorf("update 必须是对象格式")
|
||||
}
|
||||
|
||||
result, err := coll.UpdateMany(ctx, filter, bson.M{"$set": updateDoc})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("批量更新失败: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"matchedCount": result.MatchedCount,
|
||||
"modifiedCount": result.ModifiedCount,
|
||||
}, nil
|
||||
|
||||
case "deleteOne":
|
||||
filter := bson.M{}
|
||||
if f, ok := command["filter"]; ok {
|
||||
if filterMap, ok := f.(map[string]interface{}); ok {
|
||||
filter = bson.M(filterMap)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("deleteOne 操作需要 'filter' 字段")
|
||||
}
|
||||
|
||||
result, err := coll.DeleteOne(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("删除失败: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"deletedCount": result.DeletedCount,
|
||||
}, nil
|
||||
|
||||
case "deleteMany":
|
||||
filter := bson.M{}
|
||||
if f, ok := command["filter"]; ok {
|
||||
if filterMap, ok := f.(map[string]interface{}); ok {
|
||||
filter = bson.M(filterMap)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("deleteMany 操作需要 'filter' 字段")
|
||||
}
|
||||
|
||||
result, err := coll.DeleteMany(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("批量删除失败: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"deletedCount": result.DeletedCount,
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的操作: %s,支持的操作: find, count, insertOne, insertMany, updateOne, updateMany, deleteOne, deleteMany", op)
|
||||
}
|
||||
}
|
||||
|
||||
// PreviewCollectionIndexes 预览集合索引变更,只生成命令列表不执行
|
||||
func (c *MongoClient) PreviewCollectionIndexes(ctx context.Context, database, collectionName string, structure map[string]interface{}) ([]string, error) {
|
||||
coll := c.client.Database(database).Collection(collectionName)
|
||||
var commands []string
|
||||
|
||||
// 获取当前索引
|
||||
currentIndexes, err := coll.Indexes().List(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取当前索引失败: %v", err)
|
||||
}
|
||||
defer currentIndexes.Close(ctx)
|
||||
|
||||
// 解析新的索引数据
|
||||
var newIndexes []map[string]interface{}
|
||||
if idxs, ok := structure["indexes"].([]interface{}); ok {
|
||||
for _, idx := range idxs {
|
||||
if idxMap, ok := idx.(map[string]interface{}); ok {
|
||||
newIndexes = append(newIndexes, idxMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建当前索引名映射
|
||||
currentIndexMap := make(map[string]bool)
|
||||
for currentIndexes.Next(ctx) {
|
||||
var indexSpec bson.M
|
||||
if err := currentIndexes.Decode(&indexSpec); err != nil {
|
||||
continue
|
||||
}
|
||||
if name, ok := indexSpec["name"].(string); ok && name != "_id_" {
|
||||
currentIndexMap[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新索引名映射
|
||||
newIndexMap := make(map[string]bool)
|
||||
for _, idx := range newIndexes {
|
||||
if name, ok := idx["name"].(string); ok && name != "" && name != "_id_" {
|
||||
newIndexMap[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 删除不存在的索引
|
||||
for name := range currentIndexMap {
|
||||
if !newIndexMap[name] {
|
||||
cmd := fmt.Sprintf("db.%s.dropIndex(\"%s\")", collectionName, name)
|
||||
commands = append(commands, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加或更新索引
|
||||
for _, idx := range newIndexes {
|
||||
name, _ := idx["name"].(string)
|
||||
if name == "" || name == "_id_" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 构建索引键
|
||||
keys := bson.D{}
|
||||
if keysData, ok := idx["keys"].(map[string]interface{}); ok {
|
||||
for k, v := range keysData {
|
||||
var order int
|
||||
if vFloat, ok := v.(float64); ok {
|
||||
order = int(vFloat)
|
||||
} else if vInt, ok := v.(int); ok {
|
||||
order = vInt
|
||||
} else {
|
||||
order = 1 // 默认升序
|
||||
}
|
||||
keys = append(keys, bson.E{Key: k, Value: order})
|
||||
}
|
||||
} else if columnName, ok := idx["Column_name"].(string); ok && columnName != "" {
|
||||
// 兼容 MySQL 格式的索引数据
|
||||
keys = append(keys, bson.E{Key: columnName, Value: 1})
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 构建索引选项,并跟踪 unique 状态(v2: IndexOptionsBuilder 无 Unique 字段可读)
|
||||
indexOptions := options.Index()
|
||||
indexOptions.SetName(name)
|
||||
|
||||
isUnique := false
|
||||
if unique, ok := idx["unique"].(bool); ok && unique {
|
||||
indexOptions.SetUnique(true)
|
||||
isUnique = true
|
||||
} else if nonUnique, ok := idx["Non_unique"].(float64); ok && nonUnique == 0 {
|
||||
indexOptions.SetUnique(true)
|
||||
isUnique = true
|
||||
}
|
||||
|
||||
// 如果索引已存在,先删除再创建
|
||||
if currentIndexMap[name] {
|
||||
dropCmd := fmt.Sprintf("db.%s.dropIndex(\"%s\")", collectionName, name)
|
||||
commands = append(commands, dropCmd)
|
||||
}
|
||||
|
||||
// 构建命令字符串(MongoDB shell 格式)
|
||||
keysStr := "{"
|
||||
for i, key := range keys {
|
||||
if i > 0 {
|
||||
keysStr += ", "
|
||||
}
|
||||
keysStr += fmt.Sprintf("%s: %d", key.Key, key.Value)
|
||||
}
|
||||
keysStr += "}"
|
||||
|
||||
optionsStr := "{name: \"" + name + "\""
|
||||
if isUnique {
|
||||
optionsStr += ", unique: true"
|
||||
}
|
||||
optionsStr += "}"
|
||||
|
||||
cmd := fmt.Sprintf("db.%s.createIndex(%s, %s)", collectionName, keysStr, optionsStr)
|
||||
commands = append(commands, cmd)
|
||||
}
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// UpdateCollectionIndexes 更新集合索引,返回执行的命令列表
|
||||
func (c *MongoClient) UpdateCollectionIndexes(ctx context.Context, database, collectionName string, structure map[string]interface{}) ([]string, error) {
|
||||
// 先预览生成命令列表
|
||||
commands, err := c.PreviewCollectionIndexes(ctx, database, collectionName, structure)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
coll := c.client.Database(database).Collection(collectionName)
|
||||
|
||||
// 获取当前索引
|
||||
currentIndexes, err := coll.Indexes().List(ctx)
|
||||
if err != nil {
|
||||
return commands, fmt.Errorf("获取当前索引失败: %v", err)
|
||||
}
|
||||
defer currentIndexes.Close(ctx)
|
||||
|
||||
// 解析新的索引数据
|
||||
var newIndexes []map[string]interface{}
|
||||
if idxs, ok := structure["indexes"].([]interface{}); ok {
|
||||
for _, idx := range idxs {
|
||||
if idxMap, ok := idx.(map[string]interface{}); ok {
|
||||
newIndexes = append(newIndexes, idxMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建当前索引名映射
|
||||
currentIndexMap := make(map[string]bool)
|
||||
for currentIndexes.Next(ctx) {
|
||||
var indexSpec bson.M
|
||||
if err := currentIndexes.Decode(&indexSpec); err != nil {
|
||||
continue
|
||||
}
|
||||
if name, ok := indexSpec["name"].(string); ok && name != "_id_" {
|
||||
currentIndexMap[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新索引名映射
|
||||
newIndexMap := make(map[string]bool)
|
||||
for _, idx := range newIndexes {
|
||||
if name, ok := idx["name"].(string); ok && name != "" && name != "_id_" {
|
||||
newIndexMap[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 删除不存在的索引
|
||||
for name := range currentIndexMap {
|
||||
if !newIndexMap[name] {
|
||||
// v2: DropOne 只返回 error,不再返回 bson.Raw
|
||||
err := coll.Indexes().DropOne(ctx, name)
|
||||
if err != nil {
|
||||
return commands, fmt.Errorf("删除索引失败: %v, 索引名: %s", err, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加或更新索引
|
||||
for _, idx := range newIndexes {
|
||||
name, _ := idx["name"].(string)
|
||||
if name == "" || name == "_id_" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 构建索引键
|
||||
keys := bson.D{}
|
||||
if keysData, ok := idx["keys"].(map[string]interface{}); ok {
|
||||
for k, v := range keysData {
|
||||
var order int
|
||||
if vFloat, ok := v.(float64); ok {
|
||||
order = int(vFloat)
|
||||
} else if vInt, ok := v.(int); ok {
|
||||
order = vInt
|
||||
} else {
|
||||
order = 1 // 默认升序
|
||||
}
|
||||
keys = append(keys, bson.E{Key: k, Value: order})
|
||||
}
|
||||
} else if columnName, ok := idx["Column_name"].(string); ok && columnName != "" {
|
||||
// 兼容 MySQL 格式的索引数据
|
||||
keys = append(keys, bson.E{Key: columnName, Value: 1})
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 构建索引选项
|
||||
indexOptions := options.Index()
|
||||
indexOptions.SetName(name)
|
||||
|
||||
if unique, ok := idx["unique"].(bool); ok && unique {
|
||||
indexOptions.SetUnique(true)
|
||||
} else if nonUnique, ok := idx["Non_unique"].(float64); ok && nonUnique == 0 {
|
||||
indexOptions.SetUnique(true)
|
||||
}
|
||||
|
||||
// 创建索引
|
||||
indexModel := mongo.IndexModel{
|
||||
Keys: keys,
|
||||
Options: indexOptions,
|
||||
}
|
||||
|
||||
// 如果索引已存在,先删除再创建
|
||||
if currentIndexMap[name] {
|
||||
// v2: DropOne 只返回 error,不再返回 bson.Raw
|
||||
err := coll.Indexes().DropOne(ctx, name)
|
||||
if err != nil {
|
||||
return commands, fmt.Errorf("删除旧索引失败: %v, 索引名: %s", err, name)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := coll.Indexes().CreateOne(ctx, indexModel)
|
||||
if err != nil {
|
||||
return commands, fmt.Errorf("创建索引失败: %v, 索引名: %s", err, name)
|
||||
}
|
||||
}
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
@@ -1,875 +0,0 @@
|
||||
package dbclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// MySQLClient MySQL 客户端
|
||||
type MySQLClient struct {
|
||||
db *gorm.DB
|
||||
sqlDB *sql.DB
|
||||
config *MySQLConfig
|
||||
}
|
||||
|
||||
// MySQLConfig MySQL 配置
|
||||
type MySQLConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
Database string
|
||||
}
|
||||
|
||||
// NewMySQLClient 创建 MySQL 客户端
|
||||
func NewMySQLClient(config *MySQLConfig) (*MySQLClient, error) {
|
||||
// 构建 DSN
|
||||
mysqlConfig := mysqldriver.Config{
|
||||
User: config.Username,
|
||||
Passwd: config.Password,
|
||||
Net: "tcp",
|
||||
Addr: fmt.Sprintf("%s:%d", config.Host, config.Port),
|
||||
DBName: config.Database,
|
||||
Params: map[string]string{
|
||||
"charset": "utf8mb4",
|
||||
"parseTime": "True",
|
||||
"loc": "Local",
|
||||
"multiStatements": "true", // 支持多条SQL语句执行
|
||||
},
|
||||
AllowNativePasswords: true,
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
dsn := mysqlConfig.FormatDSN()
|
||||
|
||||
// GORM 配置
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
}
|
||||
|
||||
// 打开连接
|
||||
db, err := gorm.Open(mysql.Open(dsn), gormConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("连接 MySQL 失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取底层 sql.DB
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取数据库实例失败: %v", err)
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("MySQL 连接测试失败: %v", err)
|
||||
}
|
||||
|
||||
// 设置连接池参数
|
||||
sqlDB.SetMaxOpenConns(10)
|
||||
sqlDB.SetMaxIdleConns(2)
|
||||
sqlDB.SetConnMaxLifetime(5 * time.Minute)
|
||||
|
||||
return &MySQLClient{
|
||||
db: db,
|
||||
sqlDB: sqlDB,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestConnection 测试连接
|
||||
func TestMySQLConnection(host string, port int, username, password, database string) error {
|
||||
config := &MySQLConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Database: database,
|
||||
}
|
||||
client, err := NewMySQLClient(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭连接
|
||||
func (c *MySQLClient) Close() error {
|
||||
if c.sqlDB != nil {
|
||||
return c.sqlDB.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QueryResult 查询结果,包含数据和列顺序
|
||||
type QueryResult struct {
|
||||
Data []map[string]interface{}
|
||||
Columns []string
|
||||
}
|
||||
|
||||
// ExecuteQuery 执行查询 SQL
|
||||
// database 参数可选,如果提供且不为空则优先使用,否则使用配置中的数据库
|
||||
// 注意:SQL 语句应该已经包含 LIMIT 和 OFFSET(由客户端添加)
|
||||
func (c *MySQLClient) ExecuteQuery(ctx context.Context, sqlStr string, database string) (*QueryResult, error) {
|
||||
// 确定要使用的数据库
|
||||
dbName := database
|
||||
if dbName == "" {
|
||||
dbName = c.config.Database
|
||||
}
|
||||
|
||||
// 使用 Session 创建独立的数据库会话,避免影响其他查询
|
||||
db := c.db.Session(&gorm.Session{})
|
||||
|
||||
// 如果指定了数据库,先切换到该数据库
|
||||
if dbName != "" {
|
||||
if err := db.Exec(fmt.Sprintf("USE `%s`", dbName)).Error; err != nil {
|
||||
return nil, fmt.Errorf("切换数据库失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := db.Raw(sqlStr).Rows()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("执行查询失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// 检查 rows 错误
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("查询结果错误: %v", err)
|
||||
}
|
||||
|
||||
// 获取列名
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取列名失败: %v", err)
|
||||
}
|
||||
|
||||
// 如果没有列,返回空数组
|
||||
if len(columns) == 0 {
|
||||
return &QueryResult{
|
||||
Data: []map[string]interface{}{},
|
||||
Columns: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 读取数据
|
||||
var results []map[string]interface{}
|
||||
for rows.Next() {
|
||||
// 创建值数组和指针数组
|
||||
values := make([]interface{}, len(columns))
|
||||
valuePtrs := make([]interface{}, len(columns))
|
||||
for i := range values {
|
||||
valuePtrs[i] = &values[i]
|
||||
}
|
||||
|
||||
// 扫描行数据
|
||||
if err := rows.Scan(valuePtrs...); err != nil {
|
||||
return nil, fmt.Errorf("扫描数据失败: %v", err)
|
||||
}
|
||||
|
||||
// 构建结果 map,按照列顺序构建
|
||||
row := make(map[string]interface{})
|
||||
for i, col := range columns {
|
||||
val := values[i]
|
||||
// 处理 nil 值
|
||||
if val == nil {
|
||||
row[col] = nil
|
||||
} else if b, ok := val.([]byte); ok {
|
||||
// 处理 []byte 类型
|
||||
row[col] = string(b)
|
||||
} else {
|
||||
row[col] = val
|
||||
}
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
|
||||
// 检查迭代过程中的错误
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("读取数据时发生错误: %v", err)
|
||||
}
|
||||
|
||||
return &QueryResult{
|
||||
Data: results,
|
||||
Columns: columns,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExecuteUpdate 执行更新 SQL(INSERT/UPDATE/DELETE)
|
||||
// database 参数可选,如果提供且不为空则优先使用,否则使用配置中的数据库
|
||||
func (c *MySQLClient) ExecuteUpdate(ctx context.Context, sqlStr string, database string) (int64, error) {
|
||||
// 确定要使用的数据库
|
||||
dbName := database
|
||||
if dbName == "" {
|
||||
dbName = c.config.Database
|
||||
}
|
||||
|
||||
// 使用 Session 创建独立的数据库会话,避免影响其他查询
|
||||
db := c.db.Session(&gorm.Session{})
|
||||
|
||||
// 如果指定了数据库,先切换到该数据库
|
||||
if dbName != "" {
|
||||
if err := db.Exec(fmt.Sprintf("USE `%s`", dbName)).Error; err != nil {
|
||||
return 0, fmt.Errorf("切换数据库失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
result := db.Exec(sqlStr)
|
||||
if result.Error != nil {
|
||||
return 0, fmt.Errorf("执行更新失败: %v", result.Error)
|
||||
}
|
||||
return result.RowsAffected, nil
|
||||
}
|
||||
|
||||
// ListDatabases 获取数据库列表
|
||||
func (c *MySQLClient) ListDatabases(ctx context.Context) ([]string, error) {
|
||||
var databases []string
|
||||
err := c.db.Raw("SHOW DATABASES").Scan(&databases).Error
|
||||
return databases, err
|
||||
}
|
||||
|
||||
// ListTables 获取表列表
|
||||
func (c *MySQLClient) ListTables(ctx context.Context, database string) ([]string, error) {
|
||||
var tables []string
|
||||
query := "SHOW TABLES"
|
||||
if database != "" {
|
||||
query = fmt.Sprintf("SHOW TABLES FROM `%s`", database)
|
||||
}
|
||||
err := c.db.Raw(query).Scan(&tables).Error
|
||||
return tables, err
|
||||
}
|
||||
|
||||
// GetTableStructure 获取表结构
|
||||
func (c *MySQLClient) GetTableStructure(ctx context.Context, database, tableName string) ([]map[string]interface{}, error) {
|
||||
// 使用 SHOW FULL COLUMNS 来获取包含 comment 的完整字段信息
|
||||
query := fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`", tableName)
|
||||
if database != "" {
|
||||
query = fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`.`%s`", database, tableName)
|
||||
}
|
||||
|
||||
rows, err := c.db.Raw(query).Rows()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取表结构失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取列名失败: %v", err)
|
||||
}
|
||||
|
||||
var results []map[string]interface{}
|
||||
for rows.Next() {
|
||||
values := make([]interface{}, len(columns))
|
||||
valuePtrs := make([]interface{}, len(columns))
|
||||
for i := range values {
|
||||
valuePtrs[i] = &values[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(valuePtrs...); err != nil {
|
||||
return nil, fmt.Errorf("扫描数据失败: %v", err)
|
||||
}
|
||||
|
||||
row := make(map[string]interface{})
|
||||
for i, col := range columns {
|
||||
val := values[i]
|
||||
if b, ok := val.([]byte); ok {
|
||||
row[col] = string(b)
|
||||
} else if val == nil {
|
||||
row[col] = nil
|
||||
} else {
|
||||
row[col] = val
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 Comment 字段存在(SHOW FULL COLUMNS 返回的字段名是 Comment)
|
||||
if _, ok := row["Comment"]; !ok {
|
||||
row["Comment"] = ""
|
||||
}
|
||||
|
||||
results = append(results, row)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetIndexes 获取索引列表
|
||||
func (c *MySQLClient) GetIndexes(ctx context.Context, database, tableName string) ([]map[string]interface{}, error) {
|
||||
query := "SHOW INDEX FROM "
|
||||
if database != "" {
|
||||
query += fmt.Sprintf("`%s`.", database)
|
||||
}
|
||||
query += fmt.Sprintf("`%s`", tableName)
|
||||
|
||||
rows, err := c.db.Raw(query).Rows()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取索引列表失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取列名失败: %v", err)
|
||||
}
|
||||
|
||||
var results []map[string]interface{}
|
||||
for rows.Next() {
|
||||
values := make([]interface{}, len(columns))
|
||||
valuePtrs := make([]interface{}, len(columns))
|
||||
for i := range values {
|
||||
valuePtrs[i] = &values[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(valuePtrs...); err != nil {
|
||||
return nil, fmt.Errorf("扫描数据失败: %v", err)
|
||||
}
|
||||
|
||||
row := make(map[string]interface{})
|
||||
for i, col := range columns {
|
||||
val := values[i]
|
||||
if b, ok := val.([]byte); ok {
|
||||
row[col] = string(b)
|
||||
} else {
|
||||
row[col] = val
|
||||
}
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// PreviewTableStructure 预览表结构变更,只生成 SQL 语句不执行
|
||||
func (c *MySQLClient) PreviewTableStructure(ctx context.Context, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
||||
// 获取当前表结构
|
||||
currentColumns, err := c.GetTableStructure(ctx, database, tableName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取当前表结构失败: %v", err)
|
||||
}
|
||||
|
||||
currentIndexes, err := c.GetIndexes(ctx, database, tableName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取当前索引失败: %v", err)
|
||||
}
|
||||
|
||||
// 解析新的结构数据
|
||||
var newColumns []map[string]interface{}
|
||||
var newIndexes []map[string]interface{}
|
||||
|
||||
if cols, ok := structure["columns"].([]interface{}); ok {
|
||||
for _, col := range cols {
|
||||
if colMap, ok := col.(map[string]interface{}); ok {
|
||||
newColumns = append(newColumns, colMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if idxs, ok := structure["indexes"].([]interface{}); ok {
|
||||
for _, idx := range idxs {
|
||||
if idxMap, ok := idx.(map[string]interface{}); ok {
|
||||
newIndexes = append(newIndexes, idxMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 ALTER TABLE 语句
|
||||
var alterStatements []string
|
||||
|
||||
// 处理字段变更
|
||||
alterStatements = append(alterStatements, c.buildColumnAlterStatements(tableName, currentColumns, newColumns)...)
|
||||
|
||||
// 处理索引变更
|
||||
alterStatements = append(alterStatements, c.buildIndexAlterStatements(tableName, currentIndexes, newIndexes)...)
|
||||
|
||||
return alterStatements, nil
|
||||
}
|
||||
|
||||
// UpdateTableStructure 更新表结构,返回生成的 SQL 语句列表
|
||||
func (c *MySQLClient) UpdateTableStructure(ctx context.Context, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
||||
// 先预览生成 SQL 语句
|
||||
alterStatements, err := c.PreviewTableStructure(ctx, database, tableName, structure)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 执行所有 ALTER TABLE 语句
|
||||
if len(alterStatements) > 0 {
|
||||
dbName := database
|
||||
if dbName == "" {
|
||||
dbName = c.config.Database
|
||||
}
|
||||
|
||||
db := c.db.Session(&gorm.Session{})
|
||||
if dbName != "" {
|
||||
if err := db.Exec(fmt.Sprintf("USE `%s`", dbName)).Error; err != nil {
|
||||
return alterStatements, fmt.Errorf("切换数据库失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, stmt := range alterStatements {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
return alterStatements, fmt.Errorf("执行 ALTER TABLE 失败: %v, SQL: %s", err, stmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return alterStatements, nil
|
||||
}
|
||||
|
||||
// buildColumnAlterStatements 构建字段变更的 ALTER TABLE 语句
|
||||
func (c *MySQLClient) buildColumnAlterStatements(tableName string, currentColumns, newColumns []map[string]interface{}) []string {
|
||||
var statements []string
|
||||
|
||||
// 创建字段名映射和顺序映射
|
||||
currentFieldMap := make(map[string]map[string]interface{})
|
||||
currentFieldOrder := make([]string, 0, len(currentColumns))
|
||||
for _, col := range currentColumns {
|
||||
if field, ok := col["Field"].(string); ok {
|
||||
currentFieldMap[field] = col
|
||||
currentFieldOrder = append(currentFieldOrder, field)
|
||||
}
|
||||
}
|
||||
|
||||
newFieldMap := make(map[string]bool)
|
||||
newFieldOrder := make([]string, 0, len(newColumns))
|
||||
newColumnsMap := make(map[string]map[string]interface{})
|
||||
for _, col := range newColumns {
|
||||
if field, ok := col["Field"].(string); ok && field != "" {
|
||||
newFieldMap[field] = true
|
||||
newFieldOrder = append(newFieldOrder, field)
|
||||
newColumnsMap[field] = col
|
||||
}
|
||||
}
|
||||
|
||||
// 检测字段重命名:优先使用位置匹配,如果位置相同但字段名不同,认为是重命名
|
||||
renameMap := make(map[string]string) // oldName -> newName
|
||||
processedNewFields := make(map[string]bool)
|
||||
|
||||
// 第一步:使用位置匹配检测重命名(最可靠)
|
||||
for oldIndex, oldFieldName := range currentFieldOrder {
|
||||
if newFieldMap[oldFieldName] {
|
||||
continue // 字段名未改变,跳过
|
||||
}
|
||||
|
||||
// 检查新字段列表中相同位置是否有字段
|
||||
if oldIndex < len(newFieldOrder) {
|
||||
newFieldName := newFieldOrder[oldIndex]
|
||||
_, existsInCurrent := currentFieldMap[newFieldName]
|
||||
if !existsInCurrent && !processedNewFields[newFieldName] {
|
||||
// 新字段不在当前字段列表中,且位置相同,很可能是重命名
|
||||
// 进一步验证:检查类型是否相同(类型相同更可能是重命名)
|
||||
oldCol := currentFieldMap[oldFieldName]
|
||||
newCol := newColumnsMap[newFieldName]
|
||||
oldType := getStringValue(oldCol["Type"])
|
||||
newType := getStringValue(newCol["Type"])
|
||||
|
||||
// 如果类型相同,认为是重命名
|
||||
if oldType == newType {
|
||||
renameMap[oldFieldName] = newFieldName
|
||||
processedNewFields[newFieldName] = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 第二步:对于未匹配的字段,使用属性匹配(兼容旧逻辑)
|
||||
for oldFieldName, oldCol := range currentFieldMap {
|
||||
if newFieldMap[oldFieldName] {
|
||||
continue // 字段名未改变,跳过
|
||||
}
|
||||
if renameMap[oldFieldName] != "" {
|
||||
continue // 已经通过位置匹配识别为重命名
|
||||
}
|
||||
|
||||
// 查找属性完全匹配的新字段
|
||||
var matchedNewField string
|
||||
for newFieldName, newCol := range newColumnsMap {
|
||||
if processedNewFields[newFieldName] {
|
||||
continue // 已经被匹配过了
|
||||
}
|
||||
_, existsInCurrent := currentFieldMap[newFieldName]
|
||||
if !existsInCurrent {
|
||||
// 这是一个新增字段,检查属性是否匹配
|
||||
if c.isColumnPropertiesEqual(oldCol, newCol) {
|
||||
if matchedNewField == "" {
|
||||
matchedNewField = newFieldName
|
||||
} else {
|
||||
// 有多个匹配,无法确定,不认为是重命名
|
||||
matchedNewField = ""
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到唯一匹配,认为是重命名
|
||||
if matchedNewField != "" {
|
||||
renameMap[oldFieldName] = matchedNewField
|
||||
processedNewFields[matchedNewField] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 处理字段重命名
|
||||
for oldName, newName := range renameMap {
|
||||
stmt := fmt.Sprintf("ALTER TABLE `%s` RENAME COLUMN `%s` TO `%s`", tableName, oldName, newName)
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
|
||||
// 处理字段添加、修改和位置调整(排除已重命名的字段)
|
||||
for i, newCol := range newColumns {
|
||||
field, _ := newCol["Field"].(string)
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否是重命名的字段
|
||||
isRenamed := false
|
||||
var oldName string
|
||||
for old, new := range renameMap {
|
||||
if new == field {
|
||||
isRenamed = true
|
||||
oldName = old
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isRenamed {
|
||||
// 重命名的字段:如果属性有变化,需要 MODIFY COLUMN
|
||||
oldCol := currentFieldMap[oldName]
|
||||
needsModify := c.isColumnChanged(oldCol, newCol)
|
||||
|
||||
// 检查顺序变化:使用旧字段名在 currentOrder 中查找位置,与新位置比较
|
||||
oldIndex := -1
|
||||
for idx, name := range currentFieldOrder {
|
||||
if name == oldName {
|
||||
oldIndex = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
needsReorder := (oldIndex != -1 && oldIndex != i)
|
||||
|
||||
if needsModify || needsReorder {
|
||||
// 重命名后需要修改属性或位置
|
||||
stmt := c.buildModifyColumnStatement(tableName, field, newCol, newFieldOrder, i)
|
||||
if stmt != "" {
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if currentCol, exists := currentFieldMap[field]; exists {
|
||||
// 修改现有字段
|
||||
needsModify := c.isColumnChanged(currentCol, newCol)
|
||||
needsReorder := c.isColumnOrderChanged(currentFieldOrder, newFieldOrder, field, i)
|
||||
|
||||
if needsModify || needsReorder {
|
||||
stmt := c.buildModifyColumnStatement(tableName, field, newCol, newFieldOrder, i)
|
||||
if stmt != "" {
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 添加新字段(排除重命名的字段)
|
||||
stmt := c.buildAddColumnStatement(tableName, newCol, newFieldOrder, i)
|
||||
if stmt != "" {
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除不存在的字段(排除已重命名的字段)
|
||||
for field := range currentFieldMap {
|
||||
if !newFieldMap[field] && renameMap[field] == "" {
|
||||
stmt := fmt.Sprintf("ALTER TABLE `%s` DROP COLUMN `%s`", tableName, field)
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
|
||||
return statements
|
||||
}
|
||||
|
||||
// buildIndexAlterStatements 构建索引变更的 ALTER TABLE 语句
|
||||
func (c *MySQLClient) buildIndexAlterStatements(tableName string, currentIndexes, newIndexes []map[string]interface{}) []string {
|
||||
var statements []string
|
||||
|
||||
// 创建索引名映射
|
||||
currentIndexMap := make(map[string]map[string]interface{})
|
||||
for _, idx := range currentIndexes {
|
||||
if keyName, ok := idx["Key_name"].(string); ok && keyName != "PRIMARY" {
|
||||
currentIndexMap[keyName] = idx
|
||||
}
|
||||
}
|
||||
|
||||
newIndexMap := make(map[string]bool)
|
||||
for _, idx := range newIndexes {
|
||||
if keyName, ok := idx["Key_name"].(string); ok && keyName != "" && keyName != "PRIMARY" {
|
||||
newIndexMap[keyName] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 处理索引变更
|
||||
for _, newIdx := range newIndexes {
|
||||
keyName, _ := newIdx["Key_name"].(string)
|
||||
if keyName == "" || keyName == "PRIMARY" {
|
||||
continue
|
||||
}
|
||||
|
||||
if currentIdx, exists := currentIndexMap[keyName]; exists {
|
||||
// 修改现有索引
|
||||
if c.isIndexChanged(currentIdx, newIdx) {
|
||||
dropStmt := fmt.Sprintf("ALTER TABLE `%s` DROP INDEX `%s`", tableName, keyName)
|
||||
addStmt := c.buildAddIndexStatement(tableName, newIdx)
|
||||
if addStmt != "" {
|
||||
statements = append(statements, dropStmt)
|
||||
statements = append(statements, addStmt)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 添加新索引
|
||||
stmt := c.buildAddIndexStatement(tableName, newIdx)
|
||||
if stmt != "" {
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除不存在的索引
|
||||
for keyName := range currentIndexMap {
|
||||
if !newIndexMap[keyName] {
|
||||
stmt := fmt.Sprintf("ALTER TABLE `%s` DROP INDEX `%s`", tableName, keyName)
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
|
||||
return statements
|
||||
}
|
||||
|
||||
// isColumnChanged 检查字段是否发生变化(不包括字段名)
|
||||
func (c *MySQLClient) isColumnChanged(oldCol, newCol map[string]interface{}) bool {
|
||||
fields := []string{"Type", "Null", "Default", "Extra", "Comment"}
|
||||
for _, field := range fields {
|
||||
oldVal := getStringValue(oldCol[field])
|
||||
newVal := getStringValue(newCol[field])
|
||||
if oldVal != newVal {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isColumnPropertiesEqual 检查字段属性是否完全相等(不包括字段名)
|
||||
func (c *MySQLClient) isColumnPropertiesEqual(oldCol, newCol map[string]interface{}) bool {
|
||||
fields := []string{"Type", "Null", "Default", "Extra", "Key", "Comment"}
|
||||
for _, field := range fields {
|
||||
oldVal := getStringValue(oldCol[field])
|
||||
newVal := getStringValue(newCol[field])
|
||||
if oldVal != newVal {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isColumnOrderChanged 检查字段顺序是否发生变化
|
||||
func (c *MySQLClient) isColumnOrderChanged(currentOrder, newOrder []string, fieldName string, newIndex int) bool {
|
||||
// 查找字段在当前顺序中的位置
|
||||
currentIndex := -1
|
||||
for i, name := range currentOrder {
|
||||
if name == fieldName {
|
||||
currentIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 如果字段不存在于当前顺序中(新字段),不需要检查顺序
|
||||
if currentIndex == -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果索引相同,检查前面的字段是否相同
|
||||
if newIndex == currentIndex {
|
||||
// 检查前面的字段集合是否相同
|
||||
if newIndex > 0 {
|
||||
currentPrevFields := make(map[string]bool)
|
||||
for i := 0; i < currentIndex; i++ {
|
||||
currentPrevFields[currentOrder[i]] = true
|
||||
}
|
||||
|
||||
newPrevFields := make(map[string]bool)
|
||||
for i := 0; i < newIndex; i++ {
|
||||
newPrevFields[newOrder[i]] = true
|
||||
}
|
||||
|
||||
// 如果前面的字段集合不同,说明顺序变了
|
||||
if len(currentPrevFields) != len(newPrevFields) {
|
||||
return true
|
||||
}
|
||||
for f := range currentPrevFields {
|
||||
if !newPrevFields[f] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 索引不同,说明顺序变了
|
||||
return true
|
||||
}
|
||||
|
||||
// isIndexChanged 检查索引是否发生变化
|
||||
func (c *MySQLClient) isIndexChanged(oldIdx, newIdx map[string]interface{}) bool {
|
||||
oldCol := getStringValue(oldIdx["Column_name"])
|
||||
newCol := getStringValue(newIdx["Column_name"])
|
||||
if oldCol != newCol {
|
||||
return true
|
||||
}
|
||||
|
||||
oldUnique := getIntValue(oldIdx["Non_unique"])
|
||||
newUnique := getIntValue(newIdx["Non_unique"])
|
||||
return oldUnique != newUnique
|
||||
}
|
||||
|
||||
// buildAddColumnStatement 构建添加字段的语句
|
||||
func (c *MySQLClient) buildAddColumnStatement(tableName string, col map[string]interface{}, fieldOrder []string, index int) string {
|
||||
field := getStringValue(col["Field"])
|
||||
if field == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
colDef := c.buildColumnDefinition(col)
|
||||
|
||||
// 确定字段位置
|
||||
position := c.buildColumnPosition(fieldOrder, index)
|
||||
|
||||
return fmt.Sprintf("ALTER TABLE `%s` ADD COLUMN %s%s", tableName, colDef, position)
|
||||
}
|
||||
|
||||
// buildModifyColumnStatement 构建修改字段的语句
|
||||
func (c *MySQLClient) buildModifyColumnStatement(tableName, field string, col map[string]interface{}, fieldOrder []string, index int) string {
|
||||
colDef := c.buildColumnDefinition(col)
|
||||
|
||||
// 确定字段位置
|
||||
position := c.buildColumnPosition(fieldOrder, index)
|
||||
|
||||
return fmt.Sprintf("ALTER TABLE `%s` MODIFY COLUMN %s%s", tableName, colDef, position)
|
||||
}
|
||||
|
||||
// buildColumnPosition 构建字段位置子句(AFTER 或 FIRST)
|
||||
func (c *MySQLClient) buildColumnPosition(fieldOrder []string, index int) string {
|
||||
if index < 0 || index >= len(fieldOrder) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if index == 0 {
|
||||
// 第一个字段使用 FIRST
|
||||
return " FIRST"
|
||||
}
|
||||
|
||||
// 其他字段使用 AFTER 前一个字段
|
||||
prevField := fieldOrder[index-1]
|
||||
return fmt.Sprintf(" AFTER `%s`", prevField)
|
||||
}
|
||||
|
||||
// buildColumnDefinition 构建字段定义
|
||||
func (c *MySQLClient) buildColumnDefinition(col map[string]interface{}) string {
|
||||
field := getStringValue(col["Field"])
|
||||
colType := getStringValue(col["Type"])
|
||||
null := getStringValue(col["Null"])
|
||||
defaultVal := col["Default"]
|
||||
extra := getStringValue(col["Extra"])
|
||||
comment := getStringValue(col["Comment"])
|
||||
|
||||
def := fmt.Sprintf("`%s` %s", field, colType)
|
||||
|
||||
if null == "NO" {
|
||||
def += " NOT NULL"
|
||||
}
|
||||
|
||||
if defaultVal != nil {
|
||||
if defaultStr, ok := defaultVal.(string); ok {
|
||||
if defaultStr == "" {
|
||||
// 空字符串表示默认值为空字符串
|
||||
def += " DEFAULT ''"
|
||||
} else if defaultStr != "NULL" {
|
||||
// 转义单引号
|
||||
escapedDefault := strings.ReplaceAll(defaultStr, "'", "''")
|
||||
def += fmt.Sprintf(" DEFAULT '%s'", escapedDefault)
|
||||
}
|
||||
// 如果 defaultStr == "NULL",不添加 DEFAULT 子句(允许 NULL)
|
||||
} else {
|
||||
// 非字符串类型的默认值
|
||||
def += fmt.Sprintf(" DEFAULT %v", defaultVal)
|
||||
}
|
||||
}
|
||||
|
||||
if extra != "" {
|
||||
def += " " + extra
|
||||
}
|
||||
|
||||
if comment != "" {
|
||||
// 转义单引号
|
||||
escapedComment := strings.ReplaceAll(comment, "'", "''")
|
||||
def += fmt.Sprintf(" COMMENT '%s'", escapedComment)
|
||||
}
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
// buildAddIndexStatement 构建添加索引的语句
|
||||
func (c *MySQLClient) buildAddIndexStatement(tableName string, idx map[string]interface{}) string {
|
||||
keyName := getStringValue(idx["Key_name"])
|
||||
columnName := getStringValue(idx["Column_name"])
|
||||
nonUnique := getIntValue(idx["Non_unique"])
|
||||
|
||||
if keyName == "" || columnName == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
indexType := "INDEX"
|
||||
if nonUnique == 0 {
|
||||
indexType = "UNIQUE INDEX"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("ALTER TABLE `%s` ADD %s `%s` (`%s`)", tableName, indexType, keyName, columnName)
|
||||
}
|
||||
|
||||
// getStringValue 安全获取字符串值
|
||||
func getStringValue(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
// getIntValue 安全获取整数值
|
||||
func getIntValue(v interface{}) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
return val
|
||||
case int64:
|
||||
return int(val)
|
||||
case float64:
|
||||
return int(val)
|
||||
case string:
|
||||
var i int
|
||||
fmt.Sscanf(val, "%d", &i)
|
||||
return i
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
package dbclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"u-desk/internal/common"
|
||||
"u-desk/internal/crypto"
|
||||
"u-desk/internal/storage/models"
|
||||
)
|
||||
|
||||
// ConnectionPool 连接池管理器
|
||||
type ConnectionPool struct {
|
||||
mysqlClients map[uint]*MySQLClient
|
||||
redisClients map[uint]*RedisClient
|
||||
mongoClients map[uint]*MongoClient
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
globalPool *ConnectionPool
|
||||
poolOnce sync.Once
|
||||
)
|
||||
|
||||
// GetPool 获取全局连接池实例
|
||||
func GetPool() *ConnectionPool {
|
||||
poolOnce.Do(func() {
|
||||
globalPool = &ConnectionPool{
|
||||
mysqlClients: make(map[uint]*MySQLClient),
|
||||
redisClients: make(map[uint]*RedisClient),
|
||||
mongoClients: make(map[uint]*MongoClient),
|
||||
}
|
||||
})
|
||||
return globalPool
|
||||
}
|
||||
|
||||
// GetMySQLClient 获取或创建 MySQL 客户端
|
||||
func (p *ConnectionPool) GetMySQLClient(conn *models.DbConnection) (*MySQLClient, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// 检查是否已存在
|
||||
if client, ok := p.mysqlClients[conn.ID]; ok {
|
||||
// 测试连接是否有效
|
||||
if err := client.sqlDB.Ping(); err == nil {
|
||||
return client, nil
|
||||
}
|
||||
// 连接已断开,移除并重新创建
|
||||
client.Close()
|
||||
delete(p.mysqlClients, conn.ID)
|
||||
}
|
||||
|
||||
// 解密密码
|
||||
password, err := crypto.DecryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("密码解密失败: %v", err)
|
||||
}
|
||||
|
||||
// 创建新客户端
|
||||
config := &MySQLConfig{
|
||||
Host: conn.Host,
|
||||
Port: conn.Port,
|
||||
Username: conn.Username,
|
||||
Password: password, // 如果密码为空,MySQL会尝试无密码连接
|
||||
Database: conn.Database,
|
||||
}
|
||||
|
||||
client, err := NewMySQLClient(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.mysqlClients[conn.ID] = client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetRedisClient 获取或创建 Redis 客户端
|
||||
func (p *ConnectionPool) GetRedisClient(conn *models.DbConnection) (*RedisClient, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// 检查是否已存在
|
||||
if client, ok := p.redisClients[conn.ID]; ok {
|
||||
// 测试连接是否有效
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutPing)
|
||||
defer cancel()
|
||||
if err := client.client.Ping(ctx).Err(); err == nil {
|
||||
return client, nil
|
||||
}
|
||||
// 连接已断开,移除并重新创建
|
||||
client.Close()
|
||||
delete(p.redisClients, conn.ID)
|
||||
}
|
||||
|
||||
// 解密密码
|
||||
password, err := crypto.DecryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("密码解密失败: %v", err)
|
||||
}
|
||||
|
||||
// 解析 Redis DB 编号(从 Database 字段,默认为 0)
|
||||
dbNum := 0
|
||||
if conn.Database != "" {
|
||||
// 尝试解析 Database 字段为数字
|
||||
_, err := fmt.Sscanf(conn.Database, "%d", &dbNum)
|
||||
if err != nil {
|
||||
// 如果解析失败,使用默认值 0
|
||||
dbNum = 0
|
||||
}
|
||||
// 限制 DB 编号在 0-15 之间
|
||||
if dbNum < 0 || dbNum > 15 {
|
||||
dbNum = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新客户端
|
||||
config := &RedisConfig{
|
||||
Host: conn.Host,
|
||||
Port: conn.Port,
|
||||
Password: password,
|
||||
DB: dbNum,
|
||||
}
|
||||
|
||||
client, err := NewRedisClient(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.redisClients[conn.ID] = client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetMongoClient 获取或创建 MongoDB 客户端
|
||||
func (p *ConnectionPool) GetMongoClient(conn *models.DbConnection) (*MongoClient, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// 检查是否已存在
|
||||
if client, ok := p.mongoClients[conn.ID]; ok {
|
||||
// 测试连接是否有效
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutPing)
|
||||
defer cancel()
|
||||
if err := client.client.Ping(ctx, nil); err == nil {
|
||||
return client, nil
|
||||
}
|
||||
// 连接已断开,移除并重新创建
|
||||
client.Close()
|
||||
delete(p.mongoClients, conn.ID)
|
||||
}
|
||||
|
||||
// 解密密码
|
||||
password, err := crypto.DecryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("密码解密失败: %v", err)
|
||||
}
|
||||
|
||||
// 解析 Options 获取 MongoDB 连接参数
|
||||
authSource := ""
|
||||
authMechanism := ""
|
||||
if conn.Options != "" {
|
||||
var opts map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(conn.Options), &opts); err == nil {
|
||||
if as, ok := opts["authSource"].(string); ok && as != "" {
|
||||
authSource = as
|
||||
}
|
||||
if am, ok := opts["authMechanism"].(string); ok && am != "" {
|
||||
authMechanism = am
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新客户端
|
||||
config := &MongoConfig{
|
||||
Host: conn.Host,
|
||||
Port: conn.Port,
|
||||
Username: conn.Username,
|
||||
Password: password,
|
||||
Database: conn.Database,
|
||||
AuthSource: authSource,
|
||||
AuthMechanism: authMechanism,
|
||||
}
|
||||
|
||||
client, err := NewMongoClient(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.mongoClients[conn.ID] = client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// CloseConnection 关闭指定连接
|
||||
func (p *ConnectionPool) CloseConnection(connID uint, dbType string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
if client, ok := p.mysqlClients[connID]; ok {
|
||||
client.Close()
|
||||
delete(p.mysqlClients, connID)
|
||||
}
|
||||
case "redis":
|
||||
if client, ok := p.redisClients[connID]; ok {
|
||||
client.Close()
|
||||
delete(p.redisClients, connID)
|
||||
}
|
||||
case "mongo":
|
||||
if client, ok := p.mongoClients[connID]; ok {
|
||||
client.Close()
|
||||
delete(p.mongoClients, connID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CloseAll 关闭所有连接
|
||||
func (p *ConnectionPool) CloseAll() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
for _, client := range p.mysqlClients {
|
||||
client.Close()
|
||||
}
|
||||
for _, client := range p.redisClients {
|
||||
client.Close()
|
||||
}
|
||||
for _, client := range p.mongoClients {
|
||||
client.Close()
|
||||
}
|
||||
|
||||
p.mysqlClients = make(map[uint]*MySQLClient)
|
||||
p.redisClients = make(map[uint]*RedisClient)
|
||||
p.mongoClients = make(map[uint]*MongoClient)
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
package dbclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/common"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RedisClient Redis 客户端
|
||||
type RedisClient struct {
|
||||
client *redis.Client
|
||||
config *RedisConfig
|
||||
}
|
||||
|
||||
// RedisConfig Redis 配置
|
||||
type RedisConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Password string
|
||||
DB int // 数据库编号,默认 0
|
||||
}
|
||||
|
||||
// NewRedisClient 创建 Redis 客户端
|
||||
func NewRedisClient(config *RedisConfig) (*RedisClient, error) {
|
||||
addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: config.Password,
|
||||
DB: config.DB,
|
||||
DialTimeout: common.TimeoutConnect,
|
||||
ReadTimeout: 3 * time.Second,
|
||||
WriteTimeout: 3 * time.Second,
|
||||
})
|
||||
|
||||
// 测试连接
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutConnect)
|
||||
defer cancel()
|
||||
|
||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||
return nil, fmt.Errorf("Redis 连接测试失败: %v", err)
|
||||
}
|
||||
|
||||
return &RedisClient{
|
||||
client: rdb,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestRedisConnection 测试连接
|
||||
func TestRedisConnection(host string, port int, password string) error {
|
||||
config := &RedisConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Password: password,
|
||||
DB: 0,
|
||||
}
|
||||
client, err := NewRedisClient(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewRedisClientByDB 根据参数创建指定 DB 的 Redis 客户端(用于多 DB 场景)
|
||||
func NewRedisClientByDB(host string, port int, password string, dbNum int) (*RedisClient, error) {
|
||||
config := &RedisConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Password: password,
|
||||
DB: dbNum,
|
||||
}
|
||||
return NewRedisClient(config)
|
||||
}
|
||||
|
||||
// Close 关闭连接
|
||||
func (c *RedisClient) Close() error {
|
||||
if c.client != nil {
|
||||
return c.client.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteCommand 执行 Redis 命令
|
||||
func (c *RedisClient) ExecuteCommand(ctx context.Context, cmd string, args ...interface{}) (interface{}, error) {
|
||||
return c.client.Do(ctx, append([]interface{}{cmd}, args...)...).Result()
|
||||
}
|
||||
|
||||
// GetKeys 获取 Key 列表(支持 pattern,使用 SCAN 代替 KEYS 以提高性能)
|
||||
func (c *RedisClient) GetKeys(ctx context.Context, pattern string) ([]string, error) {
|
||||
if pattern == "" {
|
||||
pattern = "*"
|
||||
}
|
||||
|
||||
var keys []string
|
||||
var cursor uint64
|
||||
const count = 100 // 每次扫描的数量
|
||||
|
||||
for {
|
||||
var err error
|
||||
var batch []string
|
||||
batch, cursor, err = c.client.Scan(ctx, cursor, pattern, count).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keys = append(keys, batch...)
|
||||
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// GetKeyType 获取 Key 类型
|
||||
func (c *RedisClient) GetKeyType(ctx context.Context, key string) (string, error) {
|
||||
return c.client.Type(ctx, key).Result()
|
||||
}
|
||||
|
||||
// GetKeyValue 获取 Key 值
|
||||
func (c *RedisClient) GetKeyValue(ctx context.Context, key string) (interface{}, error) {
|
||||
keyType, err := c.GetKeyType(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch keyType {
|
||||
case "string":
|
||||
return c.client.Get(ctx, key).Result()
|
||||
case "list":
|
||||
return c.client.LRange(ctx, key, 0, -1).Result()
|
||||
case "set":
|
||||
return c.client.SMembers(ctx, key).Result()
|
||||
case "zset":
|
||||
// 对于有序集合,返回带分数的结果
|
||||
zMembers, err := c.client.ZRangeWithScores(ctx, key, 0, -1).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 转换为 map 格式,便于展示
|
||||
result := make([]map[string]interface{}, len(zMembers))
|
||||
for i, member := range zMembers {
|
||||
result[i] = map[string]interface{}{
|
||||
"member": member.Member,
|
||||
"score": member.Score,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
case "hash":
|
||||
return c.client.HGetAll(ctx, key).Result()
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的类型: %s", keyType)
|
||||
}
|
||||
}
|
||||
|
||||
// GetTTL 获取 Key 的 TTL
|
||||
func (c *RedisClient) GetTTL(ctx context.Context, key string) (time.Duration, error) {
|
||||
return c.client.TTL(ctx, key).Result()
|
||||
}
|
||||
|
||||
// GetKeyInfo 获取 Key 详细信息
|
||||
func (c *RedisClient) GetKeyInfo(ctx context.Context, key string) (map[string]interface{}, error) {
|
||||
info := map[string]interface{}{
|
||||
"key": key,
|
||||
"type": "",
|
||||
"value": nil,
|
||||
"ttl": 0,
|
||||
}
|
||||
|
||||
// 获取 Key 类型
|
||||
keyType, err := c.GetKeyType(ctx, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 Key 类型失败: %v", err)
|
||||
}
|
||||
info["type"] = keyType
|
||||
|
||||
// 获取 TTL
|
||||
ttl, err := c.GetTTL(ctx, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 TTL 失败: %v", err)
|
||||
}
|
||||
info["ttl"] = ttl.Seconds()
|
||||
|
||||
// 获取 Key 值(限制大小,避免过大)
|
||||
value, err := c.GetKeyValue(ctx, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 Key 值失败: %v", err)
|
||||
}
|
||||
info["value"] = formatValuePreview(value)
|
||||
|
||||
// 获取 Key 长度(使用 STRLEN、HLEN、SCARD、ZCARD)
|
||||
var keyLength int64
|
||||
switch keyType {
|
||||
case "string":
|
||||
keyLength, err = c.client.StrLen(ctx, key).Result()
|
||||
case "list":
|
||||
keyLength, err = c.client.LLen(ctx, key).Result()
|
||||
case "set":
|
||||
keyLength, err = c.client.SCard(ctx, key).Result()
|
||||
case "zset":
|
||||
keyLength, err = c.client.ZCard(ctx, key).Result()
|
||||
case "hash":
|
||||
keyLength, err = c.client.HLen(ctx, key).Result()
|
||||
}
|
||||
if err == nil {
|
||||
info["length"] = keyLength
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// formatValuePreview 格式化值预览(限制长度)
|
||||
func formatValuePreview(value interface{}) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
const maxPreviewLength = 200
|
||||
valueStr := fmt.Sprintf("%v", value)
|
||||
if len(valueStr) > maxPreviewLength {
|
||||
valueStr = valueStr[:maxPreviewLength] + "..."
|
||||
}
|
||||
|
||||
return valueStr
|
||||
}
|
||||
|
||||
// ListDatabases 获取数据库列表(Redis 使用 DB number)
|
||||
// Redis 没有传统数据库概念,这里返回空数组
|
||||
func (c *RedisClient) ListDatabases(ctx context.Context) ([]string, error) {
|
||||
// Redis 可以使用 DB number 来隔离数据
|
||||
// 这里可以返回当前配置的 DB 或者所有可用的 DB
|
||||
// 为简单起见,返回空数组,让用户直接操作 Key
|
||||
return []string{}, nil
|
||||
}
|
||||
@@ -1,21 +1,101 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 预编译正则表达式(避免每次调用重复编译)
|
||||
var (
|
||||
// CSS 相关
|
||||
cssImportRegex = regexp.MustCompile(`@import\s+(?:url\s*\(\s*)?["']([^"']+)["']\s*\)?\s*;`)
|
||||
cssUrlRegex = regexp.MustCompile(`url\(\s*["']?([^"')]+)["']?\s*\)`)
|
||||
|
||||
// HTML 标签
|
||||
htmlLinkTagRegex = regexp.MustCompile(`<link\s+([^>]*)>`)
|
||||
htmlScriptTagRegex = regexp.MustCompile(`<script\s+([^>]*)>`)
|
||||
htmlImgTagRegex = regexp.MustCompile(`<img\s+([^>]*)>`)
|
||||
htmlVideoTagRegex = regexp.MustCompile(`<video\s+([^>]*)>`)
|
||||
htmlSourceTagRegex = regexp.MustCompile(`<source\s+([^>]*)>`)
|
||||
htmlAudioTagRegex = regexp.MustCompile(`<audio\s+([^>]*)>`)
|
||||
htmlIframeTagRegex = regexp.MustCompile(`<iframe\s+([^>]*)>`)
|
||||
htmlObjectTagRegex = regexp.MustCompile(`<object\s+([^>]*)>`)
|
||||
htmlEmbedTagRegex = regexp.MustCompile(`<embed\s+([^>]*)>`)
|
||||
|
||||
// HTML 属性
|
||||
htmlSrcsetRegex = regexp.MustCompile(`srcset=["']([^"']+)["']`)
|
||||
htmlStyleAttrRegex = regexp.MustCompile(`style=["']([^"']+)["']`)
|
||||
htmlStyleTagRegex = regexp.MustCompile(`<style([^>]*)>([\s\S]*?)</style>`)
|
||||
|
||||
// ES6 模块语句
|
||||
es6ImportFromRegex = regexp.MustCompile(`import\s+([\s\S]*?)\s+from\s+["']([^"']+)["']`)
|
||||
es6DynamicImport = regexp.MustCompile(`import\s*\(\s*["']([^"']+)["']\s*\)`)
|
||||
es6BareImport = regexp.MustCompile(`(?m)^\s*import\s+["']([^"']+)["']`)
|
||||
|
||||
// HTML 预览路径修复
|
||||
locationPathRegex = regexp.MustCompile(`\blocation\.pathname\b`)
|
||||
)
|
||||
|
||||
// HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译)
|
||||
var attrRegexCache sync.Map // map[string]*regexp.Regexp
|
||||
|
||||
// 路径校验 sentinel error(用 errors.Is 匹配,不依赖字符串)
|
||||
var (
|
||||
ErrPathInvalidEncoding = fmt.Errorf("invalid path encoding")
|
||||
ErrPathTraversal = fmt.Errorf("path traversal detected")
|
||||
ErrPathUnsafe = fmt.Errorf("unsafe path")
|
||||
)
|
||||
|
||||
// validateFilePath 校验文件路径安全性(URL解码 + 路径遍历检测 + 安全检查)
|
||||
// 返回清理后的绝对路径,或 sentinel error
|
||||
func validateFilePath(rawPath string, logPrefix string) (string, error) {
|
||||
decodedPath, err := url.QueryUnescape(rawPath)
|
||||
if err != nil {
|
||||
return "", ErrPathInvalidEncoding
|
||||
}
|
||||
|
||||
if strings.Contains(decodedPath, "..") {
|
||||
return "", ErrPathTraversal
|
||||
}
|
||||
|
||||
// 去除代理引入的 /localfs/ 前缀(可能有多层)
|
||||
clean := decodedPath
|
||||
for strings.HasPrefix(clean, "/localfs/") || strings.HasPrefix(clean, "localfs/") {
|
||||
clean = strings.TrimPrefix(clean, "/localfs/")
|
||||
clean = strings.TrimPrefix(clean, "localfs/")
|
||||
}
|
||||
|
||||
// 平台适配:Windows 用反斜杠,Linux/macOS 保持正斜杠
|
||||
filePath := filepath.FromSlash(clean)
|
||||
filePath = filepath.Clean(filePath)
|
||||
|
||||
// 确保绝对路径(Linux 以 / 开头,Windows 以盘符开头)
|
||||
if !filepath.IsAbs(filePath) && len(filePath) > 0 && filePath[0] != '/' && filePath[1] != ':' {
|
||||
filePath = "/" + filePath
|
||||
}
|
||||
|
||||
if !isSafePath(filePath) {
|
||||
return "", ErrPathUnsafe
|
||||
}
|
||||
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
// LocalFileServer 本地文件服务器(独立的 HTTP 服务器)
|
||||
type LocalFileServer struct {
|
||||
server *http.Server
|
||||
addr string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -33,9 +113,12 @@ func StartLocalFileServer() (string, error) {
|
||||
// 注册 /localfs/ 路由
|
||||
mux.HandleFunc("/localfs/", handleLocalFileRequest)
|
||||
|
||||
// 注册 HTML 预览专用路由
|
||||
mux.HandleFunc("/localfs/html-preview", handleHtmlPreviewRequest)
|
||||
|
||||
// 创建服务器(固定端口)
|
||||
server := &http.Server{
|
||||
Addr: "localhost:18765",
|
||||
Addr: "localhost:8073",
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
@@ -50,7 +133,7 @@ func StartLocalFileServer() (string, error) {
|
||||
|
||||
localFileServer = &LocalFileServer{
|
||||
server: server,
|
||||
addr: "localhost:18765",
|
||||
addr: "localhost:8073",
|
||||
}
|
||||
|
||||
log.Printf("[LocalFileServer] 已启动,监听: %s", localFileServer.addr)
|
||||
@@ -64,6 +147,17 @@ func StartLocalFileServer() (string, error) {
|
||||
|
||||
// handleLocalFileRequest 处理本地文件请求
|
||||
func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
||||
// CORS 头:允许所有源访问(因为这是本地文件服务器)
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
|
||||
// 处理 OPTIONS 预检请求
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// 只处理 GET 请求
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -72,9 +166,20 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
log.Printf("[LocalFileHandler] 收到请求: %s", r.URL.Path)
|
||||
|
||||
// 从 URL 路径获取文件路径(移除 /localfs/ 前缀)
|
||||
pathPart := strings.TrimPrefix(r.URL.Path, "/localfs/")
|
||||
log.Printf("[LocalFileHandler] TrimPrefix 后: %s", pathPart)
|
||||
// 从 URL 路径获取文件路径(移除所有 /localfs/ 前缀,兼容代理双重前缀)
|
||||
pathPart := r.URL.Path
|
||||
for strings.HasPrefix(pathPart, "/localfs/") {
|
||||
pathPart = strings.TrimPrefix(pathPart, "/localfs/")
|
||||
}
|
||||
if pathPart == "" || pathPart == r.URL.Path {
|
||||
log.Printf("[LocalFileHandler] 路径前缀无效: original=%s", r.URL.Path)
|
||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// 仅对非绝对路径添加前导 /(Windows 盘符路径如 D:/ 已经是绝对路径,不能加 /)
|
||||
if !strings.HasPrefix(pathPart, "/") && !regexp.MustCompile(`^[A-Za-z]:`).MatchString(pathPart) {
|
||||
pathPart = "/" + pathPart
|
||||
}
|
||||
|
||||
if pathPart == "" || pathPart == r.URL.Path {
|
||||
log.Printf("[LocalFileHandler] 路径前缀无效")
|
||||
@@ -82,35 +187,25 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 🔒 修复:先进行URL解码,防止路径遍历攻击
|
||||
decodedPath, err := url.QueryUnescape(pathPart)
|
||||
// 校验路径安全性(URL解码 + 路径遍历检测 + 安全检查)
|
||||
filePath, err := validateFilePath(pathPart, "[LocalFileHandler]")
|
||||
if err != nil {
|
||||
log.Printf("[LocalFileHandler] URL解码失败: %v", err)
|
||||
log.Printf("[LocalFileHandler] 路径校验失败: %v (%s)", err, pathPart)
|
||||
switch {
|
||||
case errors.Is(err, ErrPathInvalidEncoding):
|
||||
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Printf("[LocalFileHandler] URL解码后: %s", decodedPath)
|
||||
|
||||
// 🔒 修复:在路径转换前检查是否包含危险字符
|
||||
if strings.Contains(decodedPath, "..") {
|
||||
log.Printf("[LocalFileHandler] 检测到路径遍历尝试")
|
||||
case errors.Is(err, ErrPathTraversal):
|
||||
http.Error(w, "Path traversal detected", http.StatusForbidden)
|
||||
case errors.Is(err, ErrPathUnsafe):
|
||||
http.Error(w, "Unsafe path", http.StatusForbidden)
|
||||
default:
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 路径转换(统一使用反斜杠)
|
||||
filePath := strings.ReplaceAll(decodedPath, "/", "\\")
|
||||
filePath = filepath.Clean(filePath)
|
||||
log.Printf("[LocalFileHandler] 最终路径: %s", filePath)
|
||||
|
||||
// 安全检查
|
||||
if !isSafePath(filePath) {
|
||||
log.Printf("[LocalFileHandler] 路径未通过安全检查: %s", filePath)
|
||||
http.Error(w, "Unsafe path", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// 🔒 修复:文件类型白名单检查
|
||||
// 🔒 文件类型白名单检查
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
if !isAllowedFileType(ext) {
|
||||
log.Printf("[LocalFileHandler] 不允许的文件类型: %s", ext)
|
||||
@@ -139,6 +234,50 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// CSS 文件特殊处理:转换内容中的相对路径
|
||||
if ext == ".css" {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
log.Printf("[LocalFileHandler] 读取CSS文件失败: %v", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to read CSS file: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取 CSS 文件所在目录作为 basePath
|
||||
basePath := filepath.Dir(filePath)
|
||||
|
||||
// 转换内容中的相对路径
|
||||
transformedContent := transformCssContent(string(content), basePath)
|
||||
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write([]byte(transformedContent))
|
||||
log.Printf("[LocalFileHandler] CSS文件转换完成: %s (原始=%d, 转换后=%d)", filePath, len(content), len(transformedContent))
|
||||
return
|
||||
}
|
||||
|
||||
// JavaScript 文件特殊处理:转换动态 import 路径
|
||||
if ext == ".js" || ext == ".mjs" {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
log.Printf("[LocalFileHandler] 读取JS文件失败: %v", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to read JS file: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取 JS 文件所在目录作为 basePath
|
||||
basePath := filepath.Dir(filePath)
|
||||
|
||||
// 转换动态 import 路径
|
||||
transformedContent := transformJsDynamicImports(string(content), basePath)
|
||||
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write([]byte(transformedContent))
|
||||
log.Printf("[LocalFileHandler] JS文件转换完成: %s (原始=%d, 转换后=%d)", filePath, len(content), len(transformedContent))
|
||||
return
|
||||
}
|
||||
|
||||
// 打开文件
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
@@ -207,54 +346,513 @@ func getContentType(ext string) string {
|
||||
return defaultFileTypeManager.GetMIMEType(ext)
|
||||
}
|
||||
|
||||
// ReadFileAsBase64 读取文件并返回 base64 编码的字符串
|
||||
// 用于读取从 ZIP 提取的临时图片文件
|
||||
func ReadFileAsBase64(filePath string) (string, error) {
|
||||
log.Printf("[ReadFileAsBase64] 读取文件: %s", filePath)
|
||||
|
||||
if !isSafePath(filePath) {
|
||||
return "", fmt.Errorf("路径不安全")
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("文件不存在: %s", filePath)
|
||||
}
|
||||
return "", fmt.Errorf("无法访问文件: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("[ReadFileAsBase64] 文件大小: %d bytes", fileInfo.Size())
|
||||
|
||||
// 读取文件
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 编码为 base64
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
log.Printf("[ReadFileAsBase64] 编码成功: 原始=%d, base64=%d", len(data), len(encoded))
|
||||
|
||||
// 获取文件扩展名并确定 MIME 类型
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
mimeType := getContentType(ext)
|
||||
|
||||
// 返回 data URI 格式: data:image/png;base64,iVBORw0KG...
|
||||
return fmt.Sprintf("data:%s;base64,%s", mimeType, encoded), nil
|
||||
}
|
||||
|
||||
// HandleLocalFile 处理 /localfs/ 路由的 HTTP 请求
|
||||
// 前端可以请求 http://localhost:18765/localfs/C:/path/to/image.jpg
|
||||
// 注意:此函数与 ServeHTTP 功能重复,建议统一使用 ServeHTTP
|
||||
func HandleLocalFile(w http.ResponseWriter, r *http.Request) {
|
||||
handler := NewLocalFileHandler()
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// isAllowedFileType 检查文件类型是否在白名单中
|
||||
// 使用统一的文件类型管理器
|
||||
func isAllowedFileType(ext string) bool {
|
||||
return defaultFileTypeManager.IsAllowed(ext)
|
||||
}
|
||||
|
||||
// Shutdown 优雅关闭文件服务器
|
||||
func (lfs *LocalFileServer) Shutdown() error {
|
||||
if lfs == nil || lfs.server == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lfs.mu.Lock()
|
||||
defer lfs.mu.Unlock()
|
||||
|
||||
// 创建带超时的上下文
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
log.Printf("[LocalFileServer] 正在关闭...")
|
||||
|
||||
if err := lfs.server.Shutdown(ctx); err != nil {
|
||||
log.Printf("[LocalFileServer] 关闭失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("[LocalFileServer] 已关闭")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShutdownLocalFileServer 关闭全局文件服务器
|
||||
func ShutdownLocalFileServer() error {
|
||||
if localFileServer != nil {
|
||||
return localFileServer.Shutdown()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// transformCssContent 转换 CSS 内容中的相对路径
|
||||
// basePath: CSS 文件所在目录的绝对路径
|
||||
func transformCssContent(content string, basePath string) string {
|
||||
// 1. 处理 @import 语句
|
||||
content = cssImportRegex.ReplaceAllStringFunc(content, func(match string) string {
|
||||
submatches := cssImportRegex.FindStringSubmatch(match)
|
||||
if len(submatches) < 2 {
|
||||
return match
|
||||
}
|
||||
relativePath := submatches[1]
|
||||
|
||||
// 跳过绝对 URL 和数据 URI
|
||||
if isAbsoluteURL(relativePath) || strings.HasPrefix(relativePath, "data:") {
|
||||
return match
|
||||
}
|
||||
|
||||
absolutePath := resolveCssRelativePath(basePath, relativePath)
|
||||
if absolutePath == "" {
|
||||
return match
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`@import url("%s");`, toLocalServerUrl(absolutePath))
|
||||
})
|
||||
|
||||
// 2. 处理 url() 语句
|
||||
content = cssUrlRegex.ReplaceAllStringFunc(content, func(match string) string {
|
||||
submatches := cssUrlRegex.FindStringSubmatch(match)
|
||||
if len(submatches) < 2 {
|
||||
return match
|
||||
}
|
||||
relativePath := strings.TrimSpace(submatches[1])
|
||||
|
||||
// 跳过绝对 URL、数据 URI 和绝对路径
|
||||
if isAbsoluteURL(relativePath) || strings.HasPrefix(relativePath, "data:") || strings.HasPrefix(relativePath, "/") || relativePath == "" {
|
||||
return match
|
||||
}
|
||||
|
||||
absolutePath := resolveCssRelativePath(basePath, relativePath)
|
||||
if absolutePath == "" {
|
||||
return match
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`url("%s")`, toLocalServerUrl(absolutePath))
|
||||
})
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
// resolveCssRelativePath 解析 CSS 中的相对路径为绝对路径
|
||||
func resolveCssRelativePath(basePath, relativePath string) string {
|
||||
// 清理路径
|
||||
relativePath = strings.TrimSpace(relativePath)
|
||||
if relativePath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 移除 ./ 前缀
|
||||
relativePath = strings.TrimPrefix(relativePath, "./")
|
||||
|
||||
// 使用 filepath.Join 处理 ../ 等
|
||||
// 注意:需要先将 / 转换为 \ 以便 Windows 路径正确处理
|
||||
relativePath = strings.ReplaceAll(relativePath, "/", string(filepath.Separator))
|
||||
absolutePath := filepath.Join(basePath, relativePath)
|
||||
|
||||
// 清理路径并转换回 /
|
||||
absolutePath = filepath.Clean(absolutePath)
|
||||
absolutePath = strings.ReplaceAll(absolutePath, "\\", "/")
|
||||
|
||||
return absolutePath
|
||||
}
|
||||
|
||||
// toLocalServerUrl 将绝对路径转换为 /localfs/ URL(带 URL 编码)
|
||||
func toLocalServerUrl(absolutePath string) string {
|
||||
// 确保路径使用 /
|
||||
absolutePath = strings.ReplaceAll(absolutePath, "\\", "/")
|
||||
// 对路径进行 URL 编码(分段编码,保留 /)
|
||||
parts := strings.Split(absolutePath, "/")
|
||||
for i, part := range parts {
|
||||
// Windows 驱动器字母(如 E:)需要特殊处理,冒号必须编码
|
||||
if len(part) == 2 && part[1] == ':' {
|
||||
// 将 "E:" 转换为 "E%3A"
|
||||
parts[i] = string(part[0]) + "%3A"
|
||||
} else {
|
||||
parts[i] = url.PathEscape(part)
|
||||
}
|
||||
}
|
||||
return "/localfs/" + strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
// isAbsoluteURL 检查是否为绝对 URL(http://, https://, //)
|
||||
func isAbsoluteURL(path string) bool {
|
||||
path = strings.ToLower(path)
|
||||
return strings.HasPrefix(path, "http://") ||
|
||||
strings.HasPrefix(path, "https://") ||
|
||||
strings.HasPrefix(path, "//")
|
||||
}
|
||||
|
||||
// handleHtmlPreviewRequest 处理 HTML 预览请求
|
||||
// 参数:
|
||||
// - path: HTML 文件绝对路径(URL 编码)
|
||||
// - theme: 主题(light / dark)
|
||||
func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
|
||||
// CORS
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
|
||||
// 处理 OPTIONS 预检请求
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// 只处理 GET 请求
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析参数
|
||||
rawPath := r.URL.Query().Get("path")
|
||||
theme := r.URL.Query().Get("theme")
|
||||
if theme == "" {
|
||||
theme = "light"
|
||||
}
|
||||
|
||||
// 校验路径安全性(URL解码 + 路径遍历检测 + 安全检查)
|
||||
filePath, err := validateFilePath(rawPath, "[HtmlPreview]")
|
||||
if err != nil {
|
||||
log.Printf("[HtmlPreview] 路径校验失败: %v (%s)", err, rawPath)
|
||||
switch {
|
||||
case errors.Is(err, ErrPathInvalidEncoding):
|
||||
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
|
||||
case errors.Is(err, ErrPathTraversal):
|
||||
http.Error(w, "Path traversal detected", http.StatusForbidden)
|
||||
case errors.Is(err, ErrPathUnsafe):
|
||||
http.Error(w, "Unsafe path", http.StatusForbidden)
|
||||
default:
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[HtmlPreview] 收到请求: path=%s, theme=%s", filePath, theme)
|
||||
|
||||
// 读取文件
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
log.Printf("[HtmlPreview] 读取文件失败: %v", err)
|
||||
http.Error(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取文件所在目录(用于解析相对路径)
|
||||
baseDir := filepath.Dir(filePath)
|
||||
|
||||
// 转换资源路径(将相对路径和绝对路径都转换为完整的本地文件服务器 URL)
|
||||
processedContent := transformHtmlResourcePaths(string(content), baseDir)
|
||||
|
||||
// 修复 JS 中基于 location.pathname 的相对路径计算
|
||||
// 预览模式下 location.pathname = "/localfs/html-preview",与实际文件路径不一致
|
||||
// ⚠️ 会替换所有出现位置(含JS字符串内),HTML预览场景下可接受
|
||||
correctPathname := `"/localfs/` + strings.ReplaceAll(baseDir, "\\", "/") + `/`
|
||||
processedContent = locationPathRegex.ReplaceAllString(processedContent, correctPathname)
|
||||
|
||||
// 注入链接点击拦截脚本
|
||||
finalContent := injectLinkInterceptor(processedContent)
|
||||
|
||||
// 返回处理后的 HTML
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Write([]byte(finalContent))
|
||||
|
||||
log.Printf("[HtmlPreview] 处理完成: %s (%d -> %d bytes)", filePath, len(content), len(finalContent))
|
||||
}
|
||||
|
||||
// transformHtmlResourcePaths 转换 HTML 中的资源路径为本地文件服务器 URL
|
||||
func transformHtmlResourcePaths(htmlContent string, baseDir string) string {
|
||||
if baseDir == "" {
|
||||
return htmlContent
|
||||
}
|
||||
|
||||
result := htmlContent
|
||||
|
||||
// 1. 处理 HTML 标签资源
|
||||
tagConfigs := []struct {
|
||||
pattern *regexp.Regexp
|
||||
attr string
|
||||
}{
|
||||
{htmlLinkTagRegex, "href"},
|
||||
{htmlScriptTagRegex, "src"},
|
||||
{htmlImgTagRegex, "src"},
|
||||
{htmlVideoTagRegex, "src"},
|
||||
{htmlVideoTagRegex, "poster"},
|
||||
{htmlSourceTagRegex, "src"},
|
||||
{htmlAudioTagRegex, "src"},
|
||||
{htmlIframeTagRegex, "src"},
|
||||
{htmlObjectTagRegex, "data"},
|
||||
{htmlEmbedTagRegex, "src"},
|
||||
}
|
||||
|
||||
for _, config := range tagConfigs {
|
||||
result = replaceHtmlTagAttribute(result, config.pattern, config.attr, baseDir)
|
||||
}
|
||||
|
||||
// 2. 处理 srcset 属性
|
||||
result = replaceHtmlSrcset(result, baseDir)
|
||||
|
||||
// 3. 处理内联 style 属性中的 url()
|
||||
result = replaceHtmlInlineStyleUrls(result, baseDir)
|
||||
|
||||
// 4. 处理 <style> 标签内的内容
|
||||
result = replaceHtmlStyleTagContent(result, baseDir)
|
||||
|
||||
// 5. 处理 ES6 模块语句(使用预编译正则)
|
||||
result = es6ImportFromRegex.ReplaceAllStringFunc(result, func(match string) string {
|
||||
submatches := es6ImportFromRegex.FindStringSubmatch(match)
|
||||
if len(submatches) < 3 {
|
||||
return match
|
||||
}
|
||||
modulePath := submatches[2]
|
||||
if isAbsoluteURL(modulePath) || strings.HasPrefix(modulePath, "data:") {
|
||||
return match
|
||||
}
|
||||
return fmt.Sprintf(`import %s from "%s"`, submatches[1], resolveHtmlPathToUrl(baseDir, modulePath))
|
||||
})
|
||||
|
||||
result = es6DynamicImport.ReplaceAllStringFunc(result, func(match string) string {
|
||||
submatches := es6DynamicImport.FindStringSubmatch(match)
|
||||
if len(submatches) < 2 {
|
||||
return match
|
||||
}
|
||||
modulePath := submatches[1]
|
||||
if isAbsoluteURL(modulePath) || strings.HasPrefix(modulePath, "data:") {
|
||||
return match
|
||||
}
|
||||
return fmt.Sprintf(`import("%s")`, resolveHtmlPathToUrl(baseDir, modulePath))
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// replaceHtmlTagAttribute 替换 HTML 标签中的属性路径
|
||||
func replaceHtmlTagAttribute(html string, pattern *regexp.Regexp, attrName string, baseDir string) string {
|
||||
return pattern.ReplaceAllStringFunc(html, func(match string) string {
|
||||
// 提取属性值(使用缓存的正则)
|
||||
var attrRegex *regexp.Regexp
|
||||
if v, ok := attrRegexCache.Load(attrName); ok {
|
||||
attrRegex = v.(*regexp.Regexp)
|
||||
} else {
|
||||
attrRegex = regexp.MustCompile(fmt.Sprintf(`%s=["']([^"']+)["']`, attrName))
|
||||
attrRegexCache.Store(attrName, attrRegex)
|
||||
}
|
||||
attrMatch := attrRegex.FindStringSubmatch(match)
|
||||
if attrMatch == nil {
|
||||
return match
|
||||
}
|
||||
|
||||
relativePath := attrMatch[1]
|
||||
if isAbsoluteURL(relativePath) || strings.HasPrefix(relativePath, "data:") {
|
||||
return match
|
||||
}
|
||||
|
||||
newUrl := resolveHtmlPathToUrl(baseDir, relativePath)
|
||||
return strings.Replace(match, attrMatch[0], fmt.Sprintf(`%s="%s"`, attrName, newUrl), 1)
|
||||
})
|
||||
}
|
||||
|
||||
// replaceHtmlSrcset 处理 srcset 属性
|
||||
func replaceHtmlSrcset(html string, baseDir string) string {
|
||||
return htmlSrcsetRegex.ReplaceAllStringFunc(html, func(match string) string {
|
||||
submatches := htmlSrcsetRegex.FindStringSubmatch(match)
|
||||
if len(submatches) < 2 {
|
||||
return match
|
||||
}
|
||||
|
||||
srcset := submatches[1]
|
||||
var newItems []string
|
||||
for _, item := range strings.Split(srcset, ",") {
|
||||
parts := strings.Fields(strings.TrimSpace(item))
|
||||
if len(parts) == 0 {
|
||||
continue
|
||||
}
|
||||
url := parts[0]
|
||||
descriptor := ""
|
||||
if len(parts) > 1 {
|
||||
descriptor = " " + strings.Join(parts[1:], " ")
|
||||
}
|
||||
if isAbsoluteURL(url) || strings.HasPrefix(url, "data:") {
|
||||
newItems = append(newItems, item)
|
||||
} else {
|
||||
newItems = append(newItems, resolveHtmlPathToUrl(baseDir, url)+descriptor)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(`srcset="%s"`, strings.Join(newItems, ", "))
|
||||
})
|
||||
}
|
||||
|
||||
// replaceHtmlInlineStyleUrls 处理内联 style 属性中的 url()
|
||||
func replaceHtmlInlineStyleUrls(html string, baseDir string) string {
|
||||
return htmlStyleAttrRegex.ReplaceAllStringFunc(html, func(match string) string {
|
||||
submatches := htmlStyleAttrRegex.FindStringSubmatch(match)
|
||||
if len(submatches) < 2 {
|
||||
return match
|
||||
}
|
||||
|
||||
styleContent := submatches[1]
|
||||
newStyle := cssUrlRegex.ReplaceAllStringFunc(styleContent, func(urlMatch string) string {
|
||||
urlSubmatches := cssUrlRegex.FindStringSubmatch(urlMatch)
|
||||
if len(urlSubmatches) < 2 {
|
||||
return urlMatch
|
||||
}
|
||||
url := strings.TrimSpace(urlSubmatches[1])
|
||||
if isAbsoluteURL(url) || strings.HasPrefix(url, "data:") {
|
||||
return urlMatch
|
||||
}
|
||||
return fmt.Sprintf(`url("%s")`, resolveHtmlPathToUrl(baseDir, url))
|
||||
})
|
||||
return fmt.Sprintf(`style="%s"`, newStyle)
|
||||
})
|
||||
}
|
||||
|
||||
// replaceHtmlStyleTagContent 处理 <style> 标签内的内容
|
||||
func replaceHtmlStyleTagContent(html string, baseDir string) string {
|
||||
return htmlStyleTagRegex.ReplaceAllStringFunc(html, func(match string) string {
|
||||
submatches := htmlStyleTagRegex.FindStringSubmatch(match)
|
||||
if len(submatches) < 3 {
|
||||
return match
|
||||
}
|
||||
|
||||
attrs := submatches[1]
|
||||
content := submatches[2]
|
||||
|
||||
// 处理 @import(使用预编译正则)
|
||||
content = cssImportRegex.ReplaceAllStringFunc(content, func(m string) string {
|
||||
sm := cssImportRegex.FindStringSubmatch(m)
|
||||
if len(sm) < 2 {
|
||||
return m
|
||||
}
|
||||
url := sm[1]
|
||||
if isAbsoluteURL(url) || strings.HasPrefix(url, "data:") {
|
||||
return m
|
||||
}
|
||||
return fmt.Sprintf(`@import url("%s");`, resolveHtmlPathToUrl(baseDir, url))
|
||||
})
|
||||
|
||||
// 处理 url()(使用预编译正则)
|
||||
content = cssUrlRegex.ReplaceAllStringFunc(content, func(m string) string {
|
||||
sm := cssUrlRegex.FindStringSubmatch(m)
|
||||
if len(sm) < 2 {
|
||||
return m
|
||||
}
|
||||
url := strings.TrimSpace(sm[1])
|
||||
if isAbsoluteURL(url) || strings.HasPrefix(url, "data:") {
|
||||
return m
|
||||
}
|
||||
return fmt.Sprintf(`url("%s")`, resolveHtmlPathToUrl(baseDir, url))
|
||||
})
|
||||
|
||||
return fmt.Sprintf(`<style%s>%s</style>`, attrs, content)
|
||||
})
|
||||
}
|
||||
|
||||
// resolveHtmlPathToUrl 解析路径并转换为本地文件服务器 URL
|
||||
// 支持:相对路径 (./xxx, ../xxx)、绝对路径 (/xxx)
|
||||
func resolveHtmlPathToUrl(baseDir string, path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 统一使用 / 作为分隔符
|
||||
baseDir = strings.ReplaceAll(baseDir, "\\", "/")
|
||||
|
||||
// 处理以 / 开头的绝对路径(相对于网站根目录)
|
||||
// 对于 dist/index.html,/assets/... 应该被解析为 dist/assets/...
|
||||
if strings.HasPrefix(path, "/") {
|
||||
path = path[1:] // 移除开头的 /
|
||||
}
|
||||
|
||||
// 移除 ./ 前缀
|
||||
path = strings.TrimPrefix(path, "./")
|
||||
|
||||
// 使用 filepath.Join 进行路径拼接(它会自动处理 ../)
|
||||
pathBackslash := strings.ReplaceAll(path, "/", string(filepath.Separator))
|
||||
absolutePath := filepath.Join(baseDir, pathBackslash)
|
||||
|
||||
// 清理路径并转换回 /
|
||||
absolutePath = filepath.Clean(absolutePath)
|
||||
absolutePath = strings.ReplaceAll(absolutePath, "\\", "/")
|
||||
|
||||
return toLocalServerUrl(absolutePath)
|
||||
}
|
||||
|
||||
// transformJsDynamicImports 转换 JavaScript 文件中的 import 路径
|
||||
func transformJsDynamicImports(jsContent string, baseDir string) string {
|
||||
if baseDir == "" {
|
||||
return jsContent
|
||||
}
|
||||
|
||||
result := jsContent
|
||||
|
||||
// 处理动态 import: import("...")(使用预编译正则)
|
||||
result = es6DynamicImport.ReplaceAllStringFunc(result, func(match string) string {
|
||||
submatches := es6DynamicImport.FindStringSubmatch(match)
|
||||
if len(submatches) < 2 {
|
||||
return match
|
||||
}
|
||||
modulePath := submatches[1]
|
||||
if isAbsoluteURL(modulePath) || strings.HasPrefix(modulePath, "data:") || strings.HasPrefix(modulePath, "/localfs/") {
|
||||
return match
|
||||
}
|
||||
return fmt.Sprintf(`import("%s")`, resolveHtmlPathToUrl(baseDir, modulePath))
|
||||
})
|
||||
|
||||
// 处理静态 import: import ... from "..."(使用预编译正则)
|
||||
result = es6ImportFromRegex.ReplaceAllStringFunc(result, func(match string) string {
|
||||
submatches := es6ImportFromRegex.FindStringSubmatch(match)
|
||||
if len(submatches) < 3 {
|
||||
return match
|
||||
}
|
||||
importClause := submatches[1]
|
||||
modulePath := submatches[2]
|
||||
if isAbsoluteURL(modulePath) || strings.HasPrefix(modulePath, "data:") || strings.HasPrefix(modulePath, "/localfs/") {
|
||||
return match
|
||||
}
|
||||
return fmt.Sprintf(`import %s from "%s"`, importClause, resolveHtmlPathToUrl(baseDir, modulePath))
|
||||
})
|
||||
|
||||
// 处理裸 import: import "..."(使用预编译正则)
|
||||
result = es6BareImport.ReplaceAllStringFunc(result, func(match string) string {
|
||||
submatches := es6BareImport.FindStringSubmatch(match)
|
||||
if len(submatches) < 2 {
|
||||
return match
|
||||
}
|
||||
modulePath := submatches[1]
|
||||
if isAbsoluteURL(modulePath) || strings.HasPrefix(modulePath, "data:") || strings.HasPrefix(modulePath, "/localfs/") {
|
||||
return match
|
||||
}
|
||||
return fmt.Sprintf(`import "%s"`, resolveHtmlPathToUrl(baseDir, modulePath))
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// injectLinkInterceptor 注入链接点击拦截脚本
|
||||
func injectLinkInterceptor(htmlContent string) string {
|
||||
script := `
|
||||
<script>
|
||||
document.addEventListener('click', function(e) {
|
||||
var target = e.target;
|
||||
while (target && target.tagName !== 'A') {
|
||||
target = target.parentElement;
|
||||
}
|
||||
if (target && target.tagName === 'A') {
|
||||
var href = target.getAttribute('href');
|
||||
if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('#') && !href.startsWith('javascript:')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.parent.postMessage({ type: 'openLocalFile', path: href }, '*');
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
</script>
|
||||
`
|
||||
|
||||
// 在 </body> 前插入
|
||||
if strings.Contains(htmlContent, "</body>") {
|
||||
return strings.Replace(htmlContent, "</body>", script+"</body>", 1)
|
||||
}
|
||||
// 没有 body 标签,在末尾插入
|
||||
return htmlContent + script
|
||||
}
|
||||
|
||||
@@ -220,37 +220,6 @@ func (a *AuditLogger) Close() error {
|
||||
return a.logFile.Close()
|
||||
}
|
||||
|
||||
// RotateLog 日志轮转(每天创建新文件)
|
||||
func (a *AuditLogger) RotateLog() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
// 刷新缓冲区
|
||||
if err := a.flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 关闭当前文件
|
||||
if err := a.logFile.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 生成新的日志文件名
|
||||
timestamp := time.Now().Format("2006-01-02")
|
||||
logPath := filepath.Join(filepath.Dir(a.logPath), fmt.Sprintf("audit_%s.log", timestamp))
|
||||
|
||||
// 打开新文件
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.logFile = logFile
|
||||
a.logPath = logPath
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRecentLogs 获取最近的审计日志
|
||||
func GetRecentLogs(logDir string, limit int) ([]AuditLogEntry, error) {
|
||||
// 读取今天的日志文件
|
||||
@@ -309,22 +278,8 @@ func parseLines(text string) []string {
|
||||
var globalAuditLogger *AuditLogger
|
||||
var auditLoggerOnce sync.Once
|
||||
|
||||
// InitAuditLogger 初始化全局审计日志记录器
|
||||
func InitAuditLogger(logDir string) error {
|
||||
var err error
|
||||
globalAuditLogger, err = NewAuditLogger(logDir)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAuditLogger 获取全局审计日志记录器
|
||||
func GetAuditLogger() *AuditLogger {
|
||||
return globalAuditLogger
|
||||
}
|
||||
|
||||
// CloseAuditLogger 关闭全局审计日志记录器
|
||||
func CloseAuditLogger() error {
|
||||
if globalAuditLogger != nil {
|
||||
return globalAuditLogger.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -291,6 +291,9 @@ func getAllowedExtensions() map[string]bool {
|
||||
".html": true,
|
||||
".css": true,
|
||||
".js": true,
|
||||
// 表格
|
||||
".csv": true,
|
||||
".tsv": true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,5 +370,8 @@ func getMIMETypeMapping() map[string]string {
|
||||
".json": "application/json",
|
||||
".xml": "application/xml",
|
||||
".md": "text/markdown",
|
||||
// 表格
|
||||
".csv": "text/csv; charset=utf-8",
|
||||
".tsv": "text/tab-separated-values; charset=utf-8",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,22 +13,13 @@ const (
|
||||
|
||||
// HTTP 文件服务大小限制
|
||||
MaxHTTPFileSize = 500 * 1024 * 1024 // 500MB - HTTP 访问文件最大大小
|
||||
|
||||
// 删除操作限制
|
||||
MaxDeleteSizeGB = 1 * 1024 * 1024 * 1024 // 1GB - 单个文件删除大小限制
|
||||
MaxDeleteDirSizeGB = 1 * 1024 * 1024 * 1024 // 1GB - 目录删除大小限制
|
||||
)
|
||||
|
||||
// 时间相关常量
|
||||
const (
|
||||
// 审计日志
|
||||
AuditFlushInterval = 5 * time.Second // 审计日志刷新间隔
|
||||
AuditLogBufferSize = 100 // 审计日志缓冲区大小
|
||||
|
||||
// 回收站
|
||||
RecycleBinRetentionDays = 30 // 回收站文件保留天数(天)
|
||||
RecycleBinRetentionPeriod = 30 * 24 * time.Hour // 回收站文件保留期
|
||||
|
||||
// 临时文件
|
||||
TempFileCleanupAge = 24 * time.Hour // 临时文件清理周期
|
||||
TempFileDir = "u-desk-zip" // 临时文件目录名
|
||||
@@ -36,7 +27,6 @@ const (
|
||||
|
||||
// 数量限制常量
|
||||
const (
|
||||
MaxDirectoryDepth = 15 // 最大目录深度
|
||||
MaxFileCount = 1000 // 最大文件数量(目录)
|
||||
)
|
||||
|
||||
@@ -48,15 +38,9 @@ const (
|
||||
|
||||
// 随机字符串相关常量
|
||||
const (
|
||||
RandomStringCharset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
RandomStringDefaultLength = 6 // 回收站文件名随机后缀长度
|
||||
)
|
||||
|
||||
// 文件路径相关常量
|
||||
const (
|
||||
WindowsDriveLength = 2 // Windows 盘符长度 (C:)
|
||||
)
|
||||
|
||||
// 路径遍历检测字符串
|
||||
const (
|
||||
PathTraversalPattern = ".." // 路径遍历特征字符串
|
||||
@@ -69,17 +53,5 @@ const (
|
||||
FileTypeAudio = "audio"
|
||||
FileTypeDocument = "document"
|
||||
FileTypeText = "text"
|
||||
FileTypeArchive = "archive"
|
||||
FileTypeApplication = "application"
|
||||
)
|
||||
|
||||
// 安全相关常量
|
||||
const (
|
||||
// ZIP 安全
|
||||
MinValidZipSize = 22 // ZIP 文件最小有效大小(文件头)
|
||||
ZipFileHeaderSignature = 0x504B // "PK" - ZIP 文件头签名
|
||||
|
||||
// 文件锁
|
||||
LockCheckMaxRetries = 3 // 文件锁检查最大重试次数
|
||||
LockCheckRetryInterval = 100 * time.Millisecond // 文件锁检查重试间隔
|
||||
)
|
||||
|
||||
@@ -6,130 +6,6 @@ import (
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// ErrorCode 错误码类型
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
// 通用错误
|
||||
ErrCodeGeneral ErrorCode = "GENERAL_ERROR"
|
||||
ErrCodeInvalid ErrorCode = "INVALID_ARGUMENT"
|
||||
ErrCodeNotFound ErrorCode = "NOT_FOUND"
|
||||
ErrCodePermission ErrorCode = "PERMISSION_DENIED"
|
||||
ErrCodeIO ErrorCode = "IO_ERROR"
|
||||
|
||||
// 路径相关错误
|
||||
ErrCodePathTraversal ErrorCode = "PATH_TRAVERSAL"
|
||||
ErrCodeInvalidPath ErrorCode = "INVALID_PATH"
|
||||
ErrCodeSensitivePath ErrorCode = "SENSITIVE_PATH"
|
||||
|
||||
// 文件操作错误
|
||||
ErrCodeFileNotFound ErrorCode = "FILE_NOT_FOUND"
|
||||
ErrCodeFileExists ErrorCode = "FILE_EXISTS"
|
||||
ErrCodeDirectoryNotEmpty ErrorCode = "DIRECTORY_NOT_EMPTY"
|
||||
|
||||
// 安全相关错误
|
||||
ErrCodeSecurityViolation ErrorCode = "SECURITY_VIOLATION"
|
||||
ErrCodeSizeLimit ErrorCode = "SIZE_LIMIT_EXCEEDED"
|
||||
ErrCodeFileLocked ErrorCode = "FILE_LOCKED"
|
||||
|
||||
// ZIP相关错误
|
||||
ErrCodeZipInvalid ErrorCode = "ZIP_INVALID"
|
||||
ErrCodeZipBomb ErrorCode = "ZIP_BOMB"
|
||||
ErrCodeZipExtract ErrorCode = "ZIP_EXTRACT_FAILED"
|
||||
)
|
||||
|
||||
// FileError 文件系统专用错误类型
|
||||
// 包含详细的错误上下文信息,便于调试和用户提示
|
||||
type FileNotFoundError struct {
|
||||
Path string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *FileNotFoundError) Error() string {
|
||||
return fmt.Sprintf("文件不存在: %s", e.Path)
|
||||
}
|
||||
|
||||
func (e *FileNotFoundError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// PathValidationError 路径验证错误
|
||||
type PathValidationError struct {
|
||||
Path string
|
||||
Reason string
|
||||
IsSensitive bool
|
||||
}
|
||||
|
||||
func (e *PathValidationError) Error() string {
|
||||
return fmt.Sprintf("路径验证失败: %s - %s", e.Path, e.Reason)
|
||||
}
|
||||
|
||||
// SecurityViolationError 安全违规错误
|
||||
type SecurityViolationError struct {
|
||||
Path string
|
||||
Violation string
|
||||
Suggestion string
|
||||
}
|
||||
|
||||
func (e *SecurityViolationError) Error() string {
|
||||
msg := fmt.Sprintf("安全违规: %s - %s", e.Path, e.Violation)
|
||||
if e.Suggestion != "" {
|
||||
msg += fmt.Sprintf("\n建议: %s", e.Suggestion)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// SizeLimitError 大小限制错误
|
||||
type SizeLimitError struct {
|
||||
Path string
|
||||
ActualSize int64
|
||||
MaxSize int64
|
||||
SizeType string // "file" or "directory"
|
||||
}
|
||||
|
||||
func (e *SizeLimitError) Error() string {
|
||||
return fmt.Sprintf("%s大小超限: %s (实际: %.2f GB, 限制: %.2f GB)",
|
||||
e.SizeType, e.Path,
|
||||
float64(e.ActualSize)/(1024*1024*1024),
|
||||
float64(e.MaxSize)/(1024*1024*1024),
|
||||
)
|
||||
}
|
||||
|
||||
// FileLockedError 文件锁定错误
|
||||
type FileLockedError struct {
|
||||
Path string
|
||||
ProcessInfo string
|
||||
}
|
||||
|
||||
func (e *FileLockedError) Error() string {
|
||||
msg := fmt.Sprintf("文件被占用: %s", e.Path)
|
||||
if e.ProcessInfo != "" {
|
||||
msg += fmt.Sprintf("\n占用程序: %s", e.ProcessInfo)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// WrapError 错误包装函数
|
||||
// 添加上下文信息到错误中
|
||||
func WrapError(operation string, path string, err error) error {
|
||||
return fmt.Errorf("%s 失败: %s - %w", operation, path, err)
|
||||
}
|
||||
|
||||
// WrapErrorf 格式化错误包装
|
||||
func WrapErrorf(format string, args ...interface{}) error {
|
||||
return fmt.Errorf(format, args...)
|
||||
}
|
||||
|
||||
// GetStackTrace 获取堆栈跟踪(用于调试)
|
||||
func GetStackTrace(skip int) string {
|
||||
buf := make([]byte, 4096)
|
||||
n := runtime.Stack(buf, false)
|
||||
if n > 0 {
|
||||
return string(buf[:n])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DeleteRestrictionWarning 删除限制警告
|
||||
// 用于在删除受限文件时提供详细的警告信息
|
||||
type DeleteRestrictionWarning struct {
|
||||
@@ -141,3 +17,13 @@ type DeleteRestrictionWarning struct {
|
||||
func (w *DeleteRestrictionWarning) Error() string {
|
||||
return fmt.Sprintf("删除限制警告: %s\n%s", w.Path, w.Details)
|
||||
}
|
||||
|
||||
// GetStackTrace 获取堆栈跟踪(用于调试)
|
||||
func GetStackTrace(skip int) string {
|
||||
buf := make([]byte, 4096)
|
||||
n := runtime.Stack(buf, false)
|
||||
if n > 0 {
|
||||
return string(buf[:n])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Windows API 锁相关函数和常量
|
||||
var (
|
||||
modkernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
procGetLastError = modkernel32.NewProc("GetLastError")
|
||||
procGetProcessId = modkernel32.NewProc("GetProcessId")
|
||||
)
|
||||
|
||||
// FileLockChecker 文件锁检查器
|
||||
@@ -102,37 +97,6 @@ func (c *FileLockChecker) getProcessInfo(path string) (string, error) {
|
||||
return "文件正被其他程序使用", nil
|
||||
}
|
||||
|
||||
// CheckFileWithRetry 带重试的文件锁检查
|
||||
func (c *FileLockChecker) CheckFileWithRetry(path string, maxRetries int, retryInterval time.Duration) error {
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
locked, processInfo, err := c.IsFileLocked(path)
|
||||
if err != nil && !locked {
|
||||
// 非锁相关的错误,直接返回
|
||||
return err
|
||||
}
|
||||
|
||||
if !locked {
|
||||
// 文件未被锁定,可以操作
|
||||
return nil
|
||||
}
|
||||
|
||||
// 文件被锁定
|
||||
if i < maxRetries-1 {
|
||||
// 还有重试机会,等待后重试
|
||||
time.Sleep(retryInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
// 最后一次重试失败,返回错误
|
||||
if processInfo != "" {
|
||||
return fmt.Errorf("文件被占用: %s", processInfo)
|
||||
}
|
||||
return fmt.Errorf("文件被其他程序占用,请关闭相关程序后重试")
|
||||
}
|
||||
|
||||
return fmt.Errorf("文件检查超时")
|
||||
}
|
||||
|
||||
// SafeDeleteWithLockCheck 带锁检查的安全删除
|
||||
func (c *FileLockChecker) SafeDeleteWithLockCheck(path string) error {
|
||||
// 检查文件是否被锁定
|
||||
@@ -158,20 +122,6 @@ const (
|
||||
ERROR_SHARING_VIOLATION = 32 // syscall.Errno(32)
|
||||
)
|
||||
|
||||
// BY_HANDLE_FILE_INFORMATION 文件信息结构体
|
||||
type BY_HANDLE_FILE_INFORMATION struct {
|
||||
FileAttributes uint32
|
||||
CreationTime syscall.Filetime
|
||||
LastAccessTime syscall.Filetime
|
||||
LastWriteTime syscall.Filetime
|
||||
VolumeSerialNumber uint32
|
||||
FileSizeHigh uint32
|
||||
FileSizeLow uint32
|
||||
NumberOfLinks uint32
|
||||
FileIndexHigh uint32
|
||||
FileIndexLow uint32
|
||||
}
|
||||
|
||||
// contains 检查字符串是否包含子串(不区分大小写)
|
||||
func contains(str, substr string) bool {
|
||||
return len(str) >= len(substr) && (str == substr || len(substr) == 0 ||
|
||||
@@ -203,18 +153,3 @@ func containsIgnoreCase(str, substr string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// 全局文件锁检查器
|
||||
var globalLockChecker *FileLockChecker
|
||||
|
||||
// InitFileLockChecker 初始化全局文件锁检查器
|
||||
func InitFileLockChecker() {
|
||||
globalLockChecker = NewFileLockChecker()
|
||||
}
|
||||
|
||||
// GetFileLockChecker 获取全局文件锁检查器
|
||||
func GetFileLockChecker() *FileLockChecker {
|
||||
if globalLockChecker == nil {
|
||||
globalLockChecker = NewFileLockChecker()
|
||||
}
|
||||
return globalLockChecker
|
||||
}
|
||||
|
||||
19
internal/filesystem/file_lock_linux.go
Normal file
@@ -0,0 +1,19 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package filesystem
|
||||
|
||||
// FileLockChecker 文件锁检查器(Linux 空实现)
|
||||
type FileLockChecker struct{}
|
||||
|
||||
func NewFileLockChecker() *FileLockChecker {
|
||||
return &FileLockChecker{}
|
||||
}
|
||||
|
||||
func (c *FileLockChecker) IsFileLocked(_path string) (bool, string, error) {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
func (c *FileLockChecker) SafeDeleteWithLockCheck(_path string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -8,90 +8,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ========== 向后兼容的全局函数包装器 ==========
|
||||
// 这些函数提供向后兼容性,内部委托给 FileSystemService
|
||||
// 新代码应该使用 FileSystemService 而不是这些全局函数
|
||||
|
||||
// ReadFile 读取文件内容(向后兼容包装器)
|
||||
func ReadFile(path string) (string, error) {
|
||||
service, err := GetGlobalService()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("服务未初始化: %v", err)
|
||||
}
|
||||
return service.ReadFile(path)
|
||||
}
|
||||
|
||||
// WriteFile 写入文件(向后兼容包装器)
|
||||
func WriteFile(path, content string) error {
|
||||
service, err := GetGlobalService()
|
||||
if err != nil {
|
||||
return fmt.Errorf("服务未初始化: %v", err)
|
||||
}
|
||||
return service.WriteFile(path, content)
|
||||
}
|
||||
|
||||
// ListDir 列出目录内容(向后兼容包装器)
|
||||
func ListDir(path string) ([]map[string]interface{}, error) {
|
||||
service, err := GetGlobalService()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("服务未初始化: %v", err)
|
||||
}
|
||||
return service.ListDir(path)
|
||||
}
|
||||
|
||||
// CreateDir 创建目录(向后兼容包装器)
|
||||
func CreateDir(path string) error {
|
||||
service, err := GetGlobalService()
|
||||
if err != nil {
|
||||
return fmt.Errorf("服务未初始化: %v", err)
|
||||
}
|
||||
return service.CreateDir(path)
|
||||
}
|
||||
|
||||
// CreateFile 创建空文件(向后兼容包装器)
|
||||
func CreateFile(path string) error {
|
||||
service, err := GetGlobalService()
|
||||
if err != nil {
|
||||
return fmt.Errorf("服务未初始化: %v", err)
|
||||
}
|
||||
return service.CreateFile(path)
|
||||
}
|
||||
|
||||
// DeletePath 删除文件或目录(向后兼容包装器)
|
||||
func DeletePath(path string) error {
|
||||
service, err := GetGlobalService()
|
||||
if err != nil {
|
||||
return fmt.Errorf("服务未初始化: %v", err)
|
||||
}
|
||||
return service.DeletePath(path)
|
||||
}
|
||||
|
||||
// DeletePathWithConfig 使用指定配置删除文件或目录(向后兼容包装器)
|
||||
func DeletePathWithConfig(path string, config *Config) error {
|
||||
service, err := GetGlobalService()
|
||||
if err != nil {
|
||||
return fmt.Errorf("服务未初始化: %v", err)
|
||||
}
|
||||
|
||||
// 临时替换服务的配置
|
||||
originalConfig := service.config
|
||||
service.config = config
|
||||
defer func() { service.config = originalConfig }()
|
||||
|
||||
return service.DeletePath(path)
|
||||
}
|
||||
|
||||
// GetFileInfo 获取文件信息(向后兼容包装器)
|
||||
func GetFileInfo(path string) (map[string]interface{}, error) {
|
||||
service, err := GetGlobalService()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("服务未初始化: %v", err)
|
||||
}
|
||||
return service.GetFileInfo(path)
|
||||
}
|
||||
// ========== 辅助函数 ==========
|
||||
|
||||
// OpenPath 打开文件或目录(使用系统默认程序)
|
||||
// 这是一个核心工具函数,保留为独立函数
|
||||
func OpenPath(path string) error {
|
||||
// 使用 path.validator 进行验证
|
||||
validator := NewPathValidator(DefaultConfig())
|
||||
@@ -132,16 +51,7 @@ func OpenPath(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenamePath 重命名文件或目录(向后兼容包装器)
|
||||
func RenamePath(oldPath, newPath string) error {
|
||||
service, err := GetGlobalService()
|
||||
if err != nil {
|
||||
return fmt.Errorf("服务未初始化: %v", err)
|
||||
}
|
||||
return service.RenamePath(oldPath, newPath)
|
||||
}
|
||||
|
||||
// ========== 辅助函数 ==========
|
||||
// ========== 工具函数 ==========
|
||||
|
||||
// formatBytes 格式化字节大小为人类可读格式
|
||||
func formatBytes(bytes int64) string {
|
||||
|
||||
@@ -70,11 +70,6 @@ func (l *Logger) Info(format string, args ...interface{}) {
|
||||
l.log(LogLevelInfo, "INFO", format, args...)
|
||||
}
|
||||
|
||||
// Warn 记录警告日志
|
||||
func (l *Logger) Warn(format string, args ...interface{}) {
|
||||
l.log(LogLevelWarn, "WARN", format, args...)
|
||||
}
|
||||
|
||||
// Error 记录错误日志
|
||||
func (l *Logger) Error(format string, args ...interface{}) {
|
||||
l.log(LogLevelError, "ERROR", format, args...)
|
||||
@@ -141,37 +136,10 @@ func LogError(operation string, path string, err error) {
|
||||
|
||||
var (
|
||||
globalLogger *Logger
|
||||
loggerOnce sync.Once
|
||||
)
|
||||
|
||||
// InitLogger 初始化全局日志记录器
|
||||
func InitLogger(logDir string, minLevel LogLevel) error {
|
||||
var initErr error
|
||||
loggerOnce.Do(func() {
|
||||
timestamp := time.Now().Format("2006-01-02")
|
||||
logPath := filepath.Join(logDir, fmt.Sprintf("filesystem_%s.log", timestamp))
|
||||
|
||||
logger, err := NewLogger(logPath, minLevel)
|
||||
if err != nil {
|
||||
initErr = err
|
||||
return
|
||||
}
|
||||
|
||||
globalLogger = logger
|
||||
log.Printf("[日志系统] 已启动,日志文件: %s", logPath)
|
||||
})
|
||||
return initErr
|
||||
}
|
||||
|
||||
// GetGlobalLogger 获取全局日志记录器
|
||||
func GetGlobalLogger() *Logger {
|
||||
return globalLogger
|
||||
}
|
||||
|
||||
// CloseLogger 关闭全局日志记录器
|
||||
func CloseLogger() error {
|
||||
if globalLogger != nil {
|
||||
return globalLogger.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// PathValidator 路径验证器接口
|
||||
@@ -180,16 +181,25 @@ func (v *DefaultPathValidator) isSensitivePath(path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// 默认路径验证器(缓存,避免每次调用重复初始化)
|
||||
var (
|
||||
defaultValidatorOnce sync.Once
|
||||
defaultValidator PathValidator
|
||||
)
|
||||
|
||||
func getDefaultValidator() PathValidator {
|
||||
defaultValidatorOnce.Do(func() {
|
||||
defaultValidator = NewPathValidator(DefaultConfig())
|
||||
})
|
||||
return defaultValidator
|
||||
}
|
||||
|
||||
// isSafePath 兼容函数:保持向后兼容
|
||||
// 使用默认配置的路径验证器
|
||||
func isSafePath(path string) bool {
|
||||
validator := NewPathValidator(DefaultConfig())
|
||||
return validator.IsSafe(path)
|
||||
return getDefaultValidator().IsSafe(path)
|
||||
}
|
||||
|
||||
// isSensitivePath 兼容函数:保持向后兼容
|
||||
// 使用默认配置检查敏感路径
|
||||
func isSensitivePath(path string) bool {
|
||||
validator := NewPathValidator(DefaultConfig())
|
||||
return validator.IsSensitive(path)
|
||||
return getDefaultValidator().IsSensitive(path)
|
||||
}
|
||||
|
||||
@@ -376,16 +376,6 @@ func generateRandomString(length int) string {
|
||||
// 全局回收站实例
|
||||
var globalRecycleBin *RecycleBin
|
||||
|
||||
// InitRecycleBin 初始化全局回收站
|
||||
func InitRecycleBin(binPath string) error {
|
||||
bin, err := NewRecycleBin(binPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
globalRecycleBin = bin
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRecycleBin 获取全局回收站实例
|
||||
func GetRecycleBin() *RecycleBin {
|
||||
return globalRecycleBin
|
||||
|
||||
@@ -2,17 +2,35 @@ package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/common"
|
||||
)
|
||||
|
||||
const maxReadWriteSize = 10 * 1024 * 1024 // 10MB 读写上限
|
||||
|
||||
// FileOperationResult 文件操作结果
|
||||
type FileOperationResult struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
SizeStr string `json:"size_str,omitempty"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
ModTime string `json:"mod_time,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
OldPath string `json:"old_path,omitempty"` // 仅重命名操作时有值
|
||||
Deleted bool `json:"deleted,omitempty"` // 仅删除操作时有值
|
||||
}
|
||||
|
||||
// FileSystemService 文件系统服务
|
||||
// 统一管理所有文件系统相关的功能,使用依赖注入而非全局变量
|
||||
type FileSystemService struct {
|
||||
@@ -101,18 +119,22 @@ func (s *FileSystemService) initRecycleBin() error {
|
||||
|
||||
// ========== 核心文件操作 ==========
|
||||
|
||||
// Read 读取文件内容(实现 FileService 接口)
|
||||
func (s *FileSystemService) Read(path string) (string, error) {
|
||||
return s.ReadFile(path)
|
||||
}
|
||||
|
||||
// ReadFile 读取文件内容
|
||||
// ReadFile 读取文件内容(限制最大 10MB)
|
||||
func (s *FileSystemService) ReadFile(path string) (string, error) {
|
||||
// 路径验证
|
||||
if err := s.validatePath(path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 检查文件大小,避免读取超大文件导致内存问题
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取文件信息失败: %v", err)
|
||||
}
|
||||
if info.Size() > maxReadWriteSize {
|
||||
return "", fmt.Errorf("文件过大 (%.1f MB),超过读取上限 (%d MB)", float64(info.Size())/1024/1024, maxReadWriteSize/1024/1024)
|
||||
}
|
||||
|
||||
// 读取文件
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -123,102 +145,86 @@ func (s *FileSystemService) ReadFile(path string) (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// Write 写入文件内容(实现 FileService 接口)
|
||||
func (s *FileSystemService) Write(path, content string) error {
|
||||
return s.WriteFile(path, content)
|
||||
}
|
||||
|
||||
// WriteFile 写入文件
|
||||
func (s *FileSystemService) WriteFile(path, content string) error {
|
||||
// 路径验证
|
||||
// Write 写入文件内容(实现 FileService 接口)
|
||||
// writeFile 内部写入实现(路径验证+大小检查+写入+日志)
|
||||
func (s *FileSystemService) writeFileWithLog(path string, data []byte) error {
|
||||
if err := s.validatePath(path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建目录
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, DefaultDirPermissions); err != nil {
|
||||
return fmt.Errorf("创建目录失败: %v", err)
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
data := []byte(content)
|
||||
if len(data) > maxReadWriteSize {
|
||||
return fmt.Errorf("文件过大 (%.1f MB),超过写入上限 (%d MB)", float64(len(data))/1024/1024, maxReadWriteSize/1024/1024)
|
||||
}
|
||||
if err := os.WriteFile(path, data, DefaultFilePermissions); err != nil {
|
||||
s.logWrite(path, int64(len(data)), err)
|
||||
return fmt.Errorf("写入文件失败: %v", err)
|
||||
}
|
||||
|
||||
s.logWrite(path, int64(len(data)), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 列出目录内容(实现 FileService 接口)
|
||||
func (s *FileSystemService) List(path string) ([]map[string]interface{}, error) {
|
||||
return s.ListDir(path)
|
||||
// WriteFile 写入文件
|
||||
func (s *FileSystemService) WriteFile(path, content string) error {
|
||||
return s.writeFileWithLog(path, []byte(content))
|
||||
}
|
||||
|
||||
// Open 打开文件(实现 FileService 接口)
|
||||
func (s *FileSystemService) Open(path string) error {
|
||||
// 使用系统默认程序打开文件
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = exec.Command("cmd", "/c", "start", "", path)
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", path)
|
||||
default:
|
||||
cmd = exec.Command("xdg-open", path)
|
||||
// SaveBase64File 将 base64 编码内容解码后写入二进制文件
|
||||
func (s *FileSystemService) SaveBase64File(path, base64Content string) error {
|
||||
if strings.TrimSpace(base64Content) == "" {
|
||||
return errors.New("base64 内容不能为空")
|
||||
}
|
||||
return cmd.Start()
|
||||
data, err := base64.StdEncoding.DecodeString(base64Content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("base64 解码失败: %v", err)
|
||||
}
|
||||
|
||||
// Delete 删除文件或目录(实现 FileService 接口)
|
||||
func (s *FileSystemService) Delete(path string) error {
|
||||
return s.DeletePathWithContext(context.Background(), path)
|
||||
return s.writeFileWithLog(path, data)
|
||||
}
|
||||
|
||||
// DeletePath 删除文件或目录
|
||||
func (s *FileSystemService) DeletePath(path string) error {
|
||||
func (s *FileSystemService) DeletePath(path string) (*FileOperationResult, error) {
|
||||
return s.DeletePathWithContext(context.Background(), path)
|
||||
}
|
||||
|
||||
// DeletePathWithContext 带上下文的删除操作
|
||||
func (s *FileSystemService) DeletePathWithContext(ctx context.Context, path string) error {
|
||||
// DeletePathWithContext 带上下文的删除操作,返回被删除文件的信息
|
||||
func (s *FileSystemService) DeletePathWithContext(ctx context.Context, path string) (*FileOperationResult, error) {
|
||||
// 路径验证
|
||||
if err := s.validatePath(path); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
// 获取文件信息(在删除前保存)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("文件或目录不存在")
|
||||
return nil, fmt.Errorf("文件或目录不存在")
|
||||
}
|
||||
return fmt.Errorf("获取文件信息失败: %v", err)
|
||||
return nil, fmt.Errorf("获取文件信息失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查删除限制
|
||||
exceeds, details, checkErr := CheckDeleteRestrictions(path, info, s.config)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
return nil, checkErr
|
||||
}
|
||||
|
||||
if exceeds {
|
||||
if s.config.Security.DeleteRestrictions.RequireConfirm {
|
||||
return &DeleteRestrictionWarning{
|
||||
return nil, &DeleteRestrictionWarning{
|
||||
Path: path,
|
||||
Details: details,
|
||||
Info: info,
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("删除限制: %s", details)
|
||||
return nil, fmt.Errorf("删除限制: %s", details)
|
||||
}
|
||||
|
||||
// 文件锁检查(可选)
|
||||
if s.lockChecker != nil {
|
||||
if err := s.lockChecker.SafeDeleteWithLockCheck(path); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +239,7 @@ func (s *FileSystemService) DeletePathWithContext(ctx context.Context, path stri
|
||||
s.logDelete(path, info.IsDir(), info.Size(), deleteErr)
|
||||
|
||||
if deleteErr != nil {
|
||||
return fmt.Errorf("删除失败: %v", deleteErr)
|
||||
return nil, fmt.Errorf("删除失败: %v", deleteErr)
|
||||
}
|
||||
|
||||
// 如果启用回收站,移动到回收站而非永久删除
|
||||
@@ -247,7 +253,17 @@ func (s *FileSystemService) DeletePathWithContext(ctx context.Context, path stri
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
// 返回被删除的文件信息,用于前端更新
|
||||
return &FileOperationResult{
|
||||
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
SizeStr: formatBytes(info.Size()),
|
||||
IsDir: info.IsDir(),
|
||||
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||
Mode: info.Mode().String(),
|
||||
Deleted: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListDir 列出目录内容
|
||||
@@ -274,7 +290,7 @@ func (s *FileSystemService) ListDir(path string) ([]map[string]interface{}, erro
|
||||
fullPath := filepath.Join(path, entry.Name())
|
||||
result = append(result, map[string]interface{}{
|
||||
"name": entry.Name(),
|
||||
"path": fullPath,
|
||||
"path": filepath.ToSlash(fullPath), // 统一使用正斜杠
|
||||
"is_dir": entry.IsDir(),
|
||||
"size": info.Size(),
|
||||
"mod_time": info.ModTime().Format("2006-01-02 15:04:05"),
|
||||
@@ -292,14 +308,14 @@ func (s *FileSystemService) ListDir(path string) ([]map[string]interface{}, erro
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CreateDir 创建目录
|
||||
func (s *FileSystemService) CreateDir(path string) error {
|
||||
// CreateDir 创建目录,返回创建的目录信息
|
||||
func (s *FileSystemService) CreateDir(path string) (*FileOperationResult, error) {
|
||||
if err := s.validatePath(path); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(path, DefaultDirPermissions); err != nil {
|
||||
return fmt.Errorf("创建目录失败: %v", err)
|
||||
return nil, fmt.Errorf("创建目录失败: %v", err)
|
||||
}
|
||||
|
||||
s.logAudit(AuditLogEntry{
|
||||
@@ -310,23 +326,42 @@ func (s *FileSystemService) CreateDir(path string) error {
|
||||
Success: true,
|
||||
})
|
||||
|
||||
return nil
|
||||
// 获取创建的目录信息
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
// 创建成功但获取信息失败,返回基本信息
|
||||
return &FileOperationResult{
|
||||
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
||||
Name: filepath.Base(path),
|
||||
IsDir: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateFile 创建空文件
|
||||
func (s *FileSystemService) CreateFile(path string) error {
|
||||
return &FileOperationResult{
|
||||
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
SizeStr: formatBytes(info.Size()),
|
||||
IsDir: true,
|
||||
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||
Mode: info.Mode().String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateFile 创建空文件,返回创建的文件信息
|
||||
func (s *FileSystemService) CreateFile(path string) (*FileOperationResult, error) {
|
||||
if err := s.validatePath(path); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 检查文件是否已存在
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return fmt.Errorf("文件已存在")
|
||||
return nil, fmt.Errorf("文件已存在")
|
||||
}
|
||||
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建文件失败: %v", err)
|
||||
return nil, fmt.Errorf("创建文件失败: %v", err)
|
||||
}
|
||||
file.Close()
|
||||
|
||||
@@ -338,12 +373,27 @@ func (s *FileSystemService) CreateFile(path string) error {
|
||||
Success: true,
|
||||
})
|
||||
|
||||
return nil
|
||||
// 获取创建的文件信息
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
// 创建成功但获取信息失败,返回基本信息
|
||||
return &FileOperationResult{
|
||||
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
||||
Name: filepath.Base(path),
|
||||
IsDir: false,
|
||||
Size: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetInfo 获取文件信息(实现 FileService 接口)
|
||||
func (s *FileSystemService) GetInfo(path string) (map[string]interface{}, error) {
|
||||
return s.GetFileInfo(path)
|
||||
return &FileOperationResult{
|
||||
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
SizeStr: formatBytes(info.Size()),
|
||||
IsDir: false,
|
||||
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||
Mode: info.Mode().String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetFileInfo 获取文件信息
|
||||
@@ -362,7 +412,7 @@ func (s *FileSystemService) GetFileInfo(path string) (map[string]interface{}, er
|
||||
|
||||
return map[string]interface{}{
|
||||
"name": info.Name(),
|
||||
"path": path,
|
||||
"path": filepath.ToSlash(path), // 统一使用正斜杠
|
||||
"size": info.Size(),
|
||||
"size_str": formatBytes(info.Size()),
|
||||
"is_dir": info.IsDir(),
|
||||
@@ -380,21 +430,21 @@ func (s *FileSystemService) OpenPath(path string) error {
|
||||
return OpenPath(path)
|
||||
}
|
||||
|
||||
// RenamePath 重命名文件或目录
|
||||
func (s *FileSystemService) RenamePath(oldPath, newPath string) error {
|
||||
// RenamePath 重命名文件或目录,返回新文件信息
|
||||
func (s *FileSystemService) RenamePath(oldPath, newPath string) (*FileOperationResult, error) {
|
||||
// 验证旧路径
|
||||
if err := s.validatePath(oldPath); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 验证新路径
|
||||
if err := s.validatePath(newPath); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 执行重命名
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
return fmt.Errorf("重命名失败: %v", err)
|
||||
return nil, fmt.Errorf("重命名失败: %v", err)
|
||||
}
|
||||
|
||||
s.logAudit(AuditLogEntry{
|
||||
@@ -405,36 +455,41 @@ func (s *FileSystemService) RenamePath(oldPath, newPath string) error {
|
||||
Success: true,
|
||||
})
|
||||
|
||||
return nil
|
||||
// 获取新文件信息
|
||||
info, err := os.Stat(newPath)
|
||||
if err != nil {
|
||||
// 重命名成功但获取信息失败,返回基本信息
|
||||
return &FileOperationResult{
|
||||
Path: filepath.ToSlash(newPath), // 统一使用正斜杠
|
||||
Name: filepath.Base(newPath),
|
||||
OldPath: filepath.ToSlash(oldPath),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &FileOperationResult{
|
||||
Path: filepath.ToSlash(newPath), // 统一使用正斜杠
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
SizeStr: formatBytes(info.Size()),
|
||||
IsDir: info.IsDir(),
|
||||
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||
Mode: info.Mode().String(),
|
||||
OldPath: filepath.ToSlash(oldPath),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ========== ZIP操作接口 ==========
|
||||
|
||||
// ListZip 列出ZIP文件内容
|
||||
func (s *FileSystemService) ListZip(zipPath string) ([]map[string]interface{}, error) {
|
||||
return ListZipContents(zipPath)
|
||||
}
|
||||
|
||||
// ListZipContents 列出ZIP文件内容(别名,保持向后兼容)
|
||||
func (s *FileSystemService) ListZipContents(zipPath string) ([]map[string]interface{}, error) {
|
||||
return ListZipContents(zipPath)
|
||||
}
|
||||
|
||||
// ExtractZipFile 从ZIP提取文件内容
|
||||
func (s *FileSystemService) ExtractZipFile(zipPath, filePath string) (string, error) {
|
||||
return ExtractFileFromZip(zipPath, filePath)
|
||||
}
|
||||
|
||||
// ExtractFileFromZip 从ZIP提取文件内容(别名,保持向后兼容)
|
||||
func (s *FileSystemService) ExtractFileFromZip(zipPath, filePath string) (string, error) {
|
||||
return ExtractFileFromZip(zipPath, filePath)
|
||||
}
|
||||
|
||||
// ExtractZipFileToTemp 从ZIP提取文件到临时目录
|
||||
func (s *FileSystemService) ExtractZipFileToTemp(zipPath, filePath string) (string, error) {
|
||||
return ExtractFileFromZipToTemp(zipPath, filePath)
|
||||
}
|
||||
|
||||
// ExtractFileFromZipToTemp 从ZIP提取文件到临时目录(别名,保持向后兼容)
|
||||
func (s *FileSystemService) ExtractFileFromZipToTemp(zipPath, filePath string) (string, error) {
|
||||
return ExtractFileFromZipToTemp(zipPath, filePath)
|
||||
@@ -455,7 +510,9 @@ func getCurrentTimestamp() time.Time {
|
||||
// isInRecycleBin 检查路径是否在回收站中
|
||||
func isInRecycleBin(path string) bool {
|
||||
recycleBinPath := filepath.Join(common.GetUserDataDir(), "recycle_bin")
|
||||
return filepath.HasPrefix(filepath.Clean(path), filepath.Clean(recycleBinPath))
|
||||
cleanPath := filepath.Clean(path)
|
||||
cleanBinPath := filepath.Clean(recycleBinPath)
|
||||
return len(cleanPath) >= len(cleanBinPath) && cleanPath[:len(cleanBinPath)] == cleanBinPath
|
||||
}
|
||||
|
||||
// ========== 辅助方法 ==========
|
||||
@@ -678,16 +735,3 @@ func GetGlobalService() (*FileSystemService, error) {
|
||||
return globalService, initErr
|
||||
}
|
||||
|
||||
// InitGlobalFileSystem 初始化全局文件系统(兼容旧代码)
|
||||
func InitGlobalFileSystem() error {
|
||||
_, err := GetGlobalService()
|
||||
return err
|
||||
}
|
||||
|
||||
// CloseGlobalFileSystem 关闭全局文件系统
|
||||
func CloseGlobalFileSystem(ctx context.Context) error {
|
||||
if globalService != nil {
|
||||
return globalService.Close(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// FileService 文件操作核心接口
|
||||
// 定义所有文件操作的基本功能,便于mock测试
|
||||
type FileService interface {
|
||||
// 基本操作
|
||||
Read(path string) (string, error)
|
||||
Write(path, content string) error
|
||||
Delete(path string) error
|
||||
List(path string) ([]map[string]interface{}, error)
|
||||
CreateDir(path string) error
|
||||
CreateFile(path string) error
|
||||
GetInfo(path string) (map[string]interface{}, error)
|
||||
Open(path string) error
|
||||
|
||||
// 快捷方式
|
||||
ResolveShortcut(lnkPath string) (targetPath string, err error)
|
||||
|
||||
// 配置
|
||||
GetConfig() *Config
|
||||
Close(ctx context.Context) error
|
||||
}
|
||||
|
||||
// 确保实现接口
|
||||
var _ FileService = (*FileSystemService)(nil)
|
||||
|
||||
@@ -181,7 +181,7 @@ func ListZipContents(zipPath string) ([]map[string]interface{}, error) {
|
||||
|
||||
entry := map[string]interface{}{
|
||||
"name": name,
|
||||
"path": file.Name, // zip 中的完整路径
|
||||
"path": file.Name, // zip 中的完整路径(已使用 /)
|
||||
"is_dir": isDir,
|
||||
"size": file.UncompressedSize64,
|
||||
"compressed": file.CompressedSize64,
|
||||
@@ -346,46 +346,4 @@ func GetZipFileInfo(zipPath, filePath string) (map[string]interface{}, error) {
|
||||
return result.(map[string]interface{}), nil
|
||||
}
|
||||
|
||||
// validateZipFileBasic 验证ZIP文件的基本信息(提取自ListZipContents)
|
||||
func validateZipFileBasic(zipPath string) error {
|
||||
if err := validateZipPath(zipPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法访问文件: %v", err)
|
||||
}
|
||||
|
||||
if fileInfo.Size() < MinValidZipSize {
|
||||
return fmt.Errorf("文件太小 (%d bytes)", fileInfo.Size())
|
||||
}
|
||||
|
||||
if fileInfo.Size() > MaxZipSize {
|
||||
return fmt.Errorf("ZIP文件过大 (%d bytes)", fileInfo.Size())
|
||||
}
|
||||
|
||||
return checkZipFileHeader(zipPath)
|
||||
}
|
||||
|
||||
// checkZipFileHeader 检查ZIP文件头签名
|
||||
func checkZipFileHeader(zipPath string) error {
|
||||
file, err := os.Open(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法打开文件: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
header := make([]byte, 4)
|
||||
n, err := file.Read(header)
|
||||
if err != nil || n != 4 {
|
||||
return fmt.Errorf("无法读取文件头")
|
||||
}
|
||||
|
||||
if header[0] != 0x50 || header[1] != 0x4B {
|
||||
return fmt.Errorf("不是有效的 ZIP 文件")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -65,25 +65,6 @@ func isMatchFile(file *zip.File, targetPath string) bool {
|
||||
filepath.Clean(file.Name) == filepath.Clean(targetPath)
|
||||
}
|
||||
|
||||
// openZipFileInReader 在ZIP reader中打开指定文件
|
||||
// 用于读取文件内容的辅助函数
|
||||
func openZipFileInReader(reader *zip.ReadCloser, filePath string) (io.ReadCloser, *zip.File, error) {
|
||||
for _, file := range reader.File {
|
||||
if isMatchFile(file, filePath) {
|
||||
if file.Mode().IsDir() {
|
||||
return nil, nil, fmt.Errorf("不能读取目录")
|
||||
}
|
||||
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("打开 zip 中的文件失败: %v", err)
|
||||
}
|
||||
return rc, file, nil
|
||||
}
|
||||
}
|
||||
return nil, nil, fmt.Errorf("文件在 zip 中不存在: %s", filePath)
|
||||
}
|
||||
|
||||
// readAllFromFile 从文件读取所有内容
|
||||
// 辅助函数,避免重复的 io.ReadAll 调用
|
||||
func readAllFromFile(rc io.ReadCloser) ([]byte, error) {
|
||||
@@ -103,7 +84,7 @@ func getCompressionMethodString(method uint16) string {
|
||||
func createFileInfoMap(file *zip.File, includeExtra ...bool) map[string]interface{} {
|
||||
info := map[string]interface{}{
|
||||
"name": filepath.Base(file.Name),
|
||||
"path": file.Name,
|
||||
"path": file.Name, // zip 中的路径(已使用 /)
|
||||
"is_dir": file.Mode().IsDir(),
|
||||
"size": file.UncompressedSize64,
|
||||
"compressed": file.CompressedSize64,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package model
|
||||
|
||||
// MemberInfo 用户信息表
|
||||
type MemberInfo struct {
|
||||
Memberid int `gorm:"primaryKey;column:memberid;type:int;comment:用户ID" json:"memberid"`
|
||||
Membername string `gorm:"column:membername;type:varchar(100);comment:姓名" json:"membername"`
|
||||
Account string `gorm:"column:account;type:varchar(100);comment:账号" json:"account"`
|
||||
Password string `gorm:"column:password;type:varchar(100);comment:密码" json:"-"`
|
||||
Contactphone string `gorm:"column:contactphone;type:varchar(50);comment:联系电话" json:"contactphone"`
|
||||
Organid int `gorm:"column:organid;type:int;comment:所属机构ID" json:"organid"`
|
||||
Createtime string `gorm:"column:createtime;type:varchar(50);comment:创建时间" json:"createtime"`
|
||||
Updatetime string `gorm:"column:updatetime;type:varchar(50);comment:修改时间" json:"updatetime"`
|
||||
Role int16 `gorm:"column:role;type:smallint;comment:角色类别" json:"role"`
|
||||
Status int16 `gorm:"column:status;type:smallint;comment:状态 1正常 2停用 3删除" json:"status"`
|
||||
Calluserid string `gorm:"column:calluserid;type:varchar(100);comment:坐席用户ID" json:"calluserid"`
|
||||
Remainingexport int `gorm:"column:remainingexport;type:int;comment:本月剩余导出次数" json:"remainingexport"`
|
||||
|
||||
// 虚拟字段(关联查询)
|
||||
Organname string `gorm:"-" json:"organname"` // 机构名称
|
||||
Rolename string `gorm:"-" json:"rolename"` // 角色名称
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (MemberInfo) TableName() string {
|
||||
return "member_info"
|
||||
}
|
||||
@@ -42,9 +42,10 @@ type TabConfig struct {
|
||||
var defaultTabConfig = TabConfig{
|
||||
AvailableTabs: []TabDefinition{
|
||||
{Key: "file-system", Title: "文件管理", Enabled: true},
|
||||
{Key: "db-cli", Title: "数据库", Enabled: true},
|
||||
{Key: "markdown-editor", Title: "Markdown", Enabled: true},
|
||||
{Key: "version", Title: "版本历史", Enabled: true},
|
||||
},
|
||||
VisibleTabs: []string{"file-system", "db-cli"},
|
||||
VisibleTabs: []string{"file-system", "markdown-editor", "version"},
|
||||
DefaultTab: "file-system",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"u-desk/internal/crypto"
|
||||
"u-desk/internal/dbclient"
|
||||
"u-desk/internal/storage/models"
|
||||
"u-desk/internal/storage/repository"
|
||||
)
|
||||
|
||||
// ConnectionService 连接管理服务
|
||||
type ConnectionService struct {
|
||||
repo repository.ConnectionRepository
|
||||
}
|
||||
|
||||
// NewConnectionService 创建连接服务
|
||||
func NewConnectionService() (*ConnectionService, error) {
|
||||
repo, err := repository.NewConnectionRepository()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建连接仓库失败: %v", err)
|
||||
}
|
||||
return &ConnectionService{repo: repo}, nil
|
||||
}
|
||||
|
||||
// SaveConnection 保存连接配置
|
||||
func (s *ConnectionService) SaveConnection(conn *models.DbConnection) error {
|
||||
// 验证
|
||||
if conn.Name == "" {
|
||||
return fmt.Errorf("连接名称不能为空")
|
||||
}
|
||||
if conn.Type == "" {
|
||||
return fmt.Errorf("数据库类型不能为空")
|
||||
}
|
||||
if conn.Host == "" {
|
||||
return fmt.Errorf("主机地址不能为空")
|
||||
}
|
||||
|
||||
// 检查名称是否重复
|
||||
existing, err := s.repo.FindByName(conn.Name, conn.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查连接名称失败: %v", err)
|
||||
}
|
||||
if existing != nil {
|
||||
return fmt.Errorf("连接名称已存在")
|
||||
}
|
||||
|
||||
// 处理密码
|
||||
if conn.ID > 0 {
|
||||
if conn.Password == "" {
|
||||
// 更新模式:保留原密码
|
||||
conn.Password, err = s.getPassword(conn.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// 加密新密码
|
||||
conn.Password, err = crypto.EncryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码加密失败: %v", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 新增模式:加密密码
|
||||
conn.Password, err = crypto.EncryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码加密失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return s.repo.Save(conn)
|
||||
}
|
||||
|
||||
// getPassword 获取原始密码
|
||||
func (s *ConnectionService) getPassword(id uint) (string, error) {
|
||||
existing, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取原连接配置失败: %v", err)
|
||||
}
|
||||
return existing.Password, nil
|
||||
}
|
||||
|
||||
// ListConnections 获取连接列表
|
||||
func (s *ConnectionService) ListConnections() ([]models.DbConnection, error) {
|
||||
return s.repo.FindAll()
|
||||
}
|
||||
|
||||
// GetConnection 获取连接详情
|
||||
func (s *ConnectionService) GetConnection(id uint) (*models.DbConnection, error) {
|
||||
return s.repo.FindByID(id)
|
||||
}
|
||||
|
||||
// DeleteConnection 删除连接配置
|
||||
func (s *ConnectionService) DeleteConnection(id uint) error {
|
||||
return s.repo.Delete(id)
|
||||
}
|
||||
|
||||
// TestConnection 测试连接(通过已保存的连接ID)
|
||||
func (s *ConnectionService) TestConnection(id uint) error {
|
||||
conn, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取连接配置失败: %v", err)
|
||||
}
|
||||
|
||||
// 解密密码用于测试
|
||||
password, err := crypto.DecryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码解密失败: %v", err)
|
||||
}
|
||||
|
||||
// 根据类型测试连接
|
||||
switch conn.Type {
|
||||
case "mysql":
|
||||
return dbclient.TestMySQLConnection(conn.Host, conn.Port, conn.Username, password, conn.Database)
|
||||
case "redis":
|
||||
return dbclient.TestRedisConnection(conn.Host, conn.Port, password)
|
||||
case "mongo":
|
||||
// 解析 Options 获取 MongoDB 连接参数
|
||||
authSource := ""
|
||||
authMechanism := ""
|
||||
if conn.Options != "" {
|
||||
var opts map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(conn.Options), &opts); err == nil {
|
||||
if as, ok := opts["authSource"].(string); ok && as != "" {
|
||||
authSource = as
|
||||
}
|
||||
if am, ok := opts["authMechanism"].(string); ok && am != "" {
|
||||
authMechanism = am
|
||||
}
|
||||
}
|
||||
}
|
||||
return dbclient.TestMongoConnectionWithOptions(conn.Host, conn.Port, conn.Username, password, conn.Database, authSource, authMechanism)
|
||||
default:
|
||||
return fmt.Errorf("不支持的数据库类型: %s", conn.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectionWithParams 测试连接(直接传入参数,不保存数据)
|
||||
func (s *ConnectionService) TestConnectionWithParams(connType, host string, port int, username, password, database, options string, existingId uint) error {
|
||||
// 验证必填项
|
||||
if connType == "" {
|
||||
return fmt.Errorf("数据库类型不能为空")
|
||||
}
|
||||
if host == "" {
|
||||
return fmt.Errorf("主机地址不能为空")
|
||||
}
|
||||
|
||||
// 如果是编辑模式且密码为空,尝试获取已保存的密码
|
||||
actualPassword := password
|
||||
if existingId > 0 && password == "" {
|
||||
conn, err := s.repo.FindByID(existingId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取原连接配置失败: %v", err)
|
||||
}
|
||||
// 解密原密码
|
||||
actualPassword, err = crypto.DecryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码解密失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据类型测试连接
|
||||
switch connType {
|
||||
case "mysql":
|
||||
return dbclient.TestMySQLConnection(host, port, username, actualPassword, database)
|
||||
case "redis":
|
||||
return dbclient.TestRedisConnection(host, port, actualPassword)
|
||||
case "mongo":
|
||||
// 解析 Options 获取 MongoDB 连接参数
|
||||
authSource := ""
|
||||
authMechanism := ""
|
||||
if options != "" {
|
||||
var opts map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(options), &opts); err == nil {
|
||||
if as, ok := opts["authSource"].(string); ok && as != "" {
|
||||
authSource = as
|
||||
}
|
||||
if am, ok := opts["authMechanism"].(string); ok && am != "" {
|
||||
authMechanism = am
|
||||
}
|
||||
}
|
||||
}
|
||||
return dbclient.TestMongoConnectionWithOptions(host, port, username, actualPassword, database, authSource, authMechanism)
|
||||
default:
|
||||
return fmt.Errorf("不支持的数据库类型: %s", connType)
|
||||
}
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/common"
|
||||
"u-desk/internal/dbclient"
|
||||
"u-desk/internal/storage/models"
|
||||
"u-desk/internal/storage/repository"
|
||||
)
|
||||
|
||||
// SqlExecService SQL执行服务
|
||||
type SqlExecService struct {
|
||||
connRepo repository.ConnectionRepository
|
||||
pool *dbclient.ConnectionPool
|
||||
}
|
||||
|
||||
// NewSqlExecService 创建SQL执行服务
|
||||
func NewSqlExecService() (*SqlExecService, error) {
|
||||
connRepo, err := repository.NewConnectionRepository()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SqlExecService{
|
||||
connRepo: connRepo,
|
||||
pool: dbclient.GetPool(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SqlResult SQL执行结果
|
||||
type SqlResult struct {
|
||||
Type string `json:"type"` // query/update/command
|
||||
Data interface{} `json:"data"` // 查询结果数据
|
||||
Columns []string `json:"columns"` // 列顺序(仅查询时有效)
|
||||
RowsAffected int `json:"rowsAffected"` // 影响行数
|
||||
ExecutionTime int64 `json:"executionTime"` // 执行时间(毫秒)
|
||||
}
|
||||
|
||||
// ExecuteSQL 执行SQL语句
|
||||
// 注意:SQL 语句应该已经包含分页信息(LIMIT 和 OFFSET),由客户端添加
|
||||
func (s *SqlExecService) ExecuteSQL(connectionID uint, sqlStr string, database string) (*SqlResult, error) {
|
||||
conn, err := s.connRepo.FindByID(connectionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取连接配置失败: %v", err)
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutQuery)
|
||||
defer cancel()
|
||||
|
||||
switch conn.Type {
|
||||
case "mysql":
|
||||
return s.executeMySQL(ctx, conn, sqlStr, database, startTime)
|
||||
case "redis":
|
||||
return s.executeRedis(ctx, conn, sqlStr, startTime)
|
||||
case "mongo":
|
||||
return s.executeMongo(ctx, conn, sqlStr, database, startTime)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// executeMySQL 执行MySQL SQL
|
||||
func (s *SqlExecService) executeMySQL(ctx context.Context, conn *models.DbConnection, sqlStr string, database string, startTime time.Time) (*SqlResult, error) {
|
||||
client, err := s.pool.GetMySQLClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
|
||||
}
|
||||
|
||||
sqlStr = strings.TrimSpace(sqlStr)
|
||||
sqlUpper := strings.ToUpper(sqlStr)
|
||||
|
||||
// 获取数据库参数
|
||||
dbName := database
|
||||
if dbName == "" {
|
||||
dbName = conn.Database
|
||||
}
|
||||
|
||||
result := &SqlResult{
|
||||
ExecutionTime: time.Since(startTime).Milliseconds(),
|
||||
}
|
||||
|
||||
// 判断是查询还是更新
|
||||
if strings.HasPrefix(sqlUpper, "SELECT") || strings.HasPrefix(sqlUpper, "SHOW") ||
|
||||
strings.HasPrefix(sqlUpper, "DESCRIBE") || strings.HasPrefix(sqlUpper, "DESC") ||
|
||||
strings.HasPrefix(sqlUpper, "EXPLAIN") {
|
||||
// 查询语句
|
||||
queryResult, err := client.ExecuteQuery(ctx, sqlStr, dbName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Type = "query"
|
||||
result.Data = queryResult.Data
|
||||
result.Columns = queryResult.Columns
|
||||
result.RowsAffected = len(queryResult.Data)
|
||||
} else {
|
||||
// 更新语句
|
||||
rowsAffected, err := client.ExecuteUpdate(ctx, sqlStr, dbName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Type = "update"
|
||||
result.RowsAffected = int(rowsAffected)
|
||||
result.Data = nil
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// executeRedis 执行Redis命令
|
||||
func (s *SqlExecService) executeRedis(ctx context.Context, conn *models.DbConnection, sqlStr string, startTime time.Time) (*SqlResult, error) {
|
||||
client, err := s.pool.GetRedisClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 Redis 客户端失败: %v", err)
|
||||
}
|
||||
|
||||
// 解析Redis命令
|
||||
parts := parseRedisCommand(sqlStr)
|
||||
if len(parts) == 0 {
|
||||
return nil, fmt.Errorf("Redis 命令不能为空")
|
||||
}
|
||||
|
||||
cmd := strings.ToUpper(parts[0])
|
||||
args := make([]interface{}, 0)
|
||||
for i := 1; i < len(parts); i++ {
|
||||
args = append(args, parts[i])
|
||||
}
|
||||
|
||||
data, err := client.ExecuteCommand(ctx, cmd, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &SqlResult{
|
||||
Type: "command",
|
||||
Data: data,
|
||||
RowsAffected: 1,
|
||||
ExecutionTime: time.Since(startTime).Milliseconds(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// executeMongo 执行MongoDB命令
|
||||
func (s *SqlExecService) executeMongo(ctx context.Context, conn *models.DbConnection, sqlStr string, database string, startTime time.Time) (*SqlResult, error) {
|
||||
client, err := s.pool.GetMongoClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
|
||||
}
|
||||
|
||||
// 解析MongoDB命令(JSON格式)
|
||||
var command map[string]interface{}
|
||||
sqlStr = strings.TrimSpace(sqlStr)
|
||||
if err := json.Unmarshal([]byte(sqlStr), &command); err != nil {
|
||||
return nil, fmt.Errorf("MongoDB 命令必须是有效的 JSON 格式: %v", err)
|
||||
}
|
||||
|
||||
// 确定数据库
|
||||
dbName := conn.Database
|
||||
if db, ok := command["database"].(string); ok && db != "" {
|
||||
dbName = db
|
||||
}
|
||||
if database != "" {
|
||||
dbName = database
|
||||
}
|
||||
if dbName == "" {
|
||||
return nil, fmt.Errorf("需要指定数据库名称")
|
||||
}
|
||||
|
||||
// 执行命令
|
||||
data, err := client.ExecuteCommand(ctx, dbName, command)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &SqlResult{
|
||||
Type: "command",
|
||||
Data: data,
|
||||
ExecutionTime: time.Since(startTime).Milliseconds(),
|
||||
}
|
||||
|
||||
// 根据操作类型确定影响行数
|
||||
if op, ok := command["op"].(string); ok {
|
||||
switch op {
|
||||
case "find":
|
||||
if results, ok := data.([]map[string]interface{}); ok {
|
||||
result.RowsAffected = len(results)
|
||||
}
|
||||
case "count":
|
||||
if count, ok := data.(int64); ok {
|
||||
result.RowsAffected = int(count)
|
||||
}
|
||||
case "insertOne", "deleteOne":
|
||||
result.RowsAffected = 1
|
||||
case "insertMany":
|
||||
if resultMap, ok := data.(map[string]interface{}); ok {
|
||||
if count, ok := resultMap["insertedCount"].(int); ok {
|
||||
result.RowsAffected = count
|
||||
}
|
||||
}
|
||||
default:
|
||||
result.RowsAffected = 0
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetDatabases 获取数据库列表
|
||||
func (s *SqlExecService) GetDatabases(connectionID uint) ([]string, error) {
|
||||
conn, err := s.connRepo.FindByID(connectionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取连接配置失败: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutFastQuery)
|
||||
defer cancel()
|
||||
|
||||
switch conn.Type {
|
||||
case "mysql":
|
||||
client, err := s.pool.GetMySQLClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
|
||||
}
|
||||
return client.ListDatabases(ctx)
|
||||
case "redis":
|
||||
databases := make([]string, 16)
|
||||
for i := 0; i < 16; i++ {
|
||||
databases[i] = fmt.Sprintf("%d", i)
|
||||
}
|
||||
return databases, nil
|
||||
case "mongo":
|
||||
client, err := s.pool.GetMongoClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
|
||||
}
|
||||
return client.ListDatabases(ctx)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// GetTables 获取表列表(MySQL/MongoDB)或Key列表(Redis)
|
||||
func (s *SqlExecService) GetTables(connectionID uint, database string) ([]string, error) {
|
||||
conn, err := s.connRepo.FindByID(connectionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取连接配置失败: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutFastQuery)
|
||||
defer cancel()
|
||||
|
||||
switch conn.Type {
|
||||
case "mysql":
|
||||
client, err := s.pool.GetMySQLClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
|
||||
}
|
||||
return client.ListTables(ctx, database)
|
||||
case "redis":
|
||||
client, err := s.pool.GetRedisClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 Redis 客户端失败: %v", err)
|
||||
}
|
||||
return client.GetKeys(ctx, database)
|
||||
case "mongo":
|
||||
client, err := s.pool.GetMongoClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
|
||||
}
|
||||
return client.ListCollections(ctx, database)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// parseRedisCommand 解析Redis命令
|
||||
func parseRedisCommand(cmd string) []string {
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
if cmd == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
var parts []string
|
||||
var current strings.Builder
|
||||
inQuotes := false
|
||||
quoteChar := byte(0)
|
||||
|
||||
for i := 0; i < len(cmd); i++ {
|
||||
char := cmd[i]
|
||||
if !inQuotes {
|
||||
if char == '"' || char == '\'' {
|
||||
inQuotes = true
|
||||
quoteChar = char
|
||||
} else if char == ' ' || char == '\t' {
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
} else {
|
||||
current.WriteByte(char)
|
||||
}
|
||||
} else {
|
||||
if char == quoteChar {
|
||||
inQuotes = false
|
||||
quoteChar = 0
|
||||
} else {
|
||||
current.WriteByte(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// GetTableStructure 获取表结构
|
||||
func (s *SqlExecService) GetTableStructure(connectionID uint, database, tableName string) (map[string]interface{}, error) {
|
||||
conn, err := s.connRepo.FindByID(connectionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取连接配置失败: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutQuery)
|
||||
defer cancel()
|
||||
|
||||
switch conn.Type {
|
||||
case "mysql":
|
||||
client, err := s.pool.GetMySQLClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
|
||||
}
|
||||
structure, err := client.GetTableStructure(ctx, database, tableName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"type": "mysql",
|
||||
"database": database,
|
||||
"table": tableName,
|
||||
"columns": structure,
|
||||
}, nil
|
||||
|
||||
case "mongo":
|
||||
client, err := s.pool.GetMongoClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
|
||||
}
|
||||
structure, err := client.GetCollectionStructure(ctx, database, tableName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"type": "mongo",
|
||||
"database": database,
|
||||
"collection": tableName,
|
||||
"structure": structure,
|
||||
}, nil
|
||||
|
||||
case "redis":
|
||||
client, err := s.pool.GetRedisClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 Redis 客户端失败: %v", err)
|
||||
}
|
||||
info, err := client.GetKeyInfo(ctx, tableName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"type": "redis",
|
||||
"key": tableName,
|
||||
"info": info,
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// GetIndexes 获取索引列表
|
||||
func (s *SqlExecService) GetIndexes(connectionID uint, database, tableName string) ([]map[string]interface{}, error) {
|
||||
conn, err := s.connRepo.FindByID(connectionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取连接配置失败: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutQuery)
|
||||
defer cancel()
|
||||
|
||||
switch conn.Type {
|
||||
case "mysql":
|
||||
client, err := s.pool.GetMySQLClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
|
||||
}
|
||||
return client.GetIndexes(ctx, database, tableName)
|
||||
|
||||
case "mongo", "redis":
|
||||
return []map[string]interface{}{}, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// PreviewTableStructure 预览表结构变更
|
||||
func (s *SqlExecService) PreviewTableStructure(connectionID uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
||||
conn, err := s.connRepo.FindByID(connectionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取连接配置失败: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutLongOp)
|
||||
defer cancel()
|
||||
|
||||
switch conn.Type {
|
||||
case "mysql":
|
||||
client, err := s.pool.GetMySQLClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
|
||||
}
|
||||
return client.PreviewTableStructure(ctx, database, tableName, structure)
|
||||
|
||||
case "mongo":
|
||||
client, err := s.pool.GetMongoClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
|
||||
}
|
||||
return client.PreviewCollectionIndexes(ctx, database, tableName, structure)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateTableStructure 更新表结构
|
||||
func (s *SqlExecService) UpdateTableStructure(connectionID uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
||||
conn, err := s.connRepo.FindByID(connectionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取连接配置失败: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutLongOp)
|
||||
defer cancel()
|
||||
|
||||
switch conn.Type {
|
||||
case "mysql":
|
||||
client, err := s.pool.GetMySQLClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
|
||||
}
|
||||
return client.UpdateTableStructure(ctx, database, tableName, structure)
|
||||
|
||||
case "mongo":
|
||||
client, err := s.pool.GetMongoClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
|
||||
}
|
||||
return client.UpdateCollectionIndexes(ctx, database, tableName, structure)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"u-desk/internal/storage/models"
|
||||
"u-desk/internal/storage/repository"
|
||||
)
|
||||
|
||||
// TabService 标签页管理服务
|
||||
type TabService struct {
|
||||
repo repository.TabRepository
|
||||
}
|
||||
|
||||
// NewTabService 创建标签页服务
|
||||
func NewTabService() (*TabService, error) {
|
||||
repo, err := repository.NewTabRepository()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建标签页仓库失败: %v", err)
|
||||
}
|
||||
return &TabService{repo: repo}, nil
|
||||
}
|
||||
|
||||
// SaveTabs 保存标签页列表
|
||||
func (s *TabService) SaveTabs(tabs []models.SqlTab) error {
|
||||
return s.repo.SaveAll(tabs)
|
||||
}
|
||||
|
||||
// ListTabs 获取标签页列表
|
||||
func (s *TabService) ListTabs() ([]models.SqlTab, error) {
|
||||
return s.repo.FindAll()
|
||||
}
|
||||
|
||||
// DeleteTab 删除标签页
|
||||
func (s *TabService) DeleteTab(id uint) error {
|
||||
return s.repo.Delete(id)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -62,20 +61,13 @@ func NewUpdateService(checkURL string) *UpdateService {
|
||||
|
||||
// CheckUpdate 检查更新
|
||||
func (s *UpdateService) CheckUpdate() (*UpdateCheckResult, error) {
|
||||
log.Printf("[更新检查] 开始检查更新,检查地址: %s", s.checkURL)
|
||||
|
||||
config, err := LoadUpdateConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载配置失败: %v", err)
|
||||
}
|
||||
|
||||
// 同步版本号
|
||||
currentVersionStr, err := s.syncConfigVersion(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
currentVersion, err := ParseVersion(currentVersionStr)
|
||||
// 获取当前版本(使用缓存)
|
||||
currentVersion, err := ParseVersion(GetCurrentVersion())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析当前版本失败: %v", err)
|
||||
}
|
||||
@@ -86,14 +78,6 @@ func (s *UpdateService) CheckUpdate() (*UpdateCheckResult, error) {
|
||||
return nil, fmt.Errorf("获取远程版本信息失败: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("[更新检查] 远程版本信息: 版本=%s, 下载地址=%s, 强制更新=%v, 更新日志长度=%d",
|
||||
remoteInfo.Version, remoteInfo.DownloadURL, remoteInfo.ForceUpdate, len(remoteInfo.Changelog))
|
||||
if remoteInfo.Changelog != "" {
|
||||
log.Printf("[更新检查] 更新日志内容: %s", remoteInfo.Changelog)
|
||||
} else {
|
||||
log.Printf("[更新检查] 警告: 远程接口未返回更新日志")
|
||||
}
|
||||
|
||||
// 解析远程版本号
|
||||
remoteVersion, err := ParseVersion(remoteInfo.Version)
|
||||
if err != nil {
|
||||
@@ -102,55 +86,30 @@ func (s *UpdateService) CheckUpdate() (*UpdateCheckResult, error) {
|
||||
|
||||
// 比较版本
|
||||
hasUpdate := remoteVersion.IsNewerThan(currentVersion)
|
||||
log.Printf("[更新检查] 版本比较: 当前=%s, 远程=%s, 有更新=%v",
|
||||
currentVersion.String(), remoteVersion.String(), hasUpdate)
|
||||
|
||||
// 更新最后检查时间
|
||||
config.UpdateLastCheckTime()
|
||||
|
||||
result := &UpdateCheckResult{
|
||||
return &UpdateCheckResult{
|
||||
HasUpdate: hasUpdate,
|
||||
CurrentVersion: currentVersionStr,
|
||||
CurrentVersion: GetCurrentVersion(),
|
||||
LatestVersion: remoteInfo.Version,
|
||||
DownloadURL: remoteInfo.DownloadURL,
|
||||
Changelog: remoteInfo.Changelog,
|
||||
ForceUpdate: remoteInfo.ForceUpdate,
|
||||
ReleaseDate: remoteInfo.ReleaseDate,
|
||||
FileSize: remoteInfo.FileSize,
|
||||
}
|
||||
|
||||
log.Printf("[更新检查] 检查完成: 有更新=%v", hasUpdate)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// syncConfigVersion 同步配置中的版本号
|
||||
func (s *UpdateService) syncConfigVersion(config *UpdateConfig) (string, error) {
|
||||
currentVersionStr := GetCurrentVersion()
|
||||
if currentVersionStr == "" {
|
||||
currentVersionStr = config.CurrentVersion
|
||||
log.Printf("[更新检查] 使用配置中的版本号: %s", currentVersionStr)
|
||||
} else if config.CurrentVersion != currentVersionStr {
|
||||
log.Printf("[更新检查] 配置中的版本号 (%s) 与当前版本号 (%s) 不一致,更新配置",
|
||||
config.CurrentVersion, currentVersionStr)
|
||||
config.CurrentVersion = currentVersionStr
|
||||
if err := SaveUpdateConfig(config); err != nil {
|
||||
log.Printf("[更新检查] 更新配置失败: %v", err)
|
||||
}
|
||||
}
|
||||
return currentVersionStr, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchRemoteVersionInfo 获取远程版本信息
|
||||
func (s *UpdateService) fetchRemoteVersionInfo() (*RemoteVersionInfo, error) {
|
||||
if s.checkURL == "" {
|
||||
log.Printf("[远程版本] 版本检查 URL 未配置")
|
||||
return nil, fmt.Errorf("版本检查 URL 未配置,请先设置检查地址")
|
||||
}
|
||||
|
||||
log.Printf("[远程版本] 请求远程版本信息: %s", s.checkURL)
|
||||
|
||||
// 添加时间戳参数防止缓存
|
||||
timestamp := time.Now().UnixMilli() // 使用毫秒级时间戳
|
||||
timestamp := time.Now().UnixMilli()
|
||||
var requestURL string
|
||||
if strings.Contains(s.checkURL, "?") {
|
||||
requestURL = fmt.Sprintf("%s&t=%d", s.checkURL, timestamp)
|
||||
@@ -158,8 +117,6 @@ func (s *UpdateService) fetchRemoteVersionInfo() (*RemoteVersionInfo, error) {
|
||||
requestURL = fmt.Sprintf("%s?t=%d", s.checkURL, timestamp)
|
||||
}
|
||||
|
||||
log.Printf("[远程版本] 实际请求URL: %s", requestURL)
|
||||
|
||||
// 创建 HTTP 客户端,设置超时
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
@@ -168,12 +125,10 @@ func (s *UpdateService) fetchRemoteVersionInfo() (*RemoteVersionInfo, error) {
|
||||
// 发送请求
|
||||
resp, err := client.Get(requestURL)
|
||||
if err != nil {
|
||||
log.Printf("[远程版本] 网络请求失败: %v", err)
|
||||
return nil, fmt.Errorf("网络请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
log.Printf("[远程版本] HTTP 响应状态码: %d", resp.StatusCode)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("服务器返回错误状态码: %d", resp.StatusCode)
|
||||
}
|
||||
@@ -181,25 +136,19 @@ func (s *UpdateService) fetchRemoteVersionInfo() (*RemoteVersionInfo, error) {
|
||||
// 读取响应
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("[远程版本] 读取响应失败: %v", err)
|
||||
return nil, fmt.Errorf("读取响应失败: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("[远程版本] 响应内容长度: %d 字节", len(body))
|
||||
|
||||
// 解析 JSON
|
||||
var remoteInfo RemoteVersionInfo
|
||||
if err := json.Unmarshal(body, &remoteInfo); err != nil {
|
||||
log.Printf("[远程版本] 解析 JSON 失败: %v, 响应内容: %s", err, string(body))
|
||||
return nil, fmt.Errorf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
if remoteInfo.Version == "" {
|
||||
log.Printf("[远程版本] 远程版本信息不完整,响应内容: %s", string(body))
|
||||
return nil, fmt.Errorf("远程版本信息不完整")
|
||||
}
|
||||
|
||||
log.Printf("[远程版本] 成功获取远程版本信息: %+v", remoteInfo)
|
||||
return &remoteInfo, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -44,7 +43,7 @@ func LoadUpdateConfig() (*UpdateConfig, error) {
|
||||
LastCheckTime: time.Time{}, // 启动时会立即检查
|
||||
AutoCheckEnabled: true,
|
||||
CheckIntervalMinutes: 5, // 5分钟检查一次
|
||||
CheckURL: "https://img.1216.top/u-desk/last-version.json",
|
||||
CheckURL: "https://c.1216.top/last-version.json",
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -71,18 +70,14 @@ func LoadUpdateConfig() (*UpdateConfig, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 同步最新版本号
|
||||
latestVersion := GetCurrentVersion()
|
||||
if config.CurrentVersion == "" || config.CurrentVersion != latestVersion {
|
||||
if config.CurrentVersion != "" {
|
||||
log.Printf("[配置] 配置中的版本号 (%s) 与最新版本号 (%s) 不一致", config.CurrentVersion, latestVersion)
|
||||
}
|
||||
config.CurrentVersion = latestVersion
|
||||
}
|
||||
|
||||
// 使用默认检查地址
|
||||
if config.CheckURL == "" {
|
||||
config.CheckURL = "https://img.1216.top/u-desk/last-version.json"
|
||||
config.CheckURL = "https://c.1216.top/last-version.json"
|
||||
}
|
||||
|
||||
// 确保版本号不为空(使用缓存的版本号)
|
||||
if config.CurrentVersion == "" {
|
||||
config.CurrentVersion = GetCurrentVersion()
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
@@ -107,22 +102,6 @@ func SaveUpdateConfig(config *UpdateConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShouldCheckUpdate 判断是否应该检查更新
|
||||
func (c *UpdateConfig) ShouldCheckUpdate() bool {
|
||||
if !c.AutoCheckEnabled {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果从未检查过,应该检查
|
||||
if c.LastCheckTime.IsZero() {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查是否超过间隔分钟数
|
||||
minutesSinceLastCheck := time.Since(c.LastCheckTime).Minutes()
|
||||
return minutesSinceLastCheck >= float64(c.CheckIntervalMinutes)
|
||||
}
|
||||
|
||||
// UpdateLastCheckTime 更新最后检查时间
|
||||
func (c *UpdateConfig) UpdateLastCheckTime() error {
|
||||
c.LastCheckTime = time.Now()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -8,12 +9,19 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ==================== 常量定义 ====================
|
||||
|
||||
// AppVersion 应用版本号(发布时直接修改此处)
|
||||
const AppVersion = "0.3.0"
|
||||
const AppVersion = "0.4.0"
|
||||
|
||||
// 版本号缓存
|
||||
var (
|
||||
cachedVersion string
|
||||
versionOnce sync.Once
|
||||
)
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
@@ -57,65 +65,47 @@ func ParseVersion(versionStr string) (*Version, error) {
|
||||
return &Version{Major: major, Minor: minor, Patch: patch}, nil
|
||||
}
|
||||
|
||||
// String 返回版本号字符串(格式:v1.0.0)
|
||||
func (v *Version) String() string {
|
||||
return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch)
|
||||
}
|
||||
|
||||
// Compare 比较版本号
|
||||
// 返回值:-1 表示当前版本小于目标版本,0 表示相等,1 表示大于
|
||||
func (v *Version) Compare(other *Version) int {
|
||||
switch {
|
||||
case v.Major != other.Major:
|
||||
return compareInt(v.Major, other.Major)
|
||||
return cmp.Compare(v.Major, other.Major)
|
||||
case v.Minor != other.Minor:
|
||||
return compareInt(v.Minor, other.Minor)
|
||||
return cmp.Compare(v.Minor, other.Minor)
|
||||
case v.Patch != other.Patch:
|
||||
return compareInt(v.Patch, other.Patch)
|
||||
return cmp.Compare(v.Patch, other.Patch)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// compareInt 比较两个整数
|
||||
func compareInt(a, b int) int {
|
||||
if a < b {
|
||||
return -1
|
||||
}
|
||||
if a > b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// IsNewerThan 判断是否比目标版本新
|
||||
func (v *Version) IsNewerThan(other *Version) bool {
|
||||
return v.Compare(other) > 0
|
||||
}
|
||||
|
||||
// IsOlderThan 判断是否比目标版本旧
|
||||
func (v *Version) IsOlderThan(other *Version) bool {
|
||||
return v.Compare(other) < 0
|
||||
}
|
||||
|
||||
// ==================== 版本号获取 ====================
|
||||
|
||||
// GetCurrentVersion 获取当前版本号
|
||||
// GetCurrentVersion 获取当前版本号(带缓存)
|
||||
// 优先级:硬编码版本号 > wails.json(开发模式)> 默认值
|
||||
func GetCurrentVersion() string {
|
||||
versionOnce.Do(func() {
|
||||
if AppVersion != "" {
|
||||
log.Printf("[版本] 使用硬编码版本号: %s", AppVersion)
|
||||
return AppVersion
|
||||
cachedVersion = AppVersion
|
||||
return
|
||||
}
|
||||
|
||||
version := getVersionFromWailsJSON()
|
||||
if version != "" {
|
||||
log.Printf("[版本] 从 wails.json 获取版本号: %s", version)
|
||||
return version
|
||||
cachedVersion = version
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[版本] 使用默认版本号: 0.0.1")
|
||||
return "0.0.1"
|
||||
cachedVersion = "0.0.1"
|
||||
})
|
||||
|
||||
return cachedVersion
|
||||
}
|
||||
|
||||
// ==================== 配置文件读取 ====================
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"u-desk/internal/crypto"
|
||||
"u-desk/internal/dbclient"
|
||||
"u-desk/internal/storage/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ConnectionService 连接管理服务
|
||||
type ConnectionService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewConnectionService 创建连接服务
|
||||
func NewConnectionService() (*ConnectionService, error) {
|
||||
db := GetDB()
|
||||
if db == nil {
|
||||
// 尝试重新初始化
|
||||
var err error
|
||||
db, err = Init()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("数据库初始化失败: %v", err)
|
||||
}
|
||||
}
|
||||
return &ConnectionService{db: db}, nil
|
||||
}
|
||||
|
||||
// SaveConnection 保存连接配置
|
||||
func (s *ConnectionService) SaveConnection(conn *models.DbConnection) error {
|
||||
if conn.Name == "" {
|
||||
return fmt.Errorf("连接名称不能为空")
|
||||
}
|
||||
if conn.Type == "" {
|
||||
return fmt.Errorf("数据库类型不能为空")
|
||||
}
|
||||
if conn.Host == "" {
|
||||
return fmt.Errorf("主机地址不能为空")
|
||||
}
|
||||
|
||||
// 检查名称是否重复(排除当前记录)
|
||||
var count int64
|
||||
query := s.db.Model(&models.DbConnection{}).Where("name = ?", conn.Name)
|
||||
if conn.ID > 0 {
|
||||
query = query.Where("id != ?", conn.ID)
|
||||
}
|
||||
query.Count(&count)
|
||||
if count > 0 {
|
||||
return fmt.Errorf("连接名称已存在")
|
||||
}
|
||||
|
||||
if conn.ID > 0 {
|
||||
// 更新模式
|
||||
updateData := map[string]interface{}{
|
||||
"name": conn.Name,
|
||||
"type": conn.Type,
|
||||
"host": conn.Host,
|
||||
"port": conn.Port,
|
||||
"username": conn.Username,
|
||||
"database": conn.Database,
|
||||
"options": conn.Options,
|
||||
}
|
||||
|
||||
// 如果提供了新密码,加密后更新
|
||||
if conn.Password != "" {
|
||||
encrypted, err := crypto.EncryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码加密失败: %v", err)
|
||||
}
|
||||
updateData["password"] = encrypted
|
||||
}
|
||||
// 如果密码为空,不更新密码字段(保留原密码)
|
||||
|
||||
return s.db.Model(&models.DbConnection{}).Where("id = ?", conn.ID).Updates(updateData).Error
|
||||
}
|
||||
|
||||
// 新增模式 - 必须提供密码
|
||||
if conn.Password == "" {
|
||||
return fmt.Errorf("新增连接时密码不能为空")
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
encrypted, err := crypto.EncryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码加密失败: %v", err)
|
||||
}
|
||||
conn.Password = encrypted
|
||||
|
||||
return s.db.Create(conn).Error
|
||||
}
|
||||
|
||||
// ListConnections 获取连接列表
|
||||
func (s *ConnectionService) ListConnections() ([]models.DbConnection, error) {
|
||||
var connections []models.DbConnection
|
||||
err := s.db.Order("created_at DESC").Find(&connections).Error
|
||||
return connections, err
|
||||
}
|
||||
|
||||
// GetConnection 获取连接详情
|
||||
func (s *ConnectionService) GetConnection(id uint) (*models.DbConnection, error) {
|
||||
var conn models.DbConnection
|
||||
err := s.db.First(&conn, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &conn, nil
|
||||
}
|
||||
|
||||
// DeleteConnection 删除连接配置
|
||||
func (s *ConnectionService) DeleteConnection(id uint) error {
|
||||
return s.db.Delete(&models.DbConnection{}, id).Error
|
||||
}
|
||||
|
||||
// TestConnection 测试连接(需要根据类型调用不同的测试方法)
|
||||
func (s *ConnectionService) TestConnection(conn *models.DbConnection) error {
|
||||
// 解密密码用于测试
|
||||
password, err := crypto.DecryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码解密失败: %v", err)
|
||||
}
|
||||
|
||||
// 根据类型测试连接
|
||||
switch conn.Type {
|
||||
case "mysql":
|
||||
return testMySQLConnection(conn.Host, conn.Port, conn.Username, password, conn.Database)
|
||||
case "redis":
|
||||
return testRedisConnection(conn.Host, conn.Port, password)
|
||||
case "mongo":
|
||||
// 解析 Options 获取 MongoDB 连接参数
|
||||
authSource := ""
|
||||
authMechanism := ""
|
||||
if conn.Options != "" {
|
||||
var opts map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(conn.Options), &opts); err == nil {
|
||||
if as, ok := opts["authSource"].(string); ok && as != "" {
|
||||
authSource = as
|
||||
}
|
||||
if am, ok := opts["authMechanism"].(string); ok && am != "" {
|
||||
authMechanism = am
|
||||
}
|
||||
}
|
||||
}
|
||||
return testMongoConnection(conn.Host, conn.Port, conn.Username, password, conn.Database, authSource, authMechanism)
|
||||
default:
|
||||
return fmt.Errorf("不支持的数据库类型: %s", conn.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// testMySQLConnection 测试 MySQL 连接
|
||||
func testMySQLConnection(host string, port int, username, password, database string) error {
|
||||
return dbclient.TestMySQLConnection(host, port, username, password, database)
|
||||
}
|
||||
|
||||
// testRedisConnection 测试 Redis 连接
|
||||
func testRedisConnection(host string, port int, password string) error {
|
||||
return dbclient.TestRedisConnection(host, port, password)
|
||||
}
|
||||
|
||||
// testMongoConnection 测试 MongoDB 连接
|
||||
func testMongoConnection(host string, port int, username, password, database, authSource, authMechanism string) error {
|
||||
return dbclient.TestMongoConnectionWithOptions(host, port, username, password, database, authSource, authMechanism)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// DbConnection 数据库连接配置
|
||||
type DbConnection struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"type:varchar(100);not null" json:"name"` // 连接名称
|
||||
Type string `gorm:"type:varchar(20);not null" json:"type"` // 数据库类型: mysql/redis/mongo
|
||||
Host string `gorm:"type:varchar(255);not null" json:"host"` // 主机地址
|
||||
Port int `gorm:"not null" json:"port"` // 端口
|
||||
Username string `gorm:"type:varchar(100)" json:"username"` // 用户名
|
||||
Password string `gorm:"type:varchar(500)" json:"-"` // 密码(加密存储,不返回)
|
||||
Database string `gorm:"type:varchar(100)" json:"database"` // 数据库名(MySQL/MongoDB)
|
||||
Options string `gorm:"type:text" json:"options"` // 额外选项(JSON格式)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (DbConnection) TableName() string {
|
||||
return "db_connection"
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SqlFile SQL 文件记录
|
||||
type SqlFile struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"type:varchar(200);not null" json:"name"` // 文件名
|
||||
Path string `gorm:"type:varchar(500);not null;uniqueIndex" json:"path"` // 文件路径
|
||||
Content string `gorm:"type:text" json:"content"` // 文件内容
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (SqlFile) TableName() string {
|
||||
return "sql_file"
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SqlResultHistory SQL 执行结果历史
|
||||
type SqlResultHistory struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ConnectionID uint `gorm:"index;not null" json:"connection_id"` // 连接ID
|
||||
Database string `gorm:"type:varchar(100)" json:"database"` // 数据库名
|
||||
Sql string `gorm:"type:text;not null" json:"sql"` // SQL语句
|
||||
Type string `gorm:"type:varchar(20);not null" json:"type"` // 结果类型: query/update/command
|
||||
Data string `gorm:"type:text" json:"data"` // 结果数据(JSON)
|
||||
Columns string `gorm:"type:text" json:"columns"` // 列信息(JSON)
|
||||
RowsAffected int `gorm:"default:0" json:"rows_affected"` // 影响行数
|
||||
ExecutionTime int64 `gorm:"default:0" json:"execution_time"` // 执行时间(毫秒)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (SqlResultHistory) TableName() string {
|
||||
return "sql_result_history"
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SqlTab SQL 编辑器标签页
|
||||
type SqlTab struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Title string `gorm:"type:varchar(100);not null" json:"title"` // 标签页标题
|
||||
Content string `gorm:"type:text" json:"content"` // SQL 内容
|
||||
ConnectionID *uint `gorm:"index" json:"connection_id"` // 关联的连接ID(可为空)
|
||||
Order int `gorm:"default:0" json:"order"` // 排序顺序
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (SqlTab) TableName() string {
|
||||
return "sql_tab"
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Version 版本信息
|
||||
type Version struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 主键ID
|
||||
Version string `gorm:"type:varchar(20);not null;uniqueIndex" json:"version"` // 版本号(语义化版本,如1.0.0)
|
||||
DownloadURL string `gorm:"type:varchar(500)" json:"download_url"` // 下载地址(更新包下载URL)
|
||||
Changelog string `gorm:"type:text" json:"changelog"` // 更新日志(Markdown格式)
|
||||
ForceUpdate int `gorm:"type:tinyint;not null;default:0" json:"force_update"` // 是否强制更新(1:是 0:否)
|
||||
ReleaseDate *time.Time `gorm:"type:date" json:"release_date"` // 发布日期
|
||||
CreatedAt time.Time `gorm:"autoCreateTime:false" json:"created_at"` // 创建时间(由程序设置)
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime:false" json:"updated_at"` // 更新时间(由程序设置)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (Version) TableName() string {
|
||||
return "sys_version"
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"u-desk/internal/storage"
|
||||
"u-desk/internal/storage/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ConnectionRepository interface {
|
||||
Save(conn *models.DbConnection) error
|
||||
FindAll() ([]models.DbConnection, error)
|
||||
FindByID(id uint) (*models.DbConnection, error)
|
||||
Delete(id uint) error
|
||||
FindByName(name string, excludeID uint) (*models.DbConnection, error)
|
||||
}
|
||||
|
||||
type connectionRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewConnectionRepository() (ConnectionRepository, error) {
|
||||
db := storage.GetDB()
|
||||
if db == nil {
|
||||
var err error
|
||||
db, err = storage.Init()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &connectionRepository{db}, nil
|
||||
}
|
||||
|
||||
func (r *connectionRepository) Save(conn *models.DbConnection) error {
|
||||
if conn.ID > 0 {
|
||||
return r.db.Model(&models.DbConnection{}).Where("id = ?", conn.ID).Updates(conn).Error
|
||||
}
|
||||
return r.db.Create(conn).Error
|
||||
}
|
||||
|
||||
func (r *connectionRepository) FindAll() ([]models.DbConnection, error) {
|
||||
var connections []models.DbConnection
|
||||
return connections, r.db.Order("created_at DESC").Find(&connections).Error
|
||||
}
|
||||
|
||||
func (r *connectionRepository) FindByID(id uint) (*models.DbConnection, error) {
|
||||
var conn models.DbConnection
|
||||
err := r.db.First(&conn, id).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return &conn, err
|
||||
}
|
||||
|
||||
func (r *connectionRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.DbConnection{}, id).Error
|
||||
}
|
||||
|
||||
func (r *connectionRepository) FindByName(name string, excludeID uint) (*models.DbConnection, error) {
|
||||
var conn models.DbConnection
|
||||
query := r.db.Where("name = ?", name)
|
||||
if excludeID > 0 {
|
||||
query = query.Where("id != ?", excludeID)
|
||||
}
|
||||
err := query.First(&conn).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return &conn, err
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"u-desk/internal/storage"
|
||||
"u-desk/internal/storage/models"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ResultRepository interface {
|
||||
Save(connectionID uint, database, sql string, resultType string, data interface{}, columns []string, rowsAffected int, executionTime int64) (*models.SqlResultHistory, error)
|
||||
FindByID(id uint) (*models.SqlResultHistory, error)
|
||||
FindByConnection(connectionID uint, limit int) ([]models.SqlResultHistory, error)
|
||||
Search(connectionID *uint, keyword string, limit, offset int) ([]models.SqlResultHistory, int64, error)
|
||||
Delete(id uint) error
|
||||
DeleteByConnection(connectionID uint) error
|
||||
DeleteOld(keepDays int) error
|
||||
}
|
||||
|
||||
type resultRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewResultRepository() (ResultRepository, error) {
|
||||
db := storage.GetDB()
|
||||
if db == nil {
|
||||
var err error
|
||||
db, err = storage.Init()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &resultRepository{db}, nil
|
||||
}
|
||||
|
||||
func (r *resultRepository) Save(connectionID uint, database, sql string, resultType string, data interface{}, columns []string, rowsAffected int, executionTime int64) (*models.SqlResultHistory, error) {
|
||||
dataJSON, _ := json.Marshal(data)
|
||||
columnsJSON, _ := json.Marshal(columns)
|
||||
|
||||
history := &models.SqlResultHistory{
|
||||
ConnectionID: connectionID,
|
||||
Database: database,
|
||||
Sql: sql,
|
||||
Type: resultType,
|
||||
Data: string(dataJSON),
|
||||
Columns: string(columnsJSON),
|
||||
RowsAffected: rowsAffected,
|
||||
ExecutionTime: executionTime,
|
||||
}
|
||||
|
||||
return history, r.db.Create(history).Error
|
||||
}
|
||||
|
||||
func (r *resultRepository) FindByID(id uint) (*models.SqlResultHistory, error) {
|
||||
var history models.SqlResultHistory
|
||||
err := r.db.First(&history, id).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return &history, err
|
||||
}
|
||||
|
||||
func (r *resultRepository) FindByConnection(connectionID uint, limit int) ([]models.SqlResultHistory, error) {
|
||||
var histories []models.SqlResultHistory
|
||||
query := r.db.Where("connection_id = ?", connectionID).Order("created_at DESC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
return histories, query.Find(&histories).Error
|
||||
}
|
||||
|
||||
func (r *resultRepository) Search(connectionID *uint, keyword string, limit, offset int) ([]models.SqlResultHistory, int64, error) {
|
||||
query := r.db.Model(&models.SqlResultHistory{})
|
||||
|
||||
if connectionID != nil {
|
||||
query = query.Where("connection_id = ?", *connectionID)
|
||||
}
|
||||
if keyword != "" {
|
||||
query = query.Where("sql LIKE ? OR database LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var histories []models.SqlResultHistory
|
||||
query = query.Order("created_at DESC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
if offset > 0 {
|
||||
query = query.Offset(offset)
|
||||
}
|
||||
|
||||
return histories, total, query.Find(&histories).Error
|
||||
}
|
||||
|
||||
func (r *resultRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.SqlResultHistory{}, id).Error
|
||||
}
|
||||
|
||||
func (r *resultRepository) DeleteByConnection(connectionID uint) error {
|
||||
return r.db.Where("connection_id = ?", connectionID).Delete(&models.SqlResultHistory{}).Error
|
||||
}
|
||||
|
||||
func (r *resultRepository) DeleteOld(keepDays int) error {
|
||||
return r.db.Where("created_at < ?", time.Now().AddDate(0, 0, -keepDays)).Delete(&models.SqlResultHistory{}).Error
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"u-desk/internal/storage"
|
||||
"u-desk/internal/storage/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TabRepository interface {
|
||||
SaveAll(tabs []models.SqlTab) error
|
||||
FindAll() ([]models.SqlTab, error)
|
||||
Delete(id uint) error
|
||||
DeleteAll() error
|
||||
}
|
||||
|
||||
type tabRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewTabRepository() (TabRepository, error) {
|
||||
db := storage.GetDB()
|
||||
if db == nil {
|
||||
var err error
|
||||
db, err = storage.Init()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &tabRepository{db}, nil
|
||||
}
|
||||
|
||||
func (r *tabRepository) SaveAll(tabs []models.SqlTab) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("1=1").Delete(&models.SqlTab{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tabs) > 0 {
|
||||
return tx.Create(&tabs).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *tabRepository) FindAll() ([]models.SqlTab, error) {
|
||||
var tabs []models.SqlTab
|
||||
return tabs, r.db.Order("`order` ASC, created_at ASC").Find(&tabs).Error
|
||||
}
|
||||
|
||||
func (r *tabRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.SqlTab{}, id).Error
|
||||
}
|
||||
|
||||
func (r *tabRepository) DeleteAll() error {
|
||||
return r.db.Where("1=1").Delete(&models.SqlTab{}).Error
|
||||
}
|
||||
@@ -62,9 +62,6 @@ func InitFast() (*gorm.DB, error) {
|
||||
// AutoMigrate 在启动时执行,但只在表结构不存在时创建
|
||||
// SQLite 的 AutoMigrate 很快,不会造成明显延迟
|
||||
if err := db.AutoMigrate(
|
||||
&models.DbConnection{},
|
||||
&models.SqlTab{},
|
||||
&models.SqlResultHistory{},
|
||||
&models.AppConfig{},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "u-desk",
|
||||
"outputfilename": "u-desk",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"frontend:install": "npm install",
|
||||
"frontend:build": "npm run build",
|
||||
"author": {
|
||||
|
||||
31
web/.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# 依赖
|
||||
node_modules/
|
||||
|
||||
# 构建产物
|
||||
dist/
|
||||
build/
|
||||
|
||||
# 自动生成的类型声明文件
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
|
||||
# 缓存
|
||||
*.log
|
||||
*.cache
|
||||
.vite/
|
||||
|
||||
# 编辑器
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 环境变量
|
||||
.env.local
|
||||
.env.*.local
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>U-Desk</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🖥️</text></svg>" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
1271
web/package-lock.json
generated
@@ -10,7 +10,6 @@
|
||||
"dependencies": {
|
||||
"@arco-design/web-vue": "^2.54.0",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/highlight": "^0.19.8",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
@@ -26,18 +25,24 @@
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.3",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.8",
|
||||
"@types/highlight.js": "^9.12.4",
|
||||
"@types/mermaid": "^9.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"mammoth": "^1.11.0",
|
||||
"marked": "^17.0.1",
|
||||
"mermaid": "^11.12.2",
|
||||
"vue": "^3.5.26"
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.26",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/highlight.js": "^9.12.4",
|
||||
"@types/mermaid": "^9.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"unplugin-auto-import": "^0.18.3",
|
||||
"unplugin-vue-components": "^0.27.4",
|
||||
"vite": "^7.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
db157c3d15eff27c46a5fa33f3b95e47
|
||||
c0e9e27e045c6118704c87fcf34a03de
|
||||
312
web/src/App.vue
@@ -20,6 +20,13 @@
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :content="isPinned ? '取消置顶' : '窗口置顶'">
|
||||
<a-button type="text" :class="{ 'pin-active': isPinned }" @click="handleTogglePin">
|
||||
<template #icon>
|
||||
<IconPushpin :class="{ pinned: isPinned }"/>
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<ThemeToggle/>
|
||||
|
||||
<!-- 窗口控制按钮 -->
|
||||
@@ -50,7 +57,7 @@
|
||||
<a-layout-content class="content">
|
||||
<!-- 动态渲染 Tab 内容 -->
|
||||
<!-- 使用 KeepAlive 缓存组件状态,避免切换时重新加载 -->
|
||||
<KeepAlive include="FileSystem,DbCli">
|
||||
<KeepAlive include="FileSystem">
|
||||
<component :is="getComponent(activeTab)"/>
|
||||
</KeepAlive>
|
||||
</a-layout-content>
|
||||
@@ -58,235 +65,124 @@
|
||||
<!-- 设置抽屉 -->
|
||||
<SettingsPanel
|
||||
v-model="showSettings"
|
||||
:config="appConfig"
|
||||
:config="configStore.appConfig"
|
||||
@save="handleSaveConfig"
|
||||
@open-version-history="showVersionHistory = true"
|
||||
/>
|
||||
|
||||
<!-- 版本历史抽屉 -->
|
||||
<a-drawer
|
||||
v-model:visible="showVersionHistory"
|
||||
:width="720"
|
||||
:footer="false"
|
||||
:unmount-on-close="false"
|
||||
title="版本历史"
|
||||
>
|
||||
<VersionHistory />
|
||||
</a-drawer>
|
||||
|
||||
<!-- 升级提示弹窗 -->
|
||||
<UpdateNotification
|
||||
v-model="showUpdateNotification"
|
||||
:update-info="updateInfo"
|
||||
@install="handleUpdateInstall"
|
||||
@skip="handleUpdateSkip"
|
||||
v-model="updateStore.showUpdate"
|
||||
:update-info="updateStore.updateInfo"
|
||||
@install="updateStore.installUpdate"
|
||||
/>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed, onMounted, ref, watch} from 'vue'
|
||||
import {IconSettings} from '@arco-design/web-vue/es/icon'
|
||||
import {Message} from '@arco-design/web-vue'
|
||||
import DbCli from './views/db-cli/index.vue'
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
|
||||
import {IconSettings, IconPushpin} from '@arco-design/web-vue/es/icon'
|
||||
import MarkdownEditor from './views/markdown-editor/index.vue'
|
||||
import VersionHistory from './views/version/index.vue'
|
||||
import ThemeToggle from './components/ThemeToggle.vue'
|
||||
import FileSystem from './components/FileSystem/index.vue'
|
||||
import SettingsPanel from './components/SettingsPanel.vue'
|
||||
import UpdateNotification from './components/UpdateNotification.vue'
|
||||
import {useUpdateStore} from './stores/update'
|
||||
import {useConfigStore, type AppConfig} from './stores/config'
|
||||
|
||||
// 存储键
|
||||
const ACTIVE_TAB_STORAGE_KEY = 'app-active-tab'
|
||||
|
||||
// 从 localStorage 恢复上次打开的区域,默认为 'file-system'
|
||||
// 兼容旧版:'user' 是 v0.2.x 之前的 tab key,已废弃需迁移
|
||||
const savedTab = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY)
|
||||
const activeTab = ref((savedTab === 'user' ? 'file-system' : savedTab) || 'file-system')
|
||||
const showSettings = ref(false)
|
||||
const showVersionHistory = ref(false)
|
||||
const isMaximized = ref(false)
|
||||
const isPinned = ref(false)
|
||||
|
||||
// 更新相关状态
|
||||
const showUpdateNotification = ref(false)
|
||||
const updateInfo = ref(null)
|
||||
const checkedUpdate = ref(false)
|
||||
// 使用 stores
|
||||
const updateStore = useUpdateStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
// 应用配置
|
||||
const appConfig = ref({
|
||||
tabs: [],
|
||||
visibleTabs: [],
|
||||
defaultTab: 'file-system'
|
||||
})
|
||||
// 应用配置(从 store 获取)
|
||||
const appConfig = computed(() => configStore.appConfig)
|
||||
|
||||
// 可见 Tabs(根据配置动态生成)
|
||||
const visibleTabs = computed(() => {
|
||||
if (!appConfig.value.tabs || appConfig.value.tabs.length === 0) {
|
||||
// 默认配置
|
||||
return [
|
||||
{key: 'file-system', title: '文件管理'},
|
||||
{key: 'db-cli', title: '数据库'}
|
||||
]
|
||||
}
|
||||
|
||||
return appConfig.value.tabs
|
||||
.filter(tab => tab.visible)
|
||||
.sort((a, b) => {
|
||||
const aIndex = appConfig.value.visibleTabs.indexOf(a.key)
|
||||
const bIndex = appConfig.value.visibleTabs.indexOf(b.key)
|
||||
return aIndex - bIndex
|
||||
})
|
||||
})
|
||||
|
||||
// 加载配置
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
// 检查 Wails 绑定是否准备好
|
||||
if (!window.go || !window.go.main || !window.go.main.App) {
|
||||
console.warn('Wails 绑定未准备好,等待重试...')
|
||||
setTimeout(() => loadConfig(), 100)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await window.go.main.App.GetAppConfig()
|
||||
if (result.success) {
|
||||
const tabs = result.data.tabs || []
|
||||
const visibleTabs = result.data.visibleTabs || []
|
||||
|
||||
// 确保 tabs 数组中的 visible 属性与 visibleTabs 同步
|
||||
const syncedTabs = tabs.map(tab => ({
|
||||
...tab,
|
||||
visible: visibleTabs.includes(tab.key)
|
||||
}))
|
||||
|
||||
appConfig.value = {
|
||||
tabs: syncedTabs,
|
||||
visibleTabs: visibleTabs,
|
||||
defaultTab: result.data.defaultTab || 'file-system'
|
||||
}
|
||||
|
||||
// 设置默认 Tab
|
||||
activeTab.value = appConfig.value.defaultTab
|
||||
} else {
|
||||
console.error('加载配置失败:', result.message)
|
||||
// 使用默认配置
|
||||
useDefaultConfig()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error)
|
||||
// 使用默认配置
|
||||
useDefaultConfig()
|
||||
}
|
||||
}
|
||||
|
||||
// 使用默认配置
|
||||
const useDefaultConfig = () => {
|
||||
appConfig.value = {
|
||||
tabs: [
|
||||
{key: 'file-system', title: '文件管理', visible: true, enabled: true},
|
||||
{key: 'db-cli', title: '数据库', visible: true, enabled: true}
|
||||
],
|
||||
visibleTabs: ['file-system', 'db-cli'],
|
||||
defaultTab: 'file-system'
|
||||
}
|
||||
}
|
||||
// 可见 Tabs(从 store 获取)
|
||||
const visibleTabs = computed(() => configStore.visibleTabs)
|
||||
|
||||
// 保存配置
|
||||
const handleSaveConfig = async (config) => {
|
||||
const handleSaveConfig = async (config: AppConfig) => {
|
||||
try {
|
||||
const result = await window.go.main.App.SaveAppConfig({
|
||||
tabs: config.tabs,
|
||||
visibleTabs: config.visibleTabs,
|
||||
defaultTab: config.defaultTab
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
// 更新本地配置
|
||||
appConfig.value = {
|
||||
tabs: [...config.tabs],
|
||||
visibleTabs: [...config.visibleTabs],
|
||||
defaultTab: config.defaultTab
|
||||
}
|
||||
await configStore.saveConfig(config)
|
||||
showSettings.value = false
|
||||
|
||||
// 如果当前激活的 Tab 被隐藏,切换到默认 Tab
|
||||
if (!config.visibleTabs.includes(activeTab.value)) {
|
||||
activeTab.value = config.defaultTab
|
||||
}
|
||||
|
||||
Message.success('配置保存成功')
|
||||
showSettings.value = false
|
||||
} else {
|
||||
Message.error(result.message || '保存配置失败')
|
||||
throw new Error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
// 错误已在 store 中处理
|
||||
console.error('保存配置失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 加载配置(调用 store 方法)
|
||||
const loadConfig = async () => {
|
||||
await configStore.loadConfig()
|
||||
// 设置默认 Tab
|
||||
activeTab.value = configStore.defaultTab
|
||||
}
|
||||
|
||||
// 获取组件
|
||||
const getComponent = (key) => {
|
||||
const getComponent = (key: string) => {
|
||||
const components = {
|
||||
'file-system': FileSystem,
|
||||
'db-cli': DbCli
|
||||
'markdown-editor': MarkdownEditor
|
||||
}
|
||||
return components[key] || null
|
||||
}
|
||||
|
||||
// 检查更新
|
||||
const checkForUpdates = async () => {
|
||||
try {
|
||||
// 等待 Wails 绑定准备好
|
||||
if (!window.go || !window.go.main || !window.go.main.App) {
|
||||
console.warn('Wails 绑定未准备好,延迟检查更新...')
|
||||
setTimeout(() => checkForUpdates(), 1000)
|
||||
return
|
||||
// 组件挂载时加载配置
|
||||
// 禁止 Ctrl+滚轮缩放
|
||||
const preventZoom = (e: WheelEvent) => {
|
||||
if (e.ctrlKey) e.preventDefault()
|
||||
}
|
||||
|
||||
// 获取更新配置
|
||||
const configResult = await window.go.main.App.GetUpdateConfig()
|
||||
if (!configResult.success) {
|
||||
console.error('获取更新配置失败:', configResult.message)
|
||||
return
|
||||
}
|
||||
|
||||
const config = configResult.data
|
||||
const shouldCheck = config.auto_check_enabled
|
||||
|
||||
if (!shouldCheck) {
|
||||
console.log('自动更新检查已关闭')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[自动检查] 开始检查更新...')
|
||||
|
||||
// 检查更新
|
||||
const result = await window.go.main.App.CheckUpdate()
|
||||
if (result.success && result.data) {
|
||||
checkedUpdate.value = true
|
||||
|
||||
// 检查是否已跳过此版本
|
||||
const skippedVersion = localStorage.getItem('skipped_version')
|
||||
if (result.data.has_update) {
|
||||
// 如果是强制更新,或者未跳过此版本,则显示提示
|
||||
if (result.data.force_update || skippedVersion !== result.data.latest_version) {
|
||||
console.log('[自动检查] 发现新版本:', result.data.latest_version)
|
||||
updateInfo.value = result.data
|
||||
// 延迟显示,让用户先看到应用界面
|
||||
setTimeout(() => {
|
||||
showUpdateNotification.value = true
|
||||
}, 2000)
|
||||
} else {
|
||||
console.log('[自动检查] 此版本已跳过')
|
||||
}
|
||||
} else {
|
||||
console.log('[自动检查] 已是最新版本')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查更新失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载配置和检查更新
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
// 延迟检查更新,避免阻塞应用启动
|
||||
|
||||
// 设置更新事件监听
|
||||
updateStore.setupEventListeners()
|
||||
|
||||
// 禁止 Ctrl+滚轮缩放
|
||||
document.addEventListener('wheel', preventZoom, { passive: false })
|
||||
|
||||
// 延迟检查更新(启动后 3 秒,静默模式)
|
||||
setTimeout(() => {
|
||||
if (!checkedUpdate.value) {
|
||||
checkForUpdates()
|
||||
}
|
||||
updateStore.checkForUpdates(true)
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
// 监听 activeTab 变化,自动保存到 localStorage
|
||||
watch(activeTab, (newTab) => {
|
||||
localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, newTab)
|
||||
// 组件卸载时清理事件监听
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('wheel', preventZoom)
|
||||
updateStore.removeEventListeners()
|
||||
// 兜底清除所有 Wails 事件监听器,防止泄漏
|
||||
window.runtime?.EventsOffAll?.()
|
||||
})
|
||||
|
||||
// 窗口控制方法
|
||||
@@ -300,6 +196,16 @@ const handleMinimize = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTogglePin = async () => {
|
||||
try {
|
||||
if (window.go?.main?.App?.WindowToggleAlwaysOnTop) {
|
||||
isPinned.value = await window.go.main.App.WindowToggleAlwaysOnTop()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换置顶失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMaximize = async () => {
|
||||
try {
|
||||
if (window.go?.main?.App?.WindowMaximize) {
|
||||
@@ -321,38 +227,14 @@ const handleClose = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 升级提示事件处理
|
||||
const handleUpdateInstall = async (filePath) => {
|
||||
try {
|
||||
const result = await window.go.main.App.InstallUpdate(filePath, true)
|
||||
if (result.success) {
|
||||
Message.success({
|
||||
content: '安装成功!应用将在几秒后重启...',
|
||||
duration: 3000
|
||||
})
|
||||
} else {
|
||||
Message.error(result.message || '安装失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('安装失败:', error)
|
||||
Message.error('安装失败:' + (error.message || error))
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateSkip = () => {
|
||||
// 清除跳过的版本记录(如果用户选择"稍后提醒")
|
||||
// 版本记录在组件内部处理
|
||||
}
|
||||
|
||||
// 监听 activeTab 变化,如果当前 Tab 不在可见列表中,切换到默认 Tab
|
||||
watch(activeTab, (newTab) => {
|
||||
// 保存到 localStorage
|
||||
localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, newTab)
|
||||
|
||||
// 检查 Tab 是否在可见列表中
|
||||
// 检查一级 Tab 是否在可见列表中
|
||||
const isVisible = appConfig.value.visibleTabs.includes(newTab)
|
||||
if (!isVisible && appConfig.value.visibleTabs.length > 0) {
|
||||
// 切换到默认 Tab
|
||||
if (!isVisible && appConfig.value.visibleTabs.length > 0 && newTab !== appConfig.value.defaultTab) {
|
||||
activeTab.value = appConfig.value.defaultTab
|
||||
}
|
||||
})
|
||||
@@ -439,6 +321,25 @@ watch(activeTab, (newTab) => {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.pin-active {
|
||||
color: rgb(var(--primary-6)) !important;
|
||||
}
|
||||
|
||||
.pin-active :deep(svg) {
|
||||
transform: none !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.header-actions :deep(.arco-icon-pushpin) {
|
||||
transform: rotate(45deg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.header-actions :deep(.arco-icon-pushpin.pinned) {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.window-control-btn svg {
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
@@ -474,4 +375,9 @@ watch(activeTab, (newTab) => {
|
||||
.arco-tooltip {
|
||||
--wails-draggable: no-drag;
|
||||
}
|
||||
|
||||
/* 桌面应用:禁止 html/body 级别滚动条,所有滚动由内部组件自行处理 */
|
||||
html, body {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
199
web/src/api/connection-manager.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 连接管理器 — 管理本地/远程传输层切换
|
||||
*/
|
||||
|
||||
import type { FsTransport } from './transport'
|
||||
import { WailsTransport } from './wails-transport'
|
||||
import { HttpTransport } from './http-transport'
|
||||
|
||||
export type ConnectionType = 'local' | 'remote'
|
||||
|
||||
export interface ConnectionProfile {
|
||||
id: string
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
token: string
|
||||
type: ConnectionType
|
||||
lastConnected?: number
|
||||
}
|
||||
|
||||
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
const PROFILES_KEY = 'fs_connection_profiles'
|
||||
const ACTIVE_KEY = 'fs_active_connection'
|
||||
|
||||
class ConnectionManagerImpl {
|
||||
private _transport: FsTransport | null = null
|
||||
private _profiles: ConnectionProfile[] = []
|
||||
private _activeId: string | null = null
|
||||
private _state: ConnectionState = 'disconnected'
|
||||
private _stateChangeCallbacks: ((state: ConnectionState) => void)[] = []
|
||||
private _connectSeq = 0
|
||||
|
||||
constructor() {
|
||||
this.loadProfiles()
|
||||
this.initDefaultLocal()
|
||||
}
|
||||
|
||||
private initDefaultLocal() {
|
||||
const localProfile: ConnectionProfile = {
|
||||
id: 'local-default',
|
||||
name: '本地',
|
||||
host: '',
|
||||
port: 0,
|
||||
token: '',
|
||||
type: 'local',
|
||||
}
|
||||
if (!this._profiles.find(p => p.id === localProfile.id)) {
|
||||
this._profiles.unshift(localProfile)
|
||||
}
|
||||
// 默认连接本地
|
||||
if (!this._activeId) {
|
||||
this._activeId = localProfile.id
|
||||
}
|
||||
this.applyActive()
|
||||
}
|
||||
|
||||
private loadProfiles() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROFILES_KEY)
|
||||
if (raw) this._profiles = JSON.parse(raw)
|
||||
this._activeId = localStorage.getItem(ACTIVE_KEY)
|
||||
} catch { /* 首次使用 */ }
|
||||
}
|
||||
|
||||
private saveProfiles() {
|
||||
localStorage.setItem(PROFILES_KEY, JSON.stringify(this._profiles))
|
||||
if (this._activeId) {
|
||||
localStorage.setItem(ACTIVE_KEY, this._activeId)
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: ConnectionState) {
|
||||
this._state = state
|
||||
this.notifyChange()
|
||||
}
|
||||
|
||||
private notifyChange() {
|
||||
this._stateChangeCallbacks.forEach(cb => cb(this._state))
|
||||
}
|
||||
|
||||
onStateChange(cb: (state: ConnectionState) => void) {
|
||||
this._stateChangeCallbacks.push(cb)
|
||||
}
|
||||
|
||||
get state(): ConnectionState {
|
||||
return this._state
|
||||
}
|
||||
|
||||
get profiles(): ConnectionProfile[] {
|
||||
return [...this._profiles]
|
||||
}
|
||||
|
||||
get activeProfile(): ConnectionProfile | null {
|
||||
return this._profiles.find(p => p.id === this._activeId) ?? null
|
||||
}
|
||||
|
||||
getTransport(): FsTransport {
|
||||
if (!this._transport) {
|
||||
this.applyActive()
|
||||
}
|
||||
return this._transport!
|
||||
}
|
||||
|
||||
getFileServerBaseURL(): string {
|
||||
if (this._transport instanceof HttpTransport) {
|
||||
const profile = this.activeProfile
|
||||
if (!profile) return ''
|
||||
const scheme = profile.port === 443 ? 'https' : 'http'
|
||||
const port = (profile.port === 80 || profile.port === 443) ? '' : `:${profile.port}`
|
||||
return `${scheme}://${profile.host}${port}`
|
||||
}
|
||||
// Wails 模式返回空字符串,让 useFilePreview 走原有逻辑
|
||||
return ''
|
||||
}
|
||||
|
||||
isRemote(): boolean {
|
||||
return this.activeProfile?.type === 'remote'
|
||||
}
|
||||
|
||||
connect(profileId: string): void {
|
||||
const profile = this._profiles.find(p => p.id === profileId)
|
||||
if (!profile) return
|
||||
|
||||
this._activeId = profileId
|
||||
this.saveProfiles()
|
||||
this.applyActive()
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this._activeId = 'local-default'
|
||||
this.saveProfiles()
|
||||
this.applyActive()
|
||||
}
|
||||
|
||||
addProfile(profile: Omit<ConnectionProfile, 'id'>): ConnectionProfile {
|
||||
const newProfile: ConnectionProfile = {
|
||||
...profile,
|
||||
id: crypto.randomUUID(),
|
||||
}
|
||||
this._profiles.push(newProfile)
|
||||
this.saveProfiles()
|
||||
this.notifyChange()
|
||||
return newProfile
|
||||
}
|
||||
|
||||
updateProfile(id: string, updates: Partial<ConnectionProfile>): void {
|
||||
const idx = this._profiles.findIndex(p => p.id === id)
|
||||
if (idx >= 0) {
|
||||
this._profiles[idx] = { ...this._profiles[idx], ...updates }
|
||||
this.saveProfiles()
|
||||
// 仅当连接参数变化时重新应用(避免 lastConnected 等元数据更新触发死循环)
|
||||
const REAPPLY_KEYS = ['host', 'port', 'token'] as const
|
||||
const needsReapply = REAPPLY_KEYS.some(k => k in updates)
|
||||
if (needsReapply && id === this._activeId) {
|
||||
this.applyActive()
|
||||
}
|
||||
this.notifyChange()
|
||||
}
|
||||
}
|
||||
|
||||
removeProfile(id: string): void {
|
||||
if (id === 'local-default') return // 不允许删除本地配置
|
||||
this._profiles = this._profiles.filter(p => p.id !== id)
|
||||
if (this._activeId === id) {
|
||||
this._activeId = 'local-default'
|
||||
}
|
||||
this.saveProfiles()
|
||||
this.applyActive()
|
||||
this.notifyChange()
|
||||
}
|
||||
|
||||
private applyActive() {
|
||||
const profile = this.activeProfile
|
||||
const seq = ++this._connectSeq
|
||||
if (!profile || profile.type === 'local') {
|
||||
this._transport = new WailsTransport()
|
||||
this.setState('connected')
|
||||
} else {
|
||||
this.setState('connecting')
|
||||
try {
|
||||
this._transport = new HttpTransport(profile.host, profile.port, profile.token)
|
||||
// 快速连通性检查(用轻量 ping 代替 getCommonPaths)
|
||||
this._transport.getFileInfo('/').then(() => {
|
||||
if (seq !== this._connectSeq) return // 已被后续连接覆盖
|
||||
this.setState('connected')
|
||||
this.updateProfile(profile.id!, { lastConnected: Date.now() })
|
||||
}).catch(() => {
|
||||
if (seq !== this._connectSeq) return
|
||||
this.setState('error')
|
||||
})
|
||||
} catch {
|
||||
this.setState('error')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const connectionManager = new ConnectionManagerImpl()
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* 连接相关 API
|
||||
*/
|
||||
|
||||
import type { Connection } from './types'
|
||||
|
||||
/**
|
||||
* 获取连接列表
|
||||
*/
|
||||
export async function listConnections(): Promise<Connection[]> {
|
||||
if (!window.go?.main?.App?.ListDbConnections) {
|
||||
throw new Error('ListDbConnections API 不可用')
|
||||
}
|
||||
return await window.go.main.App.ListDbConnections()
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除连接
|
||||
*/
|
||||
export async function deleteConnection(id: number): Promise<void> {
|
||||
if (!window.go?.main?.App?.DeleteDbConnection) {
|
||||
throw new Error('DeleteDbConnection API 不可用')
|
||||
}
|
||||
await window.go.main.App.DeleteDbConnection(id)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* 数据库和表相关 API
|
||||
*/
|
||||
|
||||
import type { Database, Table } from './types'
|
||||
|
||||
/**
|
||||
* 获取数据库列表
|
||||
*/
|
||||
export async function getDatabases(connectionId: number): Promise<Database[]> {
|
||||
if (!window.go?.main?.App?.GetDatabases) {
|
||||
throw new Error('GetDatabases API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetDatabases(connectionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表列表
|
||||
*/
|
||||
export async function getTables(connectionId: number, database: string): Promise<Table[]> {
|
||||
if (!window.go?.main?.App?.GetTables) {
|
||||
throw new Error('GetTables API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetTables(connectionId, database)
|
||||
}
|
||||
136
web/src/api/http-transport.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Http Transport — 远程文件操作(通过 u-fs-agent REST API)
|
||||
*/
|
||||
|
||||
import type { FsTransport, FileItem, FileOperationResult, DetectTypeResult } from './transport'
|
||||
|
||||
const CONTENT_TYPE = 'application/json'
|
||||
|
||||
export class HttpTransport implements FsTransport {
|
||||
private baseUrl: string
|
||||
private token: string
|
||||
|
||||
constructor(host: string, port: number, token: string) {
|
||||
const scheme = port === 443 ? 'https' : 'http'
|
||||
this.baseUrl = `${scheme}://${host}${port === 80 || port === 443 ? '' : ':' + port}`
|
||||
this.token = token
|
||||
}
|
||||
|
||||
private headers(): HeadersInit {
|
||||
const h: Record<string, string> = { 'Content-Type': CONTENT_TYPE }
|
||||
if (this.token) h['Authorization'] = `Bearer ${this.token}`
|
||||
return h
|
||||
}
|
||||
|
||||
private async request<T>(method: string, path: string, params?: Record<string, string>, body?: any): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`
|
||||
const searchParams = params ? '?' + new URLSearchParams(params).toString() : ''
|
||||
const opts: RequestInit = {
|
||||
method,
|
||||
headers: this.headers(),
|
||||
}
|
||||
if (body !== undefined) opts.body = JSON.stringify(body)
|
||||
|
||||
const res = await fetch(url + searchParams, opts)
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
|
||||
if (data.code >= 400) {
|
||||
throw new Error(data.message || `请求失败 (code=${data.code})`)
|
||||
}
|
||||
return data.data ?? data
|
||||
}
|
||||
|
||||
async listDir(path: string): Promise<FileItem[]> {
|
||||
return this.request<FileItem[]>('GET', '/api/v1/fs', { path })
|
||||
}
|
||||
|
||||
async getFileInfo(path: string): Promise<Record<string, any>> {
|
||||
return this.request<Record<string, any>>('GET', '/api/v1/fs', { path, get: 'stat' })
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<string> {
|
||||
const data = await this.request<{ content: string }>('GET', '/api/v1/fs/read', { path })
|
||||
return data.content
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: string): Promise<void> {
|
||||
await this.request('PUT', '/api/v1/fs/write', { path }, { content })
|
||||
}
|
||||
|
||||
async saveBase64File(path: string, content: string): Promise<void> {
|
||||
if (!content) throw new Error('无效的 base64 内容')
|
||||
await this.request('POST', '/api/v1/fs/upload', { path }, { content })
|
||||
}
|
||||
|
||||
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
|
||||
return this.request<FileOperationResult>('POST', '/api/v1/fs/create', { path: dirPath }, { type: 'file', name: filename })
|
||||
}
|
||||
|
||||
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
|
||||
return this.request<FileOperationResult>('POST', '/api/v1/fs/create', { path: parentPath }, { type: 'dir', name: dirname })
|
||||
}
|
||||
|
||||
async deletePath(path: string): Promise<FileOperationResult> {
|
||||
return this.request<FileOperationResult>('DELETE', '/api/v1/fs/delete', { path })
|
||||
}
|
||||
|
||||
async renamePath(oldPath: string, newPath: string): Promise<FileOperationResult> {
|
||||
return this.request<FileOperationResult>('PATCH', '/api/v1/fs/rename', { path: oldPath }, { new_path: newPath })
|
||||
}
|
||||
|
||||
async listZipContents(zipPath: string): Promise<FileItem[]> {
|
||||
// Wave 3 实现
|
||||
throw new Error('ZIP 操作在远程模式暂未实现')
|
||||
}
|
||||
|
||||
async extractFileFromZip(_zipPath: string, _filePath: string): Promise<string> {
|
||||
throw new Error('ZIP 操作在远程模式暂未实现')
|
||||
}
|
||||
|
||||
async extractFileFromZipToTemp(_zipPath: string, _filePath: string): Promise<string> {
|
||||
throw new Error('ZIP 操作在远程模式暂未实现')
|
||||
}
|
||||
|
||||
async getZipFileInfo(_zipPath: string, _filePath: string): Promise<FileItem> {
|
||||
throw new Error('ZIP 操作在远程模式暂未实现')
|
||||
}
|
||||
|
||||
async openPath(_path: string): Promise<void> {
|
||||
throw new Error('远程模式不支持打开本地路径')
|
||||
}
|
||||
|
||||
async getFileServerURL(): Promise<string> {
|
||||
return `${this.baseUrl}/api/v1/proxy/localfs`
|
||||
}
|
||||
|
||||
/** 远程模式预览用的认证 token(拼接到 URL query) */
|
||||
getPreviewToken(): string {
|
||||
return this.token
|
||||
}
|
||||
|
||||
async resolveShortcut(_lnkPath: string): Promise<any> {
|
||||
return null
|
||||
}
|
||||
|
||||
async detectFileTypeByContent(path: string): Promise<DetectTypeResult> {
|
||||
return this.request<DetectTypeResult>('GET', '/api/v1/fs/detect', { path })
|
||||
}
|
||||
|
||||
async getCommonPaths(): Promise<Record<string, string>> {
|
||||
return this.request<Record<string, string>>('GET', '/api/v1/system/common-paths')
|
||||
}
|
||||
|
||||
async getRecycleBinEntries(): Promise<any[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
async restoreFromRecycleBin(_path: string): Promise<void> {}
|
||||
|
||||
async deletePermanently(_path: string): Promise<void> {}
|
||||
|
||||
async emptyRecycleBin(): Promise<void> {}
|
||||
}
|
||||
@@ -3,9 +3,4 @@
|
||||
*/
|
||||
|
||||
export * from './types'
|
||||
export * from './connection'
|
||||
export * from './database'
|
||||
export * from './structure'
|
||||
export * from './query'
|
||||
export * from './tab'
|
||||
export * from './system'
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* SQL 查询相关 API
|
||||
*/
|
||||
|
||||
import type { QueryResult } from './types'
|
||||
|
||||
/**
|
||||
* 执行 SQL 查询
|
||||
*/
|
||||
export async function executeQuery(
|
||||
connectionId: number,
|
||||
sql: string,
|
||||
database?: string,
|
||||
page?: number,
|
||||
pageSize?: number
|
||||
): Promise<QueryResult> {
|
||||
if (!window.go?.main?.App?.ExecuteSQL) {
|
||||
throw new Error('ExecuteSQL API 不可用')
|
||||
}
|
||||
return await window.go.main.App.ExecuteSQL(connectionId, sql, database, page, pageSize)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* 表结构相关 API
|
||||
*/
|
||||
|
||||
import type { Structure } from './types'
|
||||
|
||||
/**
|
||||
* 获取表结构
|
||||
*/
|
||||
export async function getTableStructure(
|
||||
connectionId: number,
|
||||
database: string,
|
||||
table: string
|
||||
): Promise<Structure> {
|
||||
if (!window.go?.main?.App?.GetTableStructure) {
|
||||
throw new Error('GetTableStructure API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetTableStructure(connectionId, database, table)
|
||||
}
|
||||
@@ -1,278 +1,110 @@
|
||||
/**
|
||||
* 系统信息相关 API
|
||||
* 系统信息相关 API — 委托给 Transport 层
|
||||
* 本地模式走 Wails IPC,远程模式走 HTTP REST API
|
||||
*/
|
||||
|
||||
import type { SystemInfo, CPU, Memory, Disk, File } from './types'
|
||||
import type { File } from './types'
|
||||
import { connectionManager } from './connection-manager'
|
||||
|
||||
/**
|
||||
* 获取系统信息
|
||||
* 转换后端文件数据格式(蛇形 → 驼峰)
|
||||
*/
|
||||
export async function getSystemInfo(): Promise<SystemInfo> {
|
||||
if (!window.go?.main?.App?.GetSystemInfo) {
|
||||
throw new Error('GetSystemInfo API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetSystemInfo()
|
||||
function transformFile(file: any): File {
|
||||
return { ...file, isDir: file.is_dir, modified_time: file.mod_time }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 CPU 信息
|
||||
*/
|
||||
export async function getCPUInfo(): Promise<CPU> {
|
||||
if (!window.go?.main?.App?.GetCPUInfo) {
|
||||
throw new Error('GetCPUInfo API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetCPUInfo()
|
||||
function transformFileList(files: any[]): File[] {
|
||||
return files.map(transformFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内存信息
|
||||
*/
|
||||
export async function getMemoryInfo(): Promise<Memory> {
|
||||
if (!window.go?.main?.App?.GetMemoryInfo) {
|
||||
throw new Error('GetMemoryInfo API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetMemoryInfo()
|
||||
const t = () => connectionManager.getTransport()
|
||||
|
||||
export async function getSystemInfo() { return t().getFileInfo('/') }
|
||||
|
||||
export async function getCPUInfo() {
|
||||
if (connectionManager.isRemote()) return {}
|
||||
try { return await (window.go?.main?.App?.GetCPUInfo?.()) ?? {} } catch { return {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取磁盘信息
|
||||
*/
|
||||
export async function getDiskInfo(): Promise<Disk> {
|
||||
if (!window.go?.main?.App?.GetDiskInfo) {
|
||||
throw new Error('GetDiskInfo API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetDiskInfo()
|
||||
export async function getMemoryInfo() {
|
||||
if (connectionManager.isRemote()) return {}
|
||||
try { return await (window.go?.main?.App?.GetMemoryInfo?.()) ?? {} } catch { return {} }
|
||||
}
|
||||
|
||||
export async function getDiskInfo() {
|
||||
if (connectionManager.isRemote()) return {}
|
||||
try { return await (window.go?.main?.App?.GetDiskInfo?.()) ?? {} } catch { return {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出目录文件
|
||||
*/
|
||||
export async function listDir(path: string): Promise<File[]> {
|
||||
if (!window.go?.main?.App?.ListDir) {
|
||||
throw new Error('ListDir API 不可用')
|
||||
}
|
||||
return await window.go.main.App.ListDir(path)
|
||||
return transformFileList(await t().listDir(path))
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件
|
||||
*/
|
||||
export async function readFile(path: string): Promise<string> {
|
||||
if (!window.go?.main?.App?.ReadFile) {
|
||||
throw new Error('ReadFile API 不可用')
|
||||
}
|
||||
return await window.go.main.App.ReadFile(path)
|
||||
return t().readFile(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入文件
|
||||
*/
|
||||
export async function writeFile(path: string, content: string): Promise<void> {
|
||||
if (!window.go?.main?.App?.WriteFile) {
|
||||
throw new Error('WriteFile API 不可用')
|
||||
}
|
||||
// 确保传递的是字符串类型
|
||||
await window.go.main.App.WriteFile({
|
||||
path: String(path),
|
||||
content: String(content)
|
||||
})
|
||||
await t().writeFile(path, String(content))
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件或目录
|
||||
*/
|
||||
export async function deletePath(path: string): Promise<void> {
|
||||
if (!window.go?.main?.App?.DeletePath) {
|
||||
throw new Error('DeletePath API 不可用')
|
||||
}
|
||||
await window.go.main.App.DeletePath(path)
|
||||
export async function saveBase64File(path: string, base64Content: string): Promise<void> {
|
||||
if (!base64Content) throw new Error('无效的 base64 内容')
|
||||
await t().saveBase64File(path, base64Content)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建目录
|
||||
*/
|
||||
export async function createDir(path: string): Promise<void> {
|
||||
if (!window.go?.main?.App?.CreateDir) {
|
||||
throw new Error('CreateDir API 不可用')
|
||||
}
|
||||
await window.go.main.App.CreateDir(path)
|
||||
export async function deletePath(path: string): Promise<any> {
|
||||
return t().deletePath(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件
|
||||
*/
|
||||
export async function createFile(path: string): Promise<void> {
|
||||
if (!window.go?.main?.App?.CreateFile) {
|
||||
throw new Error('CreateFile API 不可用')
|
||||
}
|
||||
await window.go.main.App.CreateFile(path)
|
||||
export async function createDir(parentPath: string, dirname: string): Promise<any> {
|
||||
return t().createDir(parentPath, dirname)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名文件或目录
|
||||
*/
|
||||
export async function renamePath(oldPath: string, newPath: string): Promise<void> {
|
||||
if (!window.go?.main?.App?.RenamePath) {
|
||||
throw new Error('RenamePath API 不可用')
|
||||
}
|
||||
await window.go.main.App.RenamePath({
|
||||
oldPath: String(oldPath),
|
||||
newPath: String(newPath)
|
||||
})
|
||||
export async function createFile(dirPath: string, filename: string): Promise<any> {
|
||||
return t().createFile(dirPath, filename)
|
||||
}
|
||||
|
||||
export async function renamePath(oldPath: string, newPath: string): Promise<any> {
|
||||
return t().renamePath(oldPath, String(newPath))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取环境变量
|
||||
*/
|
||||
export async function getEnvVars(): Promise<Record<string, string>> {
|
||||
if (!window.go?.main?.App?.GetEnvVars) {
|
||||
throw new Error('GetEnvVars API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetEnvVars()
|
||||
try { return await (window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出 zip 文件内容
|
||||
*/
|
||||
export async function listZipContents(zipPath: string): Promise<File[]> {
|
||||
console.log('[API] listZipContents 调用:', zipPath)
|
||||
if (!window.go?.main?.App?.ListZipContents) {
|
||||
throw new Error('ListZipContents API 不可用')
|
||||
}
|
||||
try {
|
||||
const result = await window.go.main.App.ListZipContents(zipPath)
|
||||
console.log('[API] listZipContents 结果:', result?.length || 0, '个文件')
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('[API] listZipContents 错误:', error)
|
||||
throw error
|
||||
}
|
||||
return transformFileList(await t().listZipContents(zipPath))
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 zip 文件中提取单个文件内容
|
||||
*/
|
||||
export async function extractFileFromZip(zipPath: string, filePath: string): Promise<string> {
|
||||
console.log('[API] extractFileFromZip 调用:', { zipPath, filePath })
|
||||
if (!window.go?.main?.App?.ExtractFileFromZip) {
|
||||
throw new Error('ExtractFileFromZip API 不可用')
|
||||
}
|
||||
try {
|
||||
const result = await window.go.main.App.ExtractFileFromZip(zipPath, filePath)
|
||||
console.log('[API] extractFileFromZip 成功, 内容长度:', result?.length || 0)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('[API] extractFileFromZip 错误:', error)
|
||||
throw error
|
||||
}
|
||||
return t().extractFileFromZip(zipPath, filePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 zip 文件中提取单个文件到临时目录
|
||||
* 返回临时文件的完整路径,适用于图片等二进制文件
|
||||
*/
|
||||
export async function extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string> {
|
||||
console.log('[API] extractFileFromZipToTemp 调用:', { zipPath, filePath })
|
||||
if (!window.go?.main?.App?.ExtractFileFromZipToTemp) {
|
||||
throw new Error('ExtractFileFromZipToTemp API 不可用')
|
||||
}
|
||||
try {
|
||||
const result = await window.go.main.App.ExtractFileFromZipToTemp(zipPath, filePath)
|
||||
console.log('[API] extractFileFromZipToTemp 成功, 临时文件路径:', result)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('[API] extractFileFromZipToTemp 错误:', error)
|
||||
throw error
|
||||
}
|
||||
return t().extractFileFromZipToTemp(zipPath, filePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 zip 文件中特定文件的信息
|
||||
*/
|
||||
export async function getZipFileInfo(zipPath: string, filePath: string): Promise<File> {
|
||||
console.log('[API] getZipFileInfo 调用:', { zipPath, filePath })
|
||||
if (!window.go?.main?.App?.GetZipFileInfo) {
|
||||
throw new Error('GetZipFileInfo API 不可用')
|
||||
}
|
||||
try {
|
||||
const result = await window.go.main.App.GetZipFileInfo(zipPath, filePath)
|
||||
console.log('[API] getZipFileInfo 结果:', result)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('[API] getZipFileInfo 错误:', error)
|
||||
throw error
|
||||
}
|
||||
return transformFile(await t().getZipFileInfo(zipPath, filePath))
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用系统默认程序打开文件或目录
|
||||
*/
|
||||
export async function openPath(path: string): Promise<void> {
|
||||
console.log('[API] openPath 调用:', path)
|
||||
if (!window.go?.main?.App?.OpenPath) {
|
||||
throw new Error('OpenPath API 不可用')
|
||||
}
|
||||
try {
|
||||
await window.go.main.App.OpenPath(path)
|
||||
console.log('[API] openPath 成功')
|
||||
} catch (error) {
|
||||
console.error('[API] openPath 错误:', error)
|
||||
throw error
|
||||
}
|
||||
await t().openPath(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地文件服务器URL
|
||||
*/
|
||||
export async function getFileServerURL(): Promise<string> {
|
||||
if (!window.go?.main?.App?.GetFileServerURL) {
|
||||
throw new Error('GetFileServerURL API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetFileServerURL()
|
||||
return t().getFileServerURL()
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析快捷方式文件,返回目标路径信息
|
||||
*/
|
||||
export async function resolveShortcut(lnkPath: string): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
targetPath?: string
|
||||
targetExists?: boolean
|
||||
targetAccessible?: boolean
|
||||
targetInfo?: any
|
||||
}> {
|
||||
console.log('[API] resolveShortcut 调用:', lnkPath)
|
||||
if (!window.go?.main?.App?.ResolveShortcut) {
|
||||
throw new Error('ResolveShortcut API 不可用')
|
||||
}
|
||||
try {
|
||||
const result = await window.go.main.App.ResolveShortcut(lnkPath)
|
||||
console.log('[API] resolveShortcut 结果:', result)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('[API] resolveShortcut 错误:', error)
|
||||
throw error
|
||||
}
|
||||
export async function resolveShortcut(lnkPath: string): Promise<any> {
|
||||
return t().resolveShortcut(lnkPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过文件内容检测文件类型(用于小文件,500KB以内)
|
||||
*/
|
||||
export async function detectFileTypeByContent(path: string): Promise<{
|
||||
extension: string
|
||||
category: string // 'image' | 'text' | 'binary' | 'pdf' | 'archive' | 'unknown'
|
||||
mime_type: string
|
||||
confidence: number
|
||||
}> {
|
||||
if (!window.go?.main?.App?.DetectFileTypeByContent) {
|
||||
throw new Error('DetectFileTypeByContent API 不可用')
|
||||
}
|
||||
try {
|
||||
const result = await window.go.main.App.DetectFileTypeByContent(path)
|
||||
return result as any
|
||||
} catch (error) {
|
||||
console.error('[API] detectFileTypeByContent 错误:', error)
|
||||
throw error
|
||||
export async function detectFileTypeByContent(path: string) {
|
||||
return t().detectFileTypeByContent(path)
|
||||
}
|
||||
|
||||
export async function getCommonPaths() {
|
||||
return t().getCommonPaths()
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* 标签页相关 API
|
||||
*/
|
||||
|
||||
import type { Tab } from './types'
|
||||
|
||||
/**
|
||||
* 保存标签页
|
||||
*/
|
||||
export async function saveTabs(tabs: Tab[]): Promise<void> {
|
||||
if (!window.go?.main?.App?.SaveSqlTabs) {
|
||||
throw new Error('SaveSqlTabs API 不可用')
|
||||
}
|
||||
await window.go.main.App.SaveSqlTabs(tabs)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取标签页列表
|
||||
*/
|
||||
export async function listTabs(): Promise<Tab[]> {
|
||||
if (!window.go?.main?.App?.ListSqlTabs) {
|
||||
throw new Error('ListSqlTabs API 不可用')
|
||||
}
|
||||
return await window.go.main.App.ListSqlTabs()
|
||||
}
|
||||
71
web/src/api/transport.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 文件系统传输层接口
|
||||
* 本地模式走 Wails IPC,远程模式走 HTTP REST API
|
||||
* Composable 和组件不感知底层差异
|
||||
*/
|
||||
|
||||
export type FileItem = {
|
||||
name: string
|
||||
path: string
|
||||
size: number
|
||||
size_str?: string
|
||||
is_dir: boolean
|
||||
mod_time?: string
|
||||
mode?: string
|
||||
}
|
||||
|
||||
export type FileOperationResult = {
|
||||
path: string
|
||||
name: string
|
||||
size: number
|
||||
size_str?: string
|
||||
is_dir: boolean
|
||||
mod_time?: string
|
||||
mode?: string
|
||||
old_path?: string
|
||||
deleted?: boolean
|
||||
}
|
||||
|
||||
export type DetectTypeResult = {
|
||||
extension: string
|
||||
category: string
|
||||
mime_type: string
|
||||
confidence: number
|
||||
}
|
||||
|
||||
export interface FsTransport {
|
||||
// 文件列表与信息
|
||||
listDir(path: string): Promise<FileItem[]>
|
||||
getFileInfo(path: string): Promise<Record<string, any>>
|
||||
|
||||
// 文件读写
|
||||
readFile(path: string): Promise<string>
|
||||
writeFile(path: string, content: string): Promise<void>
|
||||
saveBase64File(path: string, content: string): Promise<void>
|
||||
|
||||
// 文件操作
|
||||
createFile(dirPath: string, filename: string): Promise<FileOperationResult>
|
||||
createDir(parentPath: string, dirname: string): Promise<FileOperationResult>
|
||||
deletePath(path: string): Promise<FileOperationResult>
|
||||
renamePath(oldPath: string, newPath: string): Promise<FileOperationResult>
|
||||
|
||||
// ZIP 操作(Wave 3)
|
||||
listZipContents(zipPath: string): Promise<FileItem[]>
|
||||
extractFileFromZip(zipPath: string, filePath: string): Promise<string>
|
||||
extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string>
|
||||
getZipFileInfo(zipPath: string, filePath: string): Promise<FileItem>
|
||||
|
||||
// 系统操作
|
||||
openPath(path: string): Promise<void>
|
||||
getFileServerURL(): Promise<string>
|
||||
getPreviewToken(): string
|
||||
resolveShortcut(lnkPath: string): Promise<any>
|
||||
detectFileTypeByContent(path: string): Promise<DetectTypeResult>
|
||||
getCommonPaths(): Promise<Record<string, string>>
|
||||
|
||||
// 回收站(Wave 3)
|
||||
getRecycleBinEntries(): Promise<any[]>
|
||||
restoreFromRecycleBin(path: string): Promise<void>
|
||||
deletePermanently(path: string): Promise<void>
|
||||
emptyRecycleBin(): Promise<void>
|
||||
}
|
||||
@@ -2,75 +2,6 @@
|
||||
* API 类型定义
|
||||
*/
|
||||
|
||||
// 连接
|
||||
export interface Connection {
|
||||
id: number
|
||||
name: string
|
||||
dbType: string
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
database?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
// 数据库和表
|
||||
export interface Database {
|
||||
name: string
|
||||
tableCount?: number
|
||||
}
|
||||
|
||||
export interface Table {
|
||||
name: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
// 表结构
|
||||
export interface Column {
|
||||
Field: string
|
||||
Type: string
|
||||
Null: string
|
||||
Key: string
|
||||
Default: string | null
|
||||
Comment: string
|
||||
Extra?: string
|
||||
}
|
||||
|
||||
export interface Index {
|
||||
Key_name: string
|
||||
Column_name: string
|
||||
Non_unique: number
|
||||
Seq_in_index: number
|
||||
Index_type: string
|
||||
}
|
||||
|
||||
export interface Structure {
|
||||
database: string
|
||||
table: string
|
||||
type: 'mysql' | 'mongo' | 'redis'
|
||||
columns?: Column[]
|
||||
indexes?: Index[]
|
||||
structure?: any
|
||||
info?: any
|
||||
}
|
||||
|
||||
// SQL 查询
|
||||
export interface QueryResult {
|
||||
columns: string[]
|
||||
data: any[]
|
||||
rowsAffected: number
|
||||
executionTime: number
|
||||
}
|
||||
|
||||
// 标签页
|
||||
export interface Tab {
|
||||
id?: number
|
||||
title: string
|
||||
content: string
|
||||
connectionId?: number | null
|
||||
order?: number
|
||||
}
|
||||
|
||||
// 系统信息
|
||||
export interface SystemInfo {
|
||||
os: string
|
||||
@@ -105,4 +36,5 @@ export interface File {
|
||||
size: number
|
||||
isDir: boolean
|
||||
modified?: string
|
||||
modified_time?: string
|
||||
}
|
||||
|
||||