Compare commits

..

2 Commits

Author SHA1 Message Date
Syngnat
22bd1c4c28 Release/0.5.5 (#207)
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容

- DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败
- DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试
- 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致
- 增强查询异常日志与重试路径,降低大表场景卡顿与误报

*  feat(frontend-driver): 驱动管理支持快速搜索并优化信息展示

- 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动
- 显示“匹配 x / y”统计与无结果提示
- 优化头部区域排版,提升透明/暗色场景下的视觉对齐

* 🔧 fix(connection-modal): 修复多数据源URI导入解析并校正Oracle服务名校验

- 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle
- 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为
- Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑
- 连接弹窗补充 Oracle 服务名输入项与 URI 示例

* 🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径

- 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈
- DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级
- QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致
- 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性

* 🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失

- 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度
- 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串
- 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页
- refs #142

* 🐛 fix(driver-manager): 修复驱动管理网络告警重复并强化代理引导

- 新增下载链路域名探测,区分“GitHub可达但驱动下载链路不可达”
- 网络不可达场景仅保留红色强提醒,移除重复二级告警
- 强提醒增加“打开全局代理设置”入口,优先引导使用 GoNavi 全局代理
- 统一网络检测与目录说明提示图标尺寸,修复加载期视觉不一致
- refs #141

* ♻️ refactor(frontend-interaction): 统一标签拖拽与暗色主题交互实现

- 重构Tab拖拽排序实现,统一为可配置拖拽引擎
- 规范拖拽与点击事件边界,提升交互一致性
- 统一多组件暗色透明样式策略,减少硬编码色值
- 提升Redis/表格/连接面板在透明模式下的观感一致性
- refs #144

* ♻️ refactor(update-state): 重构在线更新状态流并按版本统一进度展示

- 重构更新检查与下载状态同步流程,减少前后端状态分叉
- 进度展示严格绑定 latestVersion,避免跨版本状态串用
- 优化 about 打开场景的静默检查状态回填逻辑
- 统一下载弹窗关闭/后台隐藏行为
- 保持现有安装流程并补齐目录打开能力

* 🎨 style(sidebar-log): 将SQL执行日志入口调整为悬浮胶囊样式

- 移除侧栏底部整条日志入口容器
- 新增悬浮按钮阴影/边框/透明背景并适配明暗主题
- 为树区域预留底部空间避免入口遮挡内容

*  feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换

- 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示
- 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离
- 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则
- 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空
- refs #145

*  feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复

- 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题
- 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM
- 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条
- 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动)
- 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题
- 新增白色主题全局滚动条样式适配透明模式(App.css)
- App.tsx主题token与组件样式优化
- refs #147

* 🔧 chore(app): 清理 App.tsx 类型告警并收敛前端壳层实现

- 清除未使用代码和冗余状态
- 替换弃用 API 以消除 IDE 提示
- 显式处理浮动 Promise 避免告警
- 保持现有更新检查和代理设置行为不变

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建工具链

- 将 DuckDB 编译链从 MINGW64 切换为 MSYS2 UCRT64
- 修正 Windows AMD64 的 gcc 和 g++ 探测路径
- 增加 DuckDB 编译器版本校验步骤

* 📝 docs(contributing): 补充中英文贡献指南并统一 README 入口

- 新增英文版 CONTRIBUTING.md 作为正式贡献文档
- 新增中文版 CONTRIBUTING.zh-CN.md 作为中文贡献说明
- 调整 README 和 README.zh-CN 的贡献入口指向对应语言文档

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188) (#190)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>

*  feat(release-notes): 支持自动生成 Release 更新说明并区分配置文件命名

* 🔁 chore(sync): 回灌 main 到 dev (#192)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* 🐛 fix(branch-sync): 修复 main 回灌 dev 时 mergeable 异步计算导致漏开自动合并

- 增加 mergeable 状态轮询,避免新建同步 PR 后立即返回 UNKNOWN
- 在合并状态未稳定时输出中文告警与执行摘要
- 保持冲突分支、待计算分支与自动合并分支的处理路径清晰

* 🔁 chore(sync): 回灌 main 到 dev (#195)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

* - chore(ci): 新增全平台测试包手动构建工作流 tianqijiuyun-latiao 今天 下午4:26 (#194)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178

* fix(query-execution): 支持带前置注释的读查询结果识别

* chore(ci): 新增全平台测试包手动构建工作流

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* ♻️ refactor(frontend-sync): 优化桌面交互细节并移除 main 回灌 dev 自动化

- 优化新建连接、主题设置、侧边栏工具区与 SQL 日志的界面表现
- 调整分页、筛选、透明模式与弹窗样式,统一整体交互层次
- 收口外观参数生效逻辑并补齐多组件适配
- 删除 sync-main-to-dev 工作流并同步维护者手动回灌说明

* feat: 统一筛选条件逻辑按钮宽度 (#201)

* 🐛 fix(oracle-query): 修复 Oracle 表数据分页 SQL 兼容问题 refs #196 (#202)

*  feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路

- 统一 DuckDB 文件库与 Parquet 文件接入能力
- 补充 URI、文件选择、只读挂载与连接缓存键处理
- 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿

*  feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路

- 统一 DuckDB 文件库与 Parquet 文件接入能力
- 补充 URI、文件选择、只读挂载与连接缓存键处理
- 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿
- refs #166

* 🐛 fix(dameng): 修复达梦连接成功后数据库列表为空问题

- 调整达梦数据库列表获取策略,优先回退查询当前 schema 与当前用户
- 保留可见用户与 owner 聚合逻辑,兼容低权限账号场景
- 补充前端空列表提示与后端单元测试,降低排查成本
- close #203

*  feat(data-sync): 扩展跨库迁移链路并优化数据同步交互

- 统一同库同步与跨库迁移入口,补充模式区分与风险提示
- 扩展 ClickHouse 与 PG-like 双向迁移,并新增 PG-like、ClickHouse、TDengine 到 MongoDB 的迁移路由
- 完善 TDengine 目标端建表规划、回归测试与需求追踪文档
- refs #51

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: TSS <266256496+Zencok@users.noreply.github.com>
2026-03-09 17:36:52 +08:00
Syngnat
89c81823bc Release/0.5.4 (#199)
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容

- DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败
- DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试
- 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致
- 增强查询异常日志与重试路径,降低大表场景卡顿与误报

*  feat(frontend-driver): 驱动管理支持快速搜索并优化信息展示

- 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动
- 显示“匹配 x / y”统计与无结果提示
- 优化头部区域排版,提升透明/暗色场景下的视觉对齐

* 🔧 fix(connection-modal): 修复多数据源URI导入解析并校正Oracle服务名校验

- 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle
- 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为
- Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑
- 连接弹窗补充 Oracle 服务名输入项与 URI 示例

* 🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径

- 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈
- DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级
- QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致
- 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性

* 🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失

- 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度
- 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串
- 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页
- refs #142

* 🐛 fix(driver-manager): 修复驱动管理网络告警重复并强化代理引导

- 新增下载链路域名探测,区分“GitHub可达但驱动下载链路不可达”
- 网络不可达场景仅保留红色强提醒,移除重复二级告警
- 强提醒增加“打开全局代理设置”入口,优先引导使用 GoNavi 全局代理
- 统一网络检测与目录说明提示图标尺寸,修复加载期视觉不一致
- refs #141

* ♻️ refactor(frontend-interaction): 统一标签拖拽与暗色主题交互实现

- 重构Tab拖拽排序实现,统一为可配置拖拽引擎
- 规范拖拽与点击事件边界,提升交互一致性
- 统一多组件暗色透明样式策略,减少硬编码色值
- 提升Redis/表格/连接面板在透明模式下的观感一致性
- refs #144

* ♻️ refactor(update-state): 重构在线更新状态流并按版本统一进度展示

- 重构更新检查与下载状态同步流程,减少前后端状态分叉
- 进度展示严格绑定 latestVersion,避免跨版本状态串用
- 优化 about 打开场景的静默检查状态回填逻辑
- 统一下载弹窗关闭/后台隐藏行为
- 保持现有安装流程并补齐目录打开能力

* 🎨 style(sidebar-log): 将SQL执行日志入口调整为悬浮胶囊样式

- 移除侧栏底部整条日志入口容器
- 新增悬浮按钮阴影/边框/透明背景并适配明暗主题
- 为树区域预留底部空间避免入口遮挡内容

*  feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换

- 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示
- 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离
- 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则
- 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空
- refs #145

*  feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复

- 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题
- 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM
- 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条
- 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动)
- 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题
- 新增白色主题全局滚动条样式适配透明模式(App.css)
- App.tsx主题token与组件样式优化
- refs #147

* 🔧 chore(app): 清理 App.tsx 类型告警并收敛前端壳层实现

- 清除未使用代码和冗余状态
- 替换弃用 API 以消除 IDE 提示
- 显式处理浮动 Promise 避免告警
- 保持现有更新检查和代理设置行为不变

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建工具链

- 将 DuckDB 编译链从 MINGW64 切换为 MSYS2 UCRT64
- 修正 Windows AMD64 的 gcc 和 g++ 探测路径
- 增加 DuckDB 编译器版本校验步骤

* 📝 docs(contributing): 补充中英文贡献指南并统一 README 入口

- 新增英文版 CONTRIBUTING.md 作为正式贡献文档
- 新增中文版 CONTRIBUTING.zh-CN.md 作为中文贡献说明
- 调整 README 和 README.zh-CN 的贡献入口指向对应语言文档

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188) (#190)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>

*  feat(release-notes): 支持自动生成 Release 更新说明并区分配置文件命名

* 🔁 chore(sync): 回灌 main 到 dev (#192)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* 🐛 fix(branch-sync): 修复 main 回灌 dev 时 mergeable 异步计算导致漏开自动合并

- 增加 mergeable 状态轮询,避免新建同步 PR 后立即返回 UNKNOWN
- 在合并状态未稳定时输出中文告警与执行摘要
- 保持冲突分支、待计算分支与自动合并分支的处理路径清晰

* 🔁 chore(sync): 回灌 main 到 dev (#195)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

* - chore(ci): 新增全平台测试包手动构建工作流 tianqijiuyun-latiao 今天 下午4:26 (#194)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178

* fix(query-execution): 支持带前置注释的读查询结果识别

* chore(ci): 新增全平台测试包手动构建工作流

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* ♻️ refactor(frontend-sync): 优化桌面交互细节并移除 main 回灌 dev 自动化

- 优化新建连接、主题设置、侧边栏工具区与 SQL 日志的界面表现
- 调整分页、筛选、透明模式与弹窗样式,统一整体交互层次
- 收口外观参数生效逻辑并补齐多组件适配
- 删除 sync-main-to-dev 工作流并同步维护者手动回灌说明

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
2026-03-07 17:15:30 +08:00
39 changed files with 533 additions and 3078 deletions

View File

@@ -88,24 +88,6 @@ jobs:
with:
node-version: '20'
- name: Install UPX (macOS)
if: contains(matrix.platform, 'darwin')
run: |
brew install upx
upx --version
- name: Install UPX (Windows)
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
choco install upx --no-progress -y
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
if ($null -eq $upxCmd) {
Write-Error "❌ 未检测到 upx无法保证 Windows 产物经过压缩"
exit 1
}
& upx --version
# Linux Dependencies (GTK3, WebKit2GTK required by Wails)
- name: Install Linux Dependencies
if: contains(matrix.platform, 'linux')
@@ -120,9 +102,6 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.0-dev
fi
sudo apt-get install -y upx-ucl || sudo apt-get install -y upx
upx --version
# AppImage 运行/打包可能需要 FUSE2。不同发行版/版本包名不同,做兼容兜底。
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
@@ -298,23 +277,6 @@ jobs:
exit 1
fi
APP_NAME=$(basename "$APP_PATH")
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
if [ -z "$APP_BIN" ]; then
echo "❌ 未找到 macOS 应用主程序,无法进行 UPX 压缩!"
exit 1
fi
BEFORE_BYTES=$(wc -c <"$APP_BIN" | tr -d '[:space:]')
echo "🗜️ 正在使用 UPX 压缩 macOS 可执行文件: $APP_BIN ..."
upx --best --lzma --force "$APP_BIN"
upx -t "$APP_BIN"
AFTER_BYTES=$(wc -c <"$APP_BIN" | tr -d '[:space:]')
if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then
SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES))
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ macOS UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }'
else
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf " macOS UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }'
fi
echo "🔏 正在进行 Ad-hoc 签名..."
# 注意Ad-hoc + hardened runtime--options runtime在未配置 entitlements 时,
@@ -339,7 +301,7 @@ jobs:
mv "$DMG_NAME" "../../$FINAL_NAME"
# Windows Packaging
- name: Package Windows EXE
- name: Package Windows Portable Zip
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
@@ -350,6 +312,7 @@ jobs:
}
$target = "${{ matrix.build_name }}"
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe"
$finalZipName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.zip"
if (Test-Path "$target.exe") {
$finalExe = "$target.exe"
@@ -361,26 +324,12 @@ jobs:
exit 1
}
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
if ($null -eq $upxCmd) {
Write-Error "❌ 未找到 upx无法保证 Windows 产物经过压缩"
exit 1
}
$beforeBytes = (Get-Item -LiteralPath $finalExe).Length
Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..."
& upx --best --lzma --force $finalExe | Out-Host
& upx -t $finalExe | Out-Host
$afterBytes = (Get-Item -LiteralPath $finalExe).Length
if ($afterBytes -lt $beforeBytes) {
$savedBytes = $beforeBytes - $afterBytes
Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB))
} else {
Write-Host (" UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB))
}
Write-Host "📦 输出 Windows 可执行文件 $finalExeName..."
Write-Host "📦 生成 Windows 可执行文件 $finalExeName..."
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force
Write-Host "📦 生成 Windows 压缩包 $finalZipName..."
Compress-Archive -LiteralPath $finalExe -DestinationPath "..\\..\\$finalZipName" -Force
# Linux Packaging (tar.gz and AppImage)
- name: Package Linux
if: contains(matrix.platform, 'linux')
@@ -398,17 +347,6 @@ jobs:
fi
chmod +x "$TARGET"
BEFORE_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
echo "🗜️ 正在使用 UPX 压缩 Linux 可执行文件: $TARGET ..."
upx --best --lzma --force "$TARGET"
upx -t "$TARGET"
AFTER_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then
SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES))
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ Linux UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }'
else
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf " Linux UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }'
fi
# 1. Create tar.gz
echo "📦 正在打包 $TAR_NAME..."
@@ -481,6 +419,7 @@ jobs:
path: |
GoNavi-*.dmg
GoNavi-*.exe
GoNavi-*.zip
GoNavi-*.tar.gz
GoNavi-*.AppImage
drivers/**

View File

@@ -93,24 +93,6 @@ jobs:
with:
node-version: '20'
- name: Install UPX (macOS)
if: contains(matrix.platform, 'darwin')
run: |
brew install upx
upx --version
- name: Install UPX (Windows)
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
choco install upx --no-progress -y
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
if ($null -eq $upxCmd) {
Write-Error "❌ 未检测到 upx无法保证 Windows 测试产物经过压缩"
exit 1
}
& upx --version
- name: Install Linux Dependencies
if: contains(matrix.platform, 'linux')
run: |
@@ -123,9 +105,6 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.0-dev
fi
sudo apt-get install -y upx-ucl || sudo apt-get install -y upx
upx --version
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
@@ -263,22 +242,6 @@ jobs:
exit 1
fi
APP_NAME=$(basename "$APP_PATH")
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
if [ -z "$APP_BIN" ]; then
echo "未找到 macOS 应用主程序,无法进行 UPX 压缩"
exit 1
fi
BEFORE_BYTES=$(wc -c <"$APP_BIN" | tr -d '[:space:]')
echo "🗜️ 使用 UPX 压缩 macOS 可执行文件: $APP_BIN ..."
upx --best --lzma --force "$APP_BIN"
upx -t "$APP_BIN"
AFTER_BYTES=$(wc -c <"$APP_BIN" | tr -d '[:space:]')
if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then
SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES))
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ macOS UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }'
else
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf " macOS UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }'
fi
codesign --force --deep --sign - "$APP_NAME"
ZIP_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.zip"
DMG_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.dmg"
@@ -307,6 +270,7 @@ jobs:
Set-Location build/bin
$target = "${{ matrix.build_name }}"
$finalExeName = "GoNavi-$label-${{ matrix.os_name }}-${{ matrix.arch_name }}-run$env:GITHUB_RUN_NUMBER.exe"
$finalZipName = "GoNavi-$label-${{ matrix.os_name }}-${{ matrix.arch_name }}-run$env:GITHUB_RUN_NUMBER.zip"
if (Test-Path "$target.exe") {
$finalExe = "$target.exe"
} elseif (Test-Path "$target") {
@@ -316,25 +280,11 @@ jobs:
Write-Error "未找到构建产物 '$target'"
exit 1
}
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
if ($null -eq $upxCmd) {
Write-Error "❌ 未找到 upx无法保证 Windows 测试产物经过压缩"
exit 1
}
$beforeBytes = (Get-Item -LiteralPath $finalExe).Length
Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..."
& upx --best --lzma --force $finalExe | Out-Host
& upx -t $finalExe | Out-Host
$afterBytes = (Get-Item -LiteralPath $finalExe).Length
if ($afterBytes -lt $beforeBytes) {
$savedBytes = $beforeBytes - $afterBytes
Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB))
} else {
Write-Host (" UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB))
}
New-Item -ItemType Directory -Force -Path ..\..\artifacts | Out-Null
Copy-Item -LiteralPath $finalExe -Destination "..\..\artifacts\$finalExeName" -Force
Compress-Archive -LiteralPath $finalExe -DestinationPath "..\..\artifacts\$finalZipName" -Force
Get-FileHash "..\..\artifacts\$finalExeName" -Algorithm SHA256 | ForEach-Object { "{0} *{1}" -f $_.Hash.ToLower(), (Split-Path $_.Path -Leaf) } | Out-File "..\..\artifacts\$finalExeName.sha256" -Encoding ascii
Get-FileHash "..\..\artifacts\$finalZipName" -Algorithm SHA256 | ForEach-Object { "{0} *{1}" -f $_.Hash.ToLower(), (Split-Path $_.Path -Leaf) } | Out-File "..\..\artifacts\$finalZipName.sha256" -Encoding ascii
- name: Package Linux
if: contains(matrix.platform, 'linux')
@@ -356,17 +306,6 @@ jobs:
exit 1
fi
chmod +x "$TARGET"
BEFORE_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
echo "🗜️ 使用 UPX 压缩 Linux 可执行文件: $TARGET ..."
upx --best --lzma --force "$TARGET"
upx -t "$TARGET"
AFTER_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then
SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES))
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ Linux UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }'
else
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf " Linux UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }'
fi
tar -czvf "../../artifacts/$TAR_NAME" "$TARGET"
sha256sum "../../artifacts/$TAR_NAME" > "../../artifacts/$TAR_NAME.sha256"

View File

@@ -1,228 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
DEFAULT_DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
usage() {
cat <<'EOF'
用法:
./build-driver-agents.sh [选项]
选项:
--drivers <列表> 指定驱动列表逗号分隔例如kingbase,mongodb
--platform <GOOS/GOARCH>
目标平台,默认使用当前 Go 环境go env GOOS/GOARCH
--out-dir <目录> 输出目录根路径默认dist/driver-agents
--bundle-name <文件名> 驱动总包 zip 名称默认GoNavi-DriverAgents.zip
--strict 任一驱动构建失败即中断(默认失败后继续,最后汇总)
-h, --help 显示帮助
示例:
./build-driver-agents.sh
./build-driver-agents.sh --drivers kingbase
./build-driver-agents.sh --platform windows/amd64 --drivers kingbase,mongodb
EOF
}
normalize_driver() {
local name
name="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]' | xargs)"
case "$name" in
doris|diros) echo "doris" ;;
mariadb|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|mongodb|tdengine|clickhouse)
echo "$name"
;;
*)
return 1
;;
esac
}
build_driver_name() {
case "$1" in
doris) echo "diros" ;;
*) echo "$1" ;;
esac
}
platform_dir_name() {
case "$1" in
windows) echo "Windows" ;;
darwin) echo "MacOS" ;;
linux) echo "Linux" ;;
*) echo "Unknown" ;;
esac
}
driver_csv=""
target_platform=""
out_root="dist/driver-agents"
bundle_name="GoNavi-DriverAgents.zip"
strict_mode="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--drivers)
driver_csv="${2:-}"
shift 2
;;
--platform)
target_platform="${2:-}"
shift 2
;;
--out-dir)
out_root="${2:-}"
shift 2
;;
--bundle-name)
bundle_name="${2:-}"
shift 2
;;
--strict)
strict_mode="true"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "❌ 未知参数:$1"
usage
exit 1
;;
esac
done
if ! command -v go >/dev/null 2>&1; then
echo "❌ 未找到 Go请先安装 Go 并确保 go 在 PATH 中。"
exit 1
fi
if [[ -z "$target_platform" ]]; then
target_platform="$(go env GOOS)/$(go env GOARCH)"
fi
if [[ "$target_platform" != */* ]]; then
echo "❌ --platform 参数格式错误,应为 GOOS/GOARCH例如 darwin/arm64"
exit 1
fi
goos="${target_platform%%/*}"
goarch="${target_platform##*/}"
platform_key="${goos}-${goarch}"
platform_dir="$(platform_dir_name "$goos")"
declare -a drivers=()
if [[ -n "$driver_csv" ]]; then
IFS=',' read -r -a raw_drivers <<<"$driver_csv"
for item in "${raw_drivers[@]}"; do
normalized="$(normalize_driver "$item")" || {
echo "❌ 不支持的驱动:$item"
exit 1
}
drivers+=("$normalized")
done
else
drivers=("${DEFAULT_DRIVERS[@]}")
fi
output_dir="${out_root%/}/${platform_key}"
bundle_stage_dir="$(mktemp -d "${TMPDIR:-/tmp}/gonavi-driver-bundle.XXXXXX")"
bundle_platform_dir="$bundle_stage_dir/$platform_dir"
cleanup() {
rm -rf "$bundle_stage_dir"
}
trap cleanup EXIT
mkdir -p "$output_dir" "$bundle_platform_dir"
output_dir_abs="$(cd "$output_dir" && pwd)"
bundle_zip_path="$output_dir_abs/$bundle_name"
declare -a built_assets=()
declare -a failed_drivers=()
declare -a skipped_drivers=()
echo "🚀 开始构建 optional-driver-agent"
echo " 平台:$goos/$goarch"
echo " 输出目录:$output_dir_abs"
echo " 驱动列表:${drivers[*]}"
for driver in "${drivers[@]}"; do
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" != "amd64" ]]; then
echo "⚠️ 跳过 duckdb仅支持 windows/amd64"
skipped_drivers+=("$driver")
continue
fi
build_driver="$(build_driver_name "$driver")"
tag="gonavi_${build_driver}_driver"
asset_name="${driver}-driver-agent-${goos}-${goarch}"
if [[ "$goos" == "windows" ]]; then
asset_name="${asset_name}.exe"
fi
output_path="$output_dir_abs/$asset_name"
cgo_enabled=0
if [[ "$driver" == "duckdb" ]]; then
cgo_enabled=1
fi
echo "🔧 构建 $driver -> $asset_name (tag=$tag, CGO_ENABLED=$cgo_enabled)"
set +e
CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" GOTOOLCHAIN=auto \
go build -tags "$tag" -trimpath -ldflags "-s -w" -o "$output_path" ./cmd/optional-driver-agent
build_exit=$?
set -e
if [[ $build_exit -ne 0 ]]; then
echo "❌ 构建失败:$driver"
failed_drivers+=("$driver")
if [[ "$strict_mode" == "true" ]]; then
exit $build_exit
fi
continue
fi
cp "$output_path" "$bundle_platform_dir/$asset_name"
built_assets+=("$asset_name")
done
if [[ ${#built_assets[@]} -eq 0 ]]; then
echo "❌ 未成功构建任何驱动代理。"
exit 1
fi
rm -f "$bundle_zip_path"
if command -v zip >/dev/null 2>&1; then
(
cd "$bundle_stage_dir"
zip -qry "$bundle_zip_path" "$platform_dir"
)
elif command -v ditto >/dev/null 2>&1; then
(
cd "$bundle_stage_dir"
ditto -c -k --sequesterRsrc --keepParent "$platform_dir" "$bundle_zip_path"
)
else
echo "❌ 未找到 zip/ditto无法生成驱动总包 zip。"
exit 1
fi
echo ""
echo "✅ 构建完成"
echo " 单文件输出目录:$output_dir_abs"
echo " 驱动总包:$bundle_zip_path"
echo " 已构建:${built_assets[*]}"
if [[ ${#skipped_drivers[@]} -gt 0 ]]; then
echo " 已跳过:${skipped_drivers[*]}"
fi
if [[ ${#failed_drivers[@]} -gt 0 ]]; then
echo "⚠️ 构建失败驱动:${failed_drivers[*]}"
exit 2
fi

View File

@@ -20,75 +20,6 @@ RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'
get_file_size_bytes() {
local target="$1"
if [ ! -f "$target" ]; then
echo 0
return
fi
if stat -f%z "$target" >/dev/null 2>&1; then
stat -f%z "$target"
return
fi
if stat -c%s "$target" >/dev/null 2>&1; then
stat -c%s "$target"
return
fi
wc -c <"$target" | tr -d '[:space:]'
}
format_size_mb() {
local bytes="${1:-0}"
awk -v b="$bytes" 'BEGIN { printf "%.2fMB", b / 1024 / 1024 }'
}
try_compress_binary_with_upx() {
local exe_path="$1"
local label="$2"
if [ ! -f "$exe_path" ]; then
echo -e "${RED} ❌ 未找到 ${label} 文件:$exe_path${NC}"
exit 1
fi
if ! command -v upx >/dev/null 2>&1; then
echo -e "${RED} ❌ 未找到 upx${label} 必须进行压缩后才能继续打包。${NC}"
case "$(uname -s)" in
Darwin)
echo " 安装命令: brew install upx"
;;
Linux)
echo " 安装命令: sudo apt-get install -y upx-ucl (或对应发行版包管理器)"
;;
esac
exit 1
fi
local before_bytes after_bytes
before_bytes=$(get_file_size_bytes "$exe_path")
echo " 🗜️ 正在使用 UPX 压缩 ${label}..."
if upx --best --lzma --force "$exe_path" >/dev/null 2>&1; then
if ! upx -t "$exe_path" >/dev/null 2>&1; then
echo -e "${RED} ❌ UPX 校验失败:${label}${NC}"
exit 1
fi
after_bytes=$(get_file_size_bytes "$exe_path")
if [ "$after_bytes" -lt "$before_bytes" ]; then
local saved_bytes=$((before_bytes - after_bytes))
echo " ✅ UPX 压缩完成: $(format_size_mb "$before_bytes") -> $(format_size_mb "$after_bytes"),减少 $(format_size_mb "$saved_bytes")"
else
echo " UPX 压缩完成: $(format_size_mb "$before_bytes") -> $(format_size_mb "$after_bytes")"
fi
else
echo -e "${RED} ❌ UPX 压缩失败:${label}${NC}"
exit 1
fi
}
MAC_VOLICON_PATH="build/darwin/icon.icns"
if [ ! -f "$MAC_VOLICON_PATH" ]; then
MAC_VOLICON_PATH=""
fi
echo -e "${GREEN}🚀 开始构建 $APP_NAME $VERSION...${NC}"
# 清理并创建输出目录
@@ -105,101 +36,47 @@ if [ $? -eq 0 ]; then
# 移动 .app 到 dist
mv "$APP_SRC" "$DIST_DIR/$APP_DEST_NAME"
APP_BIN_PATH=$(find "$DIST_DIR/$APP_DEST_NAME/Contents/MacOS" -maxdepth 1 -type f -print -quit)
if [ -n "$APP_BIN_PATH" ] && [ -f "$APP_BIN_PATH" ]; then
try_compress_binary_with_upx "$APP_BIN_PATH" "macOS arm64 应用主程序"
else
echo -e "${RED} ❌ 未找到 macOS arm64 主程序文件,无法执行 UPX 压缩。${NC}"
exit 1
fi
# Ad-hoc 代码签名(无 Apple Developer 账号时防止 Gatekeeper 报已损坏)
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (arm64)..."
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
# 创建 DMG
if command -v create-dmg &> /dev/null; then
echo " 📦 正在打包 DMG (arm64)..."
# 移除已存在的 DMG (以防万一)
rm -f "$DIST_DIR/$DMG_NAME"
create-dmg \
--volname "${APP_NAME} ${VERSION}" \
--volicon "build/appicon.icns" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "$APP_DEST_NAME" 200 190 \
--hide-extension "$APP_DEST_NAME" \
--app-drop-link 600 185 \
"$DIST_DIR/$DMG_NAME" \
"$DIST_DIR/$APP_DEST_NAME"
# 检查是否生成了 rw.* 的临时文件并重命名 (create-dmg 有时会有此行为)
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
RW_FILE=$(find "$DIST_DIR" -name "rw.*.dmg" -print -quit)
if [ -n "$RW_FILE" ]; then
echo -e "${YELLOW} ⚠️ 检测到临时文件名,正在重命名...${NC}"
mv "$RW_FILE" "$DIST_DIR/$DMG_NAME"
fi
fi
# 创建 DMG
if command -v create-dmg &> /dev/null; then
echo " 📦 正在打包 DMG (arm64)..."
# 移除已存在的 DMG (以防万一)
rm -f "$DIST_DIR/$DMG_NAME"
# create-dmg 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。
STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-arm64.XXXXXX")
if [ -z "$STAGE_DIR" ] || [ ! -d "$STAGE_DIR" ]; then
echo -e "${RED} ❌ 创建 DMG 临时目录失败,跳过 DMG 打包。${NC}"
else
if command -v ditto &> /dev/null; then
ditto "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
else
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
fi
# --sandbox-safe 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口CI/本地静默打包更友好)。
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
if [ -n "$MAC_VOLICON_PATH" ]; then
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
# 删除中间的 .app 文件,保持目录整洁
rm -rf "$DIST_DIR/$APP_DEST_NAME"
if [ -f "$DIST_DIR/$DMG_NAME" ]; then
echo " ✅ 已生成 $DMG_NAME"
else
echo -e "${YELLOW} ⚠️ 未找到 macOS 卷图标 (build/darwin/icon.icns),跳过 --volicon${NC}"
echo -e "${RED} ❌ DMG 生成失败,请检查 create-dmg 输出${NC}"
fi
create-dmg "${CREATE_DMG_ARGS[@]}" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "$APP_DEST_NAME" 200 190 \
--hide-extension "$APP_DEST_NAME" \
--app-drop-link 600 185 \
"$DIST_DIR/$DMG_NAME" \
"$STAGE_DIR"
CREATE_DMG_EXIT_CODE=$?
rm -rf "$STAGE_DIR"
if [ $CREATE_DMG_EXIT_CODE -ne 0 ]; then
echo -e "${RED} ❌ create-dmg 执行失败 (exit=$CREATE_DMG_EXIT_CODE),保留 .app 以便排查。${NC}"
else
# create-dmg 可能会在失败时遗留 rw.*.dmg 中间产物;不要直接当作最终产物使用
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
RW_FILE=$(find "$DIST_DIR" -maxdepth 1 -name "rw.*.dmg" -print -quit)
if [ -n "$RW_FILE" ]; then
echo -e "${YELLOW} ⚠️ 检测到 create-dmg 中间产物: $(basename "$RW_FILE"),正在转换为可分发 DMG...${NC}"
hdiutil convert "$RW_FILE" -format UDZO -o "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
rm -f "$RW_FILE"
fi
fi
# 防御性:即使生成了目标文件,也要确保不是 UDRWUDRW 在 Finder 下可能表现为“已损坏/无法打开”)
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
DMG_FORMAT=$(hdiutil imageinfo "$DIST_DIR/$DMG_NAME" 2>/dev/null | awk -F': ' '/^Format:/{print $2; exit}')
if [ "$DMG_FORMAT" = "UDRW" ]; then
echo -e "${YELLOW} ⚠️ 检测到 UDRW可写原始映像正在转换为 UDZO...${NC}"
TMP_UDZO="$DIST_DIR/.tmp.$DMG_NAME"
rm -f "$TMP_UDZO"
hdiutil convert "$DIST_DIR/$DMG_NAME" -format UDZO -o "$TMP_UDZO" >/dev/null 2>&1 && mv "$TMP_UDZO" "$DIST_DIR/$DMG_NAME"
fi
fi
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
else
# 删除中间的 .app 文件,保持目录整洁
rm -rf "$DIST_DIR/$APP_DEST_NAME"
echo " ✅ 已生成 $DMG_NAME"
fi
fi
fi
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
echo -e "${RED} ❌ DMG 生成失败,请检查 create-dmg 输出。${NC}"
fi
fi
else
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具,跳过 DMG 打包,仅保留 .app。${NC}"
echo " 安装命令: brew install create-dmg"
fi
else
else
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具,跳过 DMG 打包,仅保留 .app。${NC}"
echo " 安装命令: brew install create-dmg"
fi
else
echo -e "${RED} ❌ macOS arm64 构建失败。${NC}"
fi
@@ -212,96 +89,44 @@ if [ $? -eq 0 ]; then
DMG_NAME="${APP_NAME}-${VERSION}-mac-amd64.dmg"
mv "$APP_SRC" "$DIST_DIR/$APP_DEST_NAME"
APP_BIN_PATH=$(find "$DIST_DIR/$APP_DEST_NAME/Contents/MacOS" -maxdepth 1 -type f -print -quit)
if [ -n "$APP_BIN_PATH" ] && [ -f "$APP_BIN_PATH" ]; then
try_compress_binary_with_upx "$APP_BIN_PATH" "macOS amd64 应用主程序"
else
echo -e "${RED} ❌ 未找到 macOS amd64 主程序文件,无法执行 UPX 压缩。${NC}"
exit 1
fi
# Ad-hoc 代码签名
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (amd64)..."
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
if command -v create-dmg &> /dev/null; then
echo " 📦 正在打包 DMG (amd64)..."
rm -f "$DIST_DIR/$DMG_NAME"
create-dmg \
--volname "${APP_NAME} ${VERSION}" \
--volicon "build/appicon.icns" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "$APP_DEST_NAME" 200 190 \
--hide-extension "$APP_DEST_NAME" \
--app-drop-link 600 185 \
"$DIST_DIR/$DMG_NAME" \
"$DIST_DIR/$APP_DEST_NAME"
if command -v create-dmg &> /dev/null; then
echo " 📦 正在打包 DMG (amd64)..."
rm -f "$DIST_DIR/$DMG_NAME"
# create-dmg 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。
STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-amd64.XXXXXX")
if [ -z "$STAGE_DIR" ] || [ ! -d "$STAGE_DIR" ]; then
echo -e "${RED} ❌ 创建 DMG 临时目录失败,跳过 DMG 打包。${NC}"
else
if command -v ditto &> /dev/null; then
ditto "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
else
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
fi
# --sandbox-safe 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口CI/本地静默打包更友好)。
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
if [ -n "$MAC_VOLICON_PATH" ]; then
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
else
echo -e "${YELLOW} ⚠️ 未找到 macOS 卷图标 (build/darwin/icon.icns),跳过 --volicon。${NC}"
fi
create-dmg "${CREATE_DMG_ARGS[@]}" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "$APP_DEST_NAME" 200 190 \
--hide-extension "$APP_DEST_NAME" \
--app-drop-link 600 185 \
"$DIST_DIR/$DMG_NAME" \
"$STAGE_DIR"
CREATE_DMG_EXIT_CODE=$?
rm -rf "$STAGE_DIR"
if [ $CREATE_DMG_EXIT_CODE -ne 0 ]; then
echo -e "${RED} ❌ create-dmg 执行失败 (exit=$CREATE_DMG_EXIT_CODE),保留 .app 以便排查。${NC}"
else
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
RW_FILE=$(find "$DIST_DIR" -maxdepth 1 -name "rw.*.dmg" -print -quit)
if [ -n "$RW_FILE" ]; then
echo -e "${YELLOW} ⚠️ 检测到 create-dmg 中间产物: $(basename "$RW_FILE"),正在转换为可分发 DMG...${NC}"
hdiutil convert "$RW_FILE" -format UDZO -o "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
rm -f "$RW_FILE"
fi
fi
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
DMG_FORMAT=$(hdiutil imageinfo "$DIST_DIR/$DMG_NAME" 2>/dev/null | awk -F': ' '/^Format:/{print $2; exit}')
if [ "$DMG_FORMAT" = "UDRW" ]; then
echo -e "${YELLOW} ⚠️ 检测到 UDRW可写原始映像正在转换为 UDZO...${NC}"
TMP_UDZO="$DIST_DIR/.tmp.$DMG_NAME"
rm -f "$TMP_UDZO"
hdiutil convert "$DIST_DIR/$DMG_NAME" -format UDZO -o "$TMP_UDZO" >/dev/null 2>&1 && mv "$TMP_UDZO" "$DIST_DIR/$DMG_NAME"
fi
fi
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
else
rm -rf "$DIST_DIR/$APP_DEST_NAME"
echo " ✅ 已生成 $DMG_NAME"
fi
fi
# 检查是否生成了 rw.* 的临时文件并重命名
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
RW_FILE=$(find "$DIST_DIR" -name "rw.*.dmg" -print -quit)
if [ -n "$RW_FILE" ]; then
echo -e "${YELLOW} ⚠️ 检测到临时文件名,正在重命名...${NC}"
mv "$RW_FILE" "$DIST_DIR/$DMG_NAME"
fi
fi
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
echo -e "${RED} ❌ DMG 生成失败。${NC}"
fi
fi
else
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具${NC}"
fi
else
echo -e "${RED} ❌ macOS amd64 构建失败${NC}"
rm -rf "$DIST_DIR/$APP_DEST_NAME"
if [ -f "$DIST_DIR/$DMG_NAME" ]; then
echo " ✅ 已生成 $DMG_NAME"
else
echo -e "${RED} ❌ DMG 生成失败${NC}"
fi
else
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具${NC}"
fi
else
echo -e "${RED} ❌ macOS amd64 构建失败。${NC}"
fi
# --- Windows AMD64 构建 ---
@@ -309,9 +134,7 @@ echo -e "${GREEN}🪟 正在构建 Windows (amd64)...${NC}"
if command -v x86_64-w64-mingw32-gcc &> /dev/null; then
wails build -platform windows/amd64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_EXE="$DIST_DIR/${APP_NAME}-${VERSION}-windows-amd64.exe"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$TARGET_EXE"
try_compress_binary_with_upx "$TARGET_EXE" "Windows amd64 可执行文件"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$DIST_DIR/${APP_NAME}-${VERSION}-windows-amd64.exe"
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-amd64.exe"
else
echo -e "${RED} ❌ Windows amd64 构建失败。${NC}"
@@ -325,9 +148,7 @@ echo -e "${GREEN}🪟 正在构建 Windows (arm64)...${NC}"
if command -v aarch64-w64-mingw32-gcc &> /dev/null; then
wails build -platform windows/arm64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_EXE="$DIST_DIR/${APP_NAME}-${VERSION}-windows-arm64.exe"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$TARGET_EXE"
try_compress_binary_with_upx "$TARGET_EXE" "Windows arm64 可执行文件"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$DIST_DIR/${APP_NAME}-${VERSION}-windows-arm64.exe"
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-arm64.exe"
else
echo -e "${RED} ❌ Windows arm64 构建失败。${NC}"
@@ -347,10 +168,8 @@ if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "x86_64" ]; then
# 本机 Linux amd64直接构建
wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$TARGET_LINUX_BIN"
chmod +x "$TARGET_LINUX_BIN"
try_compress_binary_with_upx "$TARGET_LINUX_BIN" "Linux amd64 可执行文件"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
# 打包为 tar.gz
cd "$DIST_DIR"
tar -czvf "${APP_NAME}-${VERSION}-linux-amd64.tar.gz" "${APP_NAME}-${VERSION}-linux-amd64"
@@ -367,10 +186,8 @@ elif command -v x86_64-linux-gnu-gcc &> /dev/null; then
export CGO_ENABLED=1
wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$TARGET_LINUX_BIN"
chmod +x "$TARGET_LINUX_BIN"
try_compress_binary_with_upx "$TARGET_LINUX_BIN" "Linux amd64 可执行文件"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
cd "$DIST_DIR"
tar -czvf "${APP_NAME}-${VERSION}-linux-amd64.tar.gz" "${APP_NAME}-${VERSION}-linux-amd64"
rm "${APP_NAME}-${VERSION}-linux-amd64"
@@ -391,10 +208,8 @@ if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "aarch64" ]; then
# 本机 Linux arm64直接构建
wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$TARGET_LINUX_BIN"
chmod +x "$TARGET_LINUX_BIN"
try_compress_binary_with_upx "$TARGET_LINUX_BIN" "Linux arm64 可执行文件"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
cd "$DIST_DIR"
tar -czvf "${APP_NAME}-${VERSION}-linux-arm64.tar.gz" "${APP_NAME}-${VERSION}-linux-arm64"
rm "${APP_NAME}-${VERSION}-linux-arm64"
@@ -410,10 +225,8 @@ elif command -v aarch64-linux-gnu-gcc &> /dev/null; then
export CGO_ENABLED=1
wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$TARGET_LINUX_BIN"
chmod +x "$TARGET_LINUX_BIN"
try_compress_binary_with_upx "$TARGET_LINUX_BIN" "Linux arm64 可执行文件"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
cd "$DIST_DIR"
tar -czvf "${APP_NAME}-${VERSION}-linux-arm64.tar.gz" "${APP_NAME}-${VERSION}-linux-arm64"
rm "${APP_NAME}-${VERSION}-linux-arm64"

View File

@@ -5,23 +5,6 @@
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GoNavi</title>
<script>
if (typeof window !== 'undefined' && !window.go) {
window.go = {
app: {
App: new Proxy({}, { get: () => async () => ({ success: false }) })
}
};
}
if (typeof window !== 'undefined' && !window.runtime) {
window.runtime = new Proxy({}, {
get: (target, prop) => {
if (prop === 'Environment') return async () => ({ platform: 'darwin' });
return typeof prop === 'string' && prop.startsWith('WindowIs') ? () => false : () => {};
}
});
}
</script>
</head>
<body>
<div id="root"></div>

View File

@@ -93,39 +93,27 @@ function App() {
// 同步 macOS 窗口透明度opacity=1.0 且 blur=0 时关闭 NSVisualEffectView
// 避免 GPU 持续计算窗口背后的模糊合成
useEffect(() => {
try {
void SetWindowTranslucency(resolvedAppearance.opacity, resolvedAppearance.blur).catch(() => undefined);
} catch(e) { /* ignore */ }
void SetWindowTranslucency(resolvedAppearance.opacity, resolvedAppearance.blur).catch(() => undefined);
}, [resolvedAppearance.blur, resolvedAppearance.opacity]);
useEffect(() => {
let cancelled = false;
try {
Environment()
.then((env) => {
if (cancelled) return;
const platform = String(env?.platform || '').toLowerCase();
setRuntimePlatform(platform);
setIsLinuxRuntime(platform === 'linux');
})
.catch(() => {
if (cancelled) return;
const platform = detectNavigatorPlatform();
const normalized = /linux/i.test(platform)
? 'linux'
: (/mac/i.test(platform) ? 'darwin' : (/win/i.test(platform) ? 'windows' : ''));
setRuntimePlatform(normalized);
setIsLinuxRuntime(normalized === 'linux');
});
} catch(e) {
if (cancelled) return;
const platform = detectNavigatorPlatform();
const normalized = /linux/i.test(platform)
? 'linux'
: (/mac/i.test(platform) ? 'darwin' : (/win/i.test(platform) ? 'windows' : ''));
setRuntimePlatform(normalized);
setIsLinuxRuntime(normalized === 'linux');
}
Environment()
.then((env) => {
if (cancelled) return;
const platform = String(env?.platform || '').toLowerCase();
setRuntimePlatform(platform);
setIsLinuxRuntime(platform === 'linux');
})
.catch(() => {
if (cancelled) return;
const platform = detectNavigatorPlatform();
const normalized = /linux/i.test(platform)
? 'linux'
: (/mac/i.test(platform) ? 'darwin' : (/win/i.test(platform) ? 'windows' : ''));
setRuntimePlatform(normalized);
setIsLinuxRuntime(normalized === 'linux');
});
return () => {
cancelled = true;
};
@@ -168,36 +156,32 @@ function App() {
const enabledForBackend = globalProxy.enabled && !invalidWhenEnabled;
let cancelled = false;
try {
ConfigureGlobalProxy(enabledForBackend, {
type: globalProxy.type,
host,
port: portValid ? port : (globalProxy.type === 'http' ? 8080 : 1080),
user: String(globalProxy.user || '').trim(),
password: globalProxy.password || '',
})
.then((res) => {
if (cancelled || res?.success) {
return;
}
void message.error({
content: '全局代理配置失败: ' + (res?.message || '未知错误'),
key: 'global-proxy-sync-error',
});
})
.catch((err) => {
if (cancelled) {
return;
}
const errMsg = err instanceof Error ? err.message : String(err || '未知错误');
void message.error({
content: '全局代理配置失败: ' + errMsg,
key: 'global-proxy-sync-error',
});
ConfigureGlobalProxy(enabledForBackend, {
type: globalProxy.type,
host,
port: portValid ? port : (globalProxy.type === 'http' ? 8080 : 1080),
user: String(globalProxy.user || '').trim(),
password: globalProxy.password || '',
})
.then((res) => {
if (cancelled || res?.success) {
return;
}
void message.error({
content: '全局代理配置失败: ' + (res?.message || '未知错误'),
key: 'global-proxy-sync-error',
});
} catch (e) {
console.warn("Wails API: ConfigureGlobalProxy unavailable", e);
}
})
.catch((err) => {
if (cancelled) {
return;
}
const errMsg = err instanceof Error ? err.message : String(err || '未知错误');
void message.error({
content: '全局代理配置失败: ' + errMsg,
key: 'global-proxy-sync-error',
});
});
return () => {
cancelled = true;
@@ -254,18 +238,13 @@ function App() {
return;
}
// 优先尝试全屏,若当前平台/时机不生效,后续走最大化兜底。
try {
await WindowFullscreen();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
return;
}
await WindowMaximise();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
} catch (e) {
console.warn("Wails Window APIs unavailable", e);
await WindowFullscreen();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
return;
}
await WindowMaximise();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
return;
}
@@ -336,15 +315,11 @@ function App() {
}
const nudgedWidth = width > 480 ? width - 1 : width + 1;
try {
WindowSetSize(nudgedWidth, height);
await wait(28);
WindowSetSize(width, height);
} catch(e) {}
WindowSetSize(nudgedWidth, height);
await wait(28);
WindowSetSize(width, height);
window.dispatchEvent(new Event('resize'));
lastFixAt = Date.now();
} catch(e) {
console.warn("Wails Window APIs unavailable in fixWindowScaleIfNeeded", e);
} finally {
inFlight = false;
}
@@ -674,12 +649,7 @@ function App() {
total: info.assetSize || 0,
message: ''
});
let res: any = null;
try {
res = await (window as any).go.app.App.DownloadUpdate();
} catch (e) {
console.warn("Wails API: DownloadUpdate unavailable", e);
}
const res = await (window as any).go.app.App.DownloadUpdate();
updateDownloadInFlightRef.current = false;
if (res?.success) {
const resultData = (res?.data || {}) as UpdateDownloadResultData;
@@ -1080,7 +1050,7 @@ function App() {
if (target?.closest('[data-no-titlebar-toggle="true"]')) {
return;
}
try { WindowToggleMaximise(); } catch(e) {}
WindowToggleMaximise();
};
// Sidebar Resizing
@@ -1188,9 +1158,7 @@ function App() {
}, [checkForUpdates]);
useEffect(() => {
let offDownloadProgress: any = null;
try {
offDownloadProgress = EventsOn('update:download-progress', (event: UpdateDownloadProgressEvent) => {
const offDownloadProgress = EventsOn('update:download-progress', (event: UpdateDownloadProgressEvent) => {
if (!event) return;
const status = event.status || 'downloading';
const nextStatus: 'idle' | 'start' | 'downloading' | 'done' | 'error' =
@@ -1213,11 +1181,8 @@ function App() {
message: String(event.message || '')
}));
});
} catch (e) {
console.warn("Wails API: EventsOn unavailable", e);
}
return () => {
if (offDownloadProgress) offDownloadProgress();
offDownloadProgress();
};
}, []);

View File

@@ -568,7 +568,6 @@ const ConnectionModal: React.FC<{
return {
host: primary?.host || 'localhost',
port: primary?.port || 6379,
user: parsed.username || '',
password: parsed.password || '',
useSSL: isRediss,
sslMode: isRediss ? (skipVerify ? 'skip-verify' : 'required') : 'disable',
@@ -824,15 +823,8 @@ const ConnectionModal: React.FC<{
if (hosts.length > 1 || values.redisTopology === 'cluster') {
params.set('topology', 'cluster');
}
const redisUser = String(values.user || '').trim();
const redisPassword = String(values.password || '');
let redisAuth = '';
if (redisUser || redisPassword) {
const encodedPassword = redisPassword ? encodeURIComponent(redisPassword) : '';
redisAuth = redisUser
? `${encodeURIComponent(redisUser)}${redisPassword ? `:${encodedPassword}` : ''}@`
: `:${encodedPassword}@`;
}
const redisAuth = redisPassword ? `:${encodeURIComponent(redisPassword)}@` : '';
const redisDB = Number.isFinite(Number(values.redisDB))
? Math.max(0, Math.min(15, Math.trunc(Number(values.redisDB))))
: 0;
@@ -1049,12 +1041,6 @@ const ConnectionModal: React.FC<{
useEffect(() => {
if (open) {
setLoading(false);
testInFlightRef.current = false;
if (testTimerRef.current !== null) {
window.clearTimeout(testTimerRef.current);
testTimerRef.current = null;
}
setTestResult(null); // Reset test result
setTestErrorLogOpen(false);
setDbList([]);
@@ -1193,8 +1179,7 @@ const ConnectionModal: React.FC<{
const handleOk = async () => {
try {
await form.validateFields();
const values = form.getFieldsValue(true);
const values = await form.validateFields();
const unavailableReason = await resolveDriverUnavailableReason(values.type);
if (unavailableReason) {
message.warning(unavailableReason);
@@ -1246,22 +1231,6 @@ const ConnectionModal: React.FC<{
}, 0);
};
const withClientTimeout = async <T,>(promise: Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> => {
let timer: number | null = null;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = window.setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
}),
]);
} finally {
if (timer !== null) {
window.clearTimeout(timer);
}
}
};
const buildTestFailureMessage = (reason: unknown, fallback: string) => {
const text = String(reason ?? '').trim();
const normalized = text && text !== 'undefined' && text !== 'null' ? text : fallback;
@@ -1272,8 +1241,7 @@ const ConnectionModal: React.FC<{
if (testInFlightRef.current) return;
testInFlightRef.current = true;
try {
await form.validateFields();
const values = form.getFieldsValue(true);
const values = await form.validateFields();
const unavailableReason = await resolveDriverUnavailableReason(values.type);
if (unavailableReason) {
const failMessage = buildTestFailureMessage(unavailableReason, '驱动未安装启用');
@@ -1284,21 +1252,12 @@ const ConnectionModal: React.FC<{
setLoading(true);
setTestResult(null);
const config = await buildConfig(values, false);
const timeoutSecondsRaw = Number(values.timeout);
const timeoutSeconds = Number.isFinite(timeoutSecondsRaw) && timeoutSecondsRaw > 0
? Math.min(timeoutSecondsRaw, MAX_TIMEOUT_SECONDS)
: 30;
const rpcTimeoutMs = (timeoutSeconds + 5) * 1000;
// Use different API for Redis
const isRedisType = values.type === 'redis';
const res = await withClientTimeout(
isRedisType
? RedisConnect(config as any)
: TestConnection(config as any),
rpcTimeoutMs,
`连接测试超时(>${timeoutSeconds} 秒),请检查网络/代理/SSH配置后重试`
);
const res = isRedisType
? await RedisConnect(config as any)
: await TestConnection(config as any);
if (res.success) {
setTestResult({ type: 'success', message: res.message });
@@ -1306,11 +1265,7 @@ const ConnectionModal: React.FC<{
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
} else {
// Other databases: fetch database list
const dbRes = await withClientTimeout(
DBGetDatabases(config as any),
rpcTimeoutMs,
`连接成功但拉取数据库列表超时(>${timeoutSeconds} 秒)`
);
const dbRes = await DBGetDatabases(config as any);
if (dbRes.success) {
const dbRows = Array.isArray(dbRes.data) ? dbRes.data : [];
const dbs = dbRows
@@ -1356,8 +1311,7 @@ const ConnectionModal: React.FC<{
return;
}
try {
await form.validateFields();
const values = form.getFieldsValue(true);
const values = await form.validateFields();
setDiscoveringMembers(true);
const config = await buildConfig(values, false);
const result = await MongoDiscoverMembers(config as any);
@@ -1411,16 +1365,6 @@ const ConnectionModal: React.FC<{
const defaultPort = getDefaultPortByType(type);
const isFileDbType = isFileDatabaseType(type);
const sslCapableType = supportsSSLForType(type);
// Redis 默认不展示用户名字段;若 URI 可解析则以 URI 为准覆盖 user
// 同时清理历史默认值 root避免 go-redis 发送 ACL AUTH(user, pass) 导致 WRONGPASS。
if (type === 'redis') {
if (parsedUriValues && Object.prototype.hasOwnProperty.call(parsedUriValues, 'user')) {
mergedValues.user = String((parsedUriValues as any).user || '');
} else if (String(mergedValues.user || '').trim() === 'root') {
mergedValues.user = '';
}
}
const sslModeRaw = String(mergedValues.sslMode || 'preferred').trim().toLowerCase();
const sslMode: 'preferred' | 'required' | 'skip-verify' | 'disable' = sslModeRaw === 'required'
? 'required'
@@ -1607,13 +1551,12 @@ const ConnectionModal: React.FC<{
};
};
const handleTypeSelect = (type: string) => {
const normalized = normalizeDriverType(type);
const snapshot = driverStatusMap[normalized];
if (snapshot && !snapshot.connectable) {
const driverName = snapshot.name || type;
const reason = snapshot.message || `${driverName} 驱动未安装启用,请先在驱动管理中安装`;
setTypeSelectWarning({ driverName, reason });
const handleTypeSelect = async (type: string) => {
const unavailableReason = await resolveDriverUnavailableReason(type);
if (unavailableReason) {
const normalized = normalizeDriverType(type);
const driverName = driverStatusMap[normalized]?.name || type;
setTypeSelectWarning({ driverName, reason: unavailableReason });
return;
}
setTypeSelectWarning(null);
@@ -1672,11 +1615,7 @@ const ConnectionModal: React.FC<{
redisDB: 0,
});
} else if (type !== 'custom') {
const defaultUser = type === 'clickhouse'
? 'default'
: type === 'redis'
? ''
: 'root';
const defaultUser = type === 'clickhouse' ? 'default' : 'root';
const sslCapableType = supportsSSLForType(type);
setUseSSL(false);
setUseHttpTunnel(false);
@@ -1715,10 +1654,6 @@ const ConnectionModal: React.FC<{
setMongoMembers([]);
setStep(2);
if (!driverStatusLoaded || !snapshot) {
void refreshDriverStatus();
}
};
const isFileDb = isFileDatabaseType(dbType);
@@ -1891,6 +1826,7 @@ const ConnectionModal: React.FC<{
>
<Input
placeholder={isFileDb ? (dbType === 'duckdb' ? '/path/to/db.duckdb' : '/path/to/db.sqlite') : 'localhost'}
onDoubleClick={requestTest}
/>
</Form.Item>
{isFileDb ? (

File diff suppressed because it is too large Load Diff

View File

@@ -792,20 +792,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
case 'kingbase':
case 'highgo':
case 'vastbase':
return normalizeMetadataQuerySpecs([
{
// PostgreSQL 11+ / 部分 PG-like通过 prokind 区分 FUNCTION/PROCEDURE
sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, CASE WHEN p.prokind = 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%' ORDER BY n.nspname, routine_type, p.proname`,
},
{
// PostgreSQL 10 / 不支持 prokind 的兼容路径
sql: `SELECT r.routine_schema AS schema_name, r.routine_name AS routine_name, COALESCE(NULLIF(UPPER(r.routine_type), ''), 'FUNCTION') AS routine_type FROM information_schema.routines r WHERE r.routine_schema NOT IN ('pg_catalog', 'information_schema') AND r.routine_schema NOT LIKE 'pg_%' ORDER BY r.routine_schema, routine_type, r.routine_name`,
},
{
// 最后兜底:仅函数列表,确保 prokind/routines 视图异常时仍可展示
sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, 'FUNCTION' AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%' ORDER BY n.nspname, p.proname`,
},
]);
return [{ sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, CASE WHEN p.prokind = 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%' ORDER BY n.nspname, routine_type, p.proname` }];
case 'sqlserver': {
const safeDb = quoteSqlServerIdentifier(dbName || 'master');
return [{ sql: `SELECT s.name AS schema_name, o.name AS routine_name, CASE o.type WHEN 'P' THEN 'PROCEDURE' WHEN 'FN' THEN 'FUNCTION' WHEN 'IF' THEN 'FUNCTION' WHEN 'TF' THEN 'FUNCTION' END AS routine_type FROM ${safeDb}.sys.objects o JOIN ${safeDb}.sys.schemas s ON o.schema_id = s.schema_id WHERE o.type IN ('P','FN','IF','TF') ORDER BY o.type, s.name, o.name` }];

View File

@@ -9,36 +9,6 @@ import { loader } from '@monaco-editor/react'
import * as monaco from 'monaco-editor'
loader.config({ monaco })
if (typeof window !== 'undefined' && !(window as any).go) {
(window as any).go = {
app: {
App: {
CheckUpdate: async () => ({ success: false }),
DownloadUpdate: async () => ({ success: false }),
GetSavedConnections: async () => [],
SaveConnection: async () => null,
DeleteConnection: async () => null,
OpenConnection: async () => null,
CloseConnection: async () => null,
GetDatabases: async () => [],
GetTables: async () => [],
GetTableData: async () => ({ columns: [], rows: [], total: 0 }),
GetTableColumns: async () => [],
ExecuteQuery: async () => ({ columns: [], rows: [], time: 0 }),
GetSavedQueries: async () => [],
SaveQuery: async () => null,
DeleteQuery: async () => null,
GetAppInfo: async () => ({}),
CheckForUpdates: async () => ({ success: false }),
OpenDownloadedUpdateDirectory: async () => ({ success: false }),
InstallUpdateAndRestart: async () => ({ success: false }),
ImportConfigFile: async () => ({ success: false }),
ExportData: async () => ({ success: false }),
}
}
};
}
// 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义
monaco.editor.defineTheme('transparent-dark', {
base: 'vs-dark', inherit: true, rules: [],

View File

@@ -416,10 +416,6 @@ interface AppState {
sqlLogs: SqlLog[];
tableAccessCount: Record<string, number>;
tableSortPreference: Record<string, 'name' | 'frequency'>;
tableColumnOrders: Record<string, string[]>;
enableColumnOrderMemory: boolean;
tableHiddenColumns: Record<string, string[]>;
enableHiddenColumnMemory: boolean;
addConnection: (conn: SavedConnection) => void;
updateConnection: (conn: SavedConnection) => void;
@@ -462,13 +458,6 @@ interface AppState {
recordTableAccess: (connectionId: string, dbName: string, tableName: string) => void;
setTableSortPreference: (connectionId: string, dbName: string, sortBy: 'name' | 'frequency') => void;
setTableColumnOrder: (connectionId: string, dbName: string, tableName: string, order: string[]) => void;
setEnableColumnOrderMemory: (enabled: boolean) => void;
clearTableColumnOrder: (connectionId: string, dbName: string, tableName: string) => void;
setTableHiddenColumns: (connectionId: string, dbName: string, tableName: string, hiddenColumns: string[]) => void;
setEnableHiddenColumnMemory: (enabled: boolean) => void;
clearTableHiddenColumns: (connectionId: string, dbName: string, tableName: string) => void;
}
const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
@@ -532,28 +521,6 @@ const sanitizeTableSortPreference = (value: unknown): Record<string, 'name' | 'f
return result;
};
const sanitizeTableColumnOrders = (value: unknown): Record<string, string[]> => {
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
const result: Record<string, string[]> = {};
Object.entries(raw).forEach(([key, orderArray]) => {
if (Array.isArray(orderArray)) {
result[key] = orderArray.map(col => String(col));
}
});
return result;
};
const sanitizeTableHiddenColumns = (value: unknown): Record<string, string[]> => {
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
const result: Record<string, string[]> = {};
Object.entries(raw).forEach(([key, hiddenArray]) => {
if (Array.isArray(hiddenArray)) {
result[key] = hiddenArray.map(col => String(col));
}
});
return result;
};
const sanitizeAppearance = (
appearance: Partial<{ enabled: boolean; opacity: number; blur: number }> | undefined,
version: number
@@ -631,10 +598,6 @@ export const useStore = create<AppState>()(
sqlLogs: [],
tableAccessCount: {},
tableSortPreference: {},
tableColumnOrders: {},
enableColumnOrderMemory: true,
tableHiddenColumns: {},
enableHiddenColumnMemory: true,
addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })),
updateConnection: (conn) => set((state) => ({
@@ -837,44 +800,6 @@ export const useStore = create<AppState>()(
}
};
}),
setTableColumnOrder: (connectionId, dbName, tableName, order) => set((state) => {
const key = `${connectionId}-${dbName}-${tableName}`;
return {
tableColumnOrders: {
...state.tableColumnOrders,
[key]: order
}
};
}),
clearTableColumnOrder: (connectionId, dbName, tableName) => set((state) => {
const key = `${connectionId}-${dbName}-${tableName}`;
const newOrders = { ...state.tableColumnOrders };
delete newOrders[key];
return { tableColumnOrders: newOrders };
}),
setEnableColumnOrderMemory: (enabled) => set({ enableColumnOrderMemory: !!enabled }),
setTableHiddenColumns: (connectionId, dbName, tableName, hiddenColumns) => set((state) => {
const key = `${connectionId}-${dbName}-${tableName}`;
return {
tableHiddenColumns: {
...state.tableHiddenColumns,
[key]: hiddenColumns
}
};
}),
clearTableHiddenColumns: (connectionId, dbName, tableName) => set((state) => {
const key = `${connectionId}-${dbName}-${tableName}`;
const newHidden = { ...state.tableHiddenColumns };
delete newHidden[key];
return { tableHiddenColumns: newHidden };
}),
setEnableHiddenColumnMemory: (enabled) => set({ enableHiddenColumnMemory: !!enabled }),
}),
{
name: 'lite-db-storage', // name of the item in the storage (must be unique)
@@ -900,13 +825,6 @@ export const useStore = create<AppState>()(
nextState.shortcutOptions = sanitizeShortcutOptions(state.shortcutOptions);
nextState.tableAccessCount = sanitizeTableAccessCount(state.tableAccessCount);
nextState.tableSortPreference = sanitizeTableSortPreference(state.tableSortPreference);
// 新增的列排序记忆状态不需要做版本特殊兼容,直接做基本的类型保护
const safeOrders = sanitizeTableColumnOrders(state.tableColumnOrders);
nextState.tableColumnOrders = safeOrders;
nextState.enableColumnOrderMemory = state.enableColumnOrderMemory !== false;
const safeHidden = sanitizeTableHiddenColumns(state.tableHiddenColumns);
nextState.tableHiddenColumns = safeHidden;
nextState.enableHiddenColumnMemory = state.enableHiddenColumnMemory !== false;
return nextState as AppState;
},
merge: (persistedState, currentState) => {
@@ -923,16 +841,11 @@ export const useStore = create<AppState>()(
fontSize: sanitizeFontSize(state.fontSize),
startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen),
globalProxy: sanitizeGlobalProxy(state.globalProxy),
tableSortPreference: sanitizeTableSortPreference(state.tableSortPreference),
tableColumnOrders: sanitizeTableColumnOrders(state.tableColumnOrders),
enableColumnOrderMemory: state.enableColumnOrderMemory !== false,
tableHiddenColumns: sanitizeTableHiddenColumns(state.tableHiddenColumns),
enableHiddenColumnMemory: state.enableHiddenColumnMemory !== false,
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
queryOptions: sanitizeQueryOptions(state.queryOptions),
shortcutOptions: sanitizeShortcutOptions(state.shortcutOptions),
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
tableSortPreference: sanitizeTableSortPreference(state.tableSortPreference),
};
},
partialize: (state) => ({
@@ -949,11 +862,7 @@ export const useStore = create<AppState>()(
queryOptions: state.queryOptions,
shortcutOptions: state.shortcutOptions,
tableAccessCount: state.tableAccessCount,
tableSortPreference: state.tableSortPreference,
tableColumnOrders: state.tableColumnOrders,
enableColumnOrderMemory: state.enableColumnOrderMemory,
tableHiddenColumns: state.tableHiddenColumns,
enableHiddenColumnMemory: state.enableHiddenColumnMemory
tableSortPreference: state.tableSortPreference
}), // Don't persist logs
}
)

View File

@@ -50,11 +50,6 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
return raw;
}
// SQL Server 使用 [bracket] 标识符
if (dbTypeLower === 'sqlserver' || dbTypeLower === 'mssql') {
return `[${raw.replace(/]/g, ']]')}]`;
}
// 其他数据库默认加双引号
return `"${raw.replace(/"/g, '""')}"`;
};
@@ -165,8 +160,7 @@ export const buildPaginatedSelectSQL = (
}
return `SELECT * FROM (SELECT "__gonavi_page__".*, ROWNUM "__gonavi_rn__" FROM (${orderedSql}) "__gonavi_page__" WHERE ROWNUM <= ${upperBound}) WHERE "__gonavi_rn__" > ${safeOffset}`;
}
case 'sqlserver':
case 'mssql': {
case 'sqlserver': {
const effectiveOrderBy = orderBy.trim() ? orderBy : ' ORDER BY (SELECT NULL)';
return `${base}${effectiveOrderBy} OFFSET ${safeOffset} ROWS FETCH NEXT ${safeLimit} ROWS ONLY`;
}

View File

@@ -8,8 +8,6 @@ import (
"errors"
"fmt"
"net"
"net/url"
"os"
"strings"
"sync"
"time"
@@ -220,7 +218,6 @@ func wrapConnectError(config connection.ConnectionConfig, err error) error {
if err == nil {
return nil
}
err = sanitizeMongoConnectErrorLabel(config, err)
var netErr net.Error
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) {
@@ -234,73 +231,6 @@ func wrapConnectError(config connection.ConnectionConfig, err error) error {
return withLogHint{err: err, logPath: logger.Path()}
}
type errorMessageOverride struct {
message string
cause error
}
func (e errorMessageOverride) Error() string {
return e.message
}
func (e errorMessageOverride) Unwrap() error {
return e.cause
}
func sanitizeMongoConnectErrorLabel(config connection.ConnectionConfig, err error) error {
if err == nil {
return nil
}
if strings.ToLower(strings.TrimSpace(config.Type)) != "mongodb" {
return err
}
if mongoConnectUsesTLS(config) {
return err
}
original := err.Error()
rewritten := strings.ReplaceAll(original, "SSL 主库凭据", "主库凭据")
rewritten = strings.ReplaceAll(rewritten, "SSL 从库凭据", "从库凭据")
if rewritten == original {
return err
}
return errorMessageOverride{
message: rewritten,
cause: err,
}
}
func mongoConnectUsesTLS(config connection.ConnectionConfig) bool {
if config.UseSSL {
return true
}
uriText := strings.TrimSpace(config.URI)
if uriText == "" {
return false
}
parsed, err := url.Parse(uriText)
if err != nil {
return false
}
for _, key := range []string{"tls", "ssl"} {
if enabled, known := parseMongoBool(parsed.Query().Get(key)); known {
return enabled
}
}
return strings.EqualFold(strings.TrimSpace(parsed.Scheme), "mongodb+srv")
}
func parseMongoBool(raw string) (enabled bool, known bool) {
value := strings.ToLower(strings.TrimSpace(raw))
switch value {
case "1", "true", "t", "yes", "y", "on", "required":
return true, true
case "0", "false", "f", "no", "n", "off", "disable", "disabled":
return false, true
default:
return false, false
}
}
type withLogHint struct {
err error
logPath string
@@ -308,15 +238,10 @@ type withLogHint struct {
func (e withLogHint) Error() string {
message := normalizeErrorMessage(e.err)
path := strings.TrimSpace(e.logPath)
if path == "" {
if strings.TrimSpace(e.logPath) == "" {
return message
}
info, statErr := os.Stat(path)
if statErr != nil || info.IsDir() || info.Size() <= 0 {
return message
}
return fmt.Sprintf("%s详细日志%s", message, path)
return fmt.Sprintf("%s详细日志%s", message, e.logPath)
}
func (e withLogHint) Unwrap() error {

View File

@@ -1,84 +0,0 @@
package app
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestWrapConnectError_MongoNoSSL_RemovesMisleadingSSLLabel(t *testing.T) {
config := connection.ConnectionConfig{
Type: "mongodb",
UseSSL: false,
}
sourceErr := errors.New("MongoDB 连接失败SSL 主库凭据验证失败: mock error")
wrapped := wrapConnectError(config, sourceErr)
text := wrapped.Error()
if strings.Contains(text, "SSL 主库凭据") {
t.Fatalf("expected ssl label to be removed when TLS disabled, got: %s", text)
}
if !strings.Contains(text, "主库凭据验证失败") {
t.Fatalf("expected auth label to remain, got: %s", text)
}
}
func TestWrapConnectError_MongoURIForcesTLS_KeepsSSLLabel(t *testing.T) {
config := connection.ConnectionConfig{
Type: "mongodb",
UseSSL: false,
URI: "mongodb://user:pass@127.0.0.1:27017/admin?tls=true",
}
sourceErr := errors.New("MongoDB 连接失败SSL 主库凭据验证失败: mock error")
wrapped := wrapConnectError(config, sourceErr)
text := wrapped.Error()
if !strings.Contains(text, "SSL 主库凭据") {
t.Fatalf("expected ssl label to remain when URI enables TLS, got: %s", text)
}
}
func TestWrapConnectError_MongoSRVDefaultTLS_KeepsSSLLabel(t *testing.T) {
config := connection.ConnectionConfig{
Type: "mongodb",
UseSSL: false,
URI: "mongodb+srv://user:pass@cluster0.example.com/admin",
}
sourceErr := errors.New("MongoDB 连接失败SSL 主库凭据验证失败: mock error")
wrapped := wrapConnectError(config, sourceErr)
text := wrapped.Error()
if !strings.Contains(text, "SSL 主库凭据") {
t.Fatalf("expected ssl label to remain for mongodb+srv default TLS, got: %s", text)
}
}
func TestWithLogHintError_OmitEmptyLogPath(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "gonavi.log")
if err := os.WriteFile(logPath, nil, 0o644); err != nil {
t.Fatalf("write empty log failed: %v", err)
}
err := withLogHint{err: errors.New("连接失败"), logPath: logPath}
text := err.Error()
if strings.Contains(text, "详细日志:") {
t.Fatalf("expected no log hint for empty file, got: %s", text)
}
}
func TestWithLogHintError_IncludeNonEmptyLogPath(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "gonavi.log")
if err := os.WriteFile(logPath, []byte("log entry\n"), 0o644); err != nil {
t.Fatalf("write log failed: %v", err)
}
err := withLogHint{err: errors.New("连接失败"), logPath: logPath}
text := err.Error()
if !strings.Contains(text, "详细日志:"+logPath) {
t.Fatalf("expected log hint with path, got: %s", text)
}
}

View File

@@ -73,8 +73,8 @@ func resolveDialConfigWithProxy(raw connection.ConnectionConfig) (connection.Con
// 文件型/自定义 DSN 类型不走标准 host:port不在此层改写。
return config, nil
}
if normalizedType == "mongodb" {
// MongoDB 统一由驱动侧 Dialer 处理代理,保留原始目标地址,避免将连接目标改写为本地转发地址
if normalizedType == "mongodb" && config.MongoSRV {
// Mongo SRV 由驱动侧 Dialer 处理代理,避免破坏 DNS SRV 拓扑发现
return config, nil
}

View File

@@ -1,64 +0,0 @@
package app
import (
"reflect"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestResolveDialConfigWithProxy_MongoKeepsTargetAddress(t *testing.T) {
hosts := []string{"10.20.30.40:27017", "10.20.30.41:27017"}
raw := connection.ConnectionConfig{
Type: "mongodb",
Host: "10.20.30.40",
Port: 27017,
UseProxy: true,
Proxy: connection.ProxyConfig{
Type: "socks5",
Host: "127.0.0.1",
Port: 1080,
},
Hosts: hosts,
}
got, err := resolveDialConfigWithProxy(raw)
if err != nil {
t.Fatalf("resolveDialConfigWithProxy returned error: %v", err)
}
if got.Host != raw.Host || got.Port != raw.Port {
t.Fatalf("mongo target address should be kept, got=%s:%d want=%s:%d", got.Host, got.Port, raw.Host, raw.Port)
}
if !got.UseProxy {
t.Fatalf("mongo should keep UseProxy=true for driver-level dialer")
}
if !reflect.DeepEqual(got.Hosts, hosts) {
t.Fatalf("mongo hosts should be kept, got=%v want=%v", got.Hosts, hosts)
}
}
func TestResolveDialConfigWithProxy_MongoSRVKeepsTargetAddress(t *testing.T) {
raw := connection.ConnectionConfig{
Type: "mongodb",
Host: "cluster0.example.com",
Port: 27017,
MongoSRV: true,
UseProxy: true,
Proxy: connection.ProxyConfig{
Type: "http",
Host: "127.0.0.1",
Port: 7890,
},
}
got, err := resolveDialConfigWithProxy(raw)
if err != nil {
t.Fatalf("resolveDialConfigWithProxy returned error: %v", err)
}
if got.Host != raw.Host || got.Port != raw.Port {
t.Fatalf("mongo SRV target address should be kept, got=%s:%d want=%s:%d", got.Host, got.Port, raw.Host, raw.Port)
}
if !got.UseProxy {
t.Fatalf("mongo SRV should keep UseProxy=true for driver-level dialer")
}
}

View File

@@ -72,30 +72,25 @@ func setGlobalProxyConfig(enabled bool, proxyConfig connection.ProxyConfig) (glo
}
func (a *App) ConfigureGlobalProxy(enabled bool, proxyConfig connection.ProxyConfig) connection.QueryResult {
before := currentGlobalProxyConfig()
snapshot, err := setGlobalProxyConfig(enabled, proxyConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
// 前端可能在同一配置下重复触发同步(例如严格模式或状态回放),
// 这里做幂等日志,避免重复刷屏。
if !globalProxySnapshotEqual(before, snapshot) {
if snapshot.Enabled {
authState := ""
if strings.TrimSpace(snapshot.Proxy.User) != "" {
authState = "(认证:已配置)"
}
logger.Infof(
"全局代理已启用:%s://%s:%d%s",
strings.ToLower(strings.TrimSpace(snapshot.Proxy.Type)),
strings.TrimSpace(snapshot.Proxy.Host),
snapshot.Proxy.Port,
authState,
)
} else {
logger.Infof("全局代理已关闭")
if snapshot.Enabled {
authState := ""
if strings.TrimSpace(snapshot.Proxy.User) != "" {
authState = "(认证:已配置)"
}
logger.Infof(
"全局代理已启用:%s://%s:%d%s",
strings.ToLower(strings.TrimSpace(snapshot.Proxy.Type)),
strings.TrimSpace(snapshot.Proxy.Host),
snapshot.Proxy.Port,
authState,
)
} else {
logger.Infof("全局代理已关闭")
}
return connection.QueryResult{
@@ -105,24 +100,6 @@ func (a *App) ConfigureGlobalProxy(enabled bool, proxyConfig connection.ProxyCon
}
}
func globalProxySnapshotEqual(a, b globalProxySnapshot) bool {
if a.Enabled != b.Enabled {
return false
}
if !a.Enabled {
return true
}
return proxyConfigEqual(a.Proxy, b.Proxy)
}
func proxyConfigEqual(a, b connection.ProxyConfig) bool {
return strings.EqualFold(strings.TrimSpace(a.Type), strings.TrimSpace(b.Type)) &&
strings.TrimSpace(a.Host) == strings.TrimSpace(b.Host) &&
a.Port == b.Port &&
strings.TrimSpace(a.User) == strings.TrimSpace(b.User) &&
a.Password == b.Password
}
func (a *App) GetGlobalProxyConfig() connection.QueryResult {
return connection.QueryResult{
Success: true,

View File

@@ -13,16 +13,6 @@ import (
"GoNavi-Wails/internal/utils"
)
const testConnectionTimeoutUpperBoundSeconds = 12
func normalizeTestConnectionConfig(config connection.ConnectionConfig) connection.ConnectionConfig {
normalized := config
if normalized.Timeout <= 0 || normalized.Timeout > testConnectionTimeoutUpperBoundSeconds {
normalized.Timeout = testConnectionTimeoutUpperBoundSeconds
}
return normalized
}
// Generic DB Methods
func (a *App) DBConnect(config connection.ConnectionConfig) connection.QueryResult {
@@ -38,16 +28,13 @@ func (a *App) DBConnect(config connection.ConnectionConfig) connection.QueryResu
}
func (a *App) TestConnection(config connection.ConnectionConfig) connection.QueryResult {
testConfig := normalizeTestConnectionConfig(config)
started := time.Now()
logger.Infof("TestConnection 开始:%s", formatConnSummary(testConfig))
_, err := a.getDatabaseForcePing(testConfig)
_, err := a.getDatabaseForcePing(config)
if err != nil {
logger.Error(err, "TestConnection 连接测试失败:耗时=%s %s", time.Since(started).Round(time.Millisecond), formatConnSummary(testConfig))
logger.Error(err, "TestConnection 连接测试失败:%s", formatConnSummary(config))
return connection.QueryResult{Success: false, Message: err.Error()}
}
logger.Infof("TestConnection 连接测试成功:耗时=%s %s", time.Since(started).Round(time.Millisecond), formatConnSummary(testConfig))
logger.Infof("TestConnection 连接测试成功:%s", formatConnSummary(config))
return connection.QueryResult{Success: true, Message: "连接成功"}
}

View File

@@ -1,31 +0,0 @@
package app
import (
"testing"
"GoNavi-Wails/internal/connection"
)
func TestNormalizeTestConnectionConfig_DefaultToUpperBound(t *testing.T) {
config := connection.ConnectionConfig{Type: "mongodb", Timeout: 0}
got := normalizeTestConnectionConfig(config)
if got.Timeout != testConnectionTimeoutUpperBoundSeconds {
t.Fatalf("expected timeout=%d, got=%d", testConnectionTimeoutUpperBoundSeconds, got.Timeout)
}
}
func TestNormalizeTestConnectionConfig_KeepSmallerTimeout(t *testing.T) {
config := connection.ConnectionConfig{Type: "mongodb", Timeout: 6}
got := normalizeTestConnectionConfig(config)
if got.Timeout != 6 {
t.Fatalf("expected timeout=6, got=%d", got.Timeout)
}
}
func TestNormalizeTestConnectionConfig_ClampLargeTimeout(t *testing.T) {
config := connection.ConnectionConfig{Type: "mongodb", Timeout: 60}
got := normalizeTestConnectionConfig(config)
if got.Timeout != testConnectionTimeoutUpperBoundSeconds {
t.Fatalf("expected timeout=%d, got=%d", testConnectionTimeoutUpperBoundSeconds, got.Timeout)
}
}

View File

@@ -2792,7 +2792,6 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut
driverType := normalizeDriverType(definition.Type)
displayName := resolveDriverDisplayName(definition)
forceSourceBuild := shouldForceSourceBuildForVersion(driverType, selectedVersion)
preferSourceBuildBeforeDownload := shouldPreferSourceBuildBeforeDownload(driverType, selectedVersion)
skipReuseCandidate := shouldSkipReusableAgentCandidate(driverType, selectedVersion)
info, err := os.Stat(executablePath)
@@ -2800,10 +2799,11 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut
if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil {
_ = os.Remove(executablePath)
} else {
// 用户点击“安装/重装”时应强制刷新驱动代理,避免沿用旧二进制导致修复不生效。
if removeErr := os.Remove(executablePath); removeErr != nil {
return "", "", fmt.Errorf("清理已安装 %s 驱动代理失败:%w", displayName, removeErr)
hash, hashErr := hashFileSHA256(executablePath)
if hashErr != nil {
return "", "", fmt.Errorf("读取已安装 %s 驱动代理摘要失败:%w", displayName, hashErr)
}
return fmt.Sprintf("local://existing/%s-driver-agent", driverType), hash, nil
}
}
if err == nil && info.IsDir() {
@@ -2834,22 +2834,6 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut
}
var downloadErrs []string
var sourceBuildAttempted bool
var sourceBuildErr error
if !forceSourceBuild && preferSourceBuildBeforeDownload {
sourceBuildAttempted = true
if a != nil {
a.emitDriverDownloadProgress(driverType, "downloading", 16, 100, fmt.Sprintf("优先使用本地源码构建 %s 驱动代理", displayName))
}
hash, buildErr := buildOptionalDriverAgentFromSource(definition, executablePath, selectedVersion)
if buildErr == nil {
return fmt.Sprintf("local://go-build/%s-driver-agent", driverType), hash, nil
}
sourceBuildErr = buildErr
logger.Warnf("预先本地构建 %s 驱动代理失败,将继续尝试下载预编译包:%v", displayName, buildErr)
}
if !forceSourceBuild {
downloadURLs := resolveOptionalDriverAgentDownloadURLs(definition, downloadURL)
if len(downloadURLs) > 0 {
@@ -2882,15 +2866,9 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut
a.emitDriverDownloadProgress(driverType, "downloading", 92, 100, "未命中预编译包,尝试开发态本地构建")
}
var buildErr error
if sourceBuildAttempted {
buildErr = sourceBuildErr
} else {
hash, runErr := buildOptionalDriverAgentFromSource(definition, executablePath, selectedVersion)
buildErr = runErr
if buildErr == nil {
return fmt.Sprintf("local://go-build/%s-driver-agent", driverType), hash, nil
}
hash, buildErr := buildOptionalDriverAgentFromSource(definition, executablePath, selectedVersion)
if buildErr == nil {
return fmt.Sprintf("local://go-build/%s-driver-agent", driverType), hash, nil
}
var parts []string
@@ -3108,25 +3086,12 @@ func shouldForceSourceBuildForVersion(driverType string, selectedVersion string)
return resolveMongoDriverMajorFromVersion(selectedVersion) == 1
}
func shouldPreferSourceBuildBeforeDownload(driverType string, selectedVersion string) bool {
_ = selectedVersion
switch normalizeDriverType(driverType) {
case "kingbase":
// 金仓迭代期优先本地源码构建,避免下载到旧版本预编译代理导致修复不生效。
return true
default:
return false
}
}
func shouldSkipReusableAgentCandidate(driverType string, selectedVersion string) bool {
_ = selectedVersion
switch normalizeDriverType(driverType) {
case "mongodb", "kingbase":
return true
default:
if normalizeDriverType(driverType) != "mongodb" {
return false
}
_ = selectedVersion
return true
}
func optionalDriverBuildTag(driverType string, selectedVersion string) (string, error) {

View File

@@ -9,6 +9,7 @@ import (
"strings"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
"GoNavi-Wails/internal/ssh"
"GoNavi-Wails/internal/utils"
@@ -134,26 +135,26 @@ func collectDirosAddresses(config connection.ConnectionConfig) []string {
return result
}
func (d *DirosDB) getDSN(config connection.ConnectionConfig) (string, error) {
func (d *DirosDB) getDSN(config connection.ConnectionConfig) string {
database := config.Database
protocol := "tcp"
address := normalizeMySQLAddress(config.Host, config.Port)
if config.UseSSH {
netName, err := ssh.RegisterSSHNetwork(config.SSH)
if err != nil {
return "", fmt.Errorf("创建 SSH 隧道失败:%w", err)
if err == nil {
protocol = netName
address = normalizeMySQLAddress(config.Host, config.Port)
} else {
logger.Warnf("注册 Doris SSH 网络失败,将尝试直连:地址=%s:%d 用户=%s原因%v", config.Host, config.Port, config.User, err)
}
protocol = netName
}
timeout := getConnectTimeoutSeconds(config)
tlsMode := resolveMySQLTLSMode(config)
return fmt.Sprintf(
"%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds&tls=%s",
config.User, config.Password, protocol, address, database, timeout, url.QueryEscape(tlsMode),
), nil
return fmt.Sprintf("%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds&tls=%s",
config.User, config.Password, protocol, address, database, timeout, url.QueryEscape(tlsMode))
}
func resolveDirosCredential(config connection.ConnectionConfig, addressIndex int) (string, string) {
@@ -191,11 +192,7 @@ func (d *DirosDB) Connect(config connection.ConnectionConfig) error {
candidateConfig.Port = port
candidateConfig.User, candidateConfig.Password = resolveDirosCredential(runConfig, index)
dsn, err := d.getDSN(candidateConfig)
if err != nil {
errorDetails = append(errorDetails, fmt.Sprintf("%s 生成连接串失败: %v", address, err))
continue
}
dsn := d.getDSN(candidateConfig)
db, err := sql.Open(dirosDriverName, dsn)
if err != nil {
errorDetails = append(errorDetails, fmt.Sprintf("%s 打开失败: %v", address, err))

View File

@@ -1,164 +0,0 @@
package db
import "strings"
func normalizeKingbaseIdentCommon(raw string) string {
value := strings.TrimSpace(raw)
if value == "" {
return ""
}
// 兼容被多次 JSON 序列化后的转义引号:
// \\\"schema\\\" -> \"schema\" -> "schema"
for i := 0; i < 8; i++ {
next := strings.TrimSpace(value)
next = strings.ReplaceAll(next, `\\\"`, `\"`)
next = strings.ReplaceAll(next, `\"`, `"`)
if next == value {
break
}
value = next
}
value = strings.TrimSpace(value)
stripWrapperOnce := func(text string) string {
t := strings.TrimSpace(text)
if strings.HasPrefix(t, `\`) && len(t) > 1 {
t = strings.TrimSpace(strings.TrimPrefix(t, `\`))
}
if strings.HasSuffix(t, `\`) && len(t) > 1 {
t = strings.TrimSpace(strings.TrimSuffix(t, `\`))
}
if len(t) >= 4 && strings.HasPrefix(t, `\"`) && strings.HasSuffix(t, `\"`) {
return strings.TrimSpace(t[2 : len(t)-2])
}
if len(t) >= 2 && strings.HasPrefix(t, `"`) && strings.HasSuffix(t, `"`) {
return strings.TrimSpace(t[1 : len(t)-1])
}
if len(t) >= 2 && strings.HasPrefix(t, "`") && strings.HasSuffix(t, "`") {
return strings.TrimSpace(t[1 : len(t)-1])
}
if len(t) >= 2 && strings.HasPrefix(t, "[") && strings.HasSuffix(t, "]") {
return strings.TrimSpace(t[1 : len(t)-1])
}
return t
}
for i := 0; i < 8; i++ {
next := stripWrapperOnce(value)
if next == value {
break
}
value = next
}
value = strings.TrimSpace(value)
// 兼容错误的二次引用与残留反斜杠。
value = strings.ReplaceAll(value, `\"`, `"`)
value = strings.ReplaceAll(value, `""`, "")
value = strings.TrimSpace(value)
for i := 0; i < 8; i++ {
next := strings.TrimSpace(value)
changed := false
if strings.HasPrefix(next, `\`) && len(next) > 1 {
next = strings.TrimSpace(strings.TrimPrefix(next, `\`))
changed = true
}
if strings.HasSuffix(next, `\`) && len(next) > 1 {
next = strings.TrimSpace(strings.TrimSuffix(next, `\`))
changed = true
}
if !changed || next == value {
break
}
value = next
}
return strings.TrimSpace(value)
}
func splitKingbaseQualifiedNameCommon(raw string) (schema string, table string) {
text := strings.TrimSpace(raw)
if text == "" {
return "", ""
}
sep := findKingbaseQualifiedSeparator(text)
if sep < 0 {
return "", normalizeKingbaseIdentCommon(text)
}
schemaPart := normalizeKingbaseIdentCommon(text[:sep])
tablePart := normalizeKingbaseIdentCommon(text[sep+1:])
if tablePart == "" {
if schemaPart == "" {
return "", normalizeKingbaseIdentCommon(text)
}
return "", schemaPart
}
if schemaPart == "" {
return "", tablePart
}
return schemaPart, tablePart
}
func findKingbaseQualifiedSeparator(raw string) int {
inDouble := false
inBacktick := false
inBracket := false
escaped := false
for i := 0; i < len(raw); i++ {
ch := raw[i]
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if inDouble {
if ch == '"' {
// SQL 双引号转义:"" 代表字面量 "
if i+1 < len(raw) && raw[i+1] == '"' {
i++
continue
}
inDouble = false
}
continue
}
if inBacktick {
if ch == '`' {
inBacktick = false
}
continue
}
if inBracket {
if ch == ']' {
inBracket = false
}
continue
}
switch ch {
case '"':
inDouble = true
case '`':
inBacktick = true
case '[':
inBracket = true
case '.':
return i
}
}
return -1
}

View File

@@ -1,52 +0,0 @@
package db
import "testing"
func TestNormalizeKingbaseIdentCommon(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "plain", in: "ldf_server", want: "ldf_server"},
{name: "quoted", in: `"ldf_server"`, want: "ldf_server"},
{name: "escaped quoted", in: `\"ldf_server\"`, want: "ldf_server"},
{name: "double escaped quoted", in: `\\\"ldf_server\\\"`, want: "ldf_server"},
{name: "double quoted", in: `""ldf_server""`, want: "ldf_server"},
{name: "backtick quoted", in: "`ldf_server`", want: "ldf_server"},
{name: "bracket quoted", in: "[ldf_server]", want: "ldf_server"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeKingbaseIdentCommon(tt.in); got != tt.want {
t.Fatalf("normalizeKingbaseIdentCommon(%q)=%q,want=%q", tt.in, got, tt.want)
}
})
}
}
func TestSplitKingbaseQualifiedNameCommon(t *testing.T) {
tests := []struct {
name string
in string
wantSchema string
wantTable string
}{
{name: "plain", in: "ldf_server.andon_events", wantSchema: "ldf_server", wantTable: "andon_events"},
{name: "quoted", in: `"ldf_server"."andon_events"`, wantSchema: "ldf_server", wantTable: "andon_events"},
{name: "escaped quoted", in: `\"ldf_server\".\"andon_events\"`, wantSchema: "ldf_server", wantTable: "andon_events"},
{name: "double escaped quoted", in: `\\\"ldf_server\\\".\\\"andon_events\\\"`, wantSchema: "ldf_server", wantTable: "andon_events"},
{name: "space around dot", in: ` "ldf_server" . "andon_events" `, wantSchema: "ldf_server", wantTable: "andon_events"},
{name: "table only", in: "andon_events", wantSchema: "", wantTable: "andon_events"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotSchema, gotTable := splitKingbaseQualifiedNameCommon(tt.in)
if gotSchema != tt.wantSchema || gotTable != tt.wantTable {
t.Fatalf("splitKingbaseQualifiedNameCommon(%q)=(%q,%q),want=(%q,%q)", tt.in, gotSchema, gotTable, tt.wantSchema, tt.wantTable)
}
})
}
}

View File

@@ -7,7 +7,6 @@ import (
"database/sql"
"fmt"
"net"
"regexp"
"strconv"
"strings"
"time"
@@ -137,88 +136,11 @@ func (k *KingbaseDB) Connect(config connection.ConnectionConfig) error {
if idx > 0 {
logger.Warnf("人大金仓 SSL 优先连接失败,已回退至明文连接")
}
// 获取 schema 列表以重构带有 search_path 的连接池
searchPathStr := k.getSearchPathStr()
if searchPathStr != "" {
// 将 search_path 参数拼入 DSN
finalDSN := dsn + " search_path=" + quoteConnValue(searchPathStr)
if finalDB, err := sql.Open("kingbase", finalDSN); err == nil {
k.pingTimeout = getConnectTimeout(attempt)
finalDB.SetConnMaxLifetime(5 * time.Minute)
// 临时将 k.conn 指向 finalDB 来做 ping 测试
oldConn := k.conn
k.conn = finalDB
if err := k.Ping(); err == nil {
// 成功使用带 search_path 的连接池
_ = oldConn.Close()
logger.Infof("人大金仓已配置连接级 search_path%s", searchPathStr)
} else {
_ = finalDB.Close()
k.conn = oldConn
}
}
}
if searchPathStr != "" {
timeout := k.pingTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
ctx, cancel := utils.ContextWithTimeout(timeout)
defer cancel()
if _, err := k.conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", searchPathStr)); err != nil {
logger.Warnf("人大金仓显式设置 search_path 失败:%v", err)
} else {
logger.Infof("人大金仓已设置默认 search_path%s", searchPathStr)
}
}
return nil
}
return fmt.Errorf("连接建立后验证失败:%s", strings.Join(failures, ""))
}
// getSearchPathStr 查询当前数据库中所有用户 schema配置 DSN 的 search_path。
// KingBase 默认 search_path 为 "$user", public对于自定义 schema 下的表不可见。
func (k *KingbaseDB) getSearchPathStr() string {
if k.conn == nil {
return ""
}
query := `SELECT nspname FROM pg_namespace
WHERE nspname NOT IN ('pg_catalog', 'information_schema')
AND nspname NOT LIKE 'pg_%'
ORDER BY nspname`
rows, err := k.conn.Query(query)
if err != nil {
logger.Warnf("人大金仓查询用户 schema 失败,跳过 search_path 设置:%v", err)
return ""
}
defer rows.Close()
var schemas []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
continue
}
name = strings.TrimSpace(name)
if name != "" {
// 使用 SQL 标准的双引号包裹标识符
escaped := strings.ReplaceAll(name, `"`, `""`)
schemas = append(schemas, `"`+escaped+`"`)
}
}
if len(schemas) == 0 {
return ""
}
return strings.Join(schemas, ", ")
}
func (k *KingbaseDB) Close() error {
// Close SSH forwarder first if exists
if k.forwarder != nil {
@@ -853,63 +775,64 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
}
func normalizeKingbaseIdentifier(raw string) string {
return normalizeKingbaseIdentCommon(raw)
}
value := strings.TrimSpace(raw)
if value == "" {
return ""
}
// kingbaseIdentNeedsQuote 判断标识符是否需要双引号包裹。
// 与前端 sql.ts 中 needsQuote 逻辑保持一致。
func kingbaseIdentNeedsQuote(ident string) bool {
if ident == "" {
return false
}
// 不是合法裸标识符格式(必须以字母或下划线开头,仅含字母、数字、下划线)
if matched, _ := regexp.MatchString(`^[a-zA-Z_][a-zA-Z0-9_]*$`, ident); !matched {
return true
}
// 包含大写字母时需要引号保护KingbaseES/PostgreSQL 默认将未加引号的标识符折叠为小写)
for _, r := range ident {
if r >= 'A' && r <= 'Z' {
return true
// 兼容 JSON/字符串转义后传入的标识符:\"schema\" -> "schema"
value = strings.ReplaceAll(value, `\"`, `"`)
value = strings.TrimSpace(value)
// 兼容异常多重包裹引号(例如 ""schema""、""""schema"""")。
// strings.Trim 会移除两端连续引号,迭代后可收敛到纯标识符。
for i := 0; i < 4; i++ {
next := strings.TrimSpace(strings.Trim(value, `"`))
if next == value {
break
}
value = next
}
// 是 SQL 保留字
return isKingbaseReservedWord(ident)
}
// isKingbaseReservedWord 检查是否为常见 SQL 保留字(简化版,与前端保持一致)。
func isKingbaseReservedWord(ident string) bool {
switch strings.ToLower(ident) {
case "select", "from", "where", "table", "index", "user", "order", "group", "by",
"limit", "offset", "and", "or", "not", "null", "true", "false", "key",
"primary", "foreign", "references", "default", "constraint",
"create", "drop", "alter", "insert", "update", "delete", "set", "values", "into",
"join", "left", "right", "inner", "outer", "on", "as", "is", "in", "like",
"between", "case", "when", "then", "else", "end", "having", "distinct",
"all", "any", "exists", "union", "except", "intersect",
"column", "check", "unique", "with", "grant", "revoke", "trigger",
"begin", "commit", "rollback", "schema", "database", "view", "function",
"procedure", "sequence", "type", "domain", "role", "session", "current",
"authorization", "cross", "full", "natural", "some", "cast", "fetch",
"for", "to", "do", "if", "return", "returns", "declare", "cursor", "server", "owner":
return true
// 兼容其他方言可能残留的引用形式
if len(value) >= 2 && strings.HasPrefix(value, "`") && strings.HasSuffix(value, "`") {
value = strings.TrimSpace(strings.Trim(value, "`"))
}
return false
if len(value) >= 2 && strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") {
value = strings.TrimSpace(value[1 : len(value)-1])
}
return value
}
func quoteKingbaseIdent(name string) string {
n := normalizeKingbaseIdentifier(name)
n = strings.ReplaceAll(n, `"`, `""`)
if n == "" {
return "\"\""
}
if !kingbaseIdentNeedsQuote(n) {
return n
}
n = strings.ReplaceAll(n, `"`, `""`)
return `"` + n + `"`
}
func splitKingbaseQualifiedTable(tableName string) (schema string, table string) {
return splitKingbaseQualifiedNameCommon(tableName)
raw := strings.TrimSpace(tableName)
if raw == "" {
return "", ""
}
if parts := strings.SplitN(raw, ".", 2); len(parts) == 2 {
schema = normalizeKingbaseIdentifier(parts[0])
table = normalizeKingbaseIdentifier(parts[1])
if table == "" {
return "", normalizeKingbaseIdentifier(raw)
}
if schema == "" {
return "", table
}
return schema, table
}
return "", normalizeKingbaseIdentifier(raw)
}
func (k *KingbaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {

View File

@@ -15,10 +15,8 @@ func TestNormalizeKingbaseIdentifier(t *testing.T) {
{name: "double quoted", in: `""ldf_server""`, want: "ldf_server"},
{name: "quad quoted", in: `""""ldf_server""""`, want: "ldf_server"},
{name: "escaped quoted", in: `\"ldf_server\"`, want: "ldf_server"},
{name: "double escaped quoted", in: `\\\"ldf_server\\\"`, want: "ldf_server"},
{name: "backtick quoted", in: "`ldf_server`", want: "ldf_server"},
{name: "bracket quoted", in: "[ldf_server]", want: "ldf_server"},
{name: "embedded double quotes", in: `ldf""server`, want: "ldfserver"},
}
for _, tt := range tests {
@@ -36,25 +34,10 @@ func TestQuoteKingbaseIdent(t *testing.T) {
in string
want string
}{
// 纯小写+下划线:不加引号
{name: "plain lowercase", in: "ldf_server", want: "ldf_server"},
{name: "plain lowercase 2", in: "bcs_barcode", want: "bcs_barcode"},
{name: "double quoted input", in: `""ldf_server""`, want: "ldf_server"},
{name: "escaped quoted input", in: `\"ldf_server\"`, want: "ldf_server"},
// 含大写字母:加引号
{name: "uppercase", in: "LDF_Server", want: `"LDF_Server"`},
{name: "mixed case", in: "myTable", want: `"myTable"`},
// SQL 保留字:加引号
{name: "reserved word order", in: "order", want: `"order"`},
{name: "reserved word user", in: "user", want: `"user"`},
{name: "reserved word table", in: "table", want: `"table"`},
{name: "reserved word select", in: "select", want: `"select"`},
// 含特殊字符:加引号
{name: "with hyphen", in: "my-table", want: `"my-table"`},
{name: "with space", in: "my table", want: `"my table"`},
{name: "plain", in: "ldf_server", want: `"ldf_server"`},
{name: "double quoted", in: `""ldf_server""`, want: `"ldf_server"`},
{name: "escaped quoted", in: `\"ldf_server\"`, want: `"ldf_server"`},
{name: "with embedded quote", in: `ab"cd`, want: `"ab""cd"`},
// 空值
{name: "empty", in: "", want: `""`},
}
for _, tt := range tests {
@@ -66,31 +49,6 @@ func TestQuoteKingbaseIdent(t *testing.T) {
}
}
func TestKingbaseIdentNeedsQuote(t *testing.T) {
tests := []struct {
name string
in string
want bool
}{
{name: "plain lowercase", in: "ldf_server", want: false},
{name: "starts with underscore", in: "_col", want: false},
{name: "with digits", in: "col123", want: false},
{name: "uppercase", in: "MyTable", want: true},
{name: "reserved word", in: "order", want: true},
{name: "with hyphen", in: "my-col", want: true},
{name: "starts with digit", in: "123col", want: true},
{name: "empty", in: "", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := kingbaseIdentNeedsQuote(tt.in); got != tt.want {
t.Fatalf("kingbaseIdentNeedsQuote(%q) = %v, want %v", tt.in, got, tt.want)
}
})
}
}
func TestSplitKingbaseQualifiedTable(t *testing.T) {
tests := []struct {
name string
@@ -101,7 +59,6 @@ func TestSplitKingbaseQualifiedTable(t *testing.T) {
{name: "plain qualified", in: "ldf_server.t_user", wantSchema: "ldf_server", wantTable: "t_user"},
{name: "double quoted qualified", in: `""ldf_server"".""t_user""`, wantSchema: "ldf_server", wantTable: "t_user"},
{name: "escaped qualified", in: `\"ldf_server\".\"t_user\"`, wantSchema: "ldf_server", wantTable: "t_user"},
{name: "double escaped qualified", in: `\\\"ldf_server\\\".\\\"t_user\\\"`, wantSchema: "ldf_server", wantTable: "t_user"},
{name: "bracket qualified", in: "[ldf_server].[t_user]", wantSchema: "ldf_server", wantTable: "t_user"},
{name: "table only", in: `""t_user""`, wantSchema: "", wantTable: "t_user"},
}

View File

@@ -11,6 +11,7 @@ import (
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
"GoNavi-Wails/internal/ssh"
"GoNavi-Wails/internal/utils"
@@ -24,33 +25,30 @@ type MariaDB struct {
pingTimeout time.Duration
}
func (m *MariaDB) getDSN(config connection.ConnectionConfig) (string, error) {
func (m *MariaDB) getDSN(config connection.ConnectionConfig) string {
database := config.Database
protocol := "tcp"
address := fmt.Sprintf("%s:%d", config.Host, config.Port)
if config.UseSSH {
netName, err := ssh.RegisterSSHNetwork(config.SSH)
if err != nil {
return "", fmt.Errorf("创建 SSH 隧道失败:%w", err)
if err == nil {
protocol = netName
address = fmt.Sprintf("%s:%d", config.Host, config.Port)
} else {
logger.Warnf("注册 SSH 网络失败,将尝试直连:地址=%s:%d 用户=%s原因%v", config.Host, config.Port, config.User, err)
}
protocol = netName
}
timeout := getConnectTimeoutSeconds(config)
tlsMode := resolveMySQLTLSMode(config)
return fmt.Sprintf(
"%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds&tls=%s",
config.User, config.Password, protocol, address, database, timeout, url.QueryEscape(tlsMode),
), nil
return fmt.Sprintf("%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds&tls=%s",
config.User, config.Password, protocol, address, database, timeout, url.QueryEscape(tlsMode))
}
func (m *MariaDB) Connect(config connection.ConnectionConfig) error {
dsn, err := m.getDSN(config)
if err != nil {
return err
}
dsn := m.getDSN(config)
db, err := sql.Open("mysql", dsn)
if err != nil {
return fmt.Errorf("打开数据库连接失败:%w", err)

View File

@@ -151,14 +151,10 @@ func applyMongoURI(config connection.ConnectionConfig) connection.ConnectionConf
}
}
explicitHost := strings.TrimSpace(config.Host) != ""
explicitHosts := len(config.Hosts) > 0
// 显式填写的 host/hosts 优先级高于 URI避免表单 host 被 URI 中的 localhost 覆盖。
if !explicitHost && !explicitHosts && len(hostsFromURI) > 0 {
if len(config.Hosts) == 0 && len(hostsFromURI) > 0 {
config.Hosts = hostsFromURI
}
if !explicitHost && !explicitHosts && len(hostsFromURI) > 0 {
if strings.TrimSpace(config.Host) == "" && len(hostsFromURI) > 0 {
host, port, ok := parseHostPortWithDefault(hostsFromURI[0], defaultPort)
if ok {
config.Host = host
@@ -255,11 +251,6 @@ func (m *MongoDB) getURI(config connection.ConnectionConfig) string {
params.Set("authMechanism", authMechanism)
}
// 单机模式且未指定副本集名称时,启用 directConnection 避免驱动自动跟随副本集成员发现
if strings.TrimSpace(config.Topology) != "replica" && strings.TrimSpace(config.ReplicaSet) == "" && !config.MongoSRV {
params.Set("directConnection", "true")
}
if encoded := params.Encode(); encoded != "" {
uri += "?" + encoded
}
@@ -285,44 +276,9 @@ func buildMongoAuthAttempts(config connection.ConnectionConfig) []connection.Con
return attempts
}
func mongoURIForcesTLS(uriText string) bool {
trimmed := strings.TrimSpace(uriText)
if trimmed == "" {
return false
}
parsed, err := url.Parse(trimmed)
if err != nil {
return false
}
query := parsed.Query()
for _, key := range []string{"tls", "ssl"} {
value := strings.ToLower(strings.TrimSpace(query.Get(key)))
switch value {
case "1", "true", "t", "yes", "y", "required":
return true
}
}
return false
}
func mongoAttemptSSLLabel(config connection.ConnectionConfig, fallbackToPlain bool) string {
if fallbackToPlain {
return "明文回退"
}
if mongoURIForcesTLS(config.URI) {
return "SSL"
}
enabled, _ := resolveMongoTLSSettings(config)
if enabled {
return "SSL"
}
return "明文"
}
func (m *MongoDB) Connect(config connection.ConnectionConfig) error {
runConfig := applyMongoURI(config)
connectConfig := runConfig
sshRouteHint := ""
if runConfig.UseSSH && runConfig.MongoSRV {
return fmt.Errorf("MongoDB SRV 记录模式暂不支持 SSH 隧道")
@@ -363,7 +319,6 @@ func (m *MongoDB) Connect(config connection.ConnectionConfig) error {
localConfig.URI = ""
localConfig.Hosts = []string{normalizeMongoAddress(host, port)}
connectConfig = localConfig
sshRouteHint = fmt.Sprintf("SSH隧道 %s -> %s:%d", forwarder.LocalAddr, targetHost, targetPort)
logger.Infof("MongoDB 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, targetHost, targetPort)
}
@@ -377,32 +332,20 @@ func (m *MongoDB) Connect(config connection.ConnectionConfig) error {
if shouldTrySSLPreferredFallback(connectConfig) {
sslAttempts = append(sslAttempts, withSSLDisabled(connectConfig))
}
totalAttempts := 0
for _, attemptConfig := range sslAttempts {
totalAttempts += len(buildMongoAuthAttempts(attemptConfig))
}
attemptNo := 0
var errorDetails []string
for sslIndex, sslConfig := range sslAttempts {
sslLabel := mongoAttemptSSLLabel(sslConfig, sslIndex > 0)
sslLabel := "SSL"
if sslIndex > 0 {
sslLabel = "明文回退"
}
attemptConfigs := buildMongoAuthAttempts(sslConfig)
for index, attemptConfig := range attemptConfigs {
attemptNo++
authLabel := "主库凭据"
if index > 0 {
authLabel = "从库凭据"
}
targets := collectMongoSeeds(attemptConfig)
if len(targets) == 0 {
targets = append(targets, normalizeMongoAddress(attemptConfig.Host, attemptConfig.Port))
}
attemptStarted := time.Now()
logger.Infof(
"MongoDB 连接尝试:%d/%d 模式=%s 凭据=%s 目标=%s 代理=%t",
attemptNo, totalAttempts, sslLabel, authLabel, strings.Join(targets, ","), attemptConfig.UseProxy,
)
if sslIndex > 0 {
attemptConfig.URI = ""
@@ -421,13 +364,7 @@ func (m *MongoDB) Connect(config connection.ConnectionConfig) error {
}
client, err := mongo.Connect(clientOpts)
if err != nil {
logger.Warnf("MongoDB 连接尝试失败:%d/%d 模式=%s 凭据=%s 耗时=%s 错误=%v",
attemptNo, totalAttempts, sslLabel, authLabel, time.Since(attemptStarted).Round(time.Millisecond), err)
detail := fmt.Sprintf("%s %s连接失败: %v", sslLabel, authLabel, err)
if sshRouteHint != "" {
detail = fmt.Sprintf("%s%s", detail, sshRouteHint)
}
errorDetails = append(errorDetails, detail)
errorDetails = append(errorDetails, fmt.Sprintf("%s %s连接失败: %v", sslLabel, authLabel, err))
continue
}
@@ -437,17 +374,9 @@ func (m *MongoDB) Connect(config connection.ConnectionConfig) error {
_ = client.Disconnect(ctx)
cancel()
m.client = nil
logger.Warnf("MongoDB 连接尝试验证失败:%d/%d 模式=%s 凭据=%s 耗时=%s 错误=%v",
attemptNo, totalAttempts, sslLabel, authLabel, time.Since(attemptStarted).Round(time.Millisecond), err)
detail := fmt.Sprintf("%s %s验证失败: %v", sslLabel, authLabel, err)
if sshRouteHint != "" {
detail = fmt.Sprintf("%s%s", detail, sshRouteHint)
}
errorDetails = append(errorDetails, detail)
errorDetails = append(errorDetails, fmt.Sprintf("%s %s验证失败: %v", sslLabel, authLabel, err))
continue
}
logger.Infof("MongoDB 连接尝试成功:%d/%d 模式=%s 凭据=%s 耗时=%s",
attemptNo, totalAttempts, sslLabel, authLabel, time.Since(attemptStarted).Round(time.Millisecond))
if sslIndex > 0 {
logger.Warnf("MongoDB SSL 优先连接失败,已回退至明文连接")
}

View File

@@ -1,39 +0,0 @@
//go:build gonavi_full_drivers || gonavi_mongodb_driver
package db
import (
"testing"
"GoNavi-Wails/internal/connection"
)
func TestApplyMongoURI_ExplicitHostDoesNotAdoptURIHosts(t *testing.T) {
config := connection.ConnectionConfig{
Host: "10.10.10.10",
Port: 27017,
URI: "mongodb://localhost:27017/admin",
}
got := applyMongoURI(config)
if got.Host != "10.10.10.10" {
t.Fatalf("expected host to remain explicit, got %q", got.Host)
}
if len(got.Hosts) != 0 {
t.Fatalf("expected hosts to remain empty when explicit host exists, got %v", got.Hosts)
}
}
func TestApplyMongoURI_ExplicitHostsDoesNotAdoptURIHosts(t *testing.T) {
config := connection.ConnectionConfig{
Host: "10.10.10.10",
Port: 27017,
Hosts: []string{"10.10.10.10:27017", "10.10.10.11:27017"},
URI: "mongodb://localhost:27017,localhost:27018/admin?replicaSet=rs0",
}
got := applyMongoURI(config)
if len(got.Hosts) != 2 || got.Hosts[0] != "10.10.10.10:27017" {
t.Fatalf("expected explicit hosts to stay untouched, got %v", got.Hosts)
}
}

View File

@@ -152,14 +152,10 @@ func applyMongoURI(config connection.ConnectionConfig) connection.ConnectionConf
}
}
explicitHost := strings.TrimSpace(config.Host) != ""
explicitHosts := len(config.Hosts) > 0
// 显式填写的 host/hosts 优先级高于 URI避免表单 host 被 URI 中的 localhost 覆盖。
if !explicitHost && !explicitHosts && len(hostsFromURI) > 0 {
if len(config.Hosts) == 0 && len(hostsFromURI) > 0 {
config.Hosts = hostsFromURI
}
if !explicitHost && !explicitHosts && len(hostsFromURI) > 0 {
if strings.TrimSpace(config.Host) == "" && len(hostsFromURI) > 0 {
host, port, ok := parseHostPortWithDefault(hostsFromURI[0], defaultPort)
if ok {
config.Host = host
@@ -256,11 +252,6 @@ func (m *MongoDBV1) getURI(config connection.ConnectionConfig) string {
params.Set("authMechanism", authMechanism)
}
// 单机模式且未指定副本集名称时,启用 directConnection 避免驱动自动跟随副本集成员发现
if strings.TrimSpace(config.Topology) != "replica" && strings.TrimSpace(config.ReplicaSet) == "" && !config.MongoSRV {
params.Set("directConnection", "true")
}
if encoded := params.Encode(); encoded != "" {
uri += "?" + encoded
}
@@ -286,44 +277,9 @@ func buildMongoAuthAttempts(config connection.ConnectionConfig) []connection.Con
return attempts
}
func mongoURIForcesTLS(uriText string) bool {
trimmed := strings.TrimSpace(uriText)
if trimmed == "" {
return false
}
parsed, err := url.Parse(trimmed)
if err != nil {
return false
}
query := parsed.Query()
for _, key := range []string{"tls", "ssl"} {
value := strings.ToLower(strings.TrimSpace(query.Get(key)))
switch value {
case "1", "true", "t", "yes", "y", "required":
return true
}
}
return false
}
func mongoAttemptSSLLabel(config connection.ConnectionConfig, fallbackToPlain bool) string {
if fallbackToPlain {
return "明文回退"
}
if mongoURIForcesTLS(config.URI) {
return "SSL"
}
enabled, _ := resolveMongoTLSSettings(config)
if enabled {
return "SSL"
}
return "明文"
}
func (m *MongoDBV1) Connect(config connection.ConnectionConfig) error {
runConfig := applyMongoURI(config)
connectConfig := runConfig
sshRouteHint := ""
if runConfig.UseSSH && runConfig.MongoSRV {
return fmt.Errorf("MongoDB SRV 记录模式暂不支持 SSH 隧道")
@@ -364,7 +320,6 @@ func (m *MongoDBV1) Connect(config connection.ConnectionConfig) error {
localConfig.URI = ""
localConfig.Hosts = []string{normalizeMongoAddress(host, port)}
connectConfig = localConfig
sshRouteHint = fmt.Sprintf("SSH隧道 %s -> %s:%d", forwarder.LocalAddr, targetHost, targetPort)
logger.Infof("MongoDB 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, targetHost, targetPort)
}
@@ -378,32 +333,20 @@ func (m *MongoDBV1) Connect(config connection.ConnectionConfig) error {
if shouldTrySSLPreferredFallback(connectConfig) {
sslAttempts = append(sslAttempts, withSSLDisabled(connectConfig))
}
totalAttempts := 0
for _, attemptConfig := range sslAttempts {
totalAttempts += len(buildMongoAuthAttempts(attemptConfig))
}
attemptNo := 0
var errorDetails []string
for sslIndex, sslConfig := range sslAttempts {
sslLabel := mongoAttemptSSLLabel(sslConfig, sslIndex > 0)
sslLabel := "SSL"
if sslIndex > 0 {
sslLabel = "明文回退"
}
attemptConfigs := buildMongoAuthAttempts(sslConfig)
for index, attemptConfig := range attemptConfigs {
attemptNo++
authLabel := "主库凭据"
if index > 0 {
authLabel = "从库凭据"
}
targets := collectMongoSeeds(attemptConfig)
if len(targets) == 0 {
targets = append(targets, normalizeMongoAddress(attemptConfig.Host, attemptConfig.Port))
}
attemptStarted := time.Now()
logger.Infof(
"MongoDB(v1) 连接尝试:%d/%d 模式=%s 凭据=%s 目标=%s 代理=%t",
attemptNo, totalAttempts, sslLabel, authLabel, strings.Join(targets, ","), attemptConfig.UseProxy,
)
if sslIndex > 0 {
attemptConfig.URI = ""
@@ -424,13 +367,7 @@ func (m *MongoDBV1) Connect(config connection.ConnectionConfig) error {
client, err := mongo.Connect(connectCtx, clientOpts)
connectCancel()
if err != nil {
logger.Warnf("MongoDB(v1) 连接尝试失败:%d/%d 模式=%s 凭据=%s 耗时=%s 错误=%v",
attemptNo, totalAttempts, sslLabel, authLabel, time.Since(attemptStarted).Round(time.Millisecond), err)
detail := fmt.Sprintf("%s %s连接失败: %v", sslLabel, authLabel, err)
if sshRouteHint != "" {
detail = fmt.Sprintf("%s%s", detail, sshRouteHint)
}
errorDetails = append(errorDetails, detail)
errorDetails = append(errorDetails, fmt.Sprintf("%s %s连接失败: %v", sslLabel, authLabel, err))
continue
}
@@ -440,17 +377,9 @@ func (m *MongoDBV1) Connect(config connection.ConnectionConfig) error {
_ = client.Disconnect(ctx)
cancel()
m.client = nil
logger.Warnf("MongoDB(v1) 连接尝试验证失败:%d/%d 模式=%s 凭据=%s 耗时=%s 错误=%v",
attemptNo, totalAttempts, sslLabel, authLabel, time.Since(attemptStarted).Round(time.Millisecond), err)
detail := fmt.Sprintf("%s %s验证失败: %v", sslLabel, authLabel, err)
if sshRouteHint != "" {
detail = fmt.Sprintf("%s%s", detail, sshRouteHint)
}
errorDetails = append(errorDetails, detail)
errorDetails = append(errorDetails, fmt.Sprintf("%s %s验证失败: %v", sslLabel, authLabel, err))
continue
}
logger.Infof("MongoDB(v1) 连接尝试成功:%d/%d 模式=%s 凭据=%s 耗时=%s",
attemptNo, totalAttempts, sslLabel, authLabel, time.Since(attemptStarted).Round(time.Millisecond))
if sslIndex > 0 {
logger.Warnf("MongoDB(v1) SSL 优先连接失败,已回退至明文连接")
}

View File

@@ -1,25 +0,0 @@
//go:build gonavi_mongodb_driver_v1
package db
import (
"testing"
"GoNavi-Wails/internal/connection"
)
func TestApplyMongoURIV1_ExplicitHostDoesNotAdoptURIHosts(t *testing.T) {
config := connection.ConnectionConfig{
Host: "10.10.10.10",
Port: 27017,
URI: "mongodb://localhost:27017/admin",
}
got := applyMongoURI(config)
if got.Host != "10.10.10.10" {
t.Fatalf("expected host to remain explicit, got %q", got.Host)
}
if len(got.Hosts) != 0 {
t.Fatalf("expected hosts to remain empty when explicit host exists, got %v", got.Hosts)
}
}

View File

@@ -169,26 +169,26 @@ func collectMySQLAddresses(config connection.ConnectionConfig) []string {
return result
}
func (m *MySQLDB) getDSN(config connection.ConnectionConfig) (string, error) {
func (m *MySQLDB) getDSN(config connection.ConnectionConfig) string {
database := config.Database
protocol := "tcp"
address := normalizeMySQLAddress(config.Host, config.Port)
if config.UseSSH {
netName, err := ssh.RegisterSSHNetwork(config.SSH)
if err != nil {
return "", fmt.Errorf("创建 SSH 隧道失败:%w", err)
if err == nil {
protocol = netName
address = normalizeMySQLAddress(config.Host, config.Port)
} else {
logger.Warnf("注册 SSH 网络失败,将尝试直连:地址=%s:%d 用户=%s原因%v", config.Host, config.Port, config.User, err)
}
protocol = netName
}
timeout := getConnectTimeoutSeconds(config)
tlsMode := resolveMySQLTLSMode(config)
return fmt.Sprintf(
"%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds&tls=%s",
config.User, config.Password, protocol, address, database, timeout, url.QueryEscape(tlsMode),
), nil
return fmt.Sprintf("%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds&tls=%s",
config.User, config.Password, protocol, address, database, timeout, url.QueryEscape(tlsMode))
}
func resolveMySQLCredential(config connection.ConnectionConfig, addressIndex int) (string, string) {
@@ -226,11 +226,7 @@ func (m *MySQLDB) Connect(config connection.ConnectionConfig) error {
candidateConfig.Port = port
candidateConfig.User, candidateConfig.Password = resolveMySQLCredential(runConfig, index)
dsn, err := m.getDSN(candidateConfig)
if err != nil {
errorDetails = append(errorDetails, fmt.Sprintf("%s 生成连接串失败: %v", address, err))
continue
}
dsn := m.getDSN(candidateConfig)
db, err := sql.Open("mysql", dsn)
if err != nil {
errorDetails = append(errorDetails, fmt.Sprintf("%s 打开失败: %v", address, err))

View File

@@ -1,26 +0,0 @@
package db
import (
"testing"
"GoNavi-Wails/internal/connection"
)
func TestMySQLDSN_UseSSH_ShouldFailWhenSSHInvalid(t *testing.T) {
m := &MySQLDB{}
_, err := m.getDSN(connection.ConnectionConfig{
Host: "127.0.0.1",
Port: 3306,
User: "root",
UseSSH: true,
SSH: connection.SSHConfig{
Host: "127.0.0.1",
Port: 0, // invalid port, should fail immediately
User: "bad",
Password: "bad",
},
})
if err == nil {
t.Fatalf("expected error when UseSSH=true and SSH config invalid")
}
}

View File

@@ -9,7 +9,6 @@ import (
"io"
"os"
"os/exec"
"reflect"
"runtime"
"strings"
"sync"
@@ -146,7 +145,6 @@ func (c *optionalDriverAgentClient) captureStderr(stderr io.Reader) {
if line == "" {
continue
}
logger.Warnf("%s 驱动代理 stderr: %s", driverDisplayName(c.driver), line)
c.stderrMu.Lock()
if c.stderr.Len() > 0 {
c.stderr.WriteString(" | ")
@@ -270,7 +268,6 @@ func (d *OptionalDriverAgentDB) Connect(config connection.ConnectionConfig) erro
return err
}
d.client = client
d.ensureKingbaseSearchPath(config)
return nil
}
@@ -491,16 +488,6 @@ func (d *OptionalDriverAgentDB) ApplyChanges(tableName string, changes connectio
if err != nil {
return err
}
if strings.EqualFold(d.driverType, "kingbase") {
if normalized := normalizeKingbaseAgentTableName(tableName); normalized != "" {
tableName = normalized
}
if normalized, normErr := d.normalizeKingbaseAgentChangeSet(tableName, changes); normErr == nil {
changes = normalized
} else {
logger.Warnf("Kingbase ApplyChanges 字段名规范化失败:%v", normErr)
}
}
return client.call(optionalAgentRequest{
Method: optionalAgentMethodApplyChanges,
TableName: tableName,
@@ -515,269 +502,6 @@ func (d *OptionalDriverAgentDB) requireClient() (*optionalDriverAgentClient, err
return d.client, nil
}
func (d *OptionalDriverAgentDB) ensureKingbaseSearchPath(config connection.ConnectionConfig) {
if !strings.EqualFold(d.driverType, "kingbase") {
return
}
client, err := d.requireClient()
if err != nil || client == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
schemas, err := d.listKingbaseSchemas(ctx)
if err != nil || len(schemas) == 0 {
if err != nil {
logger.Warnf("人大金仓驱动代理探测 schema 失败:%v", err)
}
return
}
searchPath := buildKingbaseSearchPathFromSchemas(schemas)
if strings.TrimSpace(searchPath) == "" {
return
}
if _, err := d.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", searchPath)); err != nil {
logger.Warnf("人大金仓驱动代理设置 search_path 失败:%v", err)
return
}
logger.Infof("人大金仓驱动代理已设置默认 search_path%s", searchPath)
}
func (d *OptionalDriverAgentDB) listKingbaseSchemas(ctx context.Context) ([]string, error) {
query := `SELECT nspname FROM pg_namespace
WHERE nspname NOT IN ('pg_catalog', 'information_schema')
AND nspname NOT LIKE 'pg_%'
ORDER BY nspname`
rows, _, err := d.QueryContext(ctx, query)
if err != nil {
return nil, err
}
schemas := make([]string, 0, len(rows))
for _, row := range rows {
for key, val := range row {
if strings.EqualFold(key, "nspname") || strings.EqualFold(key, "schema") {
name := strings.TrimSpace(fmt.Sprintf("%v", val))
if name != "" {
schemas = append(schemas, name)
}
break
}
}
if len(row) == 1 {
for _, val := range row {
name := strings.TrimSpace(fmt.Sprintf("%v", val))
if name != "" {
schemas = append(schemas, name)
}
break
}
}
}
return schemas, nil
}
func buildKingbaseSearchPathFromSchemas(schemas []string) string {
if len(schemas) == 0 {
return ""
}
seen := make(map[string]struct{}, len(schemas)+1)
parts := make([]string, 0, len(schemas)+1)
for _, name := range schemas {
trimmed := normalizeKingbaseAgentIdent(name)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
parts = append(parts, quoteKingbaseAgentIdent(trimmed))
}
if _, ok := seen["public"]; !ok {
parts = append(parts, "public")
}
return strings.Join(parts, ", ")
}
func quoteKingbaseAgentIdent(name string) string {
n := normalizeKingbaseAgentIdent(name)
if n == "" {
return "\"\""
}
n = strings.ReplaceAll(n, `"`, `""`)
return `"` + n + `"`
}
func normalizeKingbaseAgentTableName(raw string) string {
schema, table := splitKingbaseQualifiedNameCommon(raw)
if table == "" {
return ""
}
if schema == "" {
return table
}
return schema + "." + table
}
func normalizeKingbaseAgentIdent(raw string) string {
return normalizeKingbaseIdentCommon(raw)
}
type kingbaseAgentColumnIndex struct {
exact map[string]string
compact map[string]string
}
func buildKingbaseAgentColumnIndex(columns []string) kingbaseAgentColumnIndex {
exact := make(map[string]string, len(columns))
compact := make(map[string]string, len(columns))
compactSeen := make(map[string]string, len(columns))
compactDup := make(map[string]struct{}, len(columns))
for _, col := range columns {
name := normalizeKingbaseAgentIdent(col)
if name == "" {
continue
}
lower := strings.ToLower(name)
if _, ok := exact[lower]; !ok {
exact[lower] = name
}
key := normalizeKingbaseAgentCompactKey(name)
if key == "" {
continue
}
if prev, ok := compactSeen[key]; ok && !strings.EqualFold(prev, name) {
compactDup[key] = struct{}{}
continue
}
compactSeen[key] = name
}
if len(compactDup) > 0 {
for key := range compactDup {
delete(compactSeen, key)
}
}
for key, value := range compactSeen {
compact[key] = value
}
return kingbaseAgentColumnIndex{exact: exact, compact: compact}
}
func normalizeKingbaseAgentCompactKey(raw string) string {
name := normalizeKingbaseAgentIdent(raw)
if name == "" {
return ""
}
name = strings.ToLower(strings.TrimSpace(name))
name = strings.Join(strings.Fields(name), "")
name = strings.ReplaceAll(name, "_", "")
return name
}
func resolveKingbaseAgentColumnName(name string, index kingbaseAgentColumnIndex) string {
cleaned := normalizeKingbaseAgentIdent(name)
if cleaned == "" {
return name
}
lower := strings.ToLower(cleaned)
if actual, ok := index.exact[lower]; ok {
return actual
}
compact := normalizeKingbaseAgentCompactKey(cleaned)
if actual, ok := index.compact[compact]; ok {
return actual
}
return cleaned
}
func normalizeKingbaseAgentChangeSetByColumns(changes connection.ChangeSet, columns []string) (connection.ChangeSet, error) {
index := buildKingbaseAgentColumnIndex(columns)
if len(index.exact) == 0 && len(index.compact) == 0 {
return changes, nil
}
mapRow := func(row map[string]interface{}) (map[string]interface{}, error) {
if row == nil {
return row, nil
}
out := make(map[string]interface{}, len(row))
for key, value := range row {
nextKey := resolveKingbaseAgentColumnName(key, index)
if existing, ok := out[nextKey]; ok && !reflect.DeepEqual(existing, value) {
return nil, fmt.Errorf("duplicate mapped column %q", nextKey)
}
out[nextKey] = value
}
return out, nil
}
next := connection.ChangeSet{
Inserts: make([]map[string]interface{}, 0, len(changes.Inserts)),
Updates: make([]connection.UpdateRow, 0, len(changes.Updates)),
Deletes: make([]map[string]interface{}, 0, len(changes.Deletes)),
}
for _, row := range changes.Inserts {
mapped, err := mapRow(row)
if err != nil {
return changes, err
}
next.Inserts = append(next.Inserts, mapped)
}
for _, upd := range changes.Updates {
keys, err := mapRow(upd.Keys)
if err != nil {
return changes, err
}
values, err := mapRow(upd.Values)
if err != nil {
return changes, err
}
next.Updates = append(next.Updates, connection.UpdateRow{
Keys: keys,
Values: values,
})
}
for _, row := range changes.Deletes {
mapped, err := mapRow(row)
if err != nil {
return changes, err
}
next.Deletes = append(next.Deletes, mapped)
}
return next, nil
}
func (d *OptionalDriverAgentDB) normalizeKingbaseAgentChangeSet(tableName string, changes connection.ChangeSet) (connection.ChangeSet, error) {
columns, err := d.GetColumns("", tableName)
if err != nil {
return changes, err
}
if len(columns) == 0 {
return changes, nil
}
names := make([]string, 0, len(columns))
for _, col := range columns {
name := strings.TrimSpace(col.Name)
if name == "" {
continue
}
names = append(names, name)
}
return normalizeKingbaseAgentChangeSetByColumns(changes, names)
}
func timeoutMsFromContext(ctx context.Context) int64 {
deadline, ok := ctx.Deadline()
if !ok {

View File

@@ -1,67 +1,32 @@
package db
import (
"context"
"testing"
"GoNavi-Wails/internal/connection"
"time"
)
func TestNormalizeKingbaseAgentTableName(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "plain", in: "ldf_server.andon_events", want: "ldf_server.andon_events"},
{name: "quoted", in: `"ldf_server"."andon_events"`, want: "ldf_server.andon_events"},
{name: "double quoted", in: `""ldf_server"".""andon_events""`, want: "ldf_server.andon_events"},
{name: "escaped", in: `\"ldf_server\".\"andon_events\"`, want: "ldf_server.andon_events"},
{name: "double escaped", in: `\\\"ldf_server\\\".\\\"andon_events\\\"`, want: "ldf_server.andon_events"},
{name: "space around dot", in: ` "ldf_server" . "andon_events" `, want: "ldf_server.andon_events"},
{name: "table only", in: `bcs_barcode`, want: "bcs_barcode"},
{name: "table only quoted", in: `"bcs_barcode"`, want: "bcs_barcode"},
{name: "table only double quoted", in: `""bcs_barcode""`, want: "bcs_barcode"},
{name: "table only double escaped", in: `\\\"bcs_barcode\\\"`, want: "bcs_barcode"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeKingbaseAgentTableName(tt.in); got != tt.want {
t.Fatalf("normalizeKingbaseAgentTableName(%q) = %q, want %q", tt.in, got, tt.want)
}
})
func TestTimeoutMsFromContext_NoDeadline(t *testing.T) {
if got := timeoutMsFromContext(context.Background()); got != 0 {
t.Fatalf("无 deadline 时应返回 0got=%d", got)
}
}
func TestNormalizeKingbaseAgentChangeSetByColumns(t *testing.T) {
columns := []string{"andon_events_id", "event_name", "event_code"}
input := connection.ChangeSet{
Inserts: []map[string]interface{}{
{"event name": "物料1", "event_code": "EV-0001", "andon_events_id": 1},
},
Updates: []connection.UpdateRow{
{Keys: map[string]interface{}{"andon_events_id": 1}, Values: map[string]interface{}{"event name": "物料2"}},
},
Deletes: []map[string]interface{}{
{"andon_events_id": 1},
},
}
func TestTimeoutMsFromContext_WithDeadline(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
out, err := normalizeKingbaseAgentChangeSetByColumns(input, columns)
if err != nil {
t.Fatalf("normalizeKingbaseAgentChangeSetByColumns error: %v", err)
}
if _, ok := out.Inserts[0]["event_name"]; !ok {
t.Fatalf("expected insert to map \"event name\" -> \"event_name\"")
}
if _, ok := out.Inserts[0]["event name"]; ok {
t.Fatalf("unexpected insert key \"event name\" after normalization")
}
if _, ok := out.Updates[0].Values["event_name"]; !ok {
t.Fatalf("expected update values to map \"event name\" -> \"event_name\"")
}
if _, ok := out.Updates[0].Values["event name"]; ok {
t.Fatalf("unexpected update value key \"event name\" after normalization")
got := timeoutMsFromContext(ctx)
if got <= 0 {
t.Fatalf("有 deadline 时应返回正值got=%d", got)
}
}
func TestTimeoutMsFromContext_ExpiredDeadline(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer cancel()
if got := timeoutMsFromContext(ctx); got != 1 {
t.Fatalf("过期 deadline 应返回 1got=%d", got)
}
}

View File

@@ -14,9 +14,8 @@ import (
)
const (
envLogDir = "GONAVI_LOG_DIR"
appHiddenDir = ".GoNavi"
appLogDirName = "Logs"
envLogDir = "GONAVI_LOG_DIR"
appDirName = "GoNavi"
logFileName = "gonavi.log"
logRotateMaxBytes = 10 * 1024 * 1024 // 10MB
@@ -38,7 +37,7 @@ func Init() {
defer logMu.Unlock()
logPath = path
logInst = log.New(out, "", log.Ldate|log.Ltime|log.Lmicroseconds)
logInst.Printf("[INFO] 日志初始化完成,日志文件:%s", logPath)
logInst.Printf("[信息] 日志初始化完成,日志文件:%s", logPath)
})
}
@@ -63,15 +62,15 @@ func Close() {
}
func Infof(format string, args ...any) {
printf("INFO", format, args...)
printf("信息", format, args...)
}
func Warnf(format string, args ...any) {
printf("WARN", format, args...)
printf("警告", format, args...)
}
func Errorf(format string, args ...any) {
printf("ERROR", format, args...)
printf("错误", format, args...)
}
func Error(err error, format string, args ...any) {
@@ -116,58 +115,37 @@ func ErrorChain(err error) string {
func printf(level string, format string, args ...any) {
Init()
logMu.Lock()
defer logMu.Unlock()
inst := logInst
logMu.Unlock()
if inst == nil {
return
}
inst.Printf("[%s] %s", level, fmt.Sprintf(format, args...))
if logFile != nil {
_ = logFile.Sync()
}
}
func initOutput() (string, io.Writer) {
dir := strings.TrimSpace(os.Getenv(envLogDir))
if dir == "" {
dir = defaultLogDir()
base, err := os.UserConfigDir()
if err != nil || strings.TrimSpace(base) == "" {
base = os.TempDir()
}
dir = filepath.Join(base, appDirName, "logs")
}
if path, writer, ok := openLogFile(dir); ok {
return path, writer
}
fallbackDir := filepath.Join(os.TempDir(), appHiddenDir, appLogDirName)
if path, writer, ok := openLogFile(fallbackDir); ok {
return path, writer
}
return "", os.Stderr
}
func defaultLogDir() string {
home, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(home) == "" {
return filepath.Join(os.TempDir(), appHiddenDir, appLogDirName)
}
return filepath.Join(home, appHiddenDir, appLogDirName)
}
func openLogFile(dir string) (string, io.Writer, bool) {
if strings.TrimSpace(dir) == "" {
return "", nil, false
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", nil, false
return filepath.Join(dir, logFileName), os.Stderr
}
path := filepath.Join(dir, logFileName)
rotateIfNeeded(path, dir)
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return "", nil, false
return path, os.Stderr
}
logFile = f
return path, f, true
return path, f
}
func rotateIfNeeded(path, dir string) {

View File

@@ -5,7 +5,6 @@ import (
"crypto/tls"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"sync"
@@ -175,31 +174,8 @@ func (r *RedisClientImpl) toDisplayKey(key string) string {
return strings.TrimPrefix(key, prefix)
}
// sanitizeRedisPassword 对 Redis 密码进行防御性 URL 解码。
// 当密码中包含 URL 编码序列(如 %40尝试解码还原原始字符。
// 这可以防止前端 URI 构建中 encodeURIComponent 编码后的密码被误传入。
func sanitizeRedisPassword(password string) string {
if password == "" {
return password
}
// 仅当密码中包含 '%' 且后跟两位十六进制数字时,才尝试 URL 解码
if !strings.Contains(password, "%") {
return password
}
decoded, err := url.QueryUnescape(password)
if err != nil {
// 解码失败,使用原始密码
return password
}
if decoded != password {
logger.Warnf("Redis 密码检测到 URL 编码,已自动解码(原长度=%d 解码后长度=%d", len(password), len(decoded))
}
return decoded
}
// Connect establishes a connection to Redis
func (r *RedisClientImpl) Connect(config connection.ConnectionConfig) error {
config.Password = sanitizeRedisPassword(config.Password)
r.config = config
if r.config.RedisDB < 0 || r.config.RedisDB > 15 {
r.config.RedisDB = 0

View File

@@ -1,81 +0,0 @@
package redis
import "testing"
func TestSanitizeRedisPassword(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "empty password",
input: "",
expected: "",
},
{
name: "plain password without special chars",
input: "mypassword123",
expected: "mypassword123",
},
{
name: "password with @ not encoded",
input: "p@ssword",
expected: "p@ssword",
},
{
name: "password with @ URL-encoded as %40",
input: "p%40ssword",
expected: "p@ssword",
},
{
name: "password with multiple encoded chars",
input: "p%40ss%23word",
expected: "p@ss#word",
},
{
name: "password with + encoded as %2B",
input: "p%2Bss",
expected: "p+ss",
},
{
name: "password that is purely encoded",
input: "%40%23%24",
expected: "@#$",
},
{
name: "password with invalid percent encoding",
input: "p%ZZssword",
expected: "p%ZZssword",
},
{
name: "password with trailing percent",
input: "password%",
expected: "password%",
},
{
name: "password with literal percent not encoding anything",
input: "100%safe",
expected: "100%safe",
},
{
name: "password with space encoded as %20",
input: "my%20pass",
expected: "my pass",
},
{
name: "complex password with mixed content",
input: "P%40ss%23w0rd!",
expected: "P@ss#w0rd!",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sanitizeRedisPassword(tt.input)
if result != tt.expected {
t.Errorf("sanitizeRedisPassword(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}

View File

@@ -2,13 +2,10 @@ package ssh
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net"
"os"
"strconv"
"sync"
"time"
@@ -72,7 +69,7 @@ func connectSSH(config connection.SSHConfig) (*ssh.Client, error) {
}
}
}
if config.Password != "" {
authMethods = append(authMethods, ssh.Password(config.Password))
}
@@ -108,7 +105,7 @@ func RegisterSSHNetwork(sshConfig connection.SSHConfig) (string, error) {
// Generate unique network name
netName := fmt.Sprintf("ssh_%s_%d", sshConfig.Host, time.Now().UnixNano())
logger.Infof("注册 SSH 网络:%s地址=%s:%d 用户=%s", netName, sshConfig.Host, sshConfig.Port, sshConfig.User)
mysql.RegisterDialContext(netName, func(ctx context.Context, addr string) (net.Conn, error) {
return dialContext(ctx, client, "tcp", addr)
})
@@ -118,58 +115,12 @@ func RegisterSSHNetwork(sshConfig connection.SSHConfig) (string, error) {
// sshClientCache stores SSH clients to avoid creating multiple connections
var (
sshClientCache = make(map[sshClientCacheKey]*ssh.Client)
sshClientCache = make(map[string]*ssh.Client)
sshClientCacheMu sync.RWMutex
localForwarders = make(map[forwarderCacheKey]*LocalForwarder)
localForwarders = make(map[string]*LocalForwarder)
forwarderMu sync.RWMutex
)
type sshClientCacheKey struct {
host string
port int
user string
auth string
}
type forwarderCacheKey struct {
ssh sshClientCacheKey
remoteHost string
remotePort int
}
func sshAuthFingerprint(config connection.SSHConfig) string {
hasher := sha256.New()
_, _ = hasher.Write([]byte(config.Password))
_, _ = hasher.Write([]byte{0})
_, _ = hasher.Write([]byte(config.KeyPath))
if config.KeyPath != "" {
if st, err := os.Stat(config.KeyPath); err == nil {
_, _ = hasher.Write([]byte{0})
_, _ = hasher.Write([]byte(st.ModTime().UTC().Format(time.RFC3339Nano)))
_, _ = hasher.Write([]byte{0})
_, _ = hasher.Write([]byte(strconv.FormatInt(st.Size(), 10)))
} else {
_, _ = hasher.Write([]byte{0})
_, _ = hasher.Write([]byte("stat_err"))
}
}
sum := hasher.Sum(nil)
return hex.EncodeToString(sum[:8])
}
func newSSHClientCacheKey(config connection.SSHConfig) sshClientCacheKey {
return sshClientCacheKey{
host: config.Host,
port: config.Port,
user: config.User,
auth: sshAuthFingerprint(config),
}
}
func formatSSHClientKeyForLog(key sshClientCacheKey) string {
return fmt.Sprintf("%s:%d 用户=%s", key.host, key.port, key.user)
}
// LocalForwarder represents a local port forwarder through SSH
type LocalForwarder struct {
LocalAddr string
@@ -298,13 +249,9 @@ func (f *LocalForwarder) IsClosed() bool {
// GetOrCreateLocalForwarder returns a cached forwarder or creates a new one
func GetOrCreateLocalForwarder(sshConfig connection.SSHConfig, remoteHost string, remotePort int) (*LocalForwarder, error) {
key := forwarderCacheKey{
ssh: newSSHClientCacheKey(sshConfig),
remoteHost: remoteHost,
remotePort: remotePort,
}
logKey := fmt.Sprintf("%s:%d:%s->%s:%d",
sshConfig.Host, sshConfig.Port, sshConfig.User, remoteHost, remotePort)
key := fmt.Sprintf("%s:%d:%s->%s:%d",
sshConfig.Host, sshConfig.Port, sshConfig.User,
remoteHost, remotePort)
forwarderMu.RLock()
forwarder, exists := localForwarders[key]
@@ -312,7 +259,7 @@ func GetOrCreateLocalForwarder(sshConfig connection.SSHConfig, remoteHost string
// Check if exists and is still valid
if exists && forwarder != nil && !forwarder.IsClosed() {
logger.Infof("复用已有端口转发:%s", logKey)
logger.Infof("复用已有端口转发:%s", key)
return forwarder, nil
}
@@ -340,18 +287,24 @@ func CloseAllForwarders() {
forwarderMu.Lock()
defer forwarderMu.Unlock()
for _, forwarder := range localForwarders {
for key, forwarder := range localForwarders {
if forwarder != nil {
_ = forwarder.Close()
logger.Infof("已关闭端口转发:本地 %s -> 远程 %s", forwarder.LocalAddr, forwarder.RemoteAddr)
logger.Infof("已关闭端口转发:%s", key)
}
}
localForwarders = make(map[forwarderCacheKey]*LocalForwarder)
localForwarders = make(map[string]*LocalForwarder)
}
// getSSHClientCacheKey generates a unique cache key for SSH config
func getSSHClientCacheKey(config connection.SSHConfig) string {
return fmt.Sprintf("%s:%d:%s", config.Host, config.Port, config.User)
}
// GetOrCreateSSHClient returns a cached SSH client or creates a new one
func GetOrCreateSSHClient(config connection.SSHConfig) (*ssh.Client, error) {
key := newSSHClientCacheKey(config)
key := getSSHClientCacheKey(config)
sshClientCacheMu.RLock()
client, exists := sshClientCache[key]
@@ -362,11 +315,11 @@ func GetOrCreateSSHClient(config connection.SSHConfig) (*ssh.Client, error) {
session, err := client.NewSession()
if err == nil {
session.Close()
logger.Infof("复用已有 SSH 连接:%s", formatSSHClientKeyForLog(key))
logger.Infof("复用已有 SSH 连接:%s", key)
return client, nil
}
// Connection is dead, remove from cache
logger.Warnf("SSH 连接已断开,重新建立:%s (错误: %v)", formatSSHClientKeyForLog(key), err)
logger.Warnf("SSH 连接已断开,重新建立:%s (错误: %v)", key, err)
sshClientCacheMu.Lock()
delete(sshClientCache, key)
sshClientCacheMu.Unlock()
@@ -385,7 +338,7 @@ func GetOrCreateSSHClient(config connection.SSHConfig) (*ssh.Client, error) {
sshClientCache[key] = client
sshClientCacheMu.Unlock()
logger.Infof("已缓存 SSH 连接:%s", formatSSHClientKeyForLog(key))
logger.Infof("已缓存 SSH 连接:%s", key)
return client, nil
}
@@ -414,8 +367,9 @@ func CloseAllSSHClients() {
for key, client := range sshClientCache {
if client != nil {
_ = client.Close()
logger.Infof("已关闭 SSH 连接:%s", formatSSHClientKeyForLog(key))
logger.Infof("已关闭 SSH 连接:%s", key)
}
}
sshClientCache = make(map[sshClientCacheKey]*ssh.Client)
sshClientCache = make(map[string]*ssh.Client)
}

View File

@@ -1,46 +0,0 @@
package ssh
import (
"testing"
"GoNavi-Wails/internal/connection"
)
func TestNewSSHClientCacheKey_DiffPassword(t *testing.T) {
a := newSSHClientCacheKey(connection.SSHConfig{
Host: "127.0.0.1",
Port: 22,
User: "root",
Password: "a",
})
b := newSSHClientCacheKey(connection.SSHConfig{
Host: "127.0.0.1",
Port: 22,
User: "root",
Password: "b",
})
if a == b {
t.Fatalf("expected different cache key when password differs")
}
if a.host != b.host || a.port != b.port || a.user != b.user {
t.Fatalf("expected host/port/user to stay identical")
}
}
func TestNewSSHClientCacheKey_DiffKeyPath(t *testing.T) {
a := newSSHClientCacheKey(connection.SSHConfig{
Host: "127.0.0.1",
Port: 22,
User: "root",
KeyPath: "/tmp/a.key",
})
b := newSSHClientCacheKey(connection.SSHConfig{
Host: "127.0.0.1",
Port: 22,
User: "root",
KeyPath: "/tmp/b.key",
})
if a == b {
t.Fatalf("expected different cache key when keyPath differs")
}
}