feat: 18 引擎驱动全家桶 + 驱动管理/离线包 + 设置中心 + 工作台交互打磨
CI / Go server (push) Canceled after 0s
CI / Web + core packages (push) Canceled after 0s
CI / Desktop (Tauri) cargo check (push) Canceled after 0s

驱动接入(全部本机真实例验收):
- Elasticsearch 原生 SQL API、InfluxDB 原生 HTTP+Flux(官方 JDBC 付费许可,实测弃用)
- ZooKeeper/MongoDB JVM agent(连接时自动下载官方驱动 jar,sha256 钉版本到 ~/.opsxdb/drivers/)
- 达梦 JDBC agent 桥就绪(待厂商 jar+实例);TiDB 真实例验收
- 协议族代码审查放行 PolarDB/TDSQL/OceanBase/Kingbase/GaussDB/Oracle
- 驱动能力模型:按驱动开关对象树分组/右键菜单/行编辑,NoSQL 树拍平(对象直挂库节点)

驱动管理:
- 驱动管理弹框(状态/预装/离线包导入/JDK 检测)+ /api/drivers/* 三接口
- opsxdb-driver 驱动商店仓库(清单/fetch.sh/pack.sh 离线包/import.sh)
- 连接时懒加载:DMG 零 Java 产物,首连自动下载(实测 ZK 九件套 1.35s),离线包自动回退

UI/交互:
- 设置中心(⚙):常规/执行/结果/驱动/快捷键/关于,左导航+右面板(对标 opsxshell)
- 实例级展开箭头(行首 SVG chevron,替代字体回退失真的 Unicode 三角)
- 树内嵌套渲染修复:对象树挂在激活连接下;工具栏窄窗口不断行(按钮 nowrap+整颗换行)
- 侧栏连接行精简(图标+连接名)、SQLite SSH 守卫、Oracle 死参数清理

质量:
- Go 协议层/离线回退单测、E2E 门控测试(ES/ZK/Influx/Mongo/TiDB/JDBC 桥)
- 驱动注册表单一事实来源 connection-types.json,README/BACKLOG/AGENTS 同步
This commit is contained in:
logan committed 2026-09-22 01:07:46 +08:00
1 parent 26f459d1b8
commit a9510235bb
156 files changed
+17200 -2520

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
go:
name: Go server
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
- name: Build
run: cd apps/server && go build ./...
- name: Vet
run: cd apps/server && go vet ./...
- name: Test
run: cd apps/server && go test ./...
web:
name: Web + core packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Install dependencies
run: npm ci
- name: Build web
run: npm run build:web
- name: Test core
run: npm run test:core
- name: Test UI
run: npm run test:ui --if-present
rust:
name: Desktop (Tauri) cargo check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Tauri system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libsoup-3.0-dev \
javascriptcoregtk-4.1-dev \
libgtk-3-dev \
libxdo-dev
- uses: dtolnay/rust-toolchain@stable
- name: Cargo check
run: cd apps/desktop/src-tauri && cargo check
+4
View File
@@ -32,3 +32,7 @@ apps/server/bin/
logs/
apps/desktop/src-tauri/binaries/opsxdb-server-*
*.bak
# review artifacts / test outputs
.ui-review/
tests/accept-result.txt
+46
View File
@@ -0,0 +1,46 @@
# OpsxDB 开发指南
数据库**桌面**客户端(连接 / SQL 工作台 / AI 写 SQL)。请使用中文沟通。
## 产品形态(硬边界)
```text
浏览器 → 只看官网 + 文档(Gin :8788)
操作 → 只走桌面 Tauri(./run.sh gui)
apps/web (:5174) → 仅桌面 Vite 源 / 打包 frontendDist,不是浏览器产品
```
- **不要**把工作台做成浏览器 SaaS,或把 `:5174` 当对外入口。
- Gin(`apps/server`)= 官网 / docs / health / 驱动相关 API;**不是**「远程多人协作后端」。
- 连接与查询数据默认在本机;密钥与连接串按现有桌面安全实践处理。
同族:`opsxapi` 同形态;`opsxshell` 同样「浏览器=官网/docs,操作=桌面」(Gin 在 `apps/server`,**无**操作 API,原生全在 Rust)。
## 架构
```text
packages/ui + apps/web → packages/transport(tauri-native | gin) → 桌面 / 本机 Gin
apps/desktop → Tauri 2 壳
apps/server → Gin 官网 + docs + 驱动 API
connection-types/ → 引擎注册表(ready / verified / notes)
agents/ → JDBC agent 桥(Java 单文件桥 + 协议文档;Go 侧在 apps/server/internal/db/agent.go)
```
改驱动能力:先动注册表与 server/desktop 对应路径,再补 UI;未 `ready` 的引擎界面须灰态,勿标成可用。
## 怎么跑
```bash
npm install
./run.sh web # 仅官网 + docs → :8788
./run.sh gui # 桌面(推荐)
./run.sh stop | status | restart
```
改 `apps/server` 后需重编(`./run.sh restart`)。清单与验收见 `docs/BACKLOG.md`。
## 给 AI
- 跟现有 monorepo 分包,勿平行造第二套客户端。
- 需求清楚就直接改;细节以邻近代码与 BACKLOG 为准,不必先扩写长规范。
- 少加「仅 AI 可读」的重复文档;用户可见文案与 docs 内容保持同步即可。
+12
View File
@@ -0,0 +1,12 @@
# OpsxDB 项目说明
请使用中文。规则以 [AGENTS.md](./AGENTS.md) 为准。
**浏览器 = Gin 官网 + `/docs`(:8788);操作只走桌面 Tauri。** `apps/web` 只是桌面打包源。
```bash
./run.sh web # 官网/文档
./run.sh gui # 桌面工作台
```
待办见 `docs/BACKLOG.md`。勿做成浏览器客户端;勿把 Gin 当成必须在线的多人后端。
+10 -2
View File
@@ -1,4 +1,4 @@
.PHONY: install web gui all server stop status build-web test desktop
.PHONY: install web gui all server stop status build-web test test-go test-ui desktop
install:
npm install
@@ -28,5 +28,13 @@ desktop:
build-web:
npm run build:web
# test = Go 后端用例 + UI 回归
test:
npm run test:core
$(MAKE) test-go
$(MAKE) test-ui
test-go:
cd apps/server && go test ./...
test-ui:
npm run test:ui --if-present
+35 -41
View File
@@ -1,20 +1,20 @@
> **产品形态:桌面 GUI(Tauri)**。`http://127.0.0.1:5174` 仅为开发壳。文档 `/docs`、官网 `/` 由本机后端提供。
> **产品形态**:浏览器 = Gin **官网 + 文档**(`:8788`);**操作只走桌面 Tauri**。
> `apps/web`(`:5174`)仅是桌面 `devUrl`/打包源,**不是**浏览器客户端。
> AI / 协作规范见 [AGENTS.md](./AGENTS.md)(短入口 [CLAUDE.md](./CLAUDE.md))。
# OpsxDB
给运维和开发用的 **数据库客户端**(连接管理 / SQL 工作台 / AI 写 SQL)。
从 OpsxAPI 脚手架分叉:同一套 monorepo(web + Gin + 可选 Tauri),产品面换成 DB。
给运维和开发用的 **数据库桌面客户端**(连接管理 / SQL 工作台 / AI 写 SQL)。
## 架构
```
packages/ui 连接选择器 + SQL 工作台 + AI 面板
packages/core 共享工具(保留)
packages/transport 浏览器 / Gin / Tauri(保留兼容)
apps/web Vite + React(:5174)
apps/desktop Tauri 2 壳
apps/server Gin:health + 内置驱动(MySQL 族/PG 族/SQL Server/Oracle/SQLite/Redis)+ AI stub(:8788)
packages/ui 连接选择器 + SQL 工作台 + AI 面板(给桌面打包)
packages/core 共享工具
packages/transport Gin / Tauri 传输
apps/web Vite + React —— **仅桌面前端源**(勿当浏览器产品)
apps/desktop Tauri 2 壳(唯一操作入口)
apps/server Gin:官网/docs + health + 驱动 API(:8788)
```
## 怎么跑
@@ -24,45 +24,39 @@ export PATH="$HOME/.nvm/versions/node/v24.19.0/bin:/opt/homebrew/bin:$PATH"
export OPSXDB_SERVER_PORT=8788 OPSXDB_WEB_PORT=5174
npm install
./run.sh web # Gin :8788 + Vite :5174
./run.sh web # 仅官网 + docs → http://127.0.0.1:8788/
./run.sh gui # 桌面操作入口(推荐)
```
打开 http://127.0.0.1:5174/
```bash
curl -s http://127.0.0.1:8788/api/health
curl -s -X POST http://127.0.0.1:8788/api/ai/chat \
-H 'Content-Type: application/json' \
-d '{"action":"generate","prompt":"查最近用户"}'
curl -s http://127.0.0.1:8788/health
open http://127.0.0.1:8788/ # 官网
open http://127.0.0.1:8788/docs # 文档
```
## 日常怎么用
## 日常怎么用(桌面)
1. **新建连接** → 选类型,或粘贴 `mysql://user:pass@host:3306/db` 点「填入」
2. **测试** → **保存并连接**
3. 左侧点库展开表;**点表名**打开前 100 行(展开表看列)
4. 编辑器 **⌘/Ctrl+Enter** 执行当前语句或选区(不是整页脚本)
5. 结果:**复制 / CSV / INSERT**;双击单元格复制
1. `./run.sh gui` → Dock 打开 OpsxDB
2. **新建连接** → 选类型 / 粘贴 URL → **测试** → **保存并连接**
3. 左侧对象树点库/表;编辑器 **⌘/Ctrl+Enter** 执行
4. 结果集:复制 / CSV / 粘贴 / 行删(有主键)
## 已做(便捷向)
## 已做(摘要)
- 连接选择器:搜索 / 分类 / 最近 / 图标网格;URL 拆字段;按驱动表单(SQLite 文件、Redis、Oracle Service、SSL)
- 工作台:懒加载对象树、点表看数据、CodeMirror SQL、多 Tab、可拖分栏
- 会话:连接后查询只带 `sessionId`,账密不再每次上送;本机密码做了混淆
- 结果截断提示、危险 SQL 确认、查询历史、主题/Tab 持久化、快捷键
- 驱动:MySQL 族 / PostgreSQL 族(含 GaussDB、人大金仓)/ SQL Server / Oracle / SQLite / Redis
- AI 面板仍是 stub(生成/解释/优化/修复/方言/样例)
## 驱动与连接类型
- **注册表**:`connection-types/connection-types.json`(schema v1,字段说明见该目录 `README.md`)。规划为驱动目录的单一事实来源;生成器接入前以 `packages/ui/src/catalog.ts` 与 `apps/server/internal/db/open.go` 为准。
- **就绪状态**:`ready` = 可测试/连接;`verified` = 本机 docker 验收通过,依据 `tests/README.md` 驱动验收表。当前 MySQL/MariaDB/PG/SQL Server/SQLite/Redis 已验收,Oracle 代码就绪但本机未验证,达梦/MongoDB/ZK/ES/InfluxDB 未就绪。
- **路线**:常用库走 native Go 驱动编译内置(go-ora 连 Oracle,不引入 JVM);长尾库(达梦/MongoDB/ZK/InfluxDB 等)规划 agent 按需接入,驱动分发走 Maven 坐标钉版本 + sha256 + 镜像回退,计划托管于 `opsxdb-driver` 仓库(Gitee/GitHub)。
- **许可**:`connection-types/` 注册表数据从自有代码与厂商公开事实推导(见 `connection-types/NOTICE.md`);仓库整体许可证以根 LICENSE 为准。
- 桌面工作台:对象树、SQL、多 Tab、结果编辑、Redis 键浏览(单机)
- Redis Sentinel/Cluster:表单 + 后端(真拓扑待环境)
- 自绘右键(编辑器 / 库·表·键);拦系统菜单
- Gin 官网 + docs;驱动注册表见 `connection-types/`
- 新引擎接入并本机验收:Elasticsearch(原生 SQL API)、InfluxDB(原生 HTTP+Flux)、ZooKeeper / MongoDB(JVM agent)、达梦(JDBC agent 桥已验证,待实例)
- 驱动管理弹框(连接列表页 ⚙):agent 驱动状态/预装/离线包导入/JDK 检测;开源驱动首次连接自动下载到 ~/.opsxdb/drivers/
## 端口
| 服务 | 端口 | 环境变量 |
|------|------|----------|
| Gin | 8788 | `OPSXDB_SERVER_PORT` / `OPSXDB_ADDR` |
| Vite | 5174 | `OPSXDB_WEB_PORT` |
| 服务 | 端口 | 说明 |
|------|------|------|
| Gin 官网/docs/API | 8788 | 浏览器只看站点和文档 |
| Vite(桌面源) | 5174 | 仅 `gui`/打包用,非产品入口 |
## 待开发
见 [docs/BACKLOG.md](docs/BACKLOG.md)。
+82
View File
@@ -0,0 +1,82 @@
# OpsxDB JDBC Agent
纯 JDK + gson 的通用 JDBC 桥:Go 服务端通过 stdin/stdout 的 JSON Lines 协议驱动一个
本机 JVM 进程,由它加载**官方 JDBC 驱动**(用户自备 jar,许可合规)连接数据库。
设计对标 DBX 的 agent 模式;v1 先接达梦,新增驱动在
`apps/server/internal/db/agent.go` 的 `agentDriverSpecs` 登记一行即可。
## 组成
```
agents/jdbc/OpsxJdbcAgent.java JDBC 通用桥(达梦等官方 JDBC 驱动)
agents/jdbc/OpsxZkAgent.java ZooKeeper 专属桥(官方客户端,NIO socket 免 netty 全家桶之外还需 6 个 netty jar 供 SSL 类初始化)
agents/jdbc/OpsxMongoAgent.java MongoDB 专属桥(官方 sync driver + 迷你 DSL)
apps/server/internal/db/agent.go Go 侧:进程池 / 协议客户端 / 元数据与执行入口(注册表 agentDriverSpecs)
apps/server/internal/db/es.go Elasticsearch 原生接入(HTTP + SQL API)
apps/server/internal/db/influx.go InfluxDB 2.x 原生接入(HTTP + Flux)
```
> Elasticsearch 曾评估走 agent + x-pack-sql-jdbc,实测 basic 许可报
> "current license is non-compliant for [jdbc]"(JDBC/ODBC 是付费客户端),
> 故走原生 HTTP;SQL REST API 在 basic 许可可用。
## 协议(JSON Lines,一行一个对象)
请求 `{ "id": 1, "op": "open", "args": { ... } }`
响应 `{ "id": 1, "ok": true, "result": { ... } }` 或 `{ "id": 1, "ok": false, "error": "..." }`
进程启动后第一行主动推送 hello 公告(agent/version/protocol),供握手校验。
| op | args | result |
|---|---|---|
| `hello` | — | agent/version/protocol |
| `open` | url, user, password, readOnly, props{} | handle |
| `ping` | handle | —(SELECT 1,失败退 DUAL) |
| `query` | handle, sql, maxRows, timeoutSec | columns[], rows[], truncated |
| `exec` | handle, sql, timeoutSec | affectedRows(有结果集时返回行列) |
| `metaDatabases` | handle | names[](无 catalog 退化为 schemas) |
| `metaTables` | handle, schema | names[](TABLE+VIEW,走 DatabaseMetaData) |
| `metaColumns` | handle, schema, table | columns[](name/type/nullable/key) |
| `close` | handle | — |
| `shutdown` | — | 进程退出 |
## 首次启用(达梦)
0. 安装 JDK 17+(或 `export OPSXDB_JAVA_BIN=/path/to/java`)
1. agent jar 与开源驱动 jar 在**首次连接时自动下载**(DMG 不打包 Java 产物,
落盘 `~/.opsxdb/agent/` 与 `~/.opsxdb/drivers/<id>/`,sha256 钉版本);
开发期可用 `OPSXDB_AGENT_JAR` 指向本地构建的 jar
2. 离线/内网环境预放驱动(二选一):
- 开源驱动(MongoDB/ZooKeeper 全套):`git clone gitee.com:attacker/opsxdb-driver && ./opsxdb-driver/scripts/fetch.sh all`
(对标 dbeaver-driver-all 的一键离线包;Maven 自动下载 + sha256 校验)
- 商用驱动:把官方 `DmJdbcDriver-*.jar` 放到 `~/.opsxdb/drivers/dameng/`
(许可原因不代下;`OPSXDB_DAMENG_JARS` 可改目录)
- 离线/内网:opsxdb-driver 仓库 `pack.sh` 打的 zip 放 `~/.opsxdb/offline-driver-pack.zip`,
首次连接时自动导入(网络下载失败自动回退离线包)
4. gson 由 Go 侧首次使用时自动下载(Maven Central → 阿里云镜像,sha1 钉版本校验)
之后前端达梦连接即可测试/连接;未配置时错误信息会逐步提示缺什么。
## 安全约定(与主程序一致,防把业务库查崩)
- `query` 必带 `maxRows` → `Statement.setMaxRows` 硬截断(上限 20000,Go 侧深分页超限直接拒绝)
- 每条语句 `setQueryTimeout`(秒),Go 侧从请求 deadline 推算
- 危险语句拦截(`CheckDangerousSQL`)在 Go 侧入口统一执行,agent 之前
## ZooKeeper / MongoDB 的 SQL 框 DSL
**ZK**:`GET /路径`(读节点:path/data/version/ctime/mtime/ephemeral/children)、
`LS /路径`(列子节点带 stat)、`CREATE /路径 [数据]`、`SET /路径 数据`、`DELETE /路径`(须无子)。
**Mongo**:`db.<集合>.find([filter])`、`db.<集合>.count([filter])`、
`db.<集合>.insertOne({doc})`、`db.<集合>.updateOne({filter},{update})`、
`db.<集合>.deleteOne({filter})`、`db.<集合>.deleteMany({filter})`。
写动词在 Go 侧映射进危险词表(deleteMany→DELETE 等),未确认返回 NeedConfirm。
## 已知限制(v1)
- ctx 取消**不会**真正掐断 JDBC 执行(协议无中断 op),靠 timeoutSec 兜底
- SSH 隧道对 agent 无效(TCP 由 JVM 发起),入口明确拒绝而不是静默失败
- EXPLAIN 走通用 `EXPLAIN ` 前缀,未按方言定制计划格式
- 单元测试用 helper process 扮演 JVM(`go test ./internal/db -run Agent`),无需本机装 Java
- E2E(需真库,docker 见 tests/):`OPSXDB_E2E_JDBC=1`(sqlite-jdbc 验桥)、`OPSXDB_E2E_ES/ZK/INFLUX/MONGO=1`
+328
View File
@@ -0,0 +1,328 @@
// OpsxDB JDBC Agent —— 纯 JDK + gson 的通用 JDBC 桥(默认包,支持单文件源启动)。
//
// 启动(由 Go 服务端组装):
// java -cp opsx-agent-jdbc.jar:gson-2.12.1.jar OpsxJdbcAgent --jars <驱动jar目录>
//
// 协议:stdin/stdout 换行分隔 JSON(JSON Lines)。
// 请求 : {"id":1,"op":"open","args":{...}}
// 响应 : {"id":1,"ok":true,"result":{...}} 或 {"id":1,"ok":false,"error":"..."}
//
// 驱动 jar 由 --jars 目录按 URLClassLoader 加载(商用驱动如达梦不随产品分发,
// 用户把官方 DmJdbcDriver-*.jar 放进该目录即可)。
//
// 安全设计(与 Go 侧约定一致):
// - query 必带 maxRows(JDBC Statement.setMaxRows 硬截断,防 OOM/拖库)
// - 每条 Statement.setQueryTimeout(秒)兜底,防慢查询挂死业务库
// - open 支持 readOnly(true 时 Connection.setReadOnly,提示库走只读路由)
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.Driver;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicLong;
public final class OpsxJdbcAgent {
static final String AGENT = "opsx-agent-jdbc";
static final String VERSION = "0.1.0";
static final int PROTOCOL = 1;
private final Gson gson = new Gson();
private final Map<String, Connection> handles = new HashMap<>();
private final AtomicLong seq = new AtomicLong(0);
public static void main(String[] args) throws Exception {
String jarsDir = null;
for (int i = 0; i < args.length - 1; i++) {
if ("--jars".equals(args[i])) jarsDir = args[i + 1];
}
if (jarsDir != null) loadVendorDrivers(jarsDir);
new OpsxJdbcAgent().loop();
}
/** 把 --jars 目录下所有 jar 挂进 URLClassLoader 并逐个注册 Driver */
private static void loadVendorDrivers(String dir) throws Exception {
java.io.File d = new java.io.File(dir);
java.io.File[] jars = d.listFiles((f) -> f.getName().endsWith(".jar"));
if (jars == null || jars.length == 0) {
throw new IllegalStateException("驱动目录没有 jar: " + dir);
}
URL[] urls = new URL[jars.length];
for (int i = 0; i < jars.length; i++) urls[i] = jars[i].toURI().toURL();
URLClassLoader cl = new URLClassLoader(urls, OpsxJdbcAgent.class.getClassLoader());
for (java.io.File jar : jars) {
try (URLClassLoader probe = new URLClassLoader(new URL[]{jar.toURI().toURL()}, cl)) {
java.util.jar.JarFile jf = new java.util.jar.JarFile(jar);
var entries = jf.entries();
while (entries.hasMoreElements()) {
String name = entries.nextElement().getName();
if (name.endsWith("META-INF/services/java.sql.Driver")) {
try (var in = cl.getResourceAsStream(name)) {
if (in == null) continue;
java.io.BufferedReader r = new java.io.BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
String cls;
while ((cls = r.readLine()) != null) {
cls = cls.trim();
if (cls.isEmpty() || cls.startsWith("#")) continue;
Driver drv = (Driver) Class.forName(cls, true, cl).getDeclaredConstructor().newInstance();
DriverManager.registerDriver(new ShimDriver(drv));
}
}
}
}
}
}
}
/** 让 DriverManager 能用 URLClassLoader 里加载的驱动 */
static final class ShimDriver implements Driver {
private final Driver delegate;
ShimDriver(Driver d) { this.delegate = d; }
public java.sql.Connection connect(String url, Properties info) throws java.sql.SQLException { return delegate.connect(url, info); }
public boolean acceptsURL(String url) throws java.sql.SQLException { return delegate.acceptsURL(url); }
public java.sql.DriverPropertyInfo[] getPropertyInfo(String url, Properties info) throws java.sql.SQLException { return delegate.getPropertyInfo(url, info); }
public int getMajorVersion() { return delegate.getMajorVersion(); }
public int getMinorVersion() { return delegate.getMinorVersion(); }
public boolean jdbcCompliant() { return delegate.jdbcCompliant(); }
public java.util.logging.Logger getParentLogger() { return java.util.logging.Logger.getGlobal(); }
}
private void loop() throws Exception {
BufferedReader in = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out, StandardCharsets.UTF_8), true);
out.println(gson.toJson(resp(0, true, helloResult(), null))); // 首行即握手公告
String line;
while ((line = in.readLine()) != null) {
line = line.trim();
if (line.isEmpty()) continue;
JsonObject req;
try {
req = JsonParser.parseString(line).getAsJsonObject();
} catch (Exception e) {
out.println(gson.toJson(resp(0, false, null, "bad json: " + e.getMessage())));
continue;
}
long id = req.has("id") ? req.get("id").getAsLong() : 0;
String op = req.has("op") ? req.get("op").getAsString() : "";
JsonObject args = req.has("args") && req.get("args").isJsonObject() ? req.getAsJsonObject("args") : new JsonObject();
try {
JsonObject result = dispatch(op, args);
out.println(gson.toJson(resp(id, true, result, null)));
} catch (Exception e) {
out.println(gson.toJson(resp(id, false, null, e.getMessage() == null ? String.valueOf(e) : e.getMessage())));
}
if ("shutdown".equals(op)) {
out.flush();
System.exit(0);
}
}
System.exit(0);
}
private JsonObject helloResult() {
JsonObject o = new JsonObject();
o.addProperty("agent", AGENT);
o.addProperty("version", VERSION);
o.addProperty("protocol", PROTOCOL);
return o;
}
private JsonObject dispatch(String op, JsonObject a) throws Exception {
switch (op) {
case "hello": return helloResult();
case "open": return open(a);
case "ping": { stmt(getHandle(a), "SELECT 1", 5, 1); return ok(); }
case "query": return query(a);
case "exec": return exec(a);
case "metaDatabases": return metaDatabases(getHandle(a));
case "metaTables": return metaTables(getHandle(a), str(a, "schema"));
case "metaColumns": return metaColumns(getHandle(a), str(a, "schema"), str(a, "table"));
case "close": {
Connection c = handles.remove(getHandle(a));
if (c != null) c.close();
return ok();
}
default: throw new IllegalArgumentException("未知 op: " + op);
}
}
private JsonObject open(JsonObject a) throws Exception {
String url = str(a, "url");
Properties props = new Properties();
if (a.has("user") && !a.get("user").isJsonNull()) props.setProperty("user", a.get("user").getAsString());
if (a.has("password") && !a.get("password").isJsonNull()) props.setProperty("password", a.get("password").getAsString());
// 其余属性原样透传(SSL 等)
if (a.has("props") && a.get("props").isJsonObject()) {
for (var e : a.getAsJsonObject("props").entrySet()) {
if (!props.containsKey(e.getKey())) props.setProperty(e.getKey(), e.getValue().getAsString());
}
}
Connection conn = DriverManager.getConnection(url, props);
if (a.has("readOnly") && a.get("readOnly").getAsBoolean()) {
try { conn.setReadOnly(true); } catch (Exception ignored) { /* 部分驱动不支持 */ }
}
String handle = "c" + seq.incrementAndGet();
handles.put(handle, conn);
JsonObject o = new JsonObject();
o.addProperty("handle", handle);
return o;
}
private JsonObject query(JsonObject a) throws Exception {
Connection conn = getHandle(a);
String sql = str(a, "sql");
int maxRows = a.has("maxRows") ? a.get("maxRows").getAsInt() : 1000;
if (maxRows <= 0 || maxRows > 20000) maxRows = 1000;
int timeoutSec = a.has("timeoutSec") ? a.get("timeoutSec").getAsInt() : 30;
try (Statement st = conn.createStatement()) {
st.setMaxRows(maxRows);
st.setQueryTimeout(timeoutSec);
try (ResultSet rs = st.executeQuery(sql)) {
return readRows(rs, maxRows);
}
}
}
private JsonObject exec(JsonObject a) throws Exception {
Connection conn = getHandle(a);
int timeoutSec = a.has("timeoutSec") ? a.get("timeoutSec").getAsInt() : 30;
try (Statement st = conn.createStatement()) {
st.setQueryTimeout(timeoutSec);
boolean hasRs = st.execute(str(a, "sql"));
JsonObject o = new JsonObject();
if (hasRs) {
try (ResultSet rs = st.getResultSet()) {
o = readRows(rs, 1000);
o.addProperty("rowCount", o.getAsJsonArray("rows").size());
return o;
}
}
o.addProperty("affectedRows", st.getUpdateCount());
return o;
}
}
private JsonObject readRows(ResultSet rs, int maxRows) throws Exception {
ResultSetMetaData md = rs.getMetaData();
int n = md.getColumnCount();
List<String> cols = new ArrayList<>(n);
for (int i = 1; i <= n; i++) cols.add(md.getColumnLabel(i));
List<JsonObject> rows = new ArrayList<>();
int count = 0;
boolean truncated = false;
while (rs.next()) {
if (++count > maxRows) { truncated = true; break; }
JsonObject row = new JsonObject();
for (int i = 1; i <= n; i++) {
String v = rs.getString(i);
row.addProperty(cols.get(i - 1), v == null ? null : v);
}
rows.add(row);
}
JsonObject o = new JsonObject();
o.add("columns", gson.toJsonTree(cols));
o.add("rows", gson.toJsonTree(rows));
o.addProperty("truncated", truncated);
return o;
}
private JsonObject metaDatabases(Connection conn) throws Exception {
List<String> out = new ArrayList<>();
try (ResultSet rs = conn.getMetaData().getCatalogs()) {
while (rs.next()) out.add(rs.getString(1));
}
if (out.isEmpty()) { // 无 catalog 概念的库(如 Oracle/DM 常用 schema)退化为 schemas
try (ResultSet rs = conn.getMetaData().getSchemas()) {
while (rs.next()) out.add(rs.getString(1));
}
}
JsonObject o = new JsonObject();
o.add("names", gson.toJsonTree(out));
return o;
}
private JsonObject metaTables(Connection conn, String schema) throws Exception {
List<String> out = new ArrayList<>();
try (ResultSet rs = conn.getMetaData().getTables(conn.getCatalog(), emptyToNull(schema), "%", new String[]{"TABLE", "VIEW"})) {
while (rs.next()) out.add(rs.getString("TABLE_NAME"));
}
JsonObject o = new JsonObject();
o.add("names", gson.toJsonTree(out));
return o;
}
private JsonObject metaColumns(Connection conn, String schema, String table) throws Exception {
List<JsonObject> out = new ArrayList<>();
try (ResultSet rs = conn.getMetaData().getColumns(conn.getCatalog(), emptyToNull(schema), table, "%")) {
while (rs.next()) {
JsonObject col = new JsonObject();
col.addProperty("name", rs.getString("COLUMN_NAME"));
col.addProperty("type", rs.getString("TYPE_NAME"));
col.addProperty("nullable", rs.getInt("NULLABLE") == ResultSetMetaData.columnNullable);
String key = rs.getString("IS_AUTOINCREMENT");
col.addProperty("key", "YES".equalsIgnoreCase(key) ? "PRI" : "");
out.add(col);
}
}
JsonObject o = new JsonObject();
o.add("columns", gson.toJsonTree(out));
return o;
}
/** 探活:驱动差异兜底(Oracle/DM 老版本无 dual-less SELECT 1 时退 DUAL) */
private void stmt(Connection conn, String sql, int timeoutSec, int maxRows) throws Exception {
try (Statement st = conn.createStatement()) {
st.setQueryTimeout(timeoutSec);
st.setMaxRows(maxRows);
st.execute(sql);
} catch (java.sql.SQLException e) {
if (sql.equals("SELECT 1")) {
try (Statement st = conn.createStatement()) {
st.setQueryTimeout(timeoutSec);
st.setMaxRows(maxRows);
st.execute("SELECT 1 FROM DUAL");
return;
}
}
throw e;
}
}
private Connection getHandle(JsonObject a) {
String h = str(a, "handle");
Connection c = handles.get(h);
if (c == null) throw new IllegalStateException("句柄不存在或已关闭: " + h);
return c;
}
private static String str(JsonObject a, String k) {
return a.has(k) && !a.get(k).isJsonNull() ? a.get(k).getAsString() : "";
}
private static String emptyToNull(String s) { return s == null || s.isEmpty() ? null : s; }
private static JsonObject ok() { return new JsonObject(); }
private static JsonObject resp(long id, boolean ok, JsonObject result, String error) {
JsonObject o = new JsonObject();
o.addProperty("id", id);
o.addProperty("ok", ok);
if (result != null) o.add("result", result);
if (error != null) o.addProperty("error", error);
return o;
}
}
+328
View File
@@ -0,0 +1,328 @@
// OpsxDB MongoDB Agent —— 与 OpsxJdbcAgent 同协议的 Mongo 专属桥(默认包,单文件)。
//
// 启动:java -cp opsx-agent-jdbc.jar:gson-2.12.1.jar:mongo-driver-*.jar OpsxMongoAgent --jars <目录>
//
// SQL 框迷你 DSL(文档见 agents/README.md):
// db.<coll>.find([filter]) 查询(filter 为 JSON,可省略)
// db.<coll>.count([filter]) 计数
// db.<coll>.insertOne({doc}) 插入
// db.<coll>.updateOne({f},{u}) 更新(u 用 $set 等操作符)
// db.<coll>.deleteOne({f}) 删除单条
// db.<coll>.deleteMany({f}) 批量删除(Go 侧强制危险确认)
// metaDatabases=库列表;metaTables(schema=库)=集合;metaColumns=抽样文档字段并集。
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.MongoDatabase;
import com.mongodb.client.model.Filters;
import com.mongodb.client.model.Updates;
import com.mongodb.client.result.DeleteResult;
import com.mongodb.client.result.UpdateResult;
import org.bson.BsonDocument;
import org.bson.Document;
import org.bson.conversions.Bson;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class OpsxMongoAgent {
static final String AGENT = "opsx-agent-jdbc";
static final String VERSION = "0.1.0";
static final int PROTOCOL = 1;
private static final Pattern DSL = Pattern.compile(
"^db\\.(\\w+)\\.(find|count|insertOne|updateOne|deleteOne|deleteMany)\\s*(\\{.*\\})?\\s*\\)?\\s*$",
Pattern.DOTALL);
private final Gson gson = new Gson();
private final Map<String, MongoClient> handles = new LinkedHashMap<>();
private final AtomicLong seq = new AtomicLong(0);
public static void main(String[] args) throws Exception {
new OpsxMongoAgent().loop();
}
private void loop() throws Exception {
BufferedReader in = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out, StandardCharsets.UTF_8), true);
out.println(gson.toJson(resp(0, true, helloResult(), null)));
String line;
while ((line = in.readLine()) != null) {
line = line.trim();
if (line.isEmpty()) continue;
JsonObject req;
try {
req = JsonParser.parseString(line).getAsJsonObject();
} catch (Exception e) {
out.println(gson.toJson(resp(0, false, null, "bad json: " + e.getMessage())));
continue;
}
long id = req.has("id") ? req.get("id").getAsLong() : 0;
String op = req.has("op") ? req.get("op").getAsString() : "";
JsonObject args = req.has("args") && req.get("args").isJsonObject() ? req.getAsJsonObject("args") : new JsonObject();
try {
out.println(gson.toJson(resp(id, true, dispatch(op, args), null)));
} catch (Exception e) {
out.println(gson.toJson(resp(id, false, null, e.getMessage() == null ? String.valueOf(e) : e.getMessage())));
}
if ("shutdown".equals(op)) {
out.flush();
System.exit(0);
}
}
System.exit(0);
}
private JsonObject helloResult() {
JsonObject o = new JsonObject();
o.addProperty("agent", AGENT);
o.addProperty("version", VERSION);
o.addProperty("protocol", PROTOCOL);
return o;
}
private JsonObject dispatch(String op, JsonObject a) throws Exception {
switch (op) {
case "hello": return helloResult();
case "open": {
MongoClient cli = MongoClients.create(str(a, "url"));
String handle = "m" + seq.incrementAndGet();
handles.put(handle, cli);
JsonObject o = new JsonObject();
o.addProperty("handle", handle);
return o;
}
case "ping": {
Document r = zk(a).getDatabase("admin").runCommand(new Document("ping", 1));
if (r.getDouble("ok") != 1.0) throw new IllegalStateException("ping 失败: " + r.toJson());
return ok();
}
case "query": return query(a);
case "exec": return exec(a);
case "metaDatabases": {
List<String> names = new ArrayList<>();
zk(a).listDatabaseNames().into(names);
JsonObject o = new JsonObject();
o.add("names", gson.toJsonTree(names));
return o;
}
case "metaTables": {
List<String> names = new ArrayList<>();
zk(a).getDatabase(str(a, "schema")).listCollectionNames().into(names);
JsonObject o = new JsonObject();
o.add("names", gson.toJsonTree(names));
return o;
}
case "metaColumns": {
// 抽样前 5 个文档的字段并集(v1 结构探测,非权威 schema)
List<JsonObject> cols = new ArrayList<>();
Set<String> seen = new LinkedHashSet<>();
try (var it = coll(a).find().limit(5).iterator()) {
while (it.hasNext()) {
for (Map.Entry<String, Object> e : it.next().entrySet()) {
if (seen.add(e.getKey())) {
JsonObject c = new JsonObject();
c.addProperty("name", e.getKey());
c.addProperty("type", e.getValue() == null ? "unknown" : e.getValue().getClass().getSimpleName());
c.addProperty("nullable", true);
cols.add(c);
}
}
}
}
JsonObject o = new JsonObject();
o.add("columns", gson.toJsonTree(cols));
return o;
}
case "close": {
MongoClient c = handles.remove(str(a, "handle"));
if (c != null) c.close();
return ok();
}
default: throw new IllegalArgumentException("未知 op: " + op);
}
}
/** find/count 的表格式结果 */
private JsonObject query(JsonObject a) throws Exception {
Dsl d = parse(str(a, "sql"));
int maxRows = a.has("maxRows") ? a.get("maxRows").getAsInt() : 1000;
if (d.verb.equals("count")) {
long n = coll(a, d).countDocuments(d.filter == null ? new Document() : d.filter);
JsonObject o = new JsonObject();
o.add("columns", gson.toJsonTree(new String[]{"n"}));
JsonObject row = new JsonObject();
row.addProperty("n", n);
o.add("rows", gson.toJsonTree(List.of(row)));
o.addProperty("truncated", false);
return o;
}
if (!d.verb.equals("find")) {
throw new IllegalArgumentException("query 仅支持 find/count,收到: " + d.verb);
}
List<String> cols = new ArrayList<>();
cols.add("_id");
Set<String> seen = new LinkedHashSet<>(cols);
List<JsonObject> rows = new ArrayList<>();
boolean truncated = false;
try (var it = coll(a, d).find(d.filter == null ? new Document() : d.filter).limit(maxRows + 1).iterator()) {
while (it.hasNext()) {
Document doc = it.next();
if (rows.size() >= maxRows) { truncated = true; break; }
for (String k : doc.keySet()) {
if (seen.add(k)) cols.add(k);
}
JsonObject row = new JsonObject();
for (String k : cols) {
Object v = doc.get(k);
row.addProperty(k, v == null ? null : String.valueOf(v));
}
rows.add(row);
}
}
JsonObject o = new JsonObject();
o.add("columns", gson.toJsonTree(cols));
o.add("rows", gson.toJsonTree(rows));
o.addProperty("truncated", truncated);
return o;
}
/** insertOne/updateOne/deleteOne/deleteMany */
private JsonObject exec(JsonObject a) throws Exception {
Dsl d = parse(str(a, "sql"));
MongoCollection<Document> c = coll(a, d);
JsonObject o = new JsonObject();
switch (d.verb) {
case "insertOne": {
Document doc = Document.parse(d.rawJson);
c.insertOne(doc);
o.addProperty("affectedRows", 1);
return o;
}
case "updateOne": {
List<Bson> parts = splitTwo(d.rawJson);
UpdateResult r = c.updateOne(parts.get(0), parts.get(1));
o.addProperty("affectedRows", r.getModifiedCount());
return o;
}
case "deleteOne": {
DeleteResult r = c.deleteOne(d.filter == null ? new Document() : d.filter);
o.addProperty("affectedRows", r.getDeletedCount());
return o;
}
case "deleteMany": {
DeleteResult r = c.deleteMany(d.filter == null ? new Document() : d.filter);
o.addProperty("affectedRows", r.getDeletedCount());
return o;
}
default:
throw new IllegalArgumentException("exec 仅支持 insertOne/updateOne/deleteOne/deleteMany,收到: " + d.verb);
}
}
/** 把 updateOne 的 {filter},{update} 拆成两个 Bson */
private List<Bson> splitTwo(String raw) {
// 顶层逗号切分(括号深度 0 处)
int depth = 0;
for (int i = 0; i < raw.length(); i++) {
char ch = raw.charAt(i);
if (ch == '{') depth++;
else if (ch == '}') depth--;
else if (ch == ',' && depth == 0) {
return List.of(BsonDocument.parse(raw.substring(0, i).trim()),
BsonDocument.parse(raw.substring(i + 1).trim()));
}
}
throw new IllegalArgumentException("updateOne 需要 {filter},{update} 两个 JSON");
}
private Dsl parse(String sql) {
String s = sql == null ? "" : sql.trim();
if (!s.startsWith("db.")) {
throw new IllegalArgumentException("无法解析的 Mongo DSL(支持 db.<coll>.find/count/insertOne/updateOne/deleteOne/deleteMany): " + sql);
}
int dot = s.indexOf('.', 3);
if (dot < 0) {
throw new IllegalArgumentException("缺少集合名: " + sql);
}
Dsl d = new Dsl();
d.coll = s.substring(3, dot);
String rest = s.substring(dot + 1).trim();
int i = 0;
while (i < rest.length() && Character.isLetter(rest.charAt(i))) i++;
d.verb = rest.substring(0, i);
String raw = rest.substring(i).trim();
// 抽出 JSON 体:第一个 '{' 到最后一个 '}'(容忍 insertOne({...}) 的外层括号与空白)
int b = raw.indexOf('{');
if (b >= 0) {
int e = raw.lastIndexOf('}');
raw = e > b ? raw.substring(b, e + 1) : raw.substring(b);
}
d.rawJson = raw;
Set<String> verbs = Set.of("find", "count", "insertOne", "updateOne", "deleteOne", "deleteMany");
if (!verbs.contains(d.verb)) {
throw new IllegalArgumentException("不支持的操作: " + d.verb);
}
if (!d.rawJson.isEmpty() && !d.verb.equals("updateOne")) {
d.filter = Document.parse(d.rawJson);
}
return d;
}
private MongoClient zk(JsonObject a) {
MongoClient c = handles.get(str(a, "handle"));
if (c == null) throw new IllegalStateException("句柄不存在或已关闭");
return c;
}
private MongoCollection<Document> coll(JsonObject a) {
// metaColumns 等入口:schema=库,table=集合
return zk(a).getDatabase(str(a, "schema")).getCollection(str(a, "table"));
}
private MongoCollection<Document> coll(JsonObject a, Dsl d) {
String db = a.has("schema") && !str(a, "schema").isEmpty() ? str(a, "schema")
: ((JsonObject) a).has("database") ? str(a, "database") : "test";
// ExecSQL 路径未带 schema 时,从会话 Conn 透出:args 里由 Go 侧补 database
if (a.has("db") && !str(a, "db").isEmpty()) db = str(a, "db");
return zk(a).getDatabase(db).getCollection(d.coll);
}
private static final class Dsl {
String coll;
String verb;
String rawJson;
Document filter;
}
private static String str(JsonObject a, String k) {
return a.has(k) && !a.get(k).isJsonNull() ? a.get(k).getAsString() : "";
}
private static JsonObject ok() { return new JsonObject(); }
private static JsonObject resp(long id, boolean ok, JsonObject result, String error) {
JsonObject o = new JsonObject();
o.addProperty("id", id);
o.addProperty("ok", ok);
if (result != null) o.add("result", result);
if (error != null) o.addProperty("error", error);
return o;
}
}
+252
View File
@@ -0,0 +1,252 @@
// OpsxDB ZooKeeper Agent —— 与 OpsxJdbcAgent 同协议的 ZK 专属桥(默认包,单文件)。
//
// 启动:java -cp opsx-agent-jdbc.jar:gson-2.12.1.jar OpsxZkAgent --jars <zookeeper.jar 目录>
//
// SQL 约定(工作台编辑器直写):
// GET <path> 读 znode:path/data/version/ctime/mtime/ephemeral/children
// LS <path> 列子节点(带 stat)
// CREATE <path> [data] 持久节点(父须存在);SET <path> <data>;DELETE <path>(须无子)
// 写动词(CREATE/SET/DELETE)由 Go 侧危险语句拦截 + 二次确认把守。
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.KeeperException;
import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.Watcher;
import org.apache.zookeeper.ZooDefs;
import org.apache.zookeeper.ZooKeeper;
import org.apache.zookeeper.data.Stat;
public final class OpsxZkAgent {
static final String AGENT = "opsx-agent-jdbc";
static final String VERSION = "0.1.0";
static final int PROTOCOL = 1;
private final Gson gson = new Gson();
private final Map<String, ZooKeeper> handles = new HashMap<>();
private final AtomicLong seq = new AtomicLong(0);
public static void main(String[] args) throws Exception {
new OpsxZkAgent().loop();
}
private void loop() throws Exception {
BufferedReader in = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out, StandardCharsets.UTF_8), true);
out.println(gson.toJson(resp(0, true, helloResult(), null)));
String line;
while ((line = in.readLine()) != null) {
line = line.trim();
if (line.isEmpty()) continue;
JsonObject req;
try {
req = JsonParser.parseString(line).getAsJsonObject();
} catch (Exception e) {
out.println(gson.toJson(resp(0, false, null, "bad json: " + e.getMessage())));
continue;
}
long id = req.has("id") ? req.get("id").getAsLong() : 0;
String op = req.has("op") ? req.get("op").getAsString() : "";
JsonObject args = req.has("args") && req.get("args").isJsonObject() ? req.getAsJsonObject("args") : new JsonObject();
try {
out.println(gson.toJson(resp(id, true, dispatch(op, args), null)));
} catch (KeeperException e) {
out.println(gson.toJson(resp(id, false, null, "ZK " + e.code().name() + ": " + e.getMessage())));
} catch (Exception e) {
out.println(gson.toJson(resp(id, false, null, e.getMessage() == null ? String.valueOf(e) : e.getMessage())));
}
if ("shutdown".equals(op)) {
out.flush();
System.exit(0);
}
}
System.exit(0);
}
private JsonObject helloResult() {
JsonObject o = new JsonObject();
o.addProperty("agent", AGENT);
o.addProperty("version", VERSION);
o.addProperty("protocol", PROTOCOL);
return o;
}
private JsonObject dispatch(String op, JsonObject a) throws Exception {
switch (op) {
case "hello": return helloResult();
case "open": return open(a);
case "ping": {
Stat st = zk(a).exists("/", false);
if (st == null) throw new IllegalStateException("root 不存在");
return ok();
}
case "query": return query(a);
case "exec": return exec(a);
case "metaDatabases": return children(zk(a), "/");
case "metaTables": {
String schema = str(a, "schema");
String base = schema.isEmpty() ? "/" : normalize(schema);
return children(zk(a), base);
}
case "close": {
ZooKeeper z = handles.remove(str(a, "handle"));
if (z != null) z.close();
return ok();
}
default: throw new IllegalArgumentException("未知 op: " + op);
}
}
private JsonObject open(JsonObject a) throws Exception {
String connect = str(a, "url"); // host:port
if (connect.isEmpty()) throw new IllegalArgumentException("缺少 connectString");
CountDownLatch latch = new CountDownLatch(1);
ZooKeeper zk = new ZooKeeper(connect, 10000, new Watcher() {
public void process(WatchedEvent e) {
if (e.getState() == Event.KeeperState.SyncConnected
|| e.getState() == Event.KeeperState.ConnectedReadOnly) {
latch.countDown();
}
}
});
if (!latch.await(10, TimeUnit.SECONDS)) {
zk.close();
throw new IllegalStateException("ZK 连接超时: " + connect);
}
String handle = "z" + seq.incrementAndGet();
handles.put(handle, zk);
JsonObject o = new JsonObject();
o.addProperty("handle", handle);
return o;
}
/** query 承载读动词:GET / LS */
private JsonObject query(JsonObject a) throws Exception {
ZooKeeper zk = zk(a);
String sql = str(a, "sql").trim();
String[] parts = sql.split("\\s+", 2);
String verb = parts.length > 0 ? parts[0].toUpperCase() : "";
String arg = parts.length > 1 ? parts[1].trim() : "/";
if (verb.equals("GET")) {
return statRow(zk, normalize(arg), false);
}
if (verb.equals("LS")) {
return statRow(zk, normalize(arg), true);
}
throw new IllegalArgumentException("ZK query 仅支持 GET/LS,收到: " + verb);
}
/** exec 承载写动词:CREATE / SET / DELETE */
private JsonObject exec(JsonObject a) throws Exception {
ZooKeeper zk = zk(a);
String sql = str(a, "sql").trim();
String[] parts = sql.split("\\s+", 2);
String verb = parts.length > 0 ? parts[0].toUpperCase() : "";
String rest = parts.length > 1 ? parts[1] : "";
int sp = rest.indexOf(' ');
String path = normalize(sp < 0 ? rest : rest.substring(0, sp));
String data = sp < 0 ? "" : rest.substring(sp + 1);
switch (verb) {
case "CREATE":
zk.create(path, data.getBytes(StandardCharsets.UTF_8), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
break;
case "SET":
zk.setData(path, data.getBytes(StandardCharsets.UTF_8), -1);
break;
case "DELETE":
zk.delete(path, -1);
break;
default:
throw new IllegalArgumentException("ZK exec 仅支持 CREATE/SET/DELETE,收到: " + verb);
}
JsonObject o = new JsonObject();
o.addProperty("affectedRows", 1);
return o;
}
/** statRow:GET 单行 / LS 多行(每个子节点一行) */
private JsonObject statRow(ZooKeeper zk, String path, boolean listChildren) throws Exception {
List<JsonObject> rows = new ArrayList<>();
if (listChildren) {
for (String child : zk.getChildren(path, false)) {
String childPath = "/".equals(path) ? "/" + child : path + "/" + child;
rows.add(row(zk, childPath));
}
} else {
rows.add(row(zk, path));
}
JsonObject o = new JsonObject();
o.add("columns", gson.toJsonTree(new String[]{"path", "data", "version", "ctime", "mtime", "ephemeral", "children"}));
o.add("rows", gson.toJsonTree(rows));
o.addProperty("truncated", false);
return o;
}
private JsonObject row(ZooKeeper zk, String path) throws Exception {
Stat st = new Stat();
byte[] data = zk.getData(path, false, st);
JsonObject r = new JsonObject();
r.addProperty("path", path);
r.addProperty("data", data == null ? "" : new String(data, StandardCharsets.UTF_8));
r.addProperty("version", st.getVersion());
r.addProperty("ctime", st.getCtime());
r.addProperty("mtime", st.getMtime());
r.addProperty("ephemeral", st.getEphemeralOwner() != 0);
r.addProperty("children", st.getNumChildren());
return r;
}
private JsonObject children(ZooKeeper zk, String base) throws Exception {
List<String> names = new ArrayList<>();
if (zk.getChildren(base, false).isEmpty() && "/".equals(base)) {
names.add("/");
} else {
names.addAll(zk.getChildren(base, false));
}
JsonObject o = new JsonObject();
o.add("names", gson.toJsonTree(names));
return o;
}
private ZooKeeper zk(JsonObject a) {
ZooKeeper z = handles.get(str(a, "handle"));
if (z == null) throw new IllegalStateException("句柄不存在或已关闭");
return z;
}
private static String normalize(String path) {
if (path == null || path.isEmpty()) return "/";
return path.startsWith("/") ? path : "/" + path;
}
private static String str(JsonObject a, String k) {
return a.has(k) && !a.get(k).isJsonNull() ? a.get(k).getAsString() : "";
}
private static JsonObject ok() { return new JsonObject(); }
private static JsonObject resp(long id, boolean ok, JsonObject result, String error) {
JsonObject o = new JsonObject();
o.addProperty("id", id);
o.addProperty("ok", ok);
if (result != null) o.add("result", result);
if (error != null) o.addProperty("error", error);
return o;
}
}
+120 -6
View File
@@ -1,8 +1,8 @@
openapi: 3.0.3
info:
title: OpsxDB Server
version: 0.2.0
description: Database workbench API — sessions, metadata, SQL exec
version: 0.3.0
description: Database workbench API — sessions, metadata, SQL exec, cancel, DDL, search, export
servers:
- url: http://127.0.0.1:8788
paths:
@@ -38,10 +38,42 @@ paths:
summary: List columns of a table
/api/db/exec:
post:
summary: Execute SQL or Redis command
summary: Execute SQL or Redis command (timeoutMs/requestId honored; dangerous SQL two-phase confirm)
/api/db/explain:
post:
summary: Execution plan (rows for mysql/pg/sqlite, SHOWPLAN_XML for mssql, DBMS_XPLAN for oracle)
/api/db/cancel:
post:
summary: Cancel an in-flight exec/explain by requestId → {"ok":true,"cancelled":bool}
/api/db/ddl:
post:
summary: Show CREATE TABLE DDL for a table → {"ddl":"...","error":""}
/api/db/search:
post:
summary: Fuzzy search tables/views by keyword (max 50) → {"results":[...],"error":""}
/api/db/export:
post:
summary: Stream query results as csv/json/insert attachment (read-only SQL only, two-phase confirm on danger hits)
/api/db/redis/keys:
post:
summary: Redis SCAN keys (whitelist-guarded commands)
/api/db/redis/key:
post:
summary: Redis key value (type/ttl + rows)
/api/db/redis/key/delete:
post:
summary: Delete a Redis key
/api/ai/chat:
post:
summary: AI actions (currently stub)
summary: AI actions (stub fallback when unconfigured)
/api/ai/config:
get:
summary: Get masked AI config
post:
summary: Save AI provider config (apiKey stored 0600, responses masked)
/api/ai/generate:
post:
summary: AI SQL generation (action=generate)
components:
schemas:
ConnReq:
@@ -54,8 +86,90 @@ components:
password: { type: string }
database: { type: string }
service: { type: string }
ssl: { type: boolean }
sessionId: { type: string }
sql: { type: string }
table: { type: string }
limit: { type: integer }
offset: { type: integer }
key: { type: string }
pattern: { type: string }
keyword: { type: string }
format: { type: string, enum: [csv, json, insert] }
sessionId: { type: string }
ssl: { type: boolean }
sslMode: { type: string, enum: [disable, require, skip-verify, verify-ca] }
sslCA: { type: string }
sslCert: { type: string }
sslKey: { type: string }
sslCAContent: { type: string }
sslCertContent: { type: string }
sslKeyContent: { type: string }
tlsServerName: { type: string }
sshEnabled: { type: boolean }
sshHost: { type: string }
sshPort: { type: integer }
sshUser: { type: string }
sshAuth: { type: string, enum: [password, privateKey] }
sshPassword: { type: string }
sshPrivateKey: { type: string }
sshPassphrase: { type: string }
confirmDanger: { type: boolean }
timeoutMs: { type: integer }
requestId: { type: string }
CancelReq:
type: object
properties:
requestId: { type: string }
CancelResp:
type: object
properties:
ok: { type: boolean }
cancelled: { type: boolean }
DDLResp:
type: object
properties:
ddl: { type: string }
error: { type: string }
ObjectHit:
type: object
properties:
schema: { type: string }
name: { type: string }
type: { type: string, enum: [table, view] }
SearchResp:
type: object
properties:
results:
type: array
items:
$ref: "#/components/schemas/ObjectHit"
error: { type: string }
ExecResult:
type: object
properties:
columns:
type: array
items: { type: string }
rows:
type: array
items:
type: object
rowCount: { type: integer }
timeMs: { type: integer }
error: { type: string }
truncated: { type: boolean }
limit: { type: integer }
offset: { type: integer }
needConfirm: { type: boolean }
ExplainResult:
type: object
properties:
columns:
type: array
items: { type: string }
rows:
type: array
items:
type: object
text: { type: string }
timeMs: { type: integer }
error: { type: string }
+635 -6
View File
@@ -47,6 +47,137 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "atk"
version = "0.18.2"
@@ -148,6 +279,19 @@ dependencies = [
"objc2",
]
[[package]]
name = "blocking"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "brotli"
version = "8.0.4"
@@ -329,7 +473,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core",
"rand_core 0.10.1",
]
[[package]]
@@ -354,6 +498,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "cookie"
version = "0.18.2"
@@ -764,6 +917,33 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -781,6 +961,36 @@ dependencies = [
"typeid",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "event-listener"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -904,6 +1114,19 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.34"
@@ -1087,7 +1310,7 @@ dependencies = [
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core",
"rand_core 0.10.1",
"wasm-bindgen",
]
@@ -1263,6 +1486,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]]
name = "hex"
version = "0.4.3"
@@ -1788,6 +2017,12 @@ dependencies = [
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.3"
@@ -2171,11 +2406,14 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
name = "opsxdb-desktop"
version = "0.1.0"
dependencies = [
"rand 0.8.8",
"reqwest 0.12.28",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-single-instance",
"tauri-plugin-window-state",
"tokio",
"url",
]
@@ -2186,6 +2424,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2211,6 +2459,12 @@ dependencies = [
"system-deps",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2299,6 +2553,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
@@ -2344,6 +2609,20 @@ dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "portable-atomic"
version = "1.15.0"
@@ -2374,6 +2653,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@@ -2480,7 +2768,7 @@ dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
@@ -2528,6 +2816,17 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.10.2"
@@ -2536,7 +2835,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
@@ -2551,7 +2869,7 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core",
"rand_core 0.10.1",
]
[[package]]
@@ -2731,6 +3049,19 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags 2.13.2",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.45"
@@ -3061,6 +3392,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
@@ -3431,6 +3772,53 @@ dependencies = [
"tauri-utils",
]
[[package]]
name = "tauri-plugin"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db817fe9295e19b7d8357e900af31edb93703dd9fb6de524b007b47b6afc63b0"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.20",
"tokio",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
dependencies = [
"bitflags 2.13.2",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.20",
]
[[package]]
name = "tauri-runtime"
version = "2.11.3"
@@ -3531,6 +3919,19 @@ dependencies = [
"toml 1.1.6+spec-1.1.0",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.5.1"
@@ -3835,9 +4236,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -3887,6 +4300,17 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]]
name = "unic-char-property"
version = "0.9.0"
@@ -4456,6 +4880,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -4489,13 +4922,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
@@ -4526,6 +4976,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -4538,6 +4994,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -4550,12 +5012,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -4568,6 +5042,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -4580,6 +5060,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -4592,6 +5078,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -4604,6 +5096,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.5.40"
@@ -4738,6 +5236,96 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.4",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 3.0.5",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
dependencies = [
"serde",
"winnow 1.0.4",
"zvariant",
]
[[package]]
name = "zcheapstr"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
dependencies = [
"serde",
]
[[package]]
name = "zerocopy"
version = "0.8.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
@@ -4809,3 +5397,44 @@ name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zvariant"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.4",
"zcheapstr",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 3.0.5",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 3.0.5",
"winnow 1.0.4",
]
+3
View File
@@ -14,8 +14,11 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] } # path via Manager
tauri-plugin-single-instance = "2" # 二次启动聚焦已有窗口
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"] }
tokio = { version = "1", features = ["time"] }
url = "2"
rand = "0.8"
tauri-plugin-window-state = "2"
@@ -6,6 +6,7 @@
"main"
],
"permissions": [
"core:default"
"core:default",
"window-state:default"
]
}
+21 -1
View File
@@ -126,9 +126,28 @@ async fn ws_close(session_id: String) -> Result<(), String> {
Ok(())
}
/// 前端启动时拉取后端地址与 token(注入 window.__OPSXDB_API__/__OPSXDB_TOKEN__)。
#[tauri::command]
fn get_backend_config(
state: tauri::State<'_, sidecar::SidecarState>,
) -> Result<sidecar::BackendConfig, String> {
state
.backend_config()
.ok_or_else(|| "后端尚未就绪".to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
// 二次启动聚焦已有窗口
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
}
}))
.plugin(tauri_plugin_window_state::Builder::default().build())
.manage(sidecar::SidecarState::default())
.setup(|app| {
let state = app.state::<sidecar::SidecarState>();
@@ -139,7 +158,8 @@ pub fn run() {
http_send,
ws_connect,
ws_send,
ws_close
ws_close,
get_backend_config
])
.build(tauri::generate_context!())
.expect("error while building OpsxDB desktop")
+141 -37
View File
@@ -1,94 +1,198 @@
//! 桌面启动时拉起本机 OpsxDB Gin 后端(默认 127.0.0.1:8788)。
//! 桌面启动时拉起本机 OpsxDB Gin 后端。
//! - 动态端口:TcpListener 绑 127.0.0.1:0 选空闲端口,OPSXDB_ADDR 传子进程;
//! - 每次自起生成随机 token,OPSXDB_TOKEN 传子进程,与 addr 一起经 get_backend_config 注入前端;
//! - 复用 8788 上已运行后端前先比对 /health 版本主版本,不匹配则换端口自起;
//! - 失败时 emit "opsxdb://backend-failed",后端 stdout/stderr 重定向到 app_log_dir/opsxdb-server.log。
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::thread;
use std::time::Duration;
use tauri::{AppHandle, Manager, RunEvent};
use tauri::{AppHandle, Emitter, Manager, RunEvent};
const HEALTH: &str = "http://127.0.0.1:8788/health";
const ADDR: &str = "127.0.0.1:8788";
const DEFAULT_ADDR: &str = "127.0.0.1:8788";
/// 与服务端 cmd/opsxdb main.go Version 保持一致,复用前比对主版本。
const BACKEND_VERSION: &str = "0.1.0";
const EVENT_BACKEND_FAILED: &str = "opsxdb://backend-failed";
#[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BackendConfig {
pub addr: String,
pub token: String,
}
pub struct SidecarState {
child: Mutex<Option<Child>>,
config: Mutex<Option<BackendConfig>>,
}
impl Default for SidecarState {
fn default() -> Self {
Self {
child: Mutex::new(None),
config: Mutex::new(None),
}
}
}
fn health_ok() -> bool {
ureq_get(HEALTH).is_ok()
impl SidecarState {
fn set_config(&self, cfg: BackendConfig) {
if let Ok(mut g) = self.config.lock() {
*g = Some(cfg);
}
}
pub fn backend_config(&self) -> Option<BackendConfig> {
self.config.lock().ok().and_then(|g| g.clone())
}
}
/// 轻量 GET,避免再引 reqwest 阻塞;用 std 即可。
fn ureq_get(url: &str) -> Result<(), ()> {
let Ok(resp) = reqwest::blocking::Client::builder()
fn version_major(v: &str) -> &str {
v.split('.').next().unwrap_or("")
}
/// GET /health 并解析版本;失败或非 200 返回 None。
fn health_version(addr: &str) -> Option<String> {
let resp = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(1))
.build()
.and_then(|c| c.get(url).send())
else {
return Err(());
};
if resp.status().is_success() {
Ok(())
} else {
Err(())
.ok()?
.get(format!("http://{addr}/health"))
.send()
.ok()?;
if !resp.status().is_success() {
return None;
}
let v: serde_json::Value = resp.json().ok()?;
v.get("version")?.as_str().map(|s| s.to_string())
}
fn health_ok(addr: &str) -> bool {
health_version(addr).is_some()
}
/// 尝试复用 8788 上已在运行的后端:仅当主版本一致才复用,
/// 否则换端口自起而非静默连上旧版本。
fn try_reuse_existing(state: &SidecarState) -> bool {
match health_version(DEFAULT_ADDR) {
Some(v) if version_major(&v) == version_major(BACKEND_VERSION) => {
// 复用路径拿不到对方 token,按本机 dev 无鉴权处理
state.set_config(BackendConfig {
addr: format!("http://{DEFAULT_ADDR}"),
token: String::new(),
});
true
}
Some(v) => {
eprintln!(
"opsxdb: 已有后端版本 {v} 与期望 {BACKEND_VERSION} 主版本不匹配,改自动态端口自起"
);
false
}
None => false,
}
}
/// 绑 127.0.0.1:0 选一个空闲端口(立即释放,交给子进程绑定)。
fn pick_free_port() -> Option<u16> {
let ln = std::net::TcpListener::bind("127.0.0.1:0").ok()?;
ln.local_addr().ok().map(|a| a.port())
}
fn gen_token() -> String {
use rand::RngCore;
let mut b = [0u8; 32];
rand::thread_rng().fill_bytes(&mut b);
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn candidate_bins(app: &AppHandle) -> Vec<PathBuf> {
// Windows 产物为 opsxdb-server.exe
let bin = format!("opsxdb-server{}", std::env::consts::EXE_EXTENSION);
let mut out = Vec::new();
// 打包资源:externalBin → 与可执行文件同级的 binaries 或 resource
// 打包资源:externalBin → 与可执行文件同级的 binaries 或 resource
if let Ok(dir) = app.path().resource_dir() {
out.push(dir.join("opsxdb-server"));
out.push(dir.join("binaries").join("opsxdb-server"));
out.push(dir.join(&bin));
out.push(dir.join("binaries").join(&bin));
}
if let Ok(exe) = app.path().executable_dir() {
out.push(exe.join("opsxdb-server"));
out.push(exe.join("binaries").join("opsxdb-server"));
out.push(exe.join(&bin));
out.push(exe.join("binaries").join(&bin));
}
// 开发态:仓库 apps/server/bin
out.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../server/bin/opsxdb-server"));
out.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../apps/server/bin/opsxdb-server"));
// 开发态:仓库 apps/server/bin
out.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../server/bin").join(&bin));
out.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../apps/server/bin").join(&bin));
out
}
fn spawn_server(bin: &PathBuf) -> std::io::Result<Child> {
Command::new(bin)
.env("OPSXDB_ADDR", ADDR)
.env("GIN_MODE", "release")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
/// 后端 stdout/stderr 重定向到 app_log_dir/opsxdb-server.log。
fn server_log_file(app: &AppHandle) -> Option<std::fs::File> {
let dir = app.path().app_log_dir().ok()?;
std::fs::create_dir_all(&dir).ok()?;
std::fs::File::create(dir.join("opsxdb-server.log")).ok()
}
fn spawn_server(
bin: &PathBuf,
addr: &str,
token: &str,
log_file: Option<&std::fs::File>,
) -> std::io::Result<Child> {
let mut cmd = Command::new(bin);
cmd.env("OPSXDB_ADDR", addr)
.env("OPSXDB_TOKEN", token)
.env("GIN_MODE", "release");
match log_file {
Some(f) => {
let err_f = f.try_clone()?;
cmd.stdout(Stdio::from(f.try_clone()?)).stderr(Stdio::from(err_f));
}
None => {
cmd.stdout(Stdio::null()).stderr(Stdio::null());
}
}
cmd.spawn()
}
pub fn ensure_backend(app: &AppHandle, state: &SidecarState) {
if health_ok() {
if try_reuse_existing(state) {
return;
}
let Some(port) = pick_free_port() else {
let _ = app.emit(EVENT_BACKEND_FAILED, "无法分配空闲端口");
return;
};
let addr = format!("127.0.0.1:{port}");
let token = gen_token();
let log_file = server_log_file(app);
let mut last_err = String::from("未找到后端二进制");
for bin in candidate_bins(app) {
if !bin.exists() {
continue;
}
match spawn_server(&bin) {
match spawn_server(&bin, &addr, &token, log_file.as_ref()) {
Ok(child) => {
*state.child.lock().unwrap() = Some(child);
for _ in 0..30 {
if health_ok() {
if health_ok(&addr) {
state.set_config(BackendConfig {
addr: format!("http://{addr}"),
token: token.clone(),
});
return;
}
thread::sleep(Duration::from_millis(100));
}
return;
last_err = format!("后端启动后健康检查超时({})", bin.display());
}
Err(e) => {
last_err = format!("启动 {} 失败: {e}", bin.display());
continue;
}
Err(_) => continue,
}
}
let _ = app.emit(EVENT_BACKEND_FAILED, last_err);
}
pub fn stop_backend(state: &SidecarState) {
+2 -2
View File
@@ -6,7 +6,7 @@
"build": {
"beforeDevCommand": "bash -lc 'curl -fsS http://127.0.0.1:5174 >/dev/null 2>&1 || npm --prefix ../.. run dev:web'",
"devUrl": "http://127.0.0.1:5174",
"beforeBuildCommand": "bash -lc 'cd ../.. && VITE_OPSXDB_API=http://127.0.0.1:8788 npm run build:web'",
"beforeBuildCommand": "bash -lc 'cd ../.. && npm run build:web'",
"frontendDist": "../../web/dist"
},
"app": {
@@ -23,7 +23,7 @@
}
],
"security": {
"csp": null
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* ipc://localhost; object-src 'none'; base-uri 'self'"
}
},
"bundle": {
+87 -4
View File
@@ -1,17 +1,30 @@
package main
import (
"crypto/rand"
"encoding/hex"
"log"
"net"
"os"
"path/filepath"
"strings"
"opsxdb/server/internal/server"
)
// Version 服务端版本,/health 响应携带供桌面 sidecar 版本握手。
const Version = "0.1.0"
func main() {
addr := resolveListenAddr()
log.Printf("OpsxDB server listening on %s", addr)
if err := server.NewRouter().Run(addr); err != nil {
token := resolveToken(isPublicBind(addr) || bindPublicEnv())
server.Version = Version
if token != "" {
log.Printf("OpsxDB server listening on %s(已启用访问令牌)", addr)
} else {
log.Printf("OpsxDB server listening on %s", addr)
}
if err := server.NewRouter(token).Run(addr); err != nil {
log.Fatal(err)
}
}
@@ -22,9 +35,79 @@ func resolveListenAddr() string {
if v := strings.TrimSpace(os.Getenv("OPSXDB_ADDR")); v != "" {
return v
}
pub := strings.ToLower(strings.TrimSpace(os.Getenv("OPSXDB_BIND_PUBLIC")))
if pub == "1" || pub == "true" || pub == "yes" {
if bindPublicEnv() {
return ":8788"
}
return "127.0.0.1:8788"
}
func bindPublicEnv() bool {
pub := strings.ToLower(strings.TrimSpace(os.Getenv("OPSXDB_BIND_PUBLIC")))
return pub == "1" || pub == "true" || pub == "yes"
}
// isPublicBind 判断监听地址是否绑在非回环地址上。
func isPublicBind(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return false
}
if host == "" {
return true // ":8788" 监听全部网卡
}
if ip := net.ParseIP(host); ip != nil {
return !ip.IsLoopback()
}
return host != "localhost"
}
func tokenFilePath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".opsxdb", "token"), nil
}
// resolveToken 生效规则:
// - 优先 env OPSXDB_TOKEN(sidecar 拉起路径即强制 token);
// - 否则读 ~/.opsxdb/token;
// - force(绑公网)时仍无 token → crypto/rand 生成 32B hex,写 0600 文件并打印一次;
// - 本机 dev(不 force)无 token → 返回空,中间件直通。
func resolveToken(force bool) string {
if t := strings.TrimSpace(os.Getenv("OPSXDB_TOKEN")); t != "" {
return t
}
p, err := tokenFilePath()
if err == nil {
if b, err := os.ReadFile(p); err == nil {
if t := strings.TrimSpace(string(b)); t != "" {
return t
}
}
}
if !force {
return ""
}
t, err := genToken()
if err != nil {
log.Fatalf("公网监听必须配置访问令牌,且自动生成失败: %v(请设置 OPSXDB_TOKEN)", err)
}
if p, err := tokenFilePath(); err == nil {
if err := os.MkdirAll(filepath.Dir(p), 0700); err == nil {
if err := os.WriteFile(p, []byte(t+"\n"), 0600); err != nil {
log.Printf("写入 token 文件失败(仅本次进程有效): %v", err)
}
}
}
log.Printf("已生成访问令牌(仅显示一次,已写入 ~/.opsxdb/token): %s", t)
return t
}
func genToken() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
+1 -1
View File
@@ -9,6 +9,7 @@ require (
github.com/microsoft/go-mssqldb v1.8.0
github.com/redis/go-redis/v9 v9.7.0
github.com/sijms/go-ora/v2 v2.8.22
github.com/yuin/goldmark v1.8.6
golang.org/x/crypto v0.48.0
modernc.org/sqlite v1.34.5
)
@@ -49,7 +50,6 @@ require (
github.com/stretchr/testify v1.11.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/yuin/goldmark v1.8.6 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.51.0 // indirect
+89
View File
@@ -0,0 +1,89 @@
package ai
import (
"encoding/json"
"os"
"path/filepath"
"strings"
)
// Config AI 配置;provider: stub|openai(OpenAI 兼容协议)。
type Config struct {
Provider string `json:"provider"`
BaseURL string `json:"baseURL"`
APIKey string `json:"apiKey,omitempty"`
Model string `json:"model"`
}
func configPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".opsxdb", "ai.json"), nil
}
// LoadConfig 读取配置:~/.opsxdb/ai.json 优先,env(OPSXDB_AI_*)兜底逐字段补充。
func LoadConfig() Config {
cfg := Config{
Provider: strings.TrimSpace(os.Getenv("OPSXDB_AI_PROVIDER")),
BaseURL: strings.TrimSpace(os.Getenv("OPSXDB_AI_BASE_URL")),
APIKey: strings.TrimSpace(os.Getenv("OPSXDB_AI_API_KEY")),
Model: strings.TrimSpace(os.Getenv("OPSXDB_AI_MODEL")),
}
if p, err := configPath(); err == nil {
if b, err := os.ReadFile(p); err == nil {
var f Config
if json.Unmarshal(b, &f) == nil {
if f.Provider != "" {
cfg.Provider = f.Provider
}
if f.BaseURL != "" {
cfg.BaseURL = f.BaseURL
}
if f.APIKey != "" {
cfg.APIKey = f.APIKey
}
if f.Model != "" {
cfg.Model = f.Model
}
}
}
}
if cfg.Provider == "" {
if cfg.APIKey != "" {
cfg.Provider = "openai"
} else {
cfg.Provider = "stub"
}
}
return cfg
}
// SaveConfig 写 ~/.opsxdb/ai.json(0600,与本机 token 同威胁模型)。
func SaveConfig(c Config) error {
p, err := configPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil {
return err
}
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(p, b, 0600)
}
// MaskedKey 返回脱敏后的 key 展示形式(sk-***)。
func (c Config) MaskedKey() string {
k := c.APIKey
if k == "" {
return ""
}
if len(k) <= 6 {
return "***"
}
return k[:3] + "***"
}
+117
View File
@@ -0,0 +1,117 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
)
const (
defaultOpenAIBaseURL = "https://api.openai.com/v1"
defaultOpenAIModel = "gpt-4o-mini"
)
// OpenAIProvider OpenAI 兼容协议实现(DeepSeek/通义/本地 vLLM 同协议可用)。
type OpenAIProvider struct {
baseURL string
apiKey string
model string
hc *http.Client
}
func NewOpenAIProvider(cfg Config) *OpenAIProvider {
base := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
if base == "" {
base = defaultOpenAIBaseURL
}
model := strings.TrimSpace(cfg.Model)
if model == "" {
model = defaultOpenAIModel
}
return &OpenAIProvider{
baseURL: base,
apiKey: cfg.APIKey,
model: model,
hc: &http.Client{Timeout: 60 * time.Second},
}
}
func (p *OpenAIProvider) Name() string { return "openai" }
type chatCompletionReq struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
}
type chatCompletionRes struct {
Choices []struct {
Message Message `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// Complete 单次非流式调用;ctx 全透传,HTTP 层 60s 超时兜底。
func (p *OpenAIProvider) Complete(ctx context.Context, req ChatReq) (ChatRes, error) {
model := p.model
if strings.TrimSpace(req.Model) != "" {
model = strings.TrimSpace(req.Model)
}
payload := chatCompletionReq{
Model: model,
Messages: BuildMessages(req),
}
body, err := json.Marshal(payload)
if err != nil {
return ChatRes{}, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return ChatRes{}, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
resp, err := p.hc.Do(httpReq)
if err != nil {
return ChatRes{}, fmt.Errorf("AI 请求失败: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return ChatRes{}, fmt.Errorf("读取 AI 响应失败: %w", err)
}
var out chatCompletionRes
if err := json.Unmarshal(raw, &out); err != nil {
return ChatRes{}, fmt.Errorf("解析 AI 响应失败(HTTP %d)", resp.StatusCode)
}
if out.Error != nil {
return ChatRes{}, fmt.Errorf("AI 服务返回错误: %s", out.Error.Message)
}
if resp.StatusCode != http.StatusOK {
return ChatRes{}, fmt.Errorf("AI 服务 HTTP %d", resp.StatusCode)
}
if len(out.Choices) == 0 {
return ChatRes{}, fmt.Errorf("AI 服务未返回候选结果")
}
content := strings.TrimSpace(out.Choices[0].Message.Content)
return ChatRes{Content: content, SQL: extractSQL(content)}, nil
}
var sqlFence = regexp.MustCompile("(?s)```sql\\s*\n(.+?)```")
// extractSQL 从模型回复中提取 ```sql 代码块内容。
func extractSQL(content string) string {
m := sqlFence.FindStringSubmatch(content)
if len(m) < 2 {
return ""
}
return strings.TrimSpace(m[1])
}
+72
View File
@@ -0,0 +1,72 @@
package ai
import (
"fmt"
"strings"
)
// schemaContextCap 注入给模型的库表结构上下文长度上限。
const schemaContextCap = 4000
// BuildMessages 按 action 选 system 模板装配对话;req.Schema 非空时注入库表列上下文。
func BuildMessages(req ChatReq) []Message {
driver := req.Driver
if driver == "" {
driver = "mysql"
}
sys := "你是资深数据库工程师助手,服务于数据库客户端 OpsxDB。回答使用中文,SQL 代码放在 ```sql 代码块中。"
var task string
switch strings.ToLower(req.Action) {
case "generate", "ask":
task = fmt.Sprintf("根据用户描述生成一条 %s 方言的 SQL。只输出可执行 SQL 与极简说明。", driver)
case "explain":
task = "逐部分解释用户给出的 SQL:访问路径、索引使用、潜在代价。"
case "optimize":
task = "审查用户给出的 SQL 并给出优化版本:索引建议、重写建议、说明理由。"
case "fix":
task = "根据报错信息修复用户给出的 SQL,指出错误原因并给出修正后的完整 SQL。"
case "dialect":
task = fmt.Sprintf("把用户给出的 SQL 转换为 %s 方言,保持语义一致。", driver)
case "sample":
task = "为当前库生成一条有代表性的示例查询 SQL。"
default:
task = "回答用户关于数据库 / SQL 的问题。"
}
var sb strings.Builder
sb.WriteString(sys)
sb.WriteString("\n")
sb.WriteString(task)
if req.Database != "" {
sb.WriteString(fmt.Sprintf("\n当前数据库: %s(%s)", req.Database, driver))
}
if s := strings.TrimSpace(req.Schema); s != "" {
if len(s) > schemaContextCap {
s = s[:schemaContextCap]
}
sb.WriteString("\n当前数据库结构:\n")
sb.WriteString(s)
}
var user strings.Builder
if p := strings.TrimSpace(req.Prompt); p != "" {
user.WriteString(p)
}
if s := strings.TrimSpace(req.SQL); s != "" {
if user.Len() > 0 {
user.WriteString("\n\n")
}
user.WriteString("SQL:\n")
user.WriteString(s)
}
if e := strings.TrimSpace(req.Error); e != "" {
user.WriteString("\n\n报错:\n")
user.WriteString(e)
}
if user.Len() == 0 {
user.WriteString("你好")
}
return []Message{
{Role: "system", Content: sb.String()},
{Role: "user", Content: user.String()},
}
}
+45
View File
@@ -0,0 +1,45 @@
package ai
import "context"
// Message 对话消息(OpenAI 兼容协议)。
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
// ChatReq 在 stub 时代字段上演进,前端契约不变;新增 schema/model 可选字段。
type ChatReq struct {
Action string `json:"action"`
Prompt string `json:"prompt"`
SQL string `json:"sql"`
Driver string `json:"driver"`
Database string `json:"database"`
Error string `json:"error"`
Schema string `json:"schema,omitempty"` // 前端注入的库表列上下文
Model string `json:"model,omitempty"`
}
type ChatRes struct {
Content string `json:"content"`
SQL string `json:"sql,omitempty"`
}
// Provider AI 提供方抽象。本轮只做非流式 Complete;
// Stream(ctx, req) (<-chan string, error) 预留,SSE 流式留待后续版本。
type Provider interface {
Name() string
Complete(ctx context.Context, req ChatReq) (ChatRes, error)
}
// Default 按当前配置选择 Provider;未配置 API Key 时回落 stub(离线占位)。
func Default() Provider {
cfg := LoadConfig()
switch cfg.Provider {
case "openai":
if cfg.APIKey != "" {
return NewOpenAIProvider(cfg)
}
}
return stubProvider{}
}
+9 -13
View File
@@ -1,25 +1,21 @@
package ai
import (
"context"
"fmt"
"strings"
)
type ChatReq struct {
Action string `json:"action"`
Prompt string `json:"prompt"`
SQL string `json:"sql"`
Driver string `json:"driver"`
Database string `json:"database"`
Error string `json:"error"`
// stubProvider 离线占位实现:未配置真实模型时的兜底,内容为字符串模板。
type stubProvider struct{}
func (stubProvider) Name() string { return "stub" }
func (stubProvider) Complete(_ context.Context, req ChatReq) (ChatRes, error) {
return chatStub(req), nil
}
type ChatRes struct {
Content string `json:"content"`
SQL string `json:"sql,omitempty"`
}
func Chat(req ChatReq) ChatRes {
func chatStub(req ChatReq) ChatRes {
driver := req.Driver
if driver == "" {
driver = "mysql"
File diff suppressed because it is too large. Load diff
+379
View File
@@ -0,0 +1,379 @@
package db
// agent_e2e_test.go —— JDBC agent 桥端到端验证(真实 JVM + 真实 JDBC 驱动)。
// 前置:本机 JDK、agent jar(OPSXDB_AGENT_JAR 或默认 ~/.opsxdb/agents/jdbc/)。
// 触发:OPSXDB_E2E_JDBC=1 才运行(CI 无 JVM 时自动跳过)。
// 覆盖:StartAgent 全链路(gson 校验/JVM 启动/握手)→ open → DDL/DML → query → 元数据。
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestE2EAgentSQLiteBridge(t *testing.T) {
if os.Getenv("OPSXDB_E2E_JDBC") != "1" {
t.Skip("置 OPSXDB_E2E_JDBC=1 并备好 JDK 与 agent jar 后运行")
}
home, _ := os.UserHomeDir()
jars := filepath.Join(home, ".opsxdb", "agents", "jdbc", "drivers", "sqlite-e2e")
if _, err := os.Stat(filepath.Join(jars, "sqlite-jdbc-3.46.1.3.jar")); err != nil {
t.Skipf("缺 sqlite-jdbc jar: %s", jars)
}
t.Setenv("OPSXDB_E2E_JARS", jars)
spec := AgentSpec{
DriverKey: "e2e-sqlite",
DriverClass: "org.sqlite.JDBC",
URLTemplate: "jdbc:sqlite:{{.Database}}",
JarsDirEnv: "OPSXDB_E2E_JARS",
}
agentDriverSpecs[spec.DriverKey] = spec // 注册进表,AgentConfigured 才能按 JarsDirEnv 解析
t.Cleanup(func() { delete(agentDriverSpecs, spec.DriverKey) })
cli, err := StartAgent(spec)
if err != nil {
t.Fatalf("StartAgent: %v", err)
}
t.Cleanup(func() { cli.Shutdown(context.Background()) })
dbPath := filepath.Join(t.TempDir(), "e2e.db")
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
handle := ""
{
var res struct {
Handle string `json:"handle"`
}
if err := cli.call(ctx, "open", map[string]any{"url": "jdbc:sqlite:" + dbPath, "user": "", "password": ""}, &res); err != nil {
t.Fatalf("open: %v (stderr=%s)", err, cli.stderrTail.String())
}
handle = res.Handle
}
mustExec := func(sql string) {
t.Helper()
var r struct {
AffectedRows int64 `json:"affectedRows"`
}
if err := cli.call(ctx, "exec", map[string]any{"handle": handle, "sql": sql, "timeoutSec": 30}, &r); err != nil {
t.Fatalf("exec %q: %v (stderr=%s)", sql, err, cli.stderrTail.String())
}
}
mustExec("CREATE TABLE demo(id INTEGER PRIMARY KEY, name TEXT)")
mustExec("INSERT INTO demo(name) VALUES ('opsx-e2e')")
{
var res struct {
Columns []string `json:"columns"`
Rows []map[string]interface{} `json:"rows"`
}
if err := cli.call(ctx, "query", map[string]any{"handle": handle, "sql": "SELECT id, name FROM demo", "maxRows": 100, "timeoutSec": 30}, &res); err != nil {
t.Fatalf("query: %v", err)
}
if len(res.Rows) != 1 || res.Rows[0]["name"] != "opsx-e2e" {
t.Fatalf("rows = %+v", res.Rows)
}
}
{
var res struct {
Names []string `json:"names"`
}
if err := cli.call(ctx, "metaTables", map[string]any{"handle": handle, "schema": ""}, &res); err != nil {
t.Fatalf("metaTables: %v", err)
}
found := false
for _, n := range res.Names {
if n == "demo" {
found = true
}
}
if !found {
t.Fatalf("metaTables 未包含 demo: %v", res.Names)
}
}
{
var res struct {
Columns []ColumnInfo `json:"columns"`
}
if err := cli.call(ctx, "metaColumns", map[string]any{"handle": handle, "schema": "", "table": "demo"}, &res); err != nil {
t.Fatalf("metaColumns: %v", err)
}
if len(res.Columns) != 2 {
t.Fatalf("columns = %+v", res.Columns)
}
}
}
// TestE2EElasticsearch —— 真实 ES 8(docker,安全关闭)+ 官方 x-pack-sql-jdbc 走 agent 桥全链路。
// 触发:OPSXDB_E2E_ES=1;前置 ES :9200 可连、~/.opsxdb/agents/jdbc/drivers/elasticsearch/ 有官方 JDBC jar。
func TestE2EElasticsearch(t *testing.T) {
if os.Getenv("OPSXDB_E2E_ES") != "1" {
t.Skip("置 OPSXDB_E2E_ES=1 并启动 ES :9200 后运行")
}
// 种子文档(让 SHOW TABLES / SELECT 有真实对象)
req, _ := http.NewRequest(http.MethodPut, "http://127.0.0.1:9200/e2e_smoke/_doc/1?refresh=true", strings.NewReader(`{"name":"opsx","n":42}`))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Skipf("ES :9200 不可达: %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode >= 400 {
t.Fatalf("种子文档写入失败: HTTP %d", resp.StatusCode)
}
c := ConnReq{Driver: "elasticsearch", Host: "127.0.0.1", Port: 9200}
if err := TestConnection(c); err != nil {
t.Fatalf("TestConnection: %v", err)
}
dbs, err := ListDatabases(context.Background(), c)
if err != nil || len(dbs) != 1 || dbs[0] != "default" {
t.Fatalf("ListDatabases = %v, %v", dbs, err)
}
cc := c
cc.Database = "default"
tables, err := ListTables(context.Background(), cc)
if err != nil {
t.Fatalf("ListTables: %v", err)
}
found := false
for _, n := range tables {
if n == "e2e_smoke" {
found = true
}
}
if !found {
t.Fatalf("tables 未含 e2e_smoke: %v", tables)
}
r := ExecSQL(context.Background(), ConnReq{Driver: "elasticsearch", Host: "127.0.0.1", Port: 9200, Database: "default", SQL: "SELECT name, n FROM e2e_smoke", Limit: 100})
if r.Error != "" {
t.Fatalf("ExecSQL: %s (stderr 见测试日志)", r.Error)
}
if r.RowCount < 1 {
t.Fatalf("rows = %+v", r.Rows)
}
}
// TestE2EZooKeeper —— 真实 ZK(docker)+ OpsxZkAgent 全链路:连接/浏览/读写/删除。
// 触发:OPSXDB_E2E_ZK=1;前置 ZK :2181、~/.opsxdb/agents/jdbc/drivers/zookeeper/ 有官方 jar。
func TestE2EZooKeeper(t *testing.T) {
if os.Getenv("OPSXDB_E2E_ZK") != "1" {
t.Skip("置 OPSXDB_E2E_ZK=1 并启动 ZK :2181 后运行")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
c := ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181}
if err := TestConnection(c); err != nil {
t.Skipf("ZK :2181 不可达: %v", err)
}
dbs, err := ListDatabases(ctx, c)
if err != nil || len(dbs) == 0 {
t.Fatalf("ListDatabases = %v, %v", dbs, err)
}
hasZk := false
for _, n := range dbs {
if n == "zookeeper" {
hasZk = true
}
}
if !hasZk {
t.Fatalf("根下应含 zookeeper: %v", dbs)
}
cc := c
cc.Database = "zookeeper"
tables, err := ListTables(ctx, cc)
if err != nil {
t.Fatalf("ListTables: %v", err)
}
hasConfig := false
for _, n := range tables {
if n == "config" {
hasConfig = true
}
}
if !hasConfig {
t.Fatalf("/zookeeper 下应含 config: %v", tables)
}
// 读:GET
r := ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, Database: "zookeeper", SQL: "GET /zookeeper/config", Limit: 100})
if r.Error != "" {
t.Fatalf("GET: %s", r.Error)
}
if r.RowCount != 1 || r.Rows[0]["path"] != "/zookeeper/config" {
t.Fatalf("GET rows = %+v", r.Rows)
}
// 写:CREATE → GET → SET → DELETE(DELETE 无 WHERE 属危险语句,走确认通道)
w := func(sql string) {
t.Helper()
res := ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, SQL: sql, ConfirmDanger: true})
if res.Error != "" {
t.Fatalf("%s → %s", sql, res.Error)
}
}
w("CREATE /opsxe2e hello-opsx")
r = ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, SQL: "GET /opsxe2e", Limit: 100})
if r.Rows[0]["data"] != "hello-opsx" {
t.Fatalf("CREATE 后 data = %v", r.Rows[0]["data"])
}
w("SET /opsxe2e updated")
r = ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, SQL: "GET /opsxe2e", Limit: 100})
if r.Rows[0]["data"] != "updated" {
t.Fatalf("SET 后 data = %v", r.Rows[0]["data"])
}
// 危险语句未确认时必须先返回 NeedConfirm
guard := ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, SQL: "DELETE /opsxe2e"})
if !guard.NeedConfirm {
t.Fatalf("DELETE 未确认应返回 NeedConfirm,got %+v", guard)
}
w("DELETE /opsxe2e")
}
// TestE2EInfluxDB —— 真实 InfluxDB 2.7(docker)原生 HTTP 全链路:bucket/measurement/Flux 查询。
// 触发:OPSXDB_E2E_INFLUX=1;前置 InfluxDB :8086(org opsx / token opsxdb-demo-token / bucket demo)。
func TestE2EInfluxDB(t *testing.T) {
if os.Getenv("OPSXDB_E2E_INFLUX") != "1" {
t.Skip("置 OPSXDB_E2E_INFLUX=1 并启动 InfluxDB :8086 后运行")
}
// 种子点(行协议写入)
wreq, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:8086/api/v2/write?org=opsx&bucket=demo&precision=s",
strings.NewReader("e2e_m,site=local v=42"))
wreq.Header.Set("Authorization", "Token opsxdb-demo-token")
wreq.Header.Set("Content-Type", "text/plain; charset=utf-8")
wresp, err := http.DefaultClient.Do(wreq)
if err != nil {
t.Skipf("InfluxDB :8086 不可达: %v", err)
}
_, _ = io.Copy(io.Discard, wresp.Body)
wresp.Body.Close()
if wresp.StatusCode >= 400 {
t.Fatalf("种子写入失败: HTTP %d", wresp.StatusCode)
}
time.Sleep(500 * time.Millisecond) // 存储引擎落盘
c := ConnReq{Driver: "influxdb", Host: "127.0.0.1", Port: 8086, User: "opsx", Password: "opsxdb-demo-token"}
if err := TestConnection(c); err != nil {
t.Fatalf("TestConnection: %v", err)
}
dbs, err := ListDatabases(context.Background(), c)
if err != nil {
t.Fatalf("ListDatabases: %v", err)
}
hasDemo := false
for _, n := range dbs {
if n == "demo" {
hasDemo = true
}
}
if !hasDemo {
t.Fatalf("buckets 未含 demo: %v", dbs)
}
cc := c
cc.Database = "demo"
tables, err := ListTables(context.Background(), cc)
if err != nil {
t.Fatalf("ListTables: %v", err)
}
hasM := false
for _, n := range tables {
if n == "e2e_m" {
hasM = true
}
}
if !hasM {
t.Fatalf("measurements 未含 e2e_m: %v", tables)
}
r := ExecSQL(context.Background(), ConnReq{
Driver: "influxdb", Host: "127.0.0.1", Port: 8086, User: "opsx", Password: "opsxdb-demo-token",
SQL: `from(bucket: "demo") |> range(start: -1h) |> filter(fn: (r) => r._measurement == "e2e_m")`,
Limit: 100,
})
if r.Error != "" {
t.Fatalf("Flux 查询: %s", r.Error)
}
if r.RowCount < 1 {
t.Fatalf("rows = %+v", r.Rows)
}
}
// TestE2EMongoDB —— 真实 MongoDB 7 + OpsxMongoAgent 全链路:库/集合浏览、DSL 查改增删、危险确认。
// 触发:OPSXDB_E2E_MONGO=1;前置 MongoDB :27017、~/.opsxdb/agents/jdbc/drivers/mongodb/ 有官方 jar。
func TestE2EMongoDB(t *testing.T) {
if os.Getenv("OPSXDB_E2E_MONGO") != "1" {
t.Skip("置 OPSXDB_E2E_MONGO=1 并启动 MongoDB :27017 后运行")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
c := ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017}
if err := TestConnection(c); err != nil {
t.Skipf("MongoDB :27017 不可达: %v", err)
}
// 种子:插入文档(确认通道)
seed := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.insertOne({"name":"opsx","n":7})`, ConfirmDanger: false})
if seed.Error != "" {
t.Fatalf("insertOne: %s", seed.Error)
}
dbs, err := ListDatabases(ctx, c)
if err != nil || len(dbs) == 0 {
t.Fatalf("ListDatabases = %v, %v", dbs, err)
}
hasDb := false
for _, n := range dbs {
if n == "opsxe2e" {
hasDb = true
}
}
if !hasDb {
t.Fatalf("库列表未含 opsxe2e: %v", dbs)
}
cc := c
cc.Database = "opsxe2e"
tables, err := ListTables(ctx, cc)
if err != nil || len(tables) == 0 {
t.Fatalf("ListTables = %v, %v", tables, err)
}
found := false
for _, n := range tables {
if n == "c1" {
found = true
}
}
if !found {
t.Fatalf("集合列表未含 c1: %v", tables)
}
cols, err := ListColumns(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", Table: "c1"})
if err != nil || len(cols) == 0 {
t.Fatalf("ListColumns = %v, %v", cols, err)
}
// 查询
q := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.find({"name":"opsx"})`, Limit: 100})
if q.Error != "" || q.RowCount < 1 {
t.Fatalf("find: err=%s rows=%+v", q.Error, q.Rows)
}
// 危险确认:deleteMany 未确认必须先返回 NeedConfirm
guard := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.deleteMany({"name":"opsx"})`})
if !guard.NeedConfirm {
t.Fatalf("deleteMany 未确认应 NeedConfirm,got %+v", guard)
}
// 更新 + 删除(确认通道)
u := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.updateOne({"name":"opsx"}, {"$set":{"n":8}})`, ConfirmDanger: true})
if u.Error != "" {
t.Fatalf("updateOne: %s", u.Error)
}
d := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.deleteMany({"name":"opsx"})`, ConfirmDanger: true})
if d.Error != "" {
t.Fatalf("deleteMany: %s", d.Error)
}
cnt := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.count({"name":"opsx"})`})
if fmt.Sprintf("%v", cnt.Rows[0]["n"]) != "0" {
t.Fatalf("count after delete = %v", cnt.Rows[0]["n"])
}
}
+394
View File
@@ -0,0 +1,394 @@
package db
// agent_test.go —— JDBC agent 桥的协议层测试。
// 用 helper process 扮演 JVM agent(TestMain 拦截 -agent-stub),
// 无需本机安装 Java 即可验证:握手、请求/响应路由、超时、进程退出、并发串行写。
import (
"archive/zip"
"bufio"
"context"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// TestMain 拦截 stub 模式:测试二进制被当成 agent 子进程启动时直接进入协议循环。
func TestMain(m *testing.M) {
for _, a := range os.Args {
if a == "-agent-stub" {
runAgentStub()
return
}
}
os.Exit(m.Run())
}
// runAgentStub 扮演 OpsxJdbcAgent 的最小协议面(与 agents/jdbc/OpsxJdbcAgent.java 对应)。
func runAgentStub() {
out := bufio.NewWriter(os.Stdout)
announce := agentResponse{ID: 0, OK: true}
announce.Result = json.RawMessage(`{"agent":"opsx-agent-jdbc","version":"0.1.0","protocol":1}`)
write := func(r agentResponse) {
b, _ := json.Marshal(r)
_, _ = out.WriteString(string(b) + "\n")
out.Flush()
}
write(announce)
in := bufio.NewScanner(os.Stdin)
in.Buffer(make([]byte, 1024*1024), 1024*1024)
for in.Scan() {
var req agentRequest
if json.Unmarshal(in.Bytes(), &req) != nil {
continue
}
args := req.Args
fail := func(msg string) agentResponse { return agentResponse{ID: req.ID, OK: false, Error: msg} }
switch req.Op {
case "hello":
r := agentResponse{ID: req.ID, OK: true}
r.Result = json.RawMessage(`{"agent":"opsx-agent-jdbc","version":"0.1.0","protocol":1}`)
write(r)
case "open":
if args["url"] == "" || args["url"] == nil {
write(fail("missing url"))
break
}
r := agentResponse{ID: req.ID, OK: true}
r.Result = json.RawMessage(`{"handle":"h1"}`)
write(r)
case "ping":
write(agentResponse{ID: req.ID, OK: true})
case "query":
if strings.Contains(fmt.Sprintf("%v", args["sql"]), "SLOW") {
time.Sleep(2 * time.Second)
}
r := agentResponse{ID: req.ID, OK: true}
r.Result = json.RawMessage(`{"columns":["id","name"],"rows":[{"id":"1","name":"demo"}],"truncated":false}`)
write(r)
case "exec":
r := agentResponse{ID: req.ID, OK: true}
r.Result = json.RawMessage(`{"affectedRows":3}`)
write(r)
case "metaDatabases":
r := agentResponse{ID: req.ID, OK: true}
r.Result = json.RawMessage(`{"names":["MAIN","SYS"]}`)
write(r)
case "close":
write(agentResponse{ID: req.ID, OK: true})
case "shutdown":
write(agentResponse{ID: req.ID, OK: true})
os.Exit(0)
default:
write(fail("unknown op: " + req.Op))
}
}
os.Exit(0)
}
// TestAgentStubHelper 占位:stub 子进程以 -test.run=TestAgentStubHelper 启动时
// 不会走到这里(TestMain 已拦截),但名字必须存在以匹配 -test.run。
func TestAgentStubHelper(t *testing.T) {}
func startStubAgent(t *testing.T) *AgentClient {
t.Helper()
cmd := exec.Command(os.Args[0], "-test.run=TestAgentStubHelper", "--", "-agent-stub")
cli, err := StartAgentFromCmd(cmd, agentDriverSpecs["dameng"])
if err != nil {
t.Fatalf("StartAgentFromCmd: %v", err)
}
t.Cleanup(func() { cli.Shutdown(context.Background()) })
return cli
}
func TestAgentClientRoundTrip(t *testing.T) {
cli := startStubAgent(t)
ctx := context.Background()
// open → query → exec → metaDatabases → ping
handle := ""
{
var res struct {
Handle string `json:"handle"`
}
if err := cli.call(ctx, "open", map[string]any{"url": "jdbc:dm://127.0.0.1:5236", "user": "SYSDBA"}, &res); err != nil {
t.Fatalf("open: %v", err)
}
if res.Handle != "h1" {
t.Fatalf("handle = %q, want h1", res.Handle)
}
handle = res.Handle
}
{
var res struct {
Columns []string `json:"columns"`
Rows []map[string]interface{} `json:"rows"`
}
if err := cli.call(ctx, "query", map[string]any{"handle": handle, "sql": "SELECT 1", "maxRows": 100}, &res); err != nil {
t.Fatalf("query: %v", err)
}
if len(res.Columns) != 2 || res.Rows[0]["name"] != "demo" {
t.Fatalf("query result mismatch: %+v", res)
}
}
{
var res struct {
AffectedRows int64 `json:"affectedRows"`
}
if err := cli.call(ctx, "exec", map[string]any{"handle": handle, "sql": "UPDATE t SET a=1"}, &res); err != nil {
t.Fatalf("exec: %v", err)
}
if res.AffectedRows != 3 {
t.Fatalf("affectedRows = %d, want 3", res.AffectedRows)
}
}
{
var res struct {
Names []string `json:"names"`
}
if err := cli.call(ctx, "metaDatabases", map[string]any{"handle": handle}, &res); err != nil {
t.Fatalf("metaDatabases: %v", err)
}
if len(res.Names) != 2 || res.Names[0] != "MAIN" {
t.Fatalf("names = %v", res.Names)
}
}
if err := cli.call(ctx, "ping", map[string]any{"handle": handle}, nil); err != nil {
t.Fatalf("ping: %v", err)
}
}
func TestAgentCallTimeout(t *testing.T) {
cli := startStubAgent(t)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
var res map[string]any
err := cli.call(ctx, "query", map[string]any{"handle": "h1", "sql": "SELECT SLOW", "maxRows": 10}, &res)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("err = %v, want DeadlineExceeded", err)
}
}
func TestAgentProcessExit(t *testing.T) {
cli := startStubAgent(t)
cli.Kill() // 杀进程后再调用,必须报进程退出而非挂死
var res map[string]any
err := cli.call(context.Background(), "ping", map[string]any{"handle": "h1"}, &res)
if err == nil {
t.Fatal("期望进程退出错误,got nil")
}
}
func TestAgentConcurrentCalls(t *testing.T) {
cli := startStubAgent(t)
ctx := context.Background()
if err := cli.call(ctx, "open", map[string]any{"url": "jdbc:dm://h:5236"}, nil); err != nil {
t.Fatalf("open: %v", err)
}
const n = 24
var wg sync.WaitGroup
errs := make([]error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
var res struct {
Columns []string `json:"columns"`
}
errs[i] = cli.call(ctx, "query", map[string]any{"handle": "h1", "sql": "SELECT 1", "maxRows": 1}, &res)
}(i)
}
wg.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("call %d: %v", i, err)
}
}
}
func TestRenderAgentURL(t *testing.T) {
spec := agentDriverSpecs["dameng"]
url, err := renderAgentURL(spec, ConnReq{Host: "127.0.0.1", Port: 5236})
if err != nil {
t.Fatalf("render: %v", err)
}
if url != "jdbc:dm://127.0.0.1:5236" {
t.Fatalf("url = %q", url)
}
if _, err := renderAgentURL(spec, ConnReq{Host: "", Port: 5236}); err == nil {
t.Fatal("空主机必须报错")
}
if _, err := renderAgentURL(spec, ConnReq{Host: "127.0.0.1", Port: 0}); err == nil {
t.Fatal("零端口必须报错")
}
}
func TestDownloadPinnedChecksum(t *testing.T) {
payload := []byte("fake-gson-bytes")
good := sha1.Sum(payload)
cases := []struct {
name string
body []byte
want string
wantErr bool
}{
{"校验通过", payload, hex.EncodeToString(good[:]), false},
{"校验失败拒绝落盘", []byte("tampered"), hex.EncodeToString(good[:]), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(tc.body)
}))
defer srv.Close()
dest := t.TempDir() + "/gson.jar"
err := downloadPinned(srv.URL, dest, tc.want)
if tc.wantErr && err == nil {
t.Fatal("期望校验失败")
}
if !tc.wantErr {
if err != nil {
t.Fatalf("download: %v", err)
}
if _, statErr := os.Stat(dest); statErr != nil {
t.Fatalf("文件未落盘: %v", statErr)
}
} else {
if _, statErr := os.Stat(dest); statErr == nil {
t.Fatal("校验失败时文件不应存在")
}
}
})
}
}
func TestAgentConfigured(t *testing.T) {
dir := t.TempDir()
t.Setenv("OPSXDB_DAMENG_JARS", dir)
if ok, _ := AgentConfigured("dameng"); ok {
t.Fatal("空目录不应判为已配置")
}
if err := os.WriteFile(dir+"/DmJdbcDriver-8.1.3.140.jar", []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if ok, _ := AgentConfigured("dameng"); !ok {
t.Fatal("放置 jar 后应判为已配置")
}
if IsAgentDriver("mysql") {
t.Fatal("mysql 不应是 agent 驱动")
}
if !IsAgentDriver("dameng") {
t.Fatal("dameng 应是 agent 驱动")
}
}
// TestEnsureAgentRuntimeOfflinePack —— 网络不可用(镜像指向无效 + sha256 不匹配)时,
// ensureAgentRuntime 必须从离线驱动包导入 jar(pack.sh 产物)。
func TestEnsureAgentRuntimeOfflinePack(t *testing.T) {
home := t.TempDir()
t.Setenv("OPSXDB_HOME", home)
t.Setenv("OPSXDB_AGENT_JAR", filepath.Join(home, "agent", "opsx-agent-jdbc.jar"))
// 网络必失败:镜像指向本地空端口 + 错误的 sha256
t.Setenv("MAVEN_MIRROR", "http://127.0.0.1:1/none")
badSHA := sha1.Sum([]byte("bad"))
spec := AgentSpec{
DriverKey: "e2e-offline",
DriverClass: "x",
URLTemplate: "jdbc:x://{{.Host}}:{{.Port}}",
JarsDirEnv: "OPSXDB_OFFLINE_JARS",
}
agentDriverSpecs[spec.DriverKey] = spec
t.Cleanup(func() { delete(agentDriverSpecs, spec.DriverKey) })
agentDriverArtifacts[spec.DriverKey] = []agentArtifact{
{"demo.jar", "com.nope:demo:1.0", hex.EncodeToString(badSHA[:])},
}
// 构造离线包:manifest + 正确的 jar 内容(sha256 以包内 manifest 为准)
jarBytes := []byte("fake-jar-content")
files := map[string]string{"drivers/e2e-offline/demo.jar": string(jarBytes)}
manifestFiles := []map[string]string{{"path": "drivers/e2e-offline/demo.jar", "sha256": fmt.Sprintf("%x", sha256.Sum256(jarBytes))}}
zipPath := filepath.Join(home, "offline.zip")
if err := writeTestZip(zipPath, manifestFiles, files); err != nil {
t.Fatal(err)
}
t.Setenv("OPSXDB_OFFLINE_ZIP", zipPath)
// agent jar 也在包里(确保 ensureAgentJar 的离线路径不拦路)
if err := importFromOfflinePack(map[string]string{"pack/agent/opsx-agent-jdbc.jar": filepath.Join(home, "agent", "opsx-agent-jdbc.jar")}); err != nil {
// agent jar 不在此包属预期——单独构造一份最小 agent 条目
agentBytes := []byte("fake-agent")
mf2 := []map[string]string{{"path": "agent/opsx-agent-jdbc.jar", "sha256": fmt.Sprintf("%x", sha256.Sum256(agentBytes))}}
f2 := map[string]string{"agent/opsx-agent-jdbc.jar": string(agentBytes)}
if werr := writeTestZip(zipPath, append(manifestFiles, mf2...), mergeMaps(files, f2)); werr != nil {
t.Fatal(werr)
}
}
if err := ensureAgentRuntime(spec); err != nil {
t.Fatalf("ensureAgentRuntime 应走离线包成功: %v", err)
}
got, err := os.ReadFile(filepath.Join(home, "drivers", "e2e-offline", "demo.jar"))
if err != nil || string(got) != string(jarBytes) {
t.Fatalf("离线导入内容不符: %v", err)
}
// 负例:manifest hash 不符必须拒
jarBytes2 := []byte("tampered")
mf3 := []map[string]string{{"path": "drivers/e2e-offline/demo.jar", "sha256": fmt.Sprintf("%x", sha256.Sum256(jarBytes2))}}
if err := writeTestZip(zipPath, mf3, map[string]string{"drivers/e2e-offline/demo.jar": "not-the-same-content"}); err != nil {
t.Fatal(err)
}
os.Remove(filepath.Join(home, "drivers", "e2e-offline", "demo.jar"))
if err := ensureAgentRuntime(spec); err == nil {
t.Fatal("manifest 与实际内容不符时必须失败")
}
}
func writeTestZip(path string, manifestFiles []map[string]string, files map[string]string) error {
zf, err := os.Create(path)
if err != nil {
return err
}
defer zf.Close()
zw := zip.NewWriter(zf)
mf, err := zw.Create("pack/manifest.json")
if err != nil {
return err
}
b, _ := json.Marshal(map[string]any{"bundleVersion": 1, "files": manifestFiles})
if _, err := mf.Write(b); err != nil {
return err
}
for rel, content := range files {
w, err := zw.Create("pack/" + rel)
if err != nil {
return err
}
if _, err := w.Write([]byte(content)); err != nil {
return err
}
}
return zw.Close()
}
func mergeMaps(a, b map[string]string) map[string]string {
out := map[string]string{}
for k, v := range a {
out[k] = v
}
for k, v := range b {
out[k] = v
}
return out
}
+79 -22
View File
@@ -1,30 +1,87 @@
package db
import "strings"
import (
"regexp"
"strings"
)
// IsDangerousSQL 识别可能破坏数据的语句,供前端二次确认。
func IsDangerousSQL(sql string) bool {
s := stripSQLLead(sql)
if s == "" {
return false
}
up := strings.ToUpper(s)
for _, p := range []string{"DROP ", "DROP\t", "TRUNCATE ", "ALTER ", "GRANT ", "REVOKE "} {
if strings.HasPrefix(up, strings.TrimSpace(p)) || strings.HasPrefix(up, p) {
return true
}
}
if strings.HasPrefix(up, "DELETE") {
return !containsWhere(up)
}
if strings.HasPrefix(up, "UPDATE") {
return !containsWhere(up)
}
return false
// 危险语句规则:词边界匹配,作用于 StripComments 之后的纯代码文本。
// 词表:DROP/TRUNCATE/ALTER/GRANT/REVOKE/CREATE USER|ROLE/RENAME TABLE/
// REPLACE INTO/LOAD DATA/INTO OUTFILE|DUMPFILE/xp_cmdshell/COPY..PROGRAM/EXEC/CALL。
var dangerPatterns = []struct {
re *regexp.Regexp
desc string
}{
{regexp.MustCompile(`\bDROP\b`), "DROP"},
{regexp.MustCompile(`\bTRUNCATE\b`), "TRUNCATE"},
{regexp.MustCompile(`\bALTER\b`), "ALTER"},
{regexp.MustCompile(`\bGRANT\b`), "GRANT"},
{regexp.MustCompile(`\bREVOKE\b`), "REVOKE"},
{regexp.MustCompile(`\bCREATE\s+(USER|ROLE)\b`), "CREATE USER/ROLE"},
{regexp.MustCompile(`\bRENAME\s+TABLE\b`), "RENAME TABLE"},
{regexp.MustCompile(`\bREPLACE\s+INTO\b`), "REPLACE INTO"},
{regexp.MustCompile(`\bLOAD\s+DATA\b`), "LOAD DATA"},
{regexp.MustCompile(`\bINTO\s+(OUTFILE|DUMPFILE)\b`), "INTO OUTFILE/DUMPFILE"},
{regexp.MustCompile(`(?i)\bxp_cmdshell\b`), "xp_cmdshell"},
{regexp.MustCompile(`\bCOPY\b[^;]*\b(FROM|TO)\s+PROGRAM\b`), "COPY FROM/TO PROGRAM"},
}
func containsWhere(up string) bool {
return strings.Contains(up, " WHERE ") || strings.Contains(up, "\nWHERE ") || strings.Contains(up, "\tWHERE ")
var (
reWriteNoWhere = regexp.MustCompile(`\b(DELETE|UPDATE)\b`)
reWhere = regexp.MustCompile(`\bWHERE\b`)
reExecCallHead = regexp.MustCompile(`^(EXEC|EXECUTE|CALL)\b`)
)
// CheckDangerousSQL 服务端权威的危险语句判定(前端预检仅为 UX)。
// 逐语句检查;注释/字符串内的关键字不计;MySQL /*! */ 可执行注释内容参与检查;
// WITH 包裹的写操作因全文扫描 CTE 体同样命中。
// 返回命中的危险原因清单(去重)。
func CheckDangerousSQL(driver, sqlText string) []string {
_ = driver // 词表当前各驱动通用,保留参数便于后续按方言细化
seen := map[string]struct{}{}
var hits []string
add := func(desc, stmt string) {
if _, ok := seen[desc]; ok {
return
}
seen[desc] = struct{}{}
hits = append(hits, desc+": "+truncateSQL(stmt, 80))
}
for _, stmt := range SplitStatements(sqlText) {
code := strings.ToUpper(StripComments(stmt))
code = strings.TrimSpace(code)
if code == "" {
continue
}
for _, p := range dangerPatterns {
if p.re.MatchString(code) {
add(p.desc, stmt)
}
}
// DELETE/UPDATE 无 WHERE(含 WITH CTE 体内的写操作)
if reWriteNoWhere.MatchString(code) && !reWhere.MatchString(code) {
kw := reWriteNoWhere.FindString(code)
add(kw+" 无 WHERE", stmt)
}
// 存储过程/动态执行仅匹配语句开头,降低误报
if m := reExecCallHead.FindString(code); m != "" {
add(m, stmt)
}
}
return hits
}
// IsDangerousSQL 兼容旧调用:命中任意危险规则即 true。
func IsDangerousSQL(sql string) bool {
return len(CheckDangerousSQL("", sql)) > 0
}
func truncateSQL(s string, n int) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) <= n {
return s
}
return s[:n] + "…"
}
func stripSQLLead(sql string) string {
+76
View File
@@ -0,0 +1,76 @@
package db
import "testing"
// 危险语句命中/放行矩阵,含四类历史绕过:多语句、/*! */ 可执行注释、
// 注释吞掉 WHERE、WITH CTE 包裹写操作。
func TestCheckDangerousSQLMatrix(t *testing.T) {
dangerous := []string{
"DROP TABLE users",
"drop table users", // 大小写
"TRUNCATE TABLE t", // TRUNCATE
"ALTER TABLE t ADD COLUMN c int", // ALTER(误报方向维持现状)
"GRANT ALL ON *.* TO 'u'", // GRANT
"REVOKE ALL ON *.* FROM 'u'", // REVOKE
"CREATE USER 'u'@'%' IDENTIFIED BY 'p'", // CREATE USER
"CREATE ROLE app", // CREATE ROLE
"RENAME TABLE a TO b", // RENAME TABLE
"REPLACE INTO t VALUES (1)", // REPLACE INTO
"LOAD DATA INFILE '/tmp/x' INTO TABLE t", // LOAD DATA
"SELECT * INTO OUTFILE '/tmp/x' FROM t", // INTO OUTFILE
"SELECT * INTO DUMPFILE '/tmp/x' FROM t", // INTO DUMPFILE
"EXEC xp_cmdshell 'dir'", // xp_cmdshell
"EXEC sp_configure 'x'", // EXEC 开头
"CALL do_thing()", // CALL 开头
"COPY t FROM PROGRAM 'cmd'", // COPY FROM PROGRAM
"DELETE FROM users", // DELETE 无 WHERE
"UPDATE users SET x=1", // UPDATE 无 WHERE
"SELECT 1; DROP TABLE t", // 多语句绕过
"/*!50000 DROP TABLE t*/", // MySQL 可执行注释
"SELECT 1; /*!40101 SET @x = 1*/; DROP TABLE t", // 可执行注释 + 多语句
"UPDATE t SET x=1 -- WHERE id=2", // 注释吞掉 WHERE
"UPDATE t SET x=1 # WHERE id=2", // # 注释吞掉 WHERE
"UPDATE t SET x=1 /* WHERE id=2 */", // 块注释吞掉 WHERE
"WITH x AS (SELECT 1) DELETE FROM t", // CTE 包裹 DELETE 无 WHERE
"WITH x AS (SELECT 1) UPDATE t SET a=1", // CTE 包裹 UPDATE 无 WHERE
}
for _, sql := range dangerous {
if hits := CheckDangerousSQL("mysql", sql); len(hits) == 0 {
t.Errorf("应命中但未命中: %q", sql)
}
}
safe := []string{
"SELECT * FROM users",
"SELECT * FROM users WHERE id = 1",
"DELETE FROM users WHERE id = 1",
"UPDATE users SET x = 1 WHERE id = 2",
"UPDATE users SET x = 1\nWHERE id = 2",
"INSERT INTO t VALUES (1)",
"CREATE TABLE t (id int)",
"SELECT 'DROP TABLE x'", // 字符串内关键字
"SELECT * FROM t WHERE note = 'truncate it'", // 字符串内关键字
"-- DROP TABLE t\nSELECT 1", // 注释内关键字
"SELECT * FROM t /* DROP TABLE x */ WHERE 1=1", // 块注释内关键字
"SELECT * FROM updated WHERE id = 1", // 词边界:updated 不算 UPDATE
"SELECT 1; SELECT 2",
"WITH x AS (SELECT 1) SELECT * FROM x",
"SHOW TABLES",
"EXPLAIN SELECT 1",
}
for _, sql := range safe {
if hits := CheckDangerousSQL("mysql", sql); len(hits) > 0 {
t.Errorf("误报: %q → %v", sql, hits)
}
}
}
// 旧接口兼容:IsDangerousSQL 委托 CheckDangerousSQL。
func TestIsDangerousSQLCompat(t *testing.T) {
if !IsDangerousSQL("DROP TABLE users") {
t.Fatal("drop")
}
if IsDangerousSQL("SELECT * FROM users") {
t.Fatal("select")
}
}
+121
View File
@@ -0,0 +1,121 @@
package db
import (
"context"
"database/sql"
"fmt"
"strings"
)
// GetDDL 返回表/视图的建表语句。mysql 族走 SHOW CREATE TABLE,sqlite 读 sqlite_master,
// pg/mssql/oracle 用 ListColumns 元数据在 Go 侧拼装 CREATE TABLE。
func GetDDL(ctx context.Context, c ConnReq) (string, error) {
ctx = reqCtx(ctx)
if c.Table == "" {
return "", fmt.Errorf("缺少表名")
}
if isRedis(c.Driver) {
return "", fmt.Errorf("该驱动不支持 DDL 查看")
}
// pg/mssql/oracle:复用列元数据拼装,避免再查一次库
switch {
case isPostgresFamily(c.Driver), isMSSQL(c.Driver), driverKey(c.Driver) == "oracle":
cols, err := ListColumns(ctx, c)
if err != nil {
return "", err
}
return buildDDLFromColumns(c.Driver, c.Table, cols)
}
var out string
err := withDB(c, func(db *sql.DB) error {
var err error
switch {
case isMySQLFamily(c.Driver):
out, err = getDDLMySQL(ctx, db, c)
case driverKey(c.Driver) == "sqlite":
out, err = getDDLSQLite(ctx, db, c.Table)
default:
err = unsupportedDriverErr(c)
}
return err
})
if err != nil {
return "", err
}
return out, nil
}
// getDDLMySQL SHOW CREATE TABLE 第二列即 DDL(列名随对象类型变化:Create Table/Create View)。
func getDDLMySQL(ctx context.Context, dbh *sql.DB, c ConnReq) (string, error) {
ident := quoteMySQLIdent(c.Table)
if c.Database != "" {
ident = quoteMySQLIdent(c.Database) + "." + ident
}
rows, err := dbh.QueryContext(ctx, "SHOW CREATE TABLE "+ident)
if err != nil {
return "", err
}
defer rows.Close()
if rows.Next() {
var name string
var ddl sql.NullString
if err := rows.Scan(&name, &ddl); err != nil {
return "", err
}
if !ddl.Valid {
return "", fmt.Errorf("无法读取 %s 的 DDL", c.Table)
}
return ddl.String, nil
}
if err := rows.Err(); err != nil {
return "", err
}
return "", fmt.Errorf("表 %s 不存在", c.Table)
}
// getDDLSQLite sqlite_master 的 sql 列即建表语句。
func getDDLSQLite(ctx context.Context, dbh *sql.DB, table string) (string, error) {
var ddl sql.NullString
err := dbh.QueryRowContext(ctx, `SELECT sql FROM sqlite_master WHERE name = ?`, table).Scan(&ddl)
if err == sql.ErrNoRows {
return "", fmt.Errorf("表 %s 不存在", table)
}
if err != nil {
return "", err
}
if !ddl.Valid {
return "", fmt.Errorf("表 %s 无 DDL 记录", table)
}
return ddl.String, nil
}
// buildDDLFromColumns 用列元数据拼装 CREATE TABLE(类型直接用 ColumnInfo.Type)。
func buildDDLFromColumns(driver, table string, cols []ColumnInfo) (string, error) {
if len(cols) == 0 {
return "", fmt.Errorf("表 %s 不存在或无列", table)
}
var pks []string
for _, col := range cols {
if col.Key == "PRI" {
pks = append(pks, quoteIdentFor(driver, col.Name))
}
}
var b strings.Builder
fmt.Fprintf(&b, "CREATE TABLE %s (\n", quoteTableNameFor(driver, table))
for i, col := range cols {
line := " " + quoteIdentFor(driver, col.Name) + " " + col.Type
if !col.Nullable {
line += " NOT NULL"
}
// 非最后一列、或后面还有 PRIMARY KEY 子句时需要逗号
if i < len(cols)-1 || len(pks) > 0 {
line += ","
}
b.WriteString(line + "\n")
}
if len(pks) > 0 {
b.WriteString(" PRIMARY KEY (" + strings.Join(pks, ", ") + ")\n")
}
b.WriteString(");\n")
return b.String(), nil
}
+94
View File
@@ -0,0 +1,94 @@
package db
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// newSQLiteFile 建一个 sqlite 测试库文件并执行建表/插数语句,返回 ConnReq。
func newSQLiteFile(t *testing.T, stmts ...string) ConnReq {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
if err := os.WriteFile(path, []byte{}, 0600); err != nil {
t.Fatal(err)
}
for _, s := range stmts {
res := ExecSQL(context.Background(), ConnReq{Driver: "sqlite", Database: path, SQL: s})
if res.Error != "" {
t.Fatalf("初始化语句失败 %q: %s", s, res.Error)
}
}
return ConnReq{Driver: "sqlite", Database: path}
}
// R2-B5:sqlite 返回 sqlite_master 中的原始建表语句。
func TestGetDDLSQLite(t *testing.T) {
c := newSQLiteFile(t, `CREATE TABLE t_person (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER)`)
c.Table = "t_person"
ddl, err := GetDDL(context.Background(), c)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(ddl, "CREATE TABLE t_person") || !strings.Contains(ddl, "name TEXT") {
t.Fatalf("DDL 内容不符: %s", ddl)
}
c.Table = "no_such_table"
if _, err := GetDDL(context.Background(), c); err == nil {
t.Fatal("不存在的表应报错")
}
}
func TestGetDDLRedis(t *testing.T) {
_, err := GetDDL(context.Background(), ConnReq{Driver: "redis", Table: "k"})
if err == nil || !strings.Contains(err.Error(), "不支持") {
t.Fatalf("redis 应报不支持: %v", err)
}
}
func TestGetDDLMissingTable(t *testing.T) {
_, err := GetDDL(context.Background(), ConnReq{Driver: "sqlite", Table: ""})
if err == nil {
t.Fatal("空表名应报错")
}
}
// R2-B5:pg/mssql/oracle 族用列元数据拼装,各 family 引号不同。
func TestBuildDDLFromColumns(t *testing.T) {
cols := []ColumnInfo{
{Name: "id", Type: "integer", Nullable: false, Key: "PRI"},
{Name: "na\"me", Type: "text", Nullable: true},
}
// pg:双引号加倍
ddl, err := buildDDLFromColumns("postgres", "public.t", cols)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(ddl, `"public"."t"`) || !strings.Contains(ddl, `"na""me" text`) ||
!strings.Contains(ddl, `"id" integer NOT NULL,`) || !strings.Contains(ddl, `PRIMARY KEY ("id")`) {
t.Fatalf("pg DDL 不符:\n%s", ddl)
}
// mssql:中括号
ddl, err = buildDDLFromColumns("mssql", "dbo.t", cols)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(ddl, `[id] integer NOT NULL`) || !strings.Contains(ddl, `PRIMARY KEY ([id])`) {
t.Fatalf("mssql DDL 不符:\n%s", ddl)
}
// oracle:原名
ddl, err = buildDDLFromColumns("oracle", "T", cols)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(ddl, `CREATE TABLE T (`) || !strings.Contains(ddl, `PRIMARY KEY (id)`) {
t.Fatalf("oracle DDL 不符:\n%s", ddl)
}
// 无列报错
if _, err := buildDDLFromColumns("postgres", "t", nil); err == nil {
t.Fatal("无列应报错")
}
}
+193
View File
@@ -0,0 +1,193 @@
package db
// es.go —— Elasticsearch 原生接入(HTTP + X-Pack SQL REST API)。
//
// 为什么不用官方 JDBC:x-pack-sql-jdbc 在 basic 许可下直接拒绝工作
// ("current license is non-compliant for [jdbc]",实测 8.15.3),而 SQL REST API
// 在 basic 许可可用 —— 走 net/http 零依赖接入,也免除 JVM 负担。
//
// 能力边界(v1,对标 SQL 工作台):
// - 对象树:单库节点 "default" → SHOW TABLES 列出索引
// - 查询:SQL 编辑器里的 ES SQL(SELECT/SHOW/DESC…)直发 /_sql
// - ES SQL 只读:DDL/DML 由 ES 自身拒绝;危险语句拦截仍在本层先过一遍
// - 分页:/_sql 为游标式,前端 offset 翻页暂不支持(首屏 fetch_size 截断)
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
func isES(d string) bool { return driverKey(d) == "elasticsearch" }
func esBase(c ConnReq) string {
host := strings.TrimSpace(c.Host)
if host == "" {
host = "127.0.0.1"
}
port := c.Port
if port == 0 {
port = 9200
}
return fmt.Sprintf("http://%s:%d", host, port)
}
// esDo 发请求并在设置了用户时启用 Basic Auth(ES 开启 security 时用)。
func esDo(ctx context.Context, c ConnReq, method, path string, body, out any) error {
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, esBase(c)+path, rd)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if c.User != "" {
req.SetBasicAuth(c.User, c.Password)
}
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("ES HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
func esPing(c ConnReq) error {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
var info struct {
Tagline string `json:"tagline"`
}
if err := esDo(ctx, c, http.MethodGet, "/", nil, &info); err != nil {
return err
}
if info.Tagline == "" {
return fmt.Errorf("ES 响应异常:缺少 tagline")
}
return nil
}
// esSQLResult /_sql 的 JSON 响应(format=json)。
type esSQLResult struct {
Columns []struct {
Name string `json:"name"`
Type string `json:"type"`
} `json:"columns"`
Rows [][]interface{} `json:"rows"`
}
func esRunSQL(ctx context.Context, c ConnReq, sqlText string, fetchSize int) (ExecResult, error) {
var r esSQLResult
body := map[string]any{"query": sqlText}
if fetchSize > 0 {
body["fetch_size"] = fetchSize
}
if err := esDo(ctx, c, http.MethodPost, "/_sql", body, &r); err != nil {
return ExecResult{}, err
}
cols := make([]string, 0, len(r.Columns))
for _, col := range r.Columns {
cols = append(cols, col.Name)
}
rows := make([]map[string]interface{}, 0, len(r.Rows))
for _, row := range r.Rows {
m := make(map[string]interface{}, len(cols))
for i, v := range row {
if i < len(cols) {
m[cols[i]] = v
}
}
rows = append(rows, m)
}
return ExecResult{
Columns: cols,
Rows: rows,
RowCount: len(rows),
Truncated: fetchSize > 0 && len(rows) >= fetchSize,
Limit: fetchSize,
}, nil
}
func esListDatabases() []string { return []string{"default"} }
func esListTables(ctx context.Context, c ConnReq) ([]string, error) {
res, err := esRunSQL(ctx, c, "SHOW TABLES", 10000)
if err != nil {
return nil, err
}
out := []string{}
if len(res.Columns) == 0 {
return out, nil
}
// SHOW TABLES 列为 [catalog, name, type, kind]:取 "name" 列(ES 8 实测),退化为首列
idx := 0
for i, col := range res.Columns {
if col == "name" {
idx = i
break
}
}
for _, row := range res.Rows {
if v, ok := row[res.Columns[idx]].(string); ok && v != "" {
out = append(out, v)
}
}
return out, nil
}
func esListColumns(ctx context.Context, c ConnReq) ([]ColumnInfo, error) {
table := strings.TrimSpace(c.Table)
if table == "" {
return nil, fmt.Errorf("缺少表名")
}
res, err := esRunSQL(ctx, c, "DESCRIBE \""+strings.ReplaceAll(table, "\"", "")+"\"", 1000)
if err != nil {
return nil, err
}
cols := []ColumnInfo{}
for _, row := range res.Rows {
name, _ := row["column"].(string)
typ, _ := row["type"].(string)
if name == "" {
continue
}
cols = append(cols, ColumnInfo{Name: name, Type: typ, Nullable: true})
}
return cols, nil
}
// esExecSQL 全部按查询处理:ES SQL 只读,写语句由 ES 拒绝;危险语句在 ops.go 入口已拦。
func esExecSQL(ctx context.Context, c ConnReq, start time.Time) ExecResult {
if c.Offset > 0 {
return ExecResult{Columns: []string{}, Rows: []map[string]interface{}{}, Error: "ES 游标分页暂不支持翻页(仅首屏,可缩小范围重查)", TimeMs: time.Since(start).Milliseconds()}
}
limit := rowLimit(c)
if limit > 10000 {
limit = 10000
}
res, err := esRunSQL(ctx, c, strings.TrimSpace(c.SQL), limit)
if err != nil {
return execFail(start, err)
}
res.TimeMs = time.Since(start).Milliseconds()
return res
}
+252
View File
@@ -0,0 +1,252 @@
package db
import (
"bufio"
"bytes"
"context"
"database/sql"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"strings"
"time"
)
const (
DefaultExportLimit = 100000
MaxExportLimit = 500000
exportFlushRows = 512
)
// ExportAllowedQuery 导出专用的只读校验:首个关键词须为 SELECT/WITH/SHOW/DESCRIBE/EXPLAIN。
func ExportAllowedQuery(sqlText string) bool {
s := stripSQLLead(strings.TrimSpace(sqlText))
if s == "" {
return false
}
up := strings.ToUpper(s)
for _, p := range []string{"SELECT", "WITH", "SHOW", "DESCRIBE", "EXPLAIN"} {
if strings.HasPrefix(up, p+" ") || up == p || strings.HasPrefix(up, p+"\t") || strings.HasPrefix(up, p+"\n") {
return true
}
}
return false
}
// SelectAllSQL 表模式导出的取数 SQL:按 family 引用(支持 schema.table 多段)。
func SelectAllSQL(driver, table string) string {
return "SELECT * FROM " + quoteTableNameFor(driver, table)
}
// ExportQuery 把查询结果按 c.Format(csv/json/insert)流式编码写入 w,避免全量载入内存。
// limit <= 0 用默认上限 100000,硬顶 MaxExportLimit。行值经 normalize 保真(同 ExecSQL)。
func ExportQuery(ctx context.Context, c ConnReq, w io.Writer) error {
ctx = reqCtx(ctx)
format := strings.ToLower(strings.TrimSpace(c.Format))
sqlText := strings.TrimSpace(c.SQL)
if sqlText == "" {
return fmt.Errorf("缺少导出 SQL")
}
limit := c.Limit
if limit <= 0 {
limit = DefaultExportLimit
}
if limit > MaxExportLimit {
limit = MaxExportLimit
}
return withDB(c, func(db *sql.DB) error {
rows, err := db.QueryContext(ctx, sqlText)
if err != nil {
return err
}
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
return err
}
bw := bufio.NewWriterSize(w, 64*1024)
enc := newExportEncoder(bw, format, c.Driver, exportTargetTable(c), cols)
if err := enc.begin(); err != nil {
return err
}
n := 0
for rows.Next() {
if err := ctx.Err(); err != nil {
return err
}
raw := make([]interface{}, len(cols))
ptrs := make([]interface{}, len(cols))
for i := range raw {
ptrs[i] = &raw[i]
}
if err := rows.Scan(ptrs...); err != nil {
return err
}
vals := make([]interface{}, len(cols))
for i := range raw {
vals[i] = normalize(raw[i])
}
if err := enc.row(vals); err != nil {
return err
}
n++
if n%exportFlushRows == 0 {
if err := bw.Flush(); err != nil {
return err
}
}
if n >= limit {
break
}
}
if err := rows.Err(); err != nil {
return err
}
if err := enc.end(); err != nil {
return err
}
return bw.Flush()
})
}
// exportTargetTable insert 格式的目标表名:显式 table 优先,否则用通用名。
func exportTargetTable(c ConnReq) string {
if strings.TrimSpace(c.Table) != "" {
return c.Table
}
return "result"
}
// exportEncoder 按格式把行流式编码进 buffered writer。
type exportEncoder struct {
bw *bufio.Writer
format string
driver string
table string
cols []string
csvW *csv.Writer
// jsonPending 上一行 JSON,延迟一行写出,使行间逗号紧跟行尾而非独占一行
jsonPending string
n int
}
func newExportEncoder(bw *bufio.Writer, format, driver, table string, cols []string) *exportEncoder {
return &exportEncoder{bw: bw, format: format, driver: driver, table: table, cols: cols}
}
func (e *exportEncoder) begin() error {
switch e.format {
case "csv":
e.csvW = csv.NewWriter(e.bw)
return e.csvW.Write(e.cols)
case "json":
_, err := e.bw.WriteString("[\n")
return err
case "insert":
return nil
default:
return fmt.Errorf("不支持的导出格式: %s", e.format)
}
}
func (e *exportEncoder) row(vals []interface{}) error {
e.n++
switch e.format {
case "csv":
rec := make([]string, len(vals))
for i, v := range vals {
rec[i] = csvCell(v)
}
return e.csvW.Write(rec)
case "json":
m := make(map[string]interface{}, len(e.cols))
for i, col := range e.cols {
m[col] = vals[i]
}
var rb bytes.Buffer
if err := json.NewEncoder(&rb).Encode(m); err != nil {
return err
}
line := strings.TrimSuffix(rb.String(), "\n")
if e.jsonPending != "" {
if _, err := e.bw.WriteString(e.jsonPending + ",\n"); err != nil {
return err
}
}
e.jsonPending = line
return nil
case "insert":
var b strings.Builder
b.WriteString("INSERT INTO ")
b.WriteString(quoteTableNameFor(e.driver, e.table))
b.WriteString(" (")
for i, col := range e.cols {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(quoteIdentFor(e.driver, col))
}
b.WriteString(") VALUES (")
for i, v := range vals {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(sqlLiteral(v))
}
b.WriteString(");\n")
_, err := e.bw.WriteString(b.String())
return err
}
return nil
}
func (e *exportEncoder) end() error {
switch e.format {
case "csv":
e.csvW.Flush()
return e.csvW.Error()
case "json":
if e.jsonPending != "" {
if _, err := e.bw.WriteString(e.jsonPending + "\n"); err != nil {
return err
}
e.jsonPending = ""
}
_, err := e.bw.WriteString("]\n")
return err
}
return nil
}
func csvCell(v interface{}) string {
switch t := v.(type) {
case nil:
return ""
case string:
return t
default:
return fmt.Sprintf("%v", t)
}
}
// sqlLiteral insert 字面量:NULL/数字原样,字符串单引号 doubling。
func sqlLiteral(v interface{}) string {
switch t := v.(type) {
case nil:
return "NULL"
case string:
return "'" + strings.ReplaceAll(t, "'", "''") + "'"
case bool:
if t {
return "1"
}
return "0"
case time.Time:
return "'" + t.Format(time.RFC3339Nano) + "'"
case []byte:
return "'" + strings.ReplaceAll(string(t), "'", "''") + "'"
default:
return fmt.Sprintf("%v", t)
}
}
+139
View File
@@ -0,0 +1,139 @@
package db
import (
"bytes"
"context"
"encoding/csv"
"encoding/json"
"strings"
"testing"
)
func newExportConn(t *testing.T) ConnReq {
return newSQLiteFile(t,
`CREATE TABLE t_export (id INTEGER PRIMARY KEY, name TEXT, note TEXT)`,
`INSERT INTO t_export (id, name, note) VALUES (1, 'alice', 'hi, "q"')`,
`INSERT INTO t_export (id, name, note) VALUES (2, 'bo''b', NULL)`,
`INSERT INTO t_export (id, name, note) VALUES (3, 'carol', 'line1`+"\n"+`line2')`,
)
}
// R2-B7:csv 输出形状(表头 + csv 引号转义由 encoding/csv 负责)。
func TestExportCSV(t *testing.T) {
c := newExportConn(t)
c.SQL = `SELECT id, name, note FROM t_export ORDER BY id`
c.Format = "csv"
var buf bytes.Buffer
if err := ExportQuery(context.Background(), c, &buf); err != nil {
t.Fatal(err)
}
out := buf.String()
if !strings.HasPrefix(out, "id,name,note\n") {
t.Fatalf("csv 表头错误: %q", out)
}
// 含逗号/引号的字段整体加引号、内部引号 doubling
if !strings.Contains(out, `"hi, ""q"""`) {
t.Fatalf("csv 转义错误: %q", out)
}
// 单引号 doubling 是 SQL 字面量层;csv 层原样输出 'bo''b' 存好的 bo'b
if !strings.Contains(out, "2,bo'b,") {
t.Fatalf("csv 内容错误: %q", out)
}
// 多行字面量会使单条记录跨物理行,用 csv.Reader 按记录解析
recs, err := csv.NewReader(strings.NewReader(out)).ReadAll()
if err != nil {
t.Fatalf("csv 输出应可解析: %v", err)
}
if len(recs) != 4 { // 表头 + 3 行
t.Fatalf("应为表头+3 行: %q", out)
}
}
func TestExportJSON(t *testing.T) {
c := newExportConn(t)
c.SQL = `SELECT id, name, note FROM t_export ORDER BY id`
c.Format = "json"
var buf bytes.Buffer
if err := ExportQuery(context.Background(), c, &buf); err != nil {
t.Fatal(err)
}
var rows []map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &rows); err != nil {
t.Fatalf("json 输出应可解析: %v\n%s", err, buf.String())
}
if len(rows) != 3 {
t.Fatalf("应为 3 行: %v", rows)
}
if rows[0]["name"] != "alice" || rows[1]["note"] != nil {
t.Fatalf("行值错误: %v", rows)
}
}
func TestExportInsert(t *testing.T) {
c := newExportConn(t)
c.SQL = `SELECT id, name, note FROM t_export ORDER BY id`
c.Format = "insert"
c.Table = "t_export"
var buf bytes.Buffer
if err := ExportQuery(context.Background(), c, &buf); err != nil {
t.Fatal(err)
}
out := buf.String()
if !strings.Contains(out, `INSERT INTO "t_export" ("id", "name", "note") VALUES (1, 'alice', 'hi, "q"');`) {
t.Fatalf("insert 首行错误: %q", out)
}
// 字符串单引号 doubling;NULL 原样
if !strings.Contains(out, `VALUES (2, 'bo''b', NULL);`) {
t.Fatalf("insert 字面量错误: %q", out)
}
// 多行字面量内换行原样保留
if !strings.Contains(out, "line1\nline2") {
t.Fatalf("insert 换行字面量错误: %q", out)
}
}
// limit 生效(含默认上限收敛)。
func TestExportLimit(t *testing.T) {
c := newExportConn(t)
c.SQL = `SELECT id FROM t_export ORDER BY id`
c.Format = "csv"
c.Limit = 2
var buf bytes.Buffer
if err := ExportQuery(context.Background(), c, &buf); err != nil {
t.Fatal(err)
}
rows := strings.Split(strings.TrimSpace(buf.String()), "\n")
if len(rows) != 3 { // 表头 + 2 行
t.Fatalf("limit=2 应只导 2 行: %q", buf.String())
}
}
func TestExportAllowedQuery(t *testing.T) {
for _, ok := range []string{
"SELECT * FROM t", "WITH x AS (SELECT 1) SELECT * FROM x",
"SHOW TABLES", "DESCRIBE t", "EXPLAIN SELECT 1",
"-- c\nSELECT 1",
} {
if !ExportAllowedQuery(ok) {
t.Fatalf("应允许只读: %q", ok)
}
}
for _, bad := range []string{
"DELETE FROM t", "INSERT INTO t VALUES (1)", "DROP TABLE t",
"UPDATE t SET a=1", "", " ",
} {
if ExportAllowedQuery(bad) {
t.Fatalf("不应允许: %q", bad)
}
}
}
func TestExportBadFormat(t *testing.T) {
c := newExportConn(t)
c.SQL = "SELECT 1"
c.Format = "xml"
var buf bytes.Buffer
if err := ExportQuery(context.Background(), c, &buf); err == nil {
t.Fatal("未知格式应报错")
}
}
@@ -0,0 +1,72 @@
package db
// family_e2e_test.go —— 协议兼容族真实例验收(MySQL 族:TiDB;PG 族:openGauss)。
// 触发:OPSXDB_E2E_TIDB=1 / OPSXDB_E2E_GAUSS=1;验证族内真实例后注册表才可翻 verified。
import (
"context"
"os"
"testing"
"time"
)
// TestE2ETiDB —— 真实 TiDB(pingcap/tidb 单容器,MySQL 协议族)。
func TestE2ETiDB(t *testing.T) {
if os.Getenv("OPSXDB_E2E_TIDB") != "1" {
t.Skip("置 OPSXDB_E2E_TIDB=1 并启动 TiDB :4000 后运行(docker run -p 4000:4000 pingcap/tidb:v7.5.0)")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
c := ConnReq{Driver: "tidb", Host: "127.0.0.1", Port: 4000, User: "root"}
if err := TestConnection(c); err != nil {
t.Skipf("TiDB :4000 不可达: %v", err)
}
dbs, err := ListDatabases(ctx, c)
if err != nil || len(dbs) == 0 {
t.Fatalf("ListDatabases = %v, %v", dbs, err)
}
// 建库建表写读
must := func(sql string, db string) ExecResult {
t.Helper()
return ExecSQL(ctx, ConnReq{Driver: "tidb", Host: "127.0.0.1", Port: 4000, User: "root", Database: db, SQL: sql, ConfirmDanger: true})
}
if r := must("CREATE DATABASE IF NOT EXISTS opsxe2e", ""); r.Error != "" {
t.Fatalf("create db: %s", r.Error)
}
if r := must("CREATE TABLE IF NOT EXISTS fam_t(id INT PRIMARY KEY, name VARCHAR(32))", "opsxe2e"); r.Error != "" {
t.Fatalf("create table: %s", r.Error)
}
if r := must(`INSERT INTO fam_t VALUES (1, 'tidb-ok')`, "opsxe2e"); r.Error != "" {
t.Fatalf("insert: %s", r.Error)
}
r := must("SELECT * FROM fam_t", "opsxe2e")
if r.Error != "" || r.RowCount != 1 || r.Rows[0]["name"] != "tidb-ok" {
t.Fatalf("select: err=%s rows=%+v", r.Error, r.Rows)
}
}
// TestE2EOpenGauss —— 真实 openGauss(PG 协议族;Apple Silicon 需 --platform linux/amd64)。
func TestE2EOpenGauss(t *testing.T) {
if os.Getenv("OPSXDB_E2E_GAUSS") != "1" {
t.Skip("置 OPSXDB_E2E_GAUSS=1 并启动 openGauss :5432 后运行")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
c := ConnReq{Driver: "gaussdb", Host: "127.0.0.1", Port: 5432, User: "omm", Password: "Demo@123", Database: "postgres"}
if err := TestConnection(c); err != nil {
t.Skipf("openGauss :5432 不可达: %v", err)
}
dbs, err := ListDatabases(ctx, c)
if err != nil || len(dbs) == 0 {
t.Fatalf("ListDatabases = %v, %v", dbs, err)
}
q := ExecSQL(ctx, ConnReq{Driver: "gaussdb", Host: "127.0.0.1", Port: 5432, User: "omm", Password: "Demo@123", Database: "postgres", SQL: "SELECT 1 AS one"})
if q.Error != "" {
t.Fatalf("select: %s", q.Error)
}
if q.RowCount != 1 {
t.Fatalf("rows = %+v", q.Rows)
}
}
+31 -1
View File
@@ -2,14 +2,44 @@ package db
import "strings"
// quoteMySQLIdent 反引号标识符转义:内部反引号加倍(“),与前端 sql.ts quoteIdent 对齐。
func quoteMySQLIdent(s string) string {
return "`" + strings.ReplaceAll(s, "`", "") + "`"
return "`" + strings.ReplaceAll(s, "`", "``") + "`"
}
func quotePGIdent(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
// quoteIdentFor 按驱动族引用标识符:mysql 反引号加倍、pg/sqlite 双引号加倍、
// mssql 中括号加倍、oracle 沿用原名(大写约定)。
func quoteIdentFor(driver, name string) string {
switch {
case isMySQLFamily(driver):
return quoteMySQLIdent(name)
case isMSSQL(driver):
return "[" + strings.ReplaceAll(name, "]", "]]") + "]"
case driverKey(driver) == "oracle":
return name
case driverKey(driver) == "sqlite":
return quoteSQLiteIdent(name)
default:
return quotePGIdent(name)
}
}
// quoteTableNameFor 引用表名(支持 schema.table 多段,oracle 除外沿用原名)。
func quoteTableNameFor(driver, table string) string {
if driverKey(driver) == "oracle" {
return table
}
parts := strings.Split(table, ".")
for i := range parts {
parts[i] = quoteIdentFor(driver, parts[i])
}
return strings.Join(parts, ".")
}
func splitSchemaTable(name string) (schema, table string) {
name = strings.TrimSpace(name)
if i := strings.LastIndex(name, "."); i > 0 {
+46
View File
@@ -0,0 +1,46 @@
package db
import (
"context"
"sync"
)
// inflightCancels 在执行的请求注册表:requestId → cancel,供 /api/db/cancel 远程取消。
// 请求完成(正常/异常)必须 UnregisterInflight,避免 map 泄漏。
var inflightCancels = struct {
sync.Mutex
m map[string]context.CancelFunc
}{m: make(map[string]context.CancelFunc)}
// RegisterInflight 注册一个可按 requestId 取消的在执行请求。
// requestID 为空或 cancel 为 nil 时忽略。
func RegisterInflight(requestID string, cancel context.CancelFunc) {
if requestID == "" || cancel == nil {
return
}
inflightCancels.Lock()
defer inflightCancels.Unlock()
inflightCancels.m[requestID] = cancel
}
// UnregisterInflight 请求结束时注销(幂等)。
func UnregisterInflight(requestID string) {
inflightCancels.Lock()
defer inflightCancels.Unlock()
delete(inflightCancels.m, requestID)
}
// CancelInflight 按 requestId 取消在执行的请求;不存在则返回 false。
// 取消后即注销,重复取消返回 false。
func CancelInflight(requestID string) bool {
inflightCancels.Lock()
cancel, ok := inflightCancels.m[requestID]
if ok {
delete(inflightCancels.m, requestID)
}
inflightCancels.Unlock()
if ok {
cancel()
}
return ok
}
+51
View File
@@ -0,0 +1,51 @@
package db
import (
"context"
"testing"
)
// R2-B4:注册→取消→CancelFunc 被调用;注销后 CancelInflight 返回 false。
func TestInflightCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
const id = "req-cancel-1"
RegisterInflight(id, cancel)
if !CancelInflight(id) {
t.Fatal("已注册应能取消")
}
if ctx.Err() != context.Canceled {
t.Fatal("CancelFunc 应已被调用")
}
// 取消后即注销,重复取消返回 false
if CancelInflight(id) {
t.Fatal("注销后 CancelInflight 应返回 false")
}
}
func TestInflightUnregister(t *testing.T) {
_, cancel := context.WithCancel(context.Background())
defer cancel()
const id = "req-unregister-1"
RegisterInflight(id, cancel)
UnregisterInflight(id)
if CancelInflight(id) {
t.Fatal("注销后不应再取消到")
}
// 幂等:重复注销不 panic
UnregisterInflight(id)
}
// 空 id / nil cancel 注册应被忽略。
func TestInflightRegisterGuards(t *testing.T) {
RegisterInflight("", func() {})
if CancelInflight("") {
t.Fatal("空 requestId 不应注册成功")
}
RegisterInflight("req-nil-cancel", nil)
if CancelInflight("req-nil-cancel") {
t.Fatal("nil cancel 不应注册成功")
}
}
+271
View File
@@ -0,0 +1,271 @@
package db
// influx.go —— InfluxDB 2.x 原生接入(HTTP API,零第三方依赖)。
//
// 字段映射(连接表单):
//
// 主机/端口 → 服务地址(默认 :8086)
// 用户 → org(如 opsx)
// 密码 → admin token(如 opsxdb-demo-token)
// 库名 → bucket(如 demo)
//
// 能力:对象树 bucket → measurement(Flux schema.measurements);
// SQL 编辑器直写 Flux(from(bucket: ...) |> range(...) ...),结果按注解 CSV 解析为表格。
// Flux 只读(写走 /write 专用通道),危险语句拦截在 ops.go 入口统一过一遍。
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
func isInflux(d string) bool { return driverKey(d) == "influxdb" }
func ifxBase(c ConnReq) string {
host := strings.TrimSpace(c.Host)
if host == "" {
host = "127.0.0.1"
}
port := c.Port
if port == 0 {
port = 8086
}
return fmt.Sprintf("http://%s:%d", host, port)
}
func ifxToken(c ConnReq) string { return c.Password }
// ifxDo 统一请求:Token 鉴权 + org 查询参数。
func ifxDo(ctx context.Context, c ConnReq, method, path string, body io.Reader, out any) error {
req, err := http.NewRequestWithContext(ctx, method, ifxBase(c)+path, body)
if err != nil {
return err
}
if ifxToken(c) != "" {
req.Header.Set("Authorization", "Token "+ifxToken(c))
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("InfluxDB HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
func ifxOrg(c ConnReq) (string, error) {
org := strings.TrimSpace(c.User)
if org == "" {
return "", fmt.Errorf("InfluxDB 2.x 需要填「用户」= org 名(如 opsx)")
}
return org, nil
}
func ifxPing(c ConnReq) error {
org, err := ifxOrg(c)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
var out struct {
Buckets []struct {
Name string `json:"name"`
} `json:"buckets"`
}
return ifxDo(ctx, c, http.MethodGet, "/api/v2/buckets?org="+org, nil, &out)
}
func ifxListDatabases(ctx context.Context, c ConnReq) ([]string, error) {
org, err := ifxOrg(c)
if err != nil {
return nil, err
}
var out struct {
Buckets []struct {
Name string `json:"name"`
} `json:"buckets"`
}
if err := ifxDo(ctx, c, http.MethodGet, "/api/v2/buckets?org="+org, nil, &out); err != nil {
return nil, err
}
names := []string{}
for _, b := range out.Buckets {
names = append(names, b.Name)
}
return names, nil
}
// ifxQueryFlux 跑 Flux 并把注解 CSV 解析为表格。
func ifxQueryFlux(ctx context.Context, c ConnReq, flux string, maxRows int) (ExecResult, error) {
org, err := ifxOrg(c)
if err != nil {
return ExecResult{}, err
}
body := map[string]string{"query": flux, "type": "flux"}
b, err := json.Marshal(body)
if err != nil {
return ExecResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ifxBase(c)+"/api/v2/query?org="+org, strings.NewReader(string(b)))
if err != nil {
return ExecResult{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/csv")
if ifxToken(c) != "" {
req.Header.Set("Authorization", "Token "+ifxToken(c))
}
resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(req)
if err != nil {
return ExecResult{}, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return ExecResult{}, err
}
if resp.StatusCode >= 400 {
return ExecResult{}, fmt.Errorf("InfluxDB HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
return parseAnnotatedCSV(string(raw), maxRows)
}
// parseAnnotatedCSV 解析 InfluxDB 注解 CSV:
// #group/#datatype/#default 注释行 + 空行 + header 行 + 数据行。
func parseAnnotatedCSV(text string, maxRows int) (ExecResult, error) {
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
headerIdx := -1
var header []string
for i, ln := range lines {
s := strings.TrimSpace(ln)
if s == "" || strings.HasPrefix(s, "#") {
continue
}
headerIdx = i
header = strings.Split(ln, ",")
break
}
if headerIdx < 0 {
return ExecResult{Columns: []string{}, Rows: []map[string]interface{}{}}, nil
}
res := ExecResult{Columns: header, Rows: []map[string]interface{}{}}
for _, ln := range lines[headerIdx+1:] {
if strings.TrimSpace(ln) == "" {
continue
}
cells := strings.Split(ln, ",")
row := make(map[string]interface{}, len(header))
for i, col := range header {
v := ""
if i < len(cells) {
v = cells[i]
}
row[col] = v
}
res.Rows = append(res.Rows, row)
if len(res.Rows) >= maxRows {
res.Truncated = true
break
}
}
res.RowCount = len(res.Rows)
return res, nil
}
func ifxListTables(ctx context.Context, c ConnReq) ([]string, error) {
bucket := strings.TrimSpace(c.Database)
if bucket == "" {
return nil, fmt.Errorf("InfluxDB 列表需要库名(bucket)")
}
flux := fmt.Sprintf("import \"influxdata/influxdb/schema\"\nschema.measurements(bucket: \"%s\")", strings.ReplaceAll(bucket, "\"", ""))
res, err := ifxQueryFlux(ctx, c, flux, 10000)
if err != nil {
return nil, err
}
out := []string{}
if len(res.Columns) == 0 {
return out, nil
}
// schema.measurements 输出列为 [,result,table,_value]:优先 _value 列,退化为最后一列
idx := len(res.Columns) - 1
for i, col := range res.Columns {
if col == "_value" {
idx = i
break
}
}
col := res.Columns[idx]
for _, row := range res.Rows {
if v, ok := row[col].(string); ok && v != "" {
out = append(out, v)
}
}
return out, nil
}
// ifxExecSQL 编辑器内容按 Flux 处理(只读)。
func ifxExecSQL(ctx context.Context, c ConnReq, start time.Time) ExecResult {
if c.Offset > 0 {
return ExecResult{Columns: []string{}, Rows: []map[string]interface{}{}, Error: "InfluxDB Flux 结果暂不支持翻页(可缩小 range 重查)", TimeMs: time.Since(start).Milliseconds()}
}
limit := rowLimit(c)
if limit > MaxRowLimit {
limit = MaxRowLimit
}
res, err := ifxQueryFlux(ctx, c, strings.TrimSpace(c.SQL), limit)
if err != nil {
return execFail(start, err)
}
res.TimeMs = time.Since(start).Milliseconds()
return res
}
// ifxListColumns measurement 的字段探查:Flux schema.fieldKeys + tagKeys 合并。
func ifxListColumns(ctx context.Context, c ConnReq) ([]ColumnInfo, error) {
bucket := strings.TrimSpace(c.Database)
table := strings.TrimSpace(c.Table)
if bucket == "" || table == "" {
return nil, fmt.Errorf("InfluxDB 列探查需要库名与 measurement")
}
safeB := strings.ReplaceAll(bucket, "\"", "")
safeM := strings.ReplaceAll(table, "\"", "")
flux := fmt.Sprintf(`import "influxdata/influxdb/schema"
union(tables: [
schema.fieldKeys(bucket: "%s", predicate: (r) => r._measurement == "%s"),
schema.tagKeys(bucket: "%s", predicate: (r) => r._measurement == "%s")
])`, safeB, safeM, safeB, safeM)
res, err := ifxQueryFlux(ctx, c, flux, 1000)
if err != nil {
return nil, err
}
cols := []ColumnInfo{}
seen := map[string]bool{}
if len(res.Columns) == 0 {
return cols, nil
}
last := res.Columns[len(res.Columns)-1]
for _, row := range res.Rows {
name, _ := row[last].(string)
if name == "" || seen[name] {
continue
}
seen[name] = true
cols = append(cols, ColumnInfo{Name: name, Type: "tag/field", Nullable: true})
}
return cols, nil
}
+41 -20
View File
@@ -1,6 +1,7 @@
package db
import (
"context"
"database/sql"
"fmt"
"net/url"
@@ -10,6 +11,16 @@ import (
)
func openMSSQL(c ConnReq) (*sql.DB, error) {
dsn, err := mssqlDSN(c)
if err != nil {
return nil, err
}
return sql.Open("sqlserver", dsn)
}
// mssqlDSN 拼 sqlserver:// DSN;账号密码走 url.UserPassword 编码,
// 修复含空格 / '+' 等特殊字符密码连接失败的问题(url.QueryEscape 不适用 userinfo)。
func mssqlDSN(c ConnReq) (string, error) {
host := c.Host
if host == "" {
host = "127.0.0.1"
@@ -44,7 +55,7 @@ func openMSSQL(c ConnReq) (*sql.DB, error) {
q.Set("TrustServerCertificate", "false")
ca := strings.TrimSpace(c.SSLCA)
if mode == "verify-ca" && ca == "" {
return nil, fmt.Errorf("verify-ca 模式需要填写 CA 证书路径")
return "", fmt.Errorf("verify-ca 模式需要填写 CA 证书路径")
}
if ca != "" {
// go-mssqldb msdsn certificate= builds RootCAs from PEM/DER
@@ -54,15 +65,19 @@ func openMSSQL(c ConnReq) (*sql.DB, error) {
q.Set("hostnameincertificate", host)
}
default:
return nil, fmt.Errorf("未知 SSL 模式: %s", mode)
return "", fmt.Errorf("未知 SSL 模式: %s", mode)
}
dsn := fmt.Sprintf("sqlserver://%s:%s@%s:%d?%s",
url.QueryEscape(user), url.QueryEscape(c.Password), host, port, q.Encode())
return sql.Open("sqlserver", dsn)
u := &url.URL{
Scheme: "sqlserver",
User: url.UserPassword(user, c.Password),
Host: fmt.Sprintf("%s:%d", host, port),
RawQuery: q.Encode(),
}
return u.String(), nil
}
func listDatabasesMSSQL(db *sql.DB) ([]string, error) {
rows, err := db.Query(`SELECT name FROM sys.databases WHERE name NOT IN ('master','tempdb','model','msdb') ORDER BY name`)
func listDatabasesMSSQL(ctx context.Context, db *sql.DB) ([]string, error) {
rows, err := db.QueryContext(ctx, `SELECT name FROM sys.databases WHERE name NOT IN ('master','tempdb','model','msdb') ORDER BY name`)
if err != nil {
return nil, err
}
@@ -78,9 +93,9 @@ func listDatabasesMSSQL(db *sql.DB) ([]string, error) {
return out, rows.Err()
}
func listTablesMSSQL(db *sql.DB, database string) ([]string, error) {
func listTablesMSSQL(ctx context.Context, db *sql.DB, database string) ([]string, error) {
_ = database
rows, err := db.Query(`
rows, err := db.QueryContext(ctx, `
SELECT TABLE_SCHEMA + '.' + TABLE_NAME
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_TYPE = 'BASE TABLE'
@@ -100,33 +115,39 @@ func listTablesMSSQL(db *sql.DB, database string) ([]string, error) {
return out, rows.Err()
}
func listColumnsMSSQL(dbh *sql.DB, _, table string) ([]ColumnInfo, error) {
func listColumnsMSSQL(ctx context.Context, dbh *sql.DB, _, table string) ([]ColumnInfo, error) {
schema, t := splitSchemaTable(table)
if schema == "" {
schema = "dbo"
}
rows, err := dbh.Query(`
SELECT COLUMN_NAME, DATA_TYPE,
ISNULL(CAST(CHARACTER_MAXIMUM_LENGTH AS varchar), ''),
IS_NULLABLE, COLUMN_DEFAULT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @p1 AND TABLE_NAME = @p2
ORDER BY ORDINAL_POSITION`, schema, t)
// 关联 sys.indexes / sys.index_columns 补主键标记(命中则 Key='PRI')
rows, err := dbh.QueryContext(ctx, `
SELECT c.COLUMN_NAME, c.DATA_TYPE,
ISNULL(CAST(c.CHARACTER_MAXIMUM_LENGTH AS varchar), ''),
c.IS_NULLABLE, c.COLUMN_DEFAULT,
CASE WHEN pk.name IS NOT NULL THEN 'PRI' ELSE '' END
FROM INFORMATION_SCHEMA.COLUMNS c
LEFT JOIN sys.tables t ON t.name = c.TABLE_NAME AND SCHEMA_NAME(t.schema_id) = c.TABLE_SCHEMA
LEFT JOIN sys.indexes i ON i.object_id = t.object_id AND i.is_primary_key = 1
LEFT JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
LEFT JOIN sys.columns pk ON pk.object_id = ic.object_id AND pk.column_id = ic.column_id AND pk.name = c.COLUMN_NAME
WHERE c.TABLE_SCHEMA = @p1 AND c.TABLE_NAME = @p2
ORDER BY c.ORDINAL_POSITION`, schema, t)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ColumnInfo
for rows.Next() {
var name, typ, extra, nullable string
var name, typ, extra, nullable, key string
var def sql.NullString
if err := rows.Scan(&name, &typ, &extra, &nullable, &def); err != nil {
if err := rows.Scan(&name, &typ, &extra, &nullable, &def, &key); err != nil {
return nil, err
}
if extra != "" && extra != "-1" {
typ = typ + "(" + extra + ")"
}
col := ColumnInfo{Name: name, Type: typ, Nullable: nullable == "YES"}
col := ColumnInfo{Name: name, Type: typ, Nullable: nullable == "YES", Key: key}
if def.Valid {
col.Default = def.String
}
+22 -9
View File
@@ -1,11 +1,13 @@
package db
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"net"
"strings"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
@@ -41,7 +43,7 @@ func openMySQL(c ConnReq) (*sql.DB, error) {
case "disable":
// no TLS
case "skip-verify":
if c.SSLCA == "" && c.SSLCert == "" && c.SSLKey == "" {
if !hasTLSMaterial(c) {
cfg.TLSConfig = "skip-verify"
} else {
name, err := registerMySQLTLS(c)
@@ -51,7 +53,7 @@ func openMySQL(c ConnReq) (*sql.DB, error) {
cfg.TLSConfig = name
}
case "require", "verify-ca":
if c.SSLCA == "" && c.SSLCert == "" && c.SSLKey == "" && mode == "require" {
if !hasTLSMaterial(c) && mode == "require" {
cfg.TLSConfig = "true"
} else {
name, err := registerMySQLTLS(c)
@@ -66,6 +68,16 @@ func openMySQL(c ConnReq) (*sql.DB, error) {
return sql.Open("mysql", cfg.FormatDSN())
}
// hasTLSMaterial 是否提供了任何 CA/客户端证书材料(路径或 PEM 内容)。
func hasTLSMaterial(c ConnReq) bool {
return strings.TrimSpace(c.SSLCA) != "" ||
strings.TrimSpace(c.SSLCert) != "" ||
strings.TrimSpace(c.SSLKey) != "" ||
strings.TrimSpace(c.SSLCAContent) != "" ||
strings.TrimSpace(c.SSLCertContent) != "" ||
strings.TrimSpace(c.SSLKeyContent) != ""
}
func registerMySQLTLS(c ConnReq) (string, error) {
tlsCfg, err := tlsConfigFrom(c)
if err != nil {
@@ -74,7 +86,8 @@ func registerMySQLTLS(c ConnReq) (string, error) {
if tlsCfg == nil {
return "", fmt.Errorf("SSL 已启用但未能构建 TLS 配置")
}
sum := sha256.Sum256([]byte(EffectiveSSLMode(c) + "|" + c.SSLCA + "|" + c.SSLCert + "|" + c.SSLKey + "|" + c.Host))
sum := sha256.Sum256([]byte(EffectiveSSLMode(c) + "|" + c.SSLCA + "|" + c.SSLCert + "|" + c.SSLKey +
"|" + c.SSLCAContent + "|" + c.SSLCertContent + "|" + c.SSLKeyContent + "|" + c.TLSServerName + "|" + c.Host))
name := "opsxdb-" + hex.EncodeToString(sum[:8])
mysqldriver.DeregisterTLSConfig(name)
if err := mysqldriver.RegisterTLSConfig(name, tlsCfg); err != nil {
@@ -83,8 +96,8 @@ func registerMySQLTLS(c ConnReq) (string, error) {
return name, nil
}
func listDatabasesMySQL(db *sql.DB) ([]string, error) {
rows, err := db.Query("SHOW DATABASES")
func listDatabasesMySQL(ctx context.Context, db *sql.DB) ([]string, error) {
rows, err := db.QueryContext(ctx, "SHOW DATABASES")
if err != nil {
return nil, err
}
@@ -100,12 +113,12 @@ func listDatabasesMySQL(db *sql.DB) ([]string, error) {
return out, rows.Err()
}
func listTablesMySQL(db *sql.DB, database string) ([]string, error) {
func listTablesMySQL(ctx context.Context, db *sql.DB, database string) ([]string, error) {
q := "SHOW TABLES"
if database != "" {
q = "SHOW TABLES FROM " + quoteMySQLIdent(database)
}
rows, err := db.Query(q)
rows, err := db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
@@ -121,12 +134,12 @@ func listTablesMySQL(db *sql.DB, database string) ([]string, error) {
return out, rows.Err()
}
func listColumnsMySQL(db *sql.DB, database, table string) ([]ColumnInfo, error) {
func listColumnsMySQL(ctx context.Context, db *sql.DB, database, table string) ([]ColumnInfo, error) {
ident := quoteMySQLIdent(table)
if database != "" {
ident = quoteMySQLIdent(database) + "." + ident
}
rows, err := db.Query("SHOW FULL COLUMNS FROM " + ident)
rows, err := db.QueryContext(ctx, "SHOW FULL COLUMNS FROM "+ident)
if err != nil {
return nil, err
}
+29
View File
@@ -29,6 +29,15 @@ func isPostgresFamily(d string) bool {
}
}
func isMSSQL(d string) bool {
switch driverKey(d) {
case "sqlserver", "mssql":
return true
default:
return false
}
}
// Open 按驱动打开 *sql.DB
func Open(c ConnReq) (*sql.DB, error) {
var (
@@ -53,12 +62,23 @@ func Open(c ConnReq) (*sql.DB, error) {
return nil, err
}
db.SetConnMaxLifetime(2 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute) // 防空闲连接堆积
db.SetMaxOpenConns(5)
db.SetMaxIdleConns(2)
return db, nil
}
func TestConnection(c ConnReq) error {
if isAgentDriver(c.Driver) {
// JDBC agent 桥的 TCP 由 JVM 侧发起,SSH 隧道(本地端口转发)对 agent 不生效,明确拒绝
if c.SSHEnabled {
return fmt.Errorf("%s 走 JDBC agent,暂不支持 SSH 隧道", c.Driver)
}
if err := agentPing(c); err != nil {
return fmt.Errorf("数据库: %w", err)
}
return nil
}
dial, tun, err := ApplySSHTunnel(c)
if err != nil {
return err
@@ -76,6 +96,15 @@ func testDial(c ConnReq) error {
if isRedis(c.Driver) {
return testRedis(c)
}
if isES(c.Driver) {
return esPing(c)
}
if isInflux(c.Driver) {
return ifxPing(c)
}
if isAgentDriver(c.Driver) {
return agentPing(c)
}
db, err := Open(c)
if err != nil {
return err
+329 -61
View File
@@ -2,15 +2,67 @@ package db
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
func ListDatabases(c ConnReq) ([]string, error) {
// withDB 统一获取/释放 *sql.DB:
// - c.SessionID != "" → 会话内按 database 池化复用,不 Close;
// - 否则 → ApplySSHTunnel(c) + Open + defer Close(短生命周期隧道随请求关闭)。
func withDB(c ConnReq, fn func(*sql.DB) error) error {
if c.SessionID != "" {
s, err := GetSession(c.SessionID)
if err != nil {
return err
}
db, err := s.sqlDB(c.Database)
if err != nil {
return err
}
return fn(db)
}
dial, tun, err := ApplySSHTunnel(c)
if err != nil {
return err
}
if tun != nil {
defer tun.Close()
}
db, err := Open(dial)
if err != nil {
return err
}
defer db.Close()
return fn(db)
}
func unsupportedDriverErr(c ConnReq) error {
return fmt.Errorf("不支持的驱动「%s」(当前支持: mysql/mariadb/tidb、postgresql、sqlserver、oracle、sqlite、redis;agent 桥: dameng)", c.Driver)
}
func reqCtx(ctx context.Context) context.Context {
if ctx == nil {
return context.Background()
}
return ctx
}
func ListDatabases(ctx context.Context, c ConnReq) ([]string, error) {
ctx = reqCtx(ctx)
if isRedis(c.Driver) {
return listDatabasesRedis(c)
return listDatabasesRedis(ctx, c)
}
if isES(c.Driver) {
return esListDatabases(), nil
}
if isInflux(c.Driver) {
return ifxListDatabases(ctx, c)
}
if isAgentDriver(c.Driver) {
return agentListDatabases(ctx, c)
}
cc := c
switch {
@@ -25,78 +77,110 @@ func ListDatabases(c ConnReq) ([]string, error) {
cc.Database = "master"
}
}
db, err := Open(cc)
var out []string
err := withDB(cc, func(db *sql.DB) error {
var err error
switch {
case isMySQLFamily(c.Driver):
out, err = listDatabasesMySQL(ctx, db)
case isPostgresFamily(c.Driver):
out, err = listDatabasesPostgres(ctx, db)
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
out, err = listDatabasesMSSQL(ctx, db)
case driverKey(c.Driver) == "oracle":
out, err = listDatabasesOracle(ctx, db)
case driverKey(c.Driver) == "sqlite":
out, err = listDatabasesSQLite(db)
default:
err = unsupportedDriverErr(c)
}
return err
})
if err != nil {
return nil, err
}
defer db.Close()
switch {
case isMySQLFamily(c.Driver):
return listDatabasesMySQL(db)
case isPostgresFamily(c.Driver):
return listDatabasesPostgres(db)
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
return listDatabasesMSSQL(db)
case driverKey(c.Driver) == "oracle":
return listDatabasesOracle(db)
case driverKey(c.Driver) == "sqlite":
return listDatabasesSQLite(db)
default:
return nil, fmt.Errorf("unsupported driver")
}
return out, nil
}
func ListTables(c ConnReq) ([]string, error) {
func ListTables(ctx context.Context, c ConnReq) ([]string, error) {
ctx = reqCtx(ctx)
if isRedis(c.Driver) {
return listTablesRedis(c)
return listTablesRedis(ctx, c)
}
db, err := Open(c)
if isES(c.Driver) {
return esListTables(ctx, c)
}
if isInflux(c.Driver) {
return ifxListTables(ctx, c)
}
if isAgentDriver(c.Driver) {
return agentListTables(ctx, c)
}
var out []string
err := withDB(c, func(db *sql.DB) error {
var err error
switch {
case isMySQLFamily(c.Driver):
out, err = listTablesMySQL(ctx, db, c.Database)
case isPostgresFamily(c.Driver):
out, err = listTablesPostgres(ctx, db, c.Database)
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
out, err = listTablesMSSQL(ctx, db, c.Database)
case driverKey(c.Driver) == "oracle":
out, err = listTablesOracle(ctx, db, c.Database)
case driverKey(c.Driver) == "sqlite":
out, err = listTablesSQLite(ctx, db, c.Database)
default:
err = unsupportedDriverErr(c)
}
return err
})
if err != nil {
return nil, err
}
defer db.Close()
switch {
case isMySQLFamily(c.Driver):
return listTablesMySQL(db, c.Database)
case isPostgresFamily(c.Driver):
return listTablesPostgres(db, c.Database)
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
return listTablesMSSQL(db, c.Database)
case driverKey(c.Driver) == "oracle":
return listTablesOracle(db, c.Database)
case driverKey(c.Driver) == "sqlite":
return listTablesSQLite(db, c.Database)
default:
return nil, fmt.Errorf("unsupported driver")
}
return out, nil
}
func ListColumns(c ConnReq) ([]ColumnInfo, error) {
func ListColumns(ctx context.Context, c ConnReq) ([]ColumnInfo, error) {
ctx = reqCtx(ctx)
if c.Table == "" {
return nil, fmt.Errorf("缺少表名")
}
if isRedis(c.Driver) {
return listColumnsRedis(c, c.Table)
return listColumnsRedis(ctx, c, c.Table)
}
db, err := Open(c)
if isES(c.Driver) {
return esListColumns(ctx, c)
}
if isInflux(c.Driver) {
return ifxListColumns(ctx, c)
}
if isAgentDriver(c.Driver) {
return agentListColumns(ctx, c)
}
var out []ColumnInfo
err := withDB(c, func(db *sql.DB) error {
var err error
switch {
case isMySQLFamily(c.Driver):
out, err = listColumnsMySQL(ctx, db, c.Database, c.Table)
case isPostgresFamily(c.Driver):
out, err = listColumnsPostgres(ctx, db, c.Database, c.Table)
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
out, err = listColumnsMSSQL(ctx, db, c.Database, c.Table)
case driverKey(c.Driver) == "oracle":
out, err = listColumnsOracle(ctx, db, c.Database, c.Table)
case driverKey(c.Driver) == "sqlite":
out, err = listColumnsSQLite(ctx, db, c.Database, c.Table)
default:
err = unsupportedDriverErr(c)
}
return err
})
if err != nil {
return nil, err
}
defer db.Close()
switch {
case isMySQLFamily(c.Driver):
return listColumnsMySQL(db, c.Database, c.Table)
case isPostgresFamily(c.Driver):
return listColumnsPostgres(db, c.Database, c.Table)
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
return listColumnsMSSQL(db, c.Database, c.Table)
case driverKey(c.Driver) == "oracle":
return listColumnsOracle(db, c.Database, c.Table)
case driverKey(c.Driver) == "sqlite":
return listColumnsSQLite(db, c.Database, c.Table)
default:
return nil, fmt.Errorf("unsupported driver")
}
return out, nil
}
func rowLimit(c ConnReq) int {
@@ -137,12 +221,21 @@ func execFail(start time.Time, err error) ExecResult {
}
}
// needConfirm 构造危险语句确认响应(不执行)。
func needConfirm(start time.Time, hits []string) ExecResult {
return ExecResult{
Columns: []string{},
Rows: []map[string]interface{}{},
TimeMs: time.Since(start).Milliseconds(),
Error: "检测到危险语句: " + strings.Join(hits, "; "),
NeedConfirm: true,
}
}
// ExecSQL 执行查询 / 非查询语句。ctx 来自 HTTP 请求,客户端断开/Abort 时可取消 DB 调用。
func ExecSQL(ctx context.Context, c ConnReq) ExecResult {
start := time.Now()
if ctx == nil {
ctx = context.Background()
}
ctx = reqCtx(ctx)
if err := ctx.Err(); err != nil {
return execFail(start, err)
}
@@ -152,18 +245,44 @@ func ExecSQL(ctx context.Context, c ConnReq) ExecResult {
if isRedis(c.Driver) {
return execRedis(ctx, c)
}
db, err := Open(c)
// 服务端权威危险语句拦截:未确认时返回 needConfirm,不执行
if !c.ConfirmDanger {
dangerSQL := c.SQL
if driverKey(c.Driver) == "mongodb" {
// Mongo DSL 动词映射进词表:deleteMany 等无 WHERE 概念,按 DELETE/UPDATE 同等管控
dangerSQL = mongoVerbAsSQL(dangerSQL)
}
if hits := CheckDangerousSQL(c.Driver, dangerSQL); len(hits) > 0 {
return needConfirm(start, hits)
}
}
if isAgentDriver(c.Driver) {
return agentExecSQL(ctx, c, start)
}
if isES(c.Driver) {
return esExecSQL(ctx, c, start)
}
if isInflux(c.Driver) {
return ifxExecSQL(ctx, c, start)
}
var res ExecResult
err := withDB(c, func(db *sql.DB) error {
res = execOnDB(ctx, db, start, c)
return nil
})
if err != nil {
return execFail(start, err)
}
defer db.Close()
return res
}
func execOnDB(ctx context.Context, db *sql.DB, start time.Time, c ConnReq) ExecResult {
if !isQuerySQL(c.SQL) {
res, err := db.ExecContext(ctx, c.SQL)
r, err := db.ExecContext(ctx, c.SQL)
if err != nil {
return execFail(start, err)
}
affected, _ := res.RowsAffected()
affected, _ := r.RowsAffected()
return ExecResult{
Columns: []string{"affected_rows"},
Rows: []map[string]interface{}{{"affected_rows": affected}},
@@ -183,12 +302,22 @@ func ExecSQL(ctx context.Context, c ConnReq) ExecResult {
return execFail(start, err)
}
limit := rowLimit(c)
offset := c.Offset
if offset < 0 {
offset = 0
}
var resultRows []map[string]interface{}
truncated := false
skipped := 0
for rows.Next() {
if err := ctx.Err(); err != nil {
return execFail(start, err)
}
// offset 分页:先丢弃前 offset 行
if skipped < offset {
skipped++
continue
}
raw := make([]interface{}, len(cols))
ptrs := make([]interface{}, len(cols))
for i := range raw {
@@ -220,9 +349,148 @@ func ExecSQL(ctx context.Context, c ConnReq) ExecResult {
TimeMs: time.Since(start).Milliseconds(),
Truncated: truncated,
Limit: limit,
Offset: offset,
}
}
// ExplainSQL 服务端统一的执行计划入口(替代前端方言包装)。
// mysql/pg/sqlite 走行列计划;mssql 用 SHOWPLAN_XML(钉住单连接);oracle 走 DBMS_XPLAN。
func ExplainSQL(ctx context.Context, c ConnReq) ExplainResult {
start := time.Now()
ctx = reqCtx(ctx)
sqlText := strings.TrimSpace(c.SQL)
if sqlText == "" {
return ExplainResult{Error: "empty sql", TimeMs: time.Since(start).Milliseconds()}
}
sqlText = strings.TrimSuffix(sqlText, ";")
if isRedis(c.Driver) {
return ExplainResult{Error: "Redis 不支持执行计划", TimeMs: time.Since(start).Milliseconds()}
}
if isES(c.Driver) {
return ExplainResult{Error: "ES SQL 不支持执行计划", TimeMs: time.Since(start).Milliseconds()}
}
if isInflux(c.Driver) {
return ExplainResult{Error: "InfluxDB 不支持执行计划", TimeMs: time.Since(start).Milliseconds()}
}
if isAgentDriver(c.Driver) {
return agentExplain(ctx, c, sqlText)
}
// 危险语句不做计划
if hits := CheckDangerousSQL(c.Driver, sqlText); len(hits) > 0 {
return ExplainResult{Error: "检测到危险语句: " + strings.Join(hits, "; "), TimeMs: time.Since(start).Milliseconds()}
}
var res ExplainResult
err := withDB(c, func(db *sql.DB) error {
res = explainOnDB(ctx, db, c, sqlText)
return nil
})
if err != nil {
return ExplainResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
res.TimeMs = time.Since(start).Milliseconds()
return res
}
func explainOnDB(ctx context.Context, db *sql.DB, c ConnReq, sqlText string) ExplainResult {
fail := func(err error) ExplainResult {
return ExplainResult{Error: errMsg(err)}
}
switch {
case isMySQLFamily(c.Driver):
return explainRows(ctx, db, "EXPLAIN "+sqlText)
case isPostgresFamily(c.Driver):
return explainRows(ctx, db, "EXPLAIN "+sqlText)
case driverKey(c.Driver) == "sqlite":
return explainRows(ctx, db, "EXPLAIN QUERY PLAN "+sqlText)
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
// SET SHOWPLAN_XML 必须与查询同连接,钉住单连接
conn, err := db.Conn(ctx)
if err != nil {
return fail(err)
}
defer conn.Close()
if _, err := conn.ExecContext(ctx, "SET SHOWPLAN_XML ON"); err != nil {
return fail(err)
}
defer func() { _, _ = conn.ExecContext(context.Background(), "SET SHOWPLAN_XML OFF") }()
rows, err := conn.QueryContext(ctx, sqlText)
if err != nil {
return fail(err)
}
defer rows.Close()
var sb strings.Builder
for rows.Next() {
var s sql.NullString
if err := rows.Scan(&s); err != nil {
return fail(err)
}
sb.WriteString(s.String)
}
if err := rows.Err(); err != nil {
return fail(err)
}
return ExplainResult{Text: sb.String()}
case driverKey(c.Driver) == "oracle":
if _, err := db.ExecContext(ctx, "EXPLAIN PLAN FOR "+sqlText); err != nil {
return fail(err)
}
rows, err := db.QueryContext(ctx, "SELECT plan_table_output FROM TABLE(DBMS_XPLAN.DISPLAY())")
if err != nil {
return fail(err)
}
defer rows.Close()
var sb strings.Builder
for rows.Next() {
var s sql.NullString
if err := rows.Scan(&s); err != nil {
return fail(err)
}
sb.WriteString(s.String)
sb.WriteByte('\n')
}
if err := rows.Err(); err != nil {
return fail(err)
}
return ExplainResult{Text: sb.String()}
default:
return ExplainResult{Error: errMsg(unsupportedDriverErr(c))}
}
}
// explainRows 以行列形式读取执行计划(mysql/pg/sqlite)。
func explainRows(ctx context.Context, db *sql.DB, q string) ExplainResult {
rows, err := db.QueryContext(ctx, q)
if err != nil {
return ExplainResult{Error: errMsg(err)}
}
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
return ExplainResult{Error: errMsg(err)}
}
out := []map[string]interface{}{}
for rows.Next() {
raw := make([]interface{}, len(cols))
ptrs := make([]interface{}, len(cols))
for i := range raw {
ptrs[i] = &raw[i]
}
if err := rows.Scan(ptrs...); err != nil {
return ExplainResult{Error: errMsg(err)}
}
m := make(map[string]interface{}, len(cols))
for i, col := range cols {
m[col] = normalize(raw[i])
}
out = append(out, m)
}
if err := rows.Err(); err != nil {
return ExplainResult{Error: errMsg(err)}
}
return ExplainResult{Columns: cols, Rows: out}
}
func isQuerySQL(sql string) bool {
s := stripSQLLead(sql)
if s == "" {
+29 -17
View File
@@ -1,6 +1,7 @@
package db
import (
"context"
"database/sql"
"fmt"
"net/url"
@@ -58,9 +59,9 @@ func openOracle(c ConnReq) (*sql.DB, error) {
return sql.Open("oracle", dsn)
}
func listDatabasesOracle(db *sql.DB) ([]string, error) {
func listDatabasesOracle(ctx context.Context, db *sql.DB) ([]string, error) {
// 返回当前用户可见 schema
rows, err := db.Query(`SELECT username FROM all_users ORDER BY username`)
rows, err := db.QueryContext(ctx, `SELECT username FROM all_users ORDER BY username`)
if err != nil {
return nil, err
}
@@ -76,15 +77,15 @@ func listDatabasesOracle(db *sql.DB) ([]string, error) {
return out, rows.Err()
}
func listTablesOracle(db *sql.DB, schema string) ([]string, error) {
func listTablesOracle(ctx context.Context, db *sql.DB, schema string) ([]string, error) {
var (
rows *sql.Rows
err error
)
if schema == "" {
rows, err = db.Query(`SELECT table_name FROM user_tables ORDER BY table_name`)
rows, err = db.QueryContext(ctx, `SELECT table_name FROM user_tables ORDER BY table_name`)
} else {
rows, err = db.Query(`SELECT table_name FROM all_tables WHERE owner = UPPER(:1) ORDER BY table_name`, schema)
rows, err = db.QueryContext(ctx, `SELECT table_name FROM all_tables WHERE owner = UPPER(:1) ORDER BY table_name`, schema)
}
if err != nil {
return nil, err
@@ -101,7 +102,7 @@ func listTablesOracle(db *sql.DB, schema string) ([]string, error) {
return out, rows.Err()
}
func listColumnsOracle(dbh *sql.DB, schema, table string) ([]ColumnInfo, error) {
func listColumnsOracle(ctx context.Context, dbh *sql.DB, schema, table string) ([]ColumnInfo, error) {
owner, t := splitSchemaTable(table)
if owner == "" {
owner = schema
@@ -110,16 +111,27 @@ func listColumnsOracle(dbh *sql.DB, schema, table string) ([]ColumnInfo, error)
rows *sql.Rows
err error
)
// 关联约束元数据补主键标记(命中则 Key='PRI');沿用既有 UPPER() 绑定约定
if owner == "" {
rows, err = dbh.Query(`
SELECT column_name, data_type, nullable, data_default
FROM user_tab_columns WHERE table_name = UPPER(:1)
ORDER BY column_id`, t)
rows, err = dbh.QueryContext(ctx, `
SELECT c.column_name, c.data_type, c.nullable, c.data_default,
CASE WHEN acc.column_name IS NOT NULL THEN 'PRI' ELSE '' END
FROM user_tab_columns c
LEFT JOIN user_constraints ac ON ac.table_name = UPPER(:1) AND ac.constraint_type = 'P'
LEFT JOIN user_cons_columns acc ON acc.constraint_name = ac.constraint_name
AND acc.table_name = ac.table_name AND acc.column_name = c.column_name
WHERE c.table_name = UPPER(:2)
ORDER BY c.column_id`, t, t)
} else {
rows, err = dbh.Query(`
SELECT column_name, data_type, nullable, data_default
FROM all_tab_columns WHERE owner = UPPER(:1) AND table_name = UPPER(:2)
ORDER BY column_id`, owner, t)
rows, err = dbh.QueryContext(ctx, `
SELECT c.column_name, c.data_type, c.nullable, c.data_default,
CASE WHEN acc.column_name IS NOT NULL THEN 'PRI' ELSE '' END
FROM all_tab_columns c
LEFT JOIN all_constraints ac ON ac.owner = UPPER(:1) AND ac.table_name = UPPER(:2) AND ac.constraint_type = 'P'
LEFT JOIN all_cons_columns acc ON acc.owner = ac.owner AND acc.constraint_name = ac.constraint_name
AND acc.column_name = c.column_name
WHERE c.owner = UPPER(:3) AND c.table_name = UPPER(:4)
ORDER BY c.column_id`, owner, t, owner, t)
}
if err != nil {
return nil, err
@@ -127,12 +139,12 @@ func listColumnsOracle(dbh *sql.DB, schema, table string) ([]ColumnInfo, error)
defer rows.Close()
var out []ColumnInfo
for rows.Next() {
var name, typ, nullable string
var name, typ, nullable, key string
var def sql.NullString
if err := rows.Scan(&name, &typ, &nullable, &def); err != nil {
if err := rows.Scan(&name, &typ, &nullable, &def, &key); err != nil {
return nil, err
}
col := ColumnInfo{Name: name, Type: typ, Nullable: nullable == "Y"}
col := ColumnInfo{Name: name, Type: typ, Nullable: nullable == "Y", Key: key}
if def.Valid {
col.Default = def.String
}
+23 -15
View File
@@ -1,6 +1,7 @@
package db
import (
"context"
"database/sql"
"fmt"
"strings"
@@ -36,6 +37,9 @@ func openPostgres(c ConnReq) (*sql.DB, error) {
case "verify-ca":
sslmode = "verify-ca"
if strings.TrimSpace(c.SSLCA) == "" {
if strings.TrimSpace(c.SSLCAContent) != "" {
return nil, fmt.Errorf("verify-ca 模式需要 CA 证书路径(pg 驱动暂不支持证书内容,请改用 sslCA)")
}
return nil, fmt.Errorf("verify-ca 模式需要填写 CA 证书路径")
}
default:
@@ -68,8 +72,8 @@ func pqEscape(v string) string {
return "'" + strings.ReplaceAll(strings.ReplaceAll(v, `\`, `\\`), `'`, `\'`) + "'"
}
func listDatabasesPostgres(db *sql.DB) ([]string, error) {
rows, err := db.Query(`SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY 1`)
func listDatabasesPostgres(ctx context.Context, db *sql.DB) ([]string, error) {
rows, err := db.QueryContext(ctx, `SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY 1`)
if err != nil {
return nil, err
}
@@ -85,10 +89,10 @@ func listDatabasesPostgres(db *sql.DB) ([]string, error) {
return out, rows.Err()
}
func listTablesPostgres(db *sql.DB, database string) ([]string, error) {
func listTablesPostgres(ctx context.Context, db *sql.DB, database string) ([]string, error) {
// 当前连接库下的 public 表;切库需重新 Open
_ = database
rows, err := db.Query(`
rows, err := db.QueryContext(ctx, `
SELECT CASE WHEN schemaname = 'public' THEN tablename ELSE schemaname || '.' || tablename END
FROM pg_catalog.pg_tables
WHERE schemaname NOT IN ('pg_catalog','information_schema')
@@ -108,33 +112,37 @@ func listTablesPostgres(db *sql.DB, database string) ([]string, error) {
return out, rows.Err()
}
func listColumnsPostgres(dbh *sql.DB, _, table string) ([]ColumnInfo, error) {
func listColumnsPostgres(ctx context.Context, dbh *sql.DB, _, table string) ([]ColumnInfo, error) {
schema, t := splitSchemaTable(table)
if schema == "" {
schema = "public"
}
rows, err := dbh.Query(`
SELECT column_name, data_type,
COALESCE(character_maximum_length::text, numeric_precision::text, ''),
is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = $1 AND table_name = $2
ORDER BY ordinal_position`, schema, t)
// to_regclass 解析失败(表不存在/跨 schema)返回 NULL 而非报错,主键标记自然落空
rows, err := dbh.QueryContext(ctx, `
SELECT c.column_name, c.data_type,
COALESCE(c.character_maximum_length::text, c.numeric_precision::text, ''),
c.is_nullable, c.column_default,
CASE WHEN a.attname IS NOT NULL THEN 'PRI' ELSE '' END
FROM information_schema.columns c
LEFT JOIN pg_index i ON i.indrelid = to_regclass(quote_ident($1)||'.'||quote_ident($2)) AND i.indisprimary
LEFT JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attname = c.column_name AND a.attnum = ANY(i.indkey)
WHERE c.table_schema = $1 AND c.table_name = $2
ORDER BY c.ordinal_position`, schema, t)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ColumnInfo
for rows.Next() {
var name, typ, extra, nullable string
var name, typ, extra, nullable, key string
var def sql.NullString
if err := rows.Scan(&name, &typ, &extra, &nullable, &def); err != nil {
if err := rows.Scan(&name, &typ, &extra, &nullable, &def, &key); err != nil {
return nil, err
}
if extra != "" {
typ = typ + "(" + extra + ")"
}
col := ColumnInfo{Name: name, Type: typ, Nullable: nullable == "YES"}
col := ColumnInfo{Name: name, Type: typ, Nullable: nullable == "YES", Key: key}
if def.Valid {
col.Default = def.String
}
File diff suppressed because it is too large. Load diff
+164
View File
@@ -0,0 +1,164 @@
package db
import (
"context"
"net"
"strconv"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
)
func redisTestReq(t *testing.T, mr *miniredis.Miniredis) ConnReq {
t.Helper()
host, portStr, err := net.SplitHostPort(mr.Addr())
if err != nil {
t.Fatal(err)
}
port, _ := strconv.Atoi(portStr)
return ConnReq{Driver: "redis", Host: host, Port: port, Database: "0"}
}
// 写命令白名单:SET EX / HSET / EXPIRE / MSET / INCR 等应直接可用。
func TestExecRedisWriteWhitelist(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Fatal(err)
}
defer mr.Close()
base := redisTestReq(t, mr)
ctx := context.Background()
run := func(sql string) ExecResult {
c := base
c.SQL = sql
return execRedis(ctx, c)
}
if r := run(`SET k1 "v with space" EX 100`); r.Error != "" {
t.Fatalf("SET EX: %s", r.Error)
}
if v, _ := mr.Get("k1"); v != "v with space" {
t.Fatalf("SET 引号分组值被破坏: %q", v)
}
if ttl := mr.TTL("k1"); ttl != 100*time.Second {
t.Fatalf("SET EX TTL: %v", ttl)
}
if r := run("HSET h1 f1 v1 f2 v2"); r.Error != "" {
t.Fatalf("HSET: %s", r.Error)
}
if mr.HGet("h1", "f2") != "v2" {
t.Fatal("HSET 未生效")
}
if r := run("EXPIRE h1 60"); r.Error != "" {
t.Fatalf("EXPIRE: %s", r.Error)
}
if ttl := mr.TTL("h1"); ttl != 60*time.Second {
t.Fatalf("EXPIRE 未生效: %v", ttl)
}
if r := run("PERSIST h1"); r.Error != "" {
t.Fatalf("PERSIST: %s", r.Error)
}
if ttl := mr.TTL("h1"); ttl != 0 {
t.Fatalf("PERSIST 未生效: %v", ttl)
}
if r := run("MSET a 1 b 2"); r.Error != "" {
t.Fatalf("MSET: %s", r.Error)
}
if r := run("MGET a b"); r.Error != "" || r.RowCount != 2 {
t.Fatalf("MGET: %#v", r)
}
if r := run("INCR n1"); r.Error != "" {
t.Fatalf("INCR: %s", r.Error)
}
if r := run("SADD s1 m1 m2"); r.Error != "" {
t.Fatalf("SADD: %s", r.Error)
}
if r := run("ZADD z1 1.5 zm"); r.Error != "" {
t.Fatalf("ZADD: %s", r.Error)
}
if r := run("LPUSH l1 x y"); r.Error != "" {
t.Fatalf("LPUSH: %s", r.Error)
}
if r := run("RENAME a a2"); r.Error != "" {
t.Fatalf("RENAME: %s", r.Error)
}
if !mr.Exists("a2") {
t.Fatal("RENAME 未生效")
}
}
// 危险命令二段确认:FLUSHALL/KEYS 未确认不执行,确认后放行。
func TestExecRedisDangerConfirm(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Fatal(err)
}
defer mr.Close()
base := redisTestReq(t, mr)
ctx := context.Background()
_ = mr.Set("k", "v")
run := func(sql string, confirm bool) ExecResult {
c := base
c.SQL = sql
c.ConfirmDanger = confirm
return execRedis(ctx, c)
}
if r := run("FLUSHALL", false); !r.NeedConfirm {
t.Fatalf("FLUSHALL 未确认应 needConfirm: %#v", r)
}
if !mr.Exists("k") {
t.Fatal("未确认的 FLUSHALL 不应执行")
}
if r := run("KEYS *", false); !r.NeedConfirm {
t.Fatalf("KEYS 未确认应 needConfirm: %#v", r)
}
if r := run("KEYS *", true); r.Error != "" || r.RowCount != 1 {
t.Fatalf("KEYS 确认后应放行: %#v", r)
}
if r := run("FLUSHDB", true); r.Error != "" {
t.Fatalf("FLUSHDB 确认后应放行: %#v", r)
}
if mr.Exists("k") {
t.Fatal("确认后的 FLUSHDB 应已执行")
}
}
func TestExecRedisGenericDoAndScan(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Fatal(err)
}
defer mr.Close()
base := redisTestReq(t, mr)
ctx := context.Background()
_ = mr.Set("user:1", "a")
_ = mr.Set("user:2", "b")
_ = mr.Set("other", "c")
run := func(sql string) ExecResult {
c := base
c.SQL = sql
return execRedis(ctx, c)
}
if r := run("EXISTS user:1"); r.Error != "" || r.RowCount != 1 {
t.Fatalf("EXISTS via Do: %#v", r)
}
if r := run("SCAN 0 MATCH user:* COUNT 50"); r.Error != "" {
t.Fatalf("SCAN MATCH: %#v", r)
} else {
if r.RowCount != 2 {
t.Fatalf("SCAN MATCH want 2 got %d %#v", r.RowCount, r.Rows)
}
}
if r := run("ECHO hello"); r.Error != "" {
t.Fatalf("ECHO via Do: %#v", r)
}
}
+12 -8
View File
@@ -1,6 +1,7 @@
package db
import (
"context"
"net"
"strconv"
"testing"
@@ -28,7 +29,7 @@ func TestListGetDeleteRedisKeys(t *testing.T) {
port, _ := strconv.Atoi(portStr)
base := ConnReq{Driver: "redis", Host: host, Port: port, Database: "0"}
list := ListRedisKeys(base)
list := ListRedisKeys(context.Background(), base)
if list.Error != "" {
t.Fatal(list.Error)
}
@@ -50,33 +51,36 @@ func TestListGetDeleteRedisKeys(t *testing.T) {
t.Fatalf("unexpected ttl %d", byName["str:a"].TTL)
}
gv := GetRedisKey(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "str:a"})
gv := GetRedisKey(context.Background(), ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "str:a"})
if gv.Error != "" || gv.RowCount != 1 || gv.Rows[0]["value"] != "hello" {
t.Fatalf("get string %#v", gv)
}
gh := GetRedisKey(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "hash:b"})
gh := GetRedisKey(context.Background(), ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "hash:b"})
if gh.Error != "" || gh.RowCount < 1 || gh.Columns[0] != "field" {
t.Fatalf("get hash %#v", gh)
}
gl := GetRedisKey(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "list:c"})
if gh.Rows[0]["field"] != "f1" {
t.Fatalf("hash fields should be sorted, got %#v", gh.Rows)
}
gl := GetRedisKey(context.Background(), ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "list:c"})
if gl.Error != "" || gl.RowCount < 2 {
t.Fatalf("get list %#v", gl)
}
gs := GetRedisKey(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "set:d"})
gs := GetRedisKey(context.Background(), ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "set:d"})
if gs.Error != "" || gs.RowCount < 2 {
t.Fatalf("get set %#v", gs)
}
gz := GetRedisKey(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "zset:e"})
gz := GetRedisKey(context.Background(), ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "zset:e"})
if gz.Error != "" || gz.RowCount != 1 {
t.Fatalf("get zset %#v", gz)
}
pat := ListRedisKeys(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Pattern: "str:*"})
pat := ListRedisKeys(context.Background(), ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Pattern: "str:*"})
if pat.Error != "" || len(pat.Keys) != 1 || pat.Keys[0].Name != "str:a" {
t.Fatalf("pattern %#v", pat)
}
del := DeleteRedisKey(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "str:a"})
del := DeleteRedisKey(context.Background(), ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "str:a"})
if del.Error != "" {
t.Fatal(del.Error)
}
@@ -0,0 +1,28 @@
package db
import (
"strings"
"testing"
)
func TestParseRedisAddrs(t *testing.T) {
got := parseRedisAddrs("127.0.0.1:26379\n127.0.0.1:26380, 10.0.0.1:26379")
if len(got) != 3 {
t.Fatalf("want 3 addrs, got %#v", got)
}
}
func TestOpenRedisModeValidation(t *testing.T) {
_, err := openRedis(ConnReq{Driver: "redis", RedisMode: "sentinel"})
if err == nil || !strings.Contains(err.Error(), "Sentinel") {
t.Fatalf("sentinel without nodes: %v", err)
}
_, err = openRedis(ConnReq{Driver: "redis", RedisMode: "sentinel", RedisNodes: "127.0.0.1:26379"})
if err == nil || !strings.Contains(err.Error(), "masterName") {
t.Fatalf("sentinel without master: %v", err)
}
_, err = openRedis(ConnReq{Driver: "redis", RedisMode: "cluster"})
if err == nil || !strings.Contains(err.Error(), "Cluster") {
t.Fatalf("cluster without nodes: %v", err)
}
}
+131
View File
@@ -0,0 +1,131 @@
package db
import (
"context"
"database/sql"
"strings"
)
// ObjectHit 对象搜索结果条目。
type ObjectHit struct {
Schema string `json:"schema"`
Name string `json:"name"`
Type string `json:"type"` // table | view
}
// SearchObjects 按关键词模糊搜索表/视图,最多返回 50 条。redis 返回空。
func SearchObjects(ctx context.Context, c ConnReq) ([]ObjectHit, error) {
ctx = reqCtx(ctx)
if isRedis(c.Driver) {
return []ObjectHit{}, nil
}
var out []ObjectHit
err := withDB(c, func(db *sql.DB) error {
var err error
switch {
case isMySQLFamily(c.Driver):
out, err = searchObjectsMySQL(ctx, db, c.Database, c.Keyword)
case isPostgresFamily(c.Driver):
out, err = searchObjectsPostgres(ctx, db, c.Keyword)
case isMSSQL(c.Driver):
out, err = searchObjectsMSSQL(ctx, db, c.Keyword)
case driverKey(c.Driver) == "oracle":
out, err = searchObjectsOracle(ctx, db, c.Database, c.User, c.Keyword)
case driverKey(c.Driver) == "sqlite":
out, err = searchObjectsSQLite(ctx, db, c.Keyword)
default:
err = unsupportedDriverErr(c)
}
return err
})
if err != nil {
return nil, err
}
if out == nil {
out = []ObjectHit{}
}
return out, nil
}
func likePattern(keyword string) string {
return "%" + keyword + "%"
}
// hitType information_schema 的 table_type 含 VIEW 即为视图。
func hitType(tableType string) string {
if strings.Contains(strings.ToUpper(tableType), "VIEW") {
return "view"
}
return "table"
}
func searchObjectsMySQL(ctx context.Context, dbh *sql.DB, database, keyword string) ([]ObjectHit, error) {
// database 为空时用当前库 DATABASE()
q := `SELECT table_schema, table_name, table_type FROM information_schema.TABLES
WHERE table_schema = DATABASE() AND table_name LIKE ? LIMIT 50`
args := []interface{}{likePattern(keyword)}
if strings.TrimSpace(database) != "" {
q = `SELECT table_schema, table_name, table_type FROM information_schema.TABLES
WHERE table_schema = ? AND table_name LIKE ? LIMIT 50`
args = []interface{}{database, likePattern(keyword)}
}
return scanObjectHits(ctx, dbh, q, args, "mysql")
}
func searchObjectsPostgres(ctx context.Context, dbh *sql.DB, keyword string) ([]ObjectHit, error) {
return scanObjectHits(ctx, dbh,
`SELECT table_schema, table_name, table_type FROM information_schema.tables
WHERE table_schema NOT IN ('pg_catalog','information_schema') AND table_name LIKE $1 LIMIT 50`,
[]interface{}{likePattern(keyword)}, "pg")
}
func searchObjectsMSSQL(ctx context.Context, dbh *sql.DB, keyword string) ([]ObjectHit, error) {
// MSSQL 无 LIMIT,用 TOP 50
return scanObjectHits(ctx, dbh,
`SELECT TOP 50 TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES
WHERE TABLE_NAME LIKE @p1`,
[]interface{}{likePattern(keyword)}, "mssql")
}
func searchObjectsOracle(ctx context.Context, dbh *sql.DB, database, user, keyword string) ([]ObjectHit, error) {
owner := strings.ToUpper(strings.TrimSpace(database))
if owner == "" {
owner = strings.ToUpper(strings.TrimSpace(user))
}
return scanObjectHits(ctx, dbh,
`SELECT owner, table_name, 'table' FROM all_tables WHERE owner = :1 AND table_name LIKE :2
UNION ALL
SELECT owner, view_name, 'view' FROM all_views WHERE owner = :3 AND view_name LIKE :4
FETCH FIRST 50 ROWS ONLY`,
[]interface{}{owner, likePattern(keyword), owner, likePattern(keyword)}, "oracle")
}
func searchObjectsSQLite(ctx context.Context, dbh *sql.DB, keyword string) ([]ObjectHit, error) {
return scanObjectHits(ctx, dbh,
`SELECT '', name, type FROM sqlite_master WHERE name LIKE ? AND type IN ('table','view') LIMIT 50`,
[]interface{}{likePattern(keyword)}, "sqlite")
}
func scanObjectHits(ctx context.Context, dbh *sql.DB, q string, args []interface{}, dialect string) ([]ObjectHit, error) {
rows, err := dbh.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ObjectHit{}
for rows.Next() {
var schema, name, typ string
if err := rows.Scan(&schema, &name, &typ); err != nil {
return nil, err
}
t := "table"
switch dialect {
case "sqlite":
t = typ // sqlite_master.type 直接是 table/view
default:
t = hitType(typ)
}
out = append(out, ObjectHit{Schema: schema, Name: name, Type: t})
}
return out, rows.Err()
}
+55
View File
@@ -0,0 +1,55 @@
package db
import (
"context"
"testing"
)
// R2-B6:sqlite 搜索命中表与视图,类型区分正确。
func TestSearchObjectsSQLite(t *testing.T) {
c := newSQLiteFile(t,
`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`,
`CREATE TABLE orders (id INTEGER PRIMARY KEY)`,
`CREATE VIEW v_users AS SELECT id, name FROM users`,
)
c.Keyword = "user"
hits, err := SearchObjects(context.Background(), c)
if err != nil {
t.Fatal(err)
}
if len(hits) != 2 {
t.Fatalf("应命中 2 个(user 前缀的表和视图): %+v", hits)
}
byName := map[string]ObjectHit{}
for _, h := range hits {
byName[h.Name] = h
}
if byName["users"].Type != "table" {
t.Fatalf("users 应为 table: %+v", byName["users"])
}
if byName["v_users"].Type != "view" {
t.Fatalf("v_users 应为 view: %+v", byName["v_users"])
}
// 不命中
c.Keyword = "nothing_match"
hits, err = SearchObjects(context.Background(), c)
if err != nil {
t.Fatal(err)
}
if len(hits) != 0 {
t.Fatalf("不应命中: %+v", hits)
}
}
// redis 返回空结果而非报错。
func TestSearchObjectsRedis(t *testing.T) {
c := ConnReq{Driver: "redis", Keyword: "k"}
hits, err := SearchObjects(context.Background(), c)
if err != nil {
t.Fatal(err)
}
if len(hits) != 0 {
t.Fatalf("redis 应返回空: %+v", hits)
}
}
+209 -19
View File
@@ -1,10 +1,12 @@
package db
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"strconv"
"sync"
"time"
@@ -14,14 +16,21 @@ import (
const sessionTTL = 45 * time.Minute
const maxSessions = 64
// maxPoolHandles 每会话缓存的 *sql.DB 句柄上限(按 database 分键),超出时关闭最久未用。
const maxPoolHandles = 8
type Session struct {
ID string
Conn ConnReq
mu sync.Mutex
sqlPool map[string]*sql.DB
redisCli *redis.Client
tunnel *SSHTunnel
LastUsed time.Time
ID string
Conn ConnReq
mu sync.Mutex
sqlPool map[string]*sql.DB
poolUsed map[string]time.Time // sqlPool 各键最近使用时间,供 LRU 轮换
redisClis map[int]redis.UniversalClient // 按 DB 序号缓存的 Redis 客户端(DB 是连接级参数)
// agentHandles: agent 驱动(如达梦)按 database 缓存的 JDBC 句柄;
// JVM 进程走全局池(agentPool),会话关闭只关句柄、不杀进程。
agentHandles map[string]string
tunnel *SSHTunnel
LastUsed time.Time
}
var (
@@ -39,12 +48,14 @@ func init() {
}()
}
func newSessionID() string {
// newSessionID 生成 128bit 随机会话 ID;crypto/rand 失败时返回错误,
// 不再静默降级为低熵时间戳。
func newSessionID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
return "", fmt.Errorf("生成会话 ID 失败: %w", err)
}
return hex.EncodeToString(b[:])
return hex.EncodeToString(b[:]), nil
}
func OpenSession(c ConnReq) (string, error) {
@@ -54,6 +65,10 @@ func OpenSession(c ConnReq) (string, error) {
c.Pattern = ""
c.SessionID = ""
// agent 驱动(JDBC 桥)不走本地 SSH 端口转发(转发对 JVM 侧 TCP 无效)
if isAgentDriver(c.Driver) && c.SSHEnabled {
return "", fmt.Errorf("%s 走 JDBC agent,暂不支持 SSH 隧道", c.Driver)
}
dial, tun, err := ApplySSHTunnel(c)
if err != nil {
return "", err
@@ -73,16 +88,25 @@ func OpenSession(c ConnReq) (string, error) {
}
return "", fmt.Errorf("连接会话已满,请关闭不用的连接后重试")
}
id := newSessionID()
id, err := newSessionID()
if err != nil {
sessionsMu.Unlock()
if tun != nil {
tun.Close()
}
return "", err
}
// Persist dialed endpoints (127.0.0.1:local) so later ops reuse the tunnel.
stored := dial
stored.SSHEnabled = false
s := &Session{
ID: id,
Conn: stored,
sqlPool: map[string]*sql.DB{},
tunnel: tun,
LastUsed: time.Now(),
ID: id,
Conn: stored,
sqlPool: map[string]*sql.DB{},
poolUsed: map[string]time.Time{},
agentHandles: map[string]string{},
tunnel: tun,
LastUsed: time.Now(),
}
sessions[id] = s
sessionsMu.Unlock()
@@ -143,9 +167,21 @@ func (s *Session) closeAll() {
_ = dbh.Close()
delete(s.sqlPool, k)
}
if s.redisCli != nil {
_ = s.redisCli.Close()
s.redisCli = nil
s.poolUsed = map[string]time.Time{}
for k, cli := range s.redisClis {
_ = cli.Close()
delete(s.redisClis, k)
}
// agent 句柄随会话释放;JVM 进程归全局池,进程生命周期不挂钩会话
if len(s.agentHandles) > 0 {
if cli, err := getAgentClient(s.Conn.Driver); err == nil {
for _, h := range s.agentHandles {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
_ = cli.call(ctx, "close", map[string]any{"handle": h}, nil)
cancel()
}
}
s.agentHandles = map[string]string{}
}
if s.tunnel != nil {
s.tunnel.Close()
@@ -153,6 +189,151 @@ func (s *Session) closeAll() {
}
}
// agentConn 按 database 懒建/复用 agent JDBC 句柄(模式同 sqlDB)。
// 调用方不得持有 s.mu(本函数内部自行加锁;getAgentClient 用独立的全局池锁)。
func (s *Session) agentConn(c ConnReq) (*AgentClient, string, error) {
cli, err := getAgentClient(c.Driver)
if err != nil {
return nil, "", err
}
key := c.Database
s.mu.Lock()
if h, ok := s.agentHandles[key]; ok {
s.mu.Unlock()
return cli, h, nil
}
s.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
h, err := agentOpen(ctx, cli, c)
if err != nil {
return nil, "", err
}
s.mu.Lock()
defer s.mu.Unlock()
if old, ok := s.agentHandles[key]; ok {
// 双检:并发下已有别的 goroutine 建好同键句柄,回收多余句柄
go func() {
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel2()
_ = cli.call(ctx2, "close", map[string]any{"handle": h}, nil)
}()
return cli, old, nil
}
s.agentHandles[key] = h
return cli, h, nil
}
// touch 滚动续期(GetSession 已调用,池化路径同样经 GetSession 进入)。
func (s *Session) touch() {
s.mu.Lock()
s.LastUsed = time.Now()
s.mu.Unlock()
}
// sqlDB 按 database 懒加载缓存 *sql.DB,会话内复用连接池;
// 超过 maxPoolHandles 个句柄时关闭最久未用的池。
func (s *Session) sqlDB(database string) (*sql.DB, error) {
// SQLite 的 database 是文件路径,对象树的 "main" 不能覆盖它
if driverKey(s.Conn.Driver) == "sqlite" {
database = ""
}
s.mu.Lock()
if dbh, ok := s.sqlPool[database]; ok {
s.poolUsed[database] = time.Now()
s.mu.Unlock()
return dbh, nil
}
s.mu.Unlock()
conn := s.Conn
if database != "" {
conn.Database = database
}
dbh, err := Open(conn)
if err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
// 双检:并发下可能已有别的 goroutine 建好同键池
if old, ok := s.sqlPool[database]; ok {
s.poolUsed[database] = time.Now()
_ = dbh.Close()
return old, nil
}
// 超上限先淘汰最久未用
for len(s.sqlPool) >= maxPoolHandles {
var lruKey string
var lruTime time.Time
for k := range s.sqlPool {
t := s.poolUsed[k]
if lruKey == "" || t.Before(lruTime) {
lruKey, lruTime = k, t
}
}
_ = s.sqlPool[lruKey].Close()
delete(s.sqlPool, lruKey)
delete(s.poolUsed, lruKey)
}
s.sqlPool[database] = dbh
s.poolUsed[database] = time.Now()
return dbh, nil
}
// redisClient 懒建会话级 Redis 单例,避免每次命令新建连接。
func (s *Session) redisClient() (redis.UniversalClient, error) {
// 按连接配置缓存:DB 序号是 go-redis 的连接级参数(建连时钉死),
// 对象树里切换 db0/db1 必须各建一个客户端,否则全部打到首个 db。
return s.redisClientFor(s.Conn)
}
func (s *Session) redisClientFor(c ConnReq) (redis.UniversalClient, error) {
db := redisDBIndex(c)
s.mu.Lock()
defer s.mu.Unlock()
if s.redisClis == nil {
s.redisClis = map[int]redis.UniversalClient{}
}
if cli, ok := s.redisClis[db]; ok {
return cli, nil
}
// 以会话凭据 + 目标 DB 建连(Sentinel/Cluster 模式 DB 恒为 0,天然只有一份)
cli, err := openRedis(ConnReq{
Driver: s.Conn.Driver,
Host: s.Conn.Host,
Port: s.Conn.Port,
User: s.Conn.User,
Password: s.Conn.Password,
Database: strconv.Itoa(db),
RedisMode: s.Conn.RedisMode,
RedisNodes: s.Conn.RedisNodes,
MasterName: s.Conn.MasterName,
SentinelPassword: s.Conn.SentinelPassword,
SSL: s.Conn.SSL,
SSLMode: s.Conn.SSLMode,
SSLCA: s.Conn.SSLCA,
SSLCert: s.Conn.SSLCert,
SSLKey: s.Conn.SSLKey,
SSHEnabled: s.Conn.SSHEnabled,
SSHHost: s.Conn.SSHHost,
SSHPort: s.Conn.SSHPort,
SSHUser: s.Conn.SSHUser,
SSHAuth: s.Conn.SSHAuth,
SSHPassword: s.Conn.SSHPassword,
SSHPrivateKey: s.Conn.SSHPrivateKey,
SSHPassphrase: s.Conn.SSHPassphrase,
})
if err != nil {
return nil, err
}
s.redisClis[db] = cli
return cli, nil
}
func (s *Session) ConnFor(req ConnReq) ConnReq {
c := s.Conn
// SQLite 的 database 是文件路径,对象树里的 "main" 不能覆盖它
@@ -163,7 +344,16 @@ func (s *Session) ConnFor(req ConnReq) ConnReq {
c.Table = req.Table
c.Key = req.Key
c.Pattern = req.Pattern
c.Keyword = req.Keyword
c.Format = req.Format
c.Limit = req.Limit
c.Offset = req.Offset
c.ConfirmDanger = req.ConfirmDanger
// 逐请求字段:新增 ConnReq 字段时若属于"每次请求携带"语义,必须在此补拷贝,
// 否则会话路径会静默丢弃(前端全部走会话,等于功能失效)
c.TimeoutMs = req.TimeoutMs
c.Cursor = req.Cursor
c.RequestID = req.RequestID
c.SessionID = s.ID
return c
}
+244
View File
@@ -0,0 +1,244 @@
package db
import (
"context"
"database/sql"
"os"
"path/filepath"
"testing"
"time"
)
// touchFile 先建空文件(零字节即合法 SQLite 库),绕开存在性检查。
func touchFile(t *testing.T, path string) {
t.Helper()
if err := os.WriteFile(path, []byte{}, 0600); err != nil {
t.Fatal(err)
}
}
func newTestSession(driver, database string) *Session {
return &Session{
ID: "test-pool",
Conn: ConnReq{Driver: driver, Database: database},
sqlPool: map[string]*sql.DB{},
poolUsed: map[string]time.Time{},
LastUsed: time.Now(),
}
}
// 池化:同 database 复用同一 *sql.DB 句柄。
func TestSessionSqlDBReuse(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "pool.db")
touchFile(t, dbFile)
s := newTestSession("sqlite", dbFile)
defer s.closeAll()
a, err := s.sqlDB("")
if err != nil {
t.Fatal(err)
}
b, err := s.sqlDB("")
if err != nil {
t.Fatal(err)
}
if a != b {
t.Fatal("同 database 应复用同一 *sql.DB 句柄")
}
// sqlite 下 database 参数不覆盖文件路径
c, err := s.sqlDB("main")
if err != nil {
t.Fatal(err)
}
if c != a {
t.Fatal("sqlite 会话应忽略 database 复用同一句柄")
}
}
// 池化上限:超过 8 个句柄时关闭最久未用(LRU 轮换)。
func TestSessionSqlDBLRU(t *testing.T) {
// mysql 驱动 sql.Open 不立即建连,可离线验证池管理逻辑
s := newTestSession("mysql", "")
defer s.closeAll()
first, err := s.sqlDB("db0")
if err != nil {
t.Fatal(err)
}
for i := 1; i <= maxPoolHandles; i++ {
time.Sleep(2 * time.Millisecond) // 保证 LRU 时间序确定
if _, err := s.sqlDB(string(rune('a' + i))); err != nil {
t.Fatal(err)
}
}
s.mu.Lock()
n := len(s.sqlPool)
_, firstAlive := s.sqlPool["db0"]
s.mu.Unlock()
if n > maxPoolHandles {
t.Fatalf("池句柄数 %d 超上限 %d", n, maxPoolHandles)
}
if firstAlive {
t.Fatal("最久未用句柄应被淘汰")
}
_ = first
}
// 会话路径端到端:ExecSQL 走池化,两次执行共享句柄且结果正确。
func TestExecSQLSessionPooling(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "exec.db")
touchFile(t, dbFile)
id, err := OpenSession(ConnReq{Driver: "sqlite", Database: dbFile})
if err != nil {
t.Fatal(err)
}
defer CloseSession(id)
mk := func(sqlText string) ConnReq {
c, err := ResolveConn(ConnReq{SessionID: id, SQL: sqlText})
if err != nil {
t.Fatal(err)
}
return c
}
if r := ExecSQL(context.Background(), mk("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")); r.Error != "" {
t.Fatal(r.Error)
}
if r := ExecSQL(context.Background(), mk("INSERT INTO t (v) VALUES ('a'),('b'),('c')")); r.Error != "" {
t.Fatal(r.Error)
}
r := ExecSQL(context.Background(), mk("SELECT * FROM t ORDER BY id"))
if r.Error != "" || r.RowCount != 3 {
t.Fatalf("查询结果错误: %#v", r)
}
s, err := GetSession(id)
if err != nil {
t.Fatal(err)
}
s.mu.Lock()
poolSize := len(s.sqlPool)
s.mu.Unlock()
if poolSize != 1 {
t.Fatalf("会话池应仅 1 个句柄,实际 %d", poolSize)
}
// offset 分页:跳过前 1 行取 1 行
p := mk("SELECT * FROM t ORDER BY id")
p.Offset = 1
p.Limit = 1
r2 := ExecSQL(context.Background(), p)
if r2.Error != "" || r2.RowCount != 1 {
t.Fatalf("offset 分页错误: %#v", r2)
}
if r2.Rows[0]["v"] != "b" {
t.Fatalf("offset=1 应返回第 2 行,实际 %#v", r2.Rows[0])
}
if r2.Offset != 1 || !r2.Truncated {
t.Fatalf("分页元数据错误: offset=%d truncated=%v", r2.Offset, r2.Truncated)
}
// 会话路径危险确认:ConfirmDanger 必须经 ConnFor 透传
d := mk("DROP TABLE t")
if r := ExecSQL(context.Background(), d); !r.NeedConfirm {
t.Fatalf("会话路径未确认应拦截: %#v", r)
}
d.ConfirmDanger = true
if r := ExecSQL(context.Background(), d); r.Error != "" || r.NeedConfirm {
t.Fatalf("会话路径确认后应放行: %#v", r)
}
// 逐请求字段透传:RequestID/TimeoutMs 丢失会让取消/超时在前端(全走会话路径)静默失效
s2, err := GetSession(id)
if err != nil {
t.Fatal(err)
}
c2 := s2.ConnFor(ConnReq{Driver: "sqlite", SessionID: id, SQL: "SELECT 1", RequestID: "req-1", TimeoutMs: 5000})
if c2.RequestID != "req-1" || c2.TimeoutMs != 5000 {
t.Fatalf("ConnFor 应透传 RequestID/TimeoutMs: %#v", c2)
}
}
// 危险语句二段流:未确认拦截,确认后放行。
func TestExecSQLDangerConfirm(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "danger.db")
touchFile(t, dbFile)
c := ConnReq{Driver: "sqlite", Database: dbFile}
r := ExecSQL(context.Background(), ConnReq{Driver: "sqlite", Database: dbFile, SQL: "CREATE TABLE t (id int)"})
if r.Error != "" {
t.Fatal(r.Error)
}
// 未确认:DROP 不执行
r = ExecSQL(context.Background(), ConnReq{Driver: "sqlite", Database: dbFile, SQL: "DROP TABLE t"})
if !r.NeedConfirm {
t.Fatalf("应 needConfirm: %#v", r)
}
if r.Error == "" {
t.Fatal("应返回命中原因")
}
// 表仍在
r = ExecSQL(context.Background(), ConnReq{Driver: "sqlite", Database: dbFile, SQL: "SELECT count(*) AS n FROM t"})
if r.Error != "" {
t.Fatalf("DROP 不应已执行: %s", r.Error)
}
// 确认后放行
c.SQL = "DROP TABLE t"
c.ConfirmDanger = true
r = ExecSQL(context.Background(), c)
if r.Error != "" || r.NeedConfirm {
t.Fatalf("确认后应放行: %#v", r)
}
// 表已删
r = ExecSQL(context.Background(), ConnReq{Driver: "sqlite", Database: dbFile, SQL: "SELECT count(*) FROM t"})
if r.Error == "" {
t.Fatal("确认后 DROP 应已生效")
}
}
// bigint 精度:超 2^53 的 int64 以字符串无损返回。
func TestExecSQLBigint(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "bigint.db")
touchFile(t, dbFile)
mk := func(sqlText string) ConnReq {
return ConnReq{Driver: "sqlite", Database: dbFile, SQL: sqlText}
}
if r := ExecSQL(context.Background(), mk("CREATE TABLE b (id INTEGER PRIMARY KEY)")); r.Error != "" {
t.Fatal(r.Error)
}
if r := ExecSQL(context.Background(), mk("INSERT INTO b (id) VALUES (9223372036854775807)")); r.Error != "" {
t.Fatal(r.Error)
}
r := ExecSQL(context.Background(), mk("SELECT id FROM b"))
if r.Error != "" {
t.Fatal(r.Error)
}
if r.Rows[0]["id"] != "9223372036854775807" {
t.Fatalf("bigint 应字符串保真: %#v", r.Rows[0]["id"])
}
}
// EXPLAIN:sqlite 行列计划非空。
func TestExplainSQLite(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "explain.db")
touchFile(t, dbFile)
c := ConnReq{Driver: "sqlite", Database: dbFile, SQL: "CREATE TABLE e (id int)"}
if r := ExecSQL(context.Background(), c); r.Error != "" {
t.Fatal(r.Error)
}
r := ExplainSQL(context.Background(), ConnReq{Driver: "sqlite", Database: dbFile, SQL: "SELECT * FROM e"})
if r.Error != "" {
t.Fatal(r.Error)
}
if len(r.Rows) == 0 {
t.Fatal("sqlite EXPLAIN QUERY PLAN 应返回行列计划")
}
}
// SQLite 打开不存在文件应报错而非静默建空库。
func TestSQLiteMissingFile(t *testing.T) {
_, err := Open(ConnReq{Driver: "sqlite", Database: filepath.Join(t.TempDir(), "not-exist", "x.db")})
if err == nil {
t.Fatal("不存在的 sqlite 路径应报错")
}
}
+32 -5
View File
@@ -1,12 +1,17 @@
package db
import (
"context"
"database/sql"
"fmt"
"os"
"strings"
_ "modernc.org/sqlite"
)
// openSQLite 打开 SQLite 库文件;文件不存在时报错而非静默建空库。
// DSN 追加 busy_timeout / foreign_keys pragma。
func openSQLite(c ConnReq) (*sql.DB, error) {
path := c.Database
if path == "" {
@@ -15,15 +20,37 @@ func openSQLite(c ConnReq) (*sql.DB, error) {
if path == "" {
return nil, fmt.Errorf("SQLite 需要 database 填文件路径")
}
return sql.Open("sqlite", path)
dsn := path
switch {
case path == ":memory:":
// 统一走 file: URI 以便携带 _pragma 参数
dsn = "file::memory:"
case strings.HasPrefix(path, "file:"):
// 调用方自带 URI 参数,不再做存在性检查
default:
if _, err := os.Stat(path); err != nil {
return nil, fmt.Errorf("SQLite 文件不存在: %s", path)
}
dsn = "file:" + path
}
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
dsn += sep + "_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"
return sql.Open("sqlite", dsn)
}
func quoteSQLiteIdent(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
func listDatabasesSQLite(_ *sql.DB) ([]string, error) {
return []string{"main"}, nil
}
func listTablesSQLite(db *sql.DB, _ string) ([]string, error) {
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY 1`)
func listTablesSQLite(ctx context.Context, db *sql.DB, _ string) ([]string, error) {
rows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY 1`)
if err != nil {
return nil, err
}
@@ -39,8 +66,8 @@ func listTablesSQLite(db *sql.DB, _ string) ([]string, error) {
return out, rows.Err()
}
func listColumnsSQLite(dbh *sql.DB, _, table string) ([]ColumnInfo, error) {
rows, err := dbh.Query(`PRAGMA table_info(` + quotePGIdent(table) + `)`)
func listColumnsSQLite(ctx context.Context, dbh *sql.DB, _, table string) ([]ColumnInfo, error) {
rows, err := dbh.QueryContext(ctx, `PRAGMA table_info(`+quoteSQLiteIdent(table)+`)`)
if err != nil {
return nil, err
}
+172
View File
@@ -0,0 +1,172 @@
package db
import "strings"
// SplitStatements 按分号切分 SQL 为多条语句,忽略单/双引号字符串与
// 行注释(--、#)、块注释(/* */)内的分号。语义对齐前端 sql.ts splitStatements。
// 返回的语句已去除首尾空白,空语句被丢弃。
func SplitStatements(sql string) []string {
var out []string
var cur strings.Builder
i := 0
n := len(sql)
inS, inD, inLine, inBlock := false, false, false, false
flush := func() {
if s := strings.TrimSpace(cur.String()); s != "" {
out = append(out, s)
}
cur.Reset()
}
for i < n {
ch := sql[i]
var nxt byte
if i+1 < n {
nxt = sql[i+1]
}
switch {
case inLine:
cur.WriteByte(ch)
if ch == '\n' {
inLine = false
}
i++
case inBlock:
cur.WriteByte(ch)
if ch == '*' && nxt == '/' {
cur.WriteByte(nxt)
inBlock = false
i += 2
} else {
i++
}
case !inS && !inD && ch == '-' && nxt == '-':
inLine = true
cur.WriteString("--")
i += 2
case !inS && !inD && ch == '#':
inLine = true
cur.WriteByte(ch)
i++
case !inS && !inD && ch == '/' && nxt == '*':
inBlock = true
cur.WriteString("/*")
i += 2
case ch == '\'' && !inD:
inS = !inS
cur.WriteByte(ch)
i++
case ch == '"' && !inS:
inD = !inD
cur.WriteByte(ch)
i++
case ch == ';' && !inS && !inD:
flush()
i++
default:
cur.WriteByte(ch)
i++
}
}
flush()
return out
}
// StripComments 剥除 --、#、/* */ 注释,并把字符串字面量替换为 ” 占位,
// 便于在纯代码文本上做危险关键字判定。
// MySQL 可执行注释 /*! ... */ 的内容会被保留参与检查(去掉包裹的 /*! 与 */)。
func StripComments(sql string) string {
var b strings.Builder
b.Grow(len(sql))
i := 0
n := len(sql)
for i < n {
ch := sql[i]
var nxt byte
if i+1 < n {
nxt = sql[i+1]
}
switch {
// 行注释 -- 与 #
case ch == '-' && nxt == '-':
for i < n && sql[i] != '\n' {
i++
}
b.WriteByte(' ')
case ch == '#':
for i < n && sql[i] != '\n' {
i++
}
b.WriteByte(' ')
// 块注释;/*! ... */ 为 MySQL 可执行注释,保留内容
case ch == '/' && nxt == '*':
exec := i+2 < n && sql[i+2] == '!'
start := i + 2
if exec {
start = i + 3
}
j := strings.Index(sql[start:], "*/")
if j < 0 {
if exec {
b.WriteString(sql[start:])
}
return b.String()
}
if exec {
b.WriteByte(' ')
b.WriteString(sql[start : start+j])
b.WriteByte(' ')
} else {
b.WriteByte(' ')
}
i = start + j + 2
// 单引号字面量(处理 '' 与 \' 转义)
case ch == '\'':
b.WriteString("''")
i++
for i < n {
if sql[i] == '\'' {
if i+1 < n && sql[i+1] == '\'' {
i += 2
continue
}
i++
break
}
if sql[i] == '\\' && i+1 < n {
i += 2
continue
}
i++
}
// 双引号标识符/字面量同样置空内容(防 "DROP" 作为标识符误报)
case ch == '"':
b.WriteString(`""`)
i++
for i < n {
if sql[i] == '"' {
if i+1 < n && sql[i+1] == '"' {
i += 2
continue
}
i++
break
}
i++
}
// 反引号标识符内容置空
case ch == '`':
b.WriteString("``")
i++
for i < n && sql[i] != '`' {
i++
}
if i < n {
i++
}
default:
b.WriteByte(ch)
i++
}
}
return b.String()
}
+57
View File
@@ -0,0 +1,57 @@
package db
import (
"strings"
"testing"
)
func TestSplitStatements(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"SELECT 1", []string{"SELECT 1"}},
{"SELECT 1; SELECT 2;", []string{"SELECT 1", "SELECT 2"}},
{"SELECT ';'; SELECT 2", []string{"SELECT ';'", "SELECT 2"}},
{"SELECT 1; -- c;mt\nSELECT 2", []string{"SELECT 1", "-- c;mt\nSELECT 2"}},
{"/* a;b */ SELECT 1; SELECT 2", []string{"/* a;b */ SELECT 1", "SELECT 2"}},
{`SELECT "a;b"; SELECT 2`, []string{`SELECT "a;b"`, "SELECT 2"}},
{";;", nil},
{"", nil},
}
for _, tc := range cases {
got := SplitStatements(tc.in)
if len(got) != len(tc.want) {
t.Fatalf("SplitStatements(%q) = %#v, want %#v", tc.in, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("SplitStatements(%q)[%d] = %q, want %q", tc.in, i, got[i], tc.want[i])
}
}
}
}
func TestStripComments(t *testing.T) {
cases := []struct {
in string
has string // 结果应包含(大小写敏感原文)
missing string // 结果不应包含
}{
{"SELECT 1 -- WHERE x", "SELECT 1", "WHERE x"},
{"UPDATE t SET x=1 # note\nWHERE id=1", "WHERE id=1", "note"},
{"SELECT /* hint */ 1", "SELECT", "hint"},
{"SELECT /*!50000 DROP TABLE t*/", "DROP TABLE t", "/*!"},
{"SELECT 'DROP TABLE x'", "SELECT", "DROP TABLE x"},
{`SELECT "col;drop" FROM t`, "FROM t", "col;drop"},
}
for _, tc := range cases {
got := StripComments(tc.in)
if tc.has != "" && !strings.Contains(got, tc.has) {
t.Fatalf("StripComments(%q) = %q, 应包含 %q", tc.in, got, tc.has)
}
if tc.missing != "" && strings.Contains(got, tc.missing) {
t.Fatalf("StripComments(%q) = %q, 不应包含 %q", tc.in, got, tc.missing)
}
}
}
+12 -2
View File
@@ -207,10 +207,20 @@ func ApplySSHTunnel(c ConnReq) (ConnReq, *SSHTunnel, error) {
if err != nil {
return c, nil, fmt.Errorf("SSH: %w", err)
}
return rewriteHostForTunnel(c, tun.LocalPort), tun, nil
}
// rewriteHostForTunnel 把连接改写到隧道本地端口(127.0.0.1:<localPort>)。
// 改写会丢失真实 Host,导致隧道内 TLS ServerName 校验失败(127.0.0.1 不在证书 SAN 里),
// 故改写前把原 Host 记入 TLSServerName(用户显式指定时保留其值)。
func rewriteHostForTunnel(c ConnReq, localPort int) ConnReq {
out := c
if strings.TrimSpace(out.TLSServerName) == "" {
out.TLSServerName = out.Host
}
out.Host = "127.0.0.1"
out.Port = tun.LocalPort
out.Port = localPort
// Avoid nested tunnel if helpers re-enter
out.SSHEnabled = false
return out, tun, nil
return out
}
@@ -0,0 +1,36 @@
package db
import "testing"
// R2-B1:SSH 隧道改写 Host 后,TLS ServerName 必须保留原 Host(隧道内 TLS 校验依赖)。
func TestRewriteHostForTunnelPreservesServerName(t *testing.T) {
c := ConnReq{Host: "db.internal", Port: 3306, SSHEnabled: true}
out := rewriteHostForTunnel(c, 45678)
if out.Host != "127.0.0.1" || out.Port != 45678 {
t.Fatalf("隧道地址改写错误: %s:%d", out.Host, out.Port)
}
if out.SSHEnabled {
t.Fatal("SSHEnabled 应置 false,防嵌套隧道")
}
if out.TLSServerName != "db.internal" {
t.Fatalf("TLSServerName 应保留原 Host, got %q", out.TLSServerName)
}
}
// 用户显式指定 tlsServerName 时,隧道改写不得覆盖。
func TestRewriteHostForTunnelKeepsExplicitServerName(t *testing.T) {
c := ConnReq{Host: "db.internal", TLSServerName: "db.example.com", SSHEnabled: true}
out := rewriteHostForTunnel(c, 40001)
if out.TLSServerName != "db.example.com" {
t.Fatalf("显式 TLSServerName 应保留, got %q", out.TLSServerName)
}
}
func TestTLSServerNameFor(t *testing.T) {
if got := tlsServerNameFor(ConnReq{Host: "db.example"}); got != "db.example" {
t.Fatalf("空 tlsserverName 应用 Host, got %q", got)
}
if got := tlsServerNameFor(ConnReq{Host: "127.0.0.1", TLSServerName: "db.example"}); got != "db.example" {
t.Fatalf("显式 tlsserverName 优先, got %q", got)
}
}
+40 -7
View File
@@ -30,6 +30,15 @@ func SSLEnabled(c ConnReq) bool {
return EffectiveSSLMode(c) != "disable"
}
// tlsServerNameFor 返回 TLS 校验用的 ServerName:显式 tlsServerName 优先,否则用 Host。
// SSH 隧道把 Host 改写成 127.0.0.1 之前,ApplySSHTunnel 会把原 Host 存入 TLSServerName。
func tlsServerNameFor(c ConnReq) string {
if v := strings.TrimSpace(c.TLSServerName); v != "" {
return v
}
return strings.TrimSpace(c.Host)
}
// tlsConfigFrom builds a *tls.Config from ConnReq paths/mode.
// Returns (nil, nil) when SSL is disabled.
func tlsConfigFrom(c ConnReq) (*tls.Config, error) {
@@ -41,7 +50,7 @@ func tlsConfigFrom(c ConnReq) (*tls.Config, error) {
cfg := &tls.Config{
MinVersion: tls.VersionTLS12,
}
if host := strings.TrimSpace(c.Host); host != "" {
if host := tlsServerNameFor(c); host != "" {
cfg.ServerName = host
}
@@ -56,14 +65,26 @@ func tlsConfigFrom(c ConnReq) (*tls.Config, error) {
return nil, fmt.Errorf("未知 SSL 模式: %s", mode)
}
if path := strings.TrimSpace(c.SSLCA); path != "" {
pem, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取 CA 证书失败 (%s): %w", path, err)
caContent := strings.TrimSpace(c.SSLCAContent)
caPath := strings.TrimSpace(c.SSLCA)
if caContent != "" || caPath != "" {
// 内容优先于路径(免在服务端落盘/共享文件)
var pem []byte
if caContent != "" {
pem = []byte(caContent)
} else {
var err error
pem, err = os.ReadFile(caPath)
if err != nil {
return nil, fmt.Errorf("读取 CA 证书失败 (%s): %w", caPath, err)
}
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("CA 证书不是有效 PEM: %s", path)
if caContent != "" {
return nil, fmt.Errorf("CA 证书内容不是有效 PEM")
}
return nil, fmt.Errorf("CA 证书不是有效 PEM: %s", caPath)
}
cfg.RootCAs = pool
if mode == "verify-ca" {
@@ -98,9 +119,21 @@ func tlsConfigFrom(c ConnReq) (*tls.Config, error) {
return nil, fmt.Errorf("verify-ca 模式需要填写 CA 证书路径")
}
certContent := strings.TrimSpace(c.SSLCertContent)
keyContent := strings.TrimSpace(c.SSLKeyContent)
certPath := strings.TrimSpace(c.SSLCert)
keyPath := strings.TrimSpace(c.SSLKey)
if certPath != "" || keyPath != "" {
if certContent != "" || keyContent != "" {
// 内容与路径二选一,各自成对:证书与密钥必须同时给
if certContent == "" || keyContent == "" {
return nil, fmt.Errorf("客户端证书与密钥需同时填写")
}
cert, err := tls.X509KeyPair([]byte(certContent), []byte(keyContent))
if err != nil {
return nil, fmt.Errorf("加载客户端证书内容失败: %w", err)
}
cfg.Certificates = []tls.Certificate{cert}
} else if certPath != "" || keyPath != "" {
if certPath == "" || keyPath == "" {
return nil, fmt.Errorf("客户端证书与密钥需同时填写")
}
+72
View File
@@ -84,6 +84,78 @@ func TestTLSConfigFromBadCAPath(t *testing.T) {
}
}
// R2-B1:显式 tlsServerName 优先于 Host(tlsConfigFrom 层)。
func TestTLSConfigFromTLSServerName(t *testing.T) {
cfg, err := tlsConfigFrom(ConnReq{SSLMode: "require", Host: "127.0.0.1", TLSServerName: "db.example.com"})
if err != nil {
t.Fatal(err)
}
if cfg == nil || cfg.ServerName != "db.example.com" {
t.Fatalf("ServerName 应取 tlsServerName, got %#v", cfg)
}
}
// R2-B2:CA/客户端证书内容优先于路径;成对校验仍然生效。
func TestTLSConfigFromContentCerts(t *testing.T) {
caPEM, certPEM, keyPEM := genTestCertMaterials(t)
cfg, err := tlsConfigFrom(ConnReq{
SSLMode: "require", Host: "db.example.com",
SSLCAContent: caPEM, SSLCertContent: certPEM, SSLKeyContent: keyPEM,
})
if err != nil {
t.Fatal(err)
}
if cfg == nil || cfg.RootCAs == nil {
t.Fatal("内容 CA 应构建 RootCAs")
}
if len(cfg.Certificates) != 1 {
t.Fatal("内容客户端证书应加载")
}
if cfg.ServerName != "db.example.com" {
t.Fatalf("ServerName 错误: %q", cfg.ServerName)
}
// 内容 CA 非法 PEM 应报错(不回退路径)
if _, err := tlsConfigFrom(ConnReq{SSLMode: "require", SSLCAContent: "not-a-pem"}); err == nil {
t.Fatal("非法 CA 内容应报错")
}
// 证书与密钥仍需成对:只给内容证书不给内容密钥
if _, err := tlsConfigFrom(ConnReq{SSLMode: "require", SSLCertContent: certPEM}); err == nil {
t.Fatal("半对内容证书应报错")
}
}
// genTestCertMaterials 生成自签名 CA 与客户端证书 PEM 串。
func genTestCertMaterials(t *testing.T) (caPEM, certPEM, keyPEM string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatal(err)
}
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{CommonName: "opsxdb-test-cert"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
return certPEM, certPEM, keyPEM // 自签名,CA 与客户端证书同一张
}
func writeTestCA(path string) error {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
+62 -3
View File
@@ -1,6 +1,11 @@
package db
import "time"
import (
"encoding/hex"
"strconv"
"time"
"unicode/utf8"
)
const DefaultRowLimit = 1000
const MaxRowLimit = 10000
@@ -20,11 +25,31 @@ type ConnReq struct {
SSLCA string `json:"sslCA"`
SSLCert string `json:"sslCert"`
SSLKey string `json:"sslKey"`
// 证书 PEM 内容,优先于上面的文件路径(客户端与服务端分离部署时免共享文件)
SSLCAContent string `json:"sslCAContent"`
SSLCertContent string `json:"sslCertContent"`
SSLKeyContent string `json:"sslKeyContent"`
// TLSServerName 显式指定 TLS 校验用的 ServerName;SSH 隧道改写 Host 前
// 会把原 Host 自动填入,避免隧道内 TLS 校验失败。
TLSServerName string `json:"tlsServerName"`
SessionID string `json:"sessionId"`
Table string `json:"table"`
Limit int `json:"limit"`
Key string `json:"key"` // Redis key (get/delete)
Offset int `json:"offset"` // 结果分页:跳过前 offset 行再按 limit 收集
Key string `json:"key"` // Redis key (get/delete)
Pattern string `json:"pattern"` // Redis SCAN match
Cursor int64 `json:"cursor"` // Redis 值分页游标(hash/set=SCAN 游标;list/zset=偏移)
Keyword string `json:"keyword"` // 对象搜索关键词
Format string `json:"format"` // 导出格式: csv | json | insert
// TimeoutMs > 0 时 /db/exec、/db/explain 以此为语句超时
TimeoutMs int `json:"timeoutMs"`
// RequestID 非空时注册 in-flight 注册表,可通过 /api/db/cancel 取消
RequestID string `json:"requestId"`
// ConfirmDanger 为 true 表示前端已完成危险语句确认,服务端放行本次执行。
ConfirmDanger bool `json:"confirmDanger"`
SSHEnabled bool `json:"sshEnabled"`
SSHHost string `json:"sshHost"`
@@ -34,6 +59,15 @@ type ConnReq struct {
SSHPassword string `json:"sshPassword"`
SSHPrivateKey string `json:"sshPrivateKey"`
SSHPassphrase string `json:"sshPassphrase"`
// Redis 拓扑:standalone(默认)| sentinel | cluster
RedisMode string `json:"redisMode"`
// Sentinel/Cluster 节点列表,host:port 多行或逗号分隔
RedisNodes string `json:"redisNodes"`
// Sentinel 主节点名(必填)
MasterName string `json:"masterName"`
// Sentinel 自身密码(可空);Redis 密码仍用 Password
SentinelPassword string `json:"sentinelPassword"`
}
type ColumnInfo struct {
@@ -54,14 +88,38 @@ type ExecResult struct {
Error string `json:"error,omitempty"`
Truncated bool `json:"truncated,omitempty"`
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
// NeedConfirm 为 true 表示命中危险语句,需前端确认后带 confirmDanger 重发。
NeedConfirm bool `json:"needConfirm,omitempty"`
// Redis 值游标分页:>0 还有更多(HGETALL/SMEMBERS/ZRANGE/LRANGE 单键读透传)
NextCursor int64 `json:"nextCursor,omitempty"`
}
// ExplainResult 执行计划结果:行列(计划表格)与 Text(XML/文本计划)二选一或兼有。
type ExplainResult struct {
Columns []string `json:"columns,omitempty"`
Rows []map[string]interface{} `json:"rows,omitempty"`
Text string `json:"text,omitempty"` // MSSQL SHOWPLAN_XML / Oracle 文本计划
TimeMs int64 `json:"timeMs"`
Error string `json:"error,omitempty"`
}
func normalize(v interface{}) interface{} {
switch t := v.(type) {
case nil:
return nil
case int64:
// bigint 超 JS 安全整数时转字符串保真,避免前端精度丢失错行
if t > 9007199254740991 || t < -9007199254740991 {
return strconv.FormatInt(t, 10)
}
return t
case []byte:
return string(t)
if utf8.Valid(t) {
return string(t)
}
// BLOB 等非 UTF-8 数据以十六进制标注返回,替代乱码
return `\x` + hex.EncodeToString(t)
case time.Time:
return t.Format(time.RFC3339Nano)
default:
@@ -94,5 +152,6 @@ type RedisKeyValue struct {
TimeMs int64 `json:"timeMs"`
Truncated bool `json:"truncated,omitempty"`
Limit int `json:"limit,omitempty"`
NextCursor int64 `json:"nextCursor"` // >0 表示还有更多(续载时回传 cursor)
Error string `json:"error,omitempty"`
}
+61
View File
@@ -0,0 +1,61 @@
package db
import (
"strings"
"testing"
)
func TestNormalizeBigint(t *testing.T) {
// 超 JS 安全整数 → 字符串保真
if got := normalize(int64(9223372036854775807)); got != "9223372036854775807" {
t.Fatalf("bigint max: %#v", got)
}
if got := normalize(int64(-9223372036854775807)); got != "-9223372036854775807" {
t.Fatalf("bigint min: %#v", got)
}
// 安全范围内保持 number
if got := normalize(int64(9007199254740991)); got != int64(9007199254740991) {
t.Fatalf("safe int: %#v", got)
}
if got := normalize(int64(9007199254740992)); got != "9007199254740992" {
t.Fatalf("边界+1 应转字符串: %#v", got)
}
}
func TestNormalizeBytes(t *testing.T) {
if got := normalize([]byte("hello")); got != "hello" {
t.Fatalf("utf8 bytes: %#v", got)
}
if got := normalize([]byte("中文")); got != "中文" {
t.Fatalf("utf8 中文: %#v", got)
}
// 非 UTF-8 → \x 十六进制
if got := normalize([]byte{0xff, 0x00, 0x01}); got != `\xff0001` {
t.Fatalf("blob hex: %#v", got)
}
if got := normalize(nil); got != nil {
t.Fatalf("nil: %#v", got)
}
}
func TestMssqlDSNEncoding(t *testing.T) {
// 含空格 / '+' 的密码应经 userinfo 编码,不被 QueryEscape 破坏
dsn, err := mssqlDSN(ConnReq{Driver: "sqlserver", Host: "h1", Port: 1433, User: "sa", Password: "p@ ss+word", Database: "db1"})
if err != nil {
t.Fatal(err)
}
want := "p%40%20ss+word"
if !strings.Contains(dsn, want) {
t.Fatalf("DSN 密码编码不符合预期: %s(应含 %s)", dsn, want)
}
if !strings.Contains(dsn, "sqlserver://sa:") || !strings.Contains(dsn, "@h1:1433") {
t.Fatalf("DSN 结构错误: %s", dsn)
}
if !strings.Contains(dsn, "database=db1") {
t.Fatalf("DSN 缺 database: %s", dsn)
}
// verify-ca 无 CA 应报错
if _, err := mssqlDSN(ConnReq{Driver: "sqlserver", Host: "h1", SSLMode: "verify-ca"}); err == nil {
t.Fatal("verify-ca 缺 CA 应报错")
}
}
@@ -15,7 +15,7 @@ OpsxDB 是一款**本地优先**的桌面数据库工作台,面向运维与开
| 安全通道 | 每连接独立配置 SSL/TLS 与 SSH 隧道;密码本机混淆存储 |
| AI 面板 | 生成 / 解释 / 优化 / 修复 / 方言转换 / 样例 SQL |
| 危险操作防护 | DROP / TRUNCATE / 无 WHERE 的 UPDATE·DELETE 执行前确认 |
| 双形态 | 桌面 GUI(Tauri 2)与 Web(单二进制 + 静态页)共用一套界面 |
| 桌面唯一形态 | 桌面 GUI(Tauri 2),本地后端随应用自动拉起 |
## 支持的数据库
@@ -33,7 +33,7 @@ OpsxDB 是一款**本地优先**的桌面数据库工作台,面向运维与开
## 为什么选 OpsxDB
- **数据主权**:连接配置与查询历史只存在本机;后端默认仅监听 127.0.0.1。
- **开箱即用**:`./run.sh web` 一条命令起 Web 形态;桌面版双击即用,后端随应用拉起。
- **开箱即用**:桌面版双击即用,本地后端随应用自动拉起;官网与文档也由它内置提供。
- **为快设计**:点表即看数据、⌘Enter 即执行、结果三键取走(复制 / CSV / INSERT)。
- **轻量克制**:一个 Go 二进制 + 一份前端,内网可部署,无遥测无账号。
@@ -4,17 +4,18 @@
桌面版(主交付形态)双击 OpsxDB 应用即可,本地后端随应用自动拉起。
从源码跑 Web / 桌面形态:
从源码运行(开发):
```bash
git clone <repo> opsxdb && cd opsxdb
npm install
./run.sh web # Web 形态:Gin :8788 + Vite 开发壳 :5174
./run.sh gui # 桌面形态:Tauri 2 窗口(复用同一套界面)
./run.sh gui # 桌面形态(开发):Tauri 窗口 + 本地后端
./run.sh web # 仅启动官网与文档 → http://127.0.0.1:8788/
./run.sh package # 打桌面安装包(DMG,内嵌后端)
```
> **注意**:浏览器里的 `:5174` 是 Vite 热更新开发壳,仅调试用;产品页面(官网 `/`、文档 `/docs`)由后端 `:8788` 直接提供。
> **注意**:客户端只在桌面 App 里运行。`./run.sh gui` 起的 `:5174` 是桌面 devUrl 的资产服务,不是浏览器客户端;浏览器只用于官网 `/` 与文档 `/docs`(后端 `:8788` 直接提供)。
## 2. 新建连接
@@ -24,6 +24,7 @@ OpsxDB 的界面为「高频操作」设计:从打开应用到看到数据,
- 点**表名**:直接打开前 100 行数据;
- 展开**表**:查看列结构;
- Redis:按键分隔符编文件夹,点键打开类型化详情(TTL / JSON / 增删字段);
- 库列表懒加载,大实例也不卡。
## 编辑器与结果
@@ -20,7 +20,7 @@ postgres://opsx:pass@127.0.0.1:5432/app
| MySQL / PostgreSQL / SQL Server | standard | host · port · user · password · database · ssl · ssh |
| Oracle | oracle | host · port · user · password · **service**(如 ORCL) |
| SQLite | file | database 填本地文件路径(如 `~/data/demo.sqlite`) |
| Redis | redis | host · port · password · database 序号 |
| Redis | redis | host · port · password · database 序号 · **模式**(单机 / 哨兵 / 集群)· 键分隔符 |
表单只收集服务端实际消费的字段;历史遗留的死参数不会出现在表单里。
@@ -39,4 +39,12 @@ postgres://opsx:pass@127.0.0.1:5432/app
数据库在跳板机后面时,开启 **ssh** 字段组,填写跳板机地址与凭据:OpsxDB 会先建 SSH 隧道,再把数据库连接走隧道转发,无需把库端口暴露到公网。
## Redis 哨兵与集群
- **哨兵**:模式选 Sentinel,填写 Sentinel 节点列表(`host:port`,多行)和主节点名 `masterName`;
- **集群**:模式选 Cluster,填写至少一个种子节点。对象树只显示 DB 0(Cluster 无 `SELECT`);
- 单机模式只填 host/port,与原先一致。
字段细节与键浏览见 [Redis 工作台](redis)。
> **提示**:连接保存后,后续查询仅携带 `sessionId`,账密不会随每次请求上送;密码在本机混淆存储。
@@ -4,6 +4,7 @@ SQL 工作台是日常使用率最高的界面,所有交互围绕「快」设
- **⌘/Ctrl + Enter**:执行光标所在的**当前语句**;有选区时执行**选区**;
- 不是「整页脚本」模式 —— 多条语句互不干扰,想跑哪句把光标放哪;
- Redis 连接共用同一编辑器,按**行**执行命令(见 [Redis 工作台](redis)),不另开 CLI;
- 执行在会话上运行,连接断开后自动提示。
## 多 Tab 与分栏
+75 -16
View File
@@ -1,34 +1,93 @@
Redis 连接使用专用表单(redis),连上后既是命令行又是键空间浏览器。
Redis 连接使用专用表单(redis)。连上后,对象树是键空间浏览器,编辑器是 Redis 命令行 —— **和 SQL 共用同一套输入框**,不另开 CLI 页。
## 为什么命令行不单独做一套
SQL 工作台已经有:多 Tab、⌘/Ctrl+Enter、查询历史、书签、危险确认、取消。Redis 命令同样是「输入 → 执行 → 结果表」。
拆成独立 CLI(终端 REPL)会让快捷键、历史、危险确认分叉,也和 Navicat 式多引擎工作台不一致。
因此:
| | SQL | Redis |
| --- | --- | --- |
| 输入框 | 同一 CodeMirror | 同一 CodeMirror(占位/补全换成命令) |
| 切分 | 分号语句 | **按行**(`SET k "a;b"` 不会被分号拆开) |
| 执行 | ⌘/Ctrl+Enter 当前语句或选区 | ⌘/Ctrl+Enter 当前行或选区 |
| 结果 | 结果表 | 命令结果表;从对象树打开键则进入键详情 |
Another Redis Desktop Manager 的独立 CLI Tab、opsxsql 的单行命令 + 输出日志,列为后续 P1,不是现在的主路径。
## 键空间浏览
对象树对 Redis 显示 DB0–15(Cluster 仅 0)。展开库后:
- **SCAN 过滤**:输入 `user:*` 点「应用」;生产大库请带前缀,避免扫全库。最多展示 500 个键,超出提示截断。
- **按分隔符编文件夹**:连接表单的「键分隔符」默认 `:`,`CEX:FOLLOW:FOLLOWERS:1` 显示为 `CEX / FOLLOW / FOLLOWERS / 1`。文件夹显示数量;右键可「只看此前缀」「在此新建键」。
- **类型色点**:string 绿 / hash 蓝 / list 橙 / set 紫 / zset 青。
- **点键**:打开键详情(不是假 SELECT)。
## 键详情
对标 Another Redis Desktop Manager 的 Hash/List/Set 视图:
- 类型徽章、键名(回车重命名)、TTL(回车或「保存 TTL」,`-1` 永久)、刷新、导出还原命令、删除;
- **结构**表:Hash 为 ID / Field / Value,JSON 值自动美化;可新增字段、编辑、删除;
- **JSON** 视图:整体格式化;
- 表内关键字过滤。
String 可直接改值保存。
## 命令执行
SQL 编辑器同样适用于 Redis —— 直接输入命令,⌘/Ctrl + Enter 执行:
编辑器直接写 Redis 命令:
```bash
PING
SET demo:key "hello" EX 60
GET demo:key
KEYS demo:*
DEL demo:key
HGETALL demo:hash
SCAN 0 MATCH demo:* COUNT 100
INFO memory
```
- 返回值直接显示在结果面板;
- 集群 / 哨兵模式按各自连接方式配置(哨兵填写主节点名)。
- 未点名的命令走通用 `Do`(`EXISTS` / `ECHO` / `INFO` / `CLIENT LIST` 等不必再加白名单);
- `FLUSHALL` / `FLUSHDB` / `KEYS` / `CONFIG` 仍要二段确认;
- `KEYS *` 在大库上会阻塞 —— 请用 `SCAN MATCH`。
## 键空间浏览
### 切换逻辑库(SELECT)
- **KEYS 扫描**:按模式浏览键(生产大库建议用前缀收窄范围);
- 键类型视图:string / hash / list / set / zset 分类型展示;
- **TTL 管理**:查看与设置过期时间,常用于缓存排障;
- 批量操作:选中多个键批量删除。
`SELECT 1` 是 Redis 官方的切库命令,编辑器直接支持 —— 语义与 redis-cli 一致:**切换后本 Tab 的后续命令都在目标库执行**:
> **注意**:`KEYS *` 在大库上会阻塞 Redis,OpsxDB 不做强制拦截 —— 生产环境请自觉使用带前缀的模式。
```bash
SELECT 1
GET db1:only:key # 在 db1 执行
```
- 没有也没有 `USE`(那是 SQL 语法)—— 输入 `USE db1` 会得到 Redis 原生报错
`ERR unknown command 'USE'`,与 redis-cli 行为一致;
- 切库只影响当前查询 Tab(多 Tab 可各连各的库),左侧对象树点开对应库可浏览键;
- Cluster 模式只有 DB 0,`SELECT` 无效。
## 连接参数
| 字段 | 说明 |
| --- | --- |
| host / port | 默认 127.0.0.1:6379 |
| password | ACL / requirepass |
| database | 逻辑库序号(0-15 常见) |
| ssl | 红色连接(TLS)开关 |
| host / port | 单机默认 127.0.0.1:6379 |
| password / user | ACL / requirepass |
| database | 逻辑库序号(0–15 常见;Cluster 忽略) |
| 键分隔符 | 对象树文件夹分隔,默认 `:` |
| ssl | TLS |
| redisMode | `standalone`(默认)/ `sentinel` / `cluster` |
| redisNodes | Sentinel / Cluster 节点列表,`host:port` 多行或逗号分隔 |
| masterName | Sentinel 主节点名(必填) |
| sentinelPassword | Sentinel 密码(可空) |
### Sentinel
模式选「哨兵」,填写至少一个 Sentinel 地址和 `masterName`(如 `mymaster`)。逻辑库序号仍可用。
### Cluster
模式选「集群」,填写至少一个种子节点。Cluster 不支持 `SELECT` 多库,对象树只暴露 DB 0。槽位路由由客户端处理,键浏览走 `SCAN`。
> **环境**:Sentinel / Cluster 表单与后端已接好;真实拓扑联调见 `docs/BACKLOG.md` 环境缺口。
+4 -4
View File
@@ -1,10 +1,10 @@
## 产品形态
**Q:浏览器里的 5174 端口是什么?**
Vite 热更新开发壳,仅开发调试用。产品形态是桌面应用(Tauri 2)或自托管 Web 服务,页面与 API 由后端 `:8788` 提供。
**Q:能在浏览器里操作数据库吗?**
不能。OpsxDB 客户端唯一形态是桌面 App(Tauri 2);浏览器只承载官网与文档(由本地后端 `:8788` 内置提供)。开发时的 `:5174` 只是桌面 devUrl 的资产服务,不是网页客户端。
**Q:桌面版和 Web 版功能有差别吗?**
没有。同一套 UI、同一份后端,桌面版只是多了应用壳(自动拉起本地后端、原生窗口、安装包形态)。
**Q:官网和文档部署在哪?**
就内置在你运行的那个二进制里:`http://127.0.0.1:8788/` 是官网,`/docs` 是文档;无需额外部署。
**Q:怎么打安装包?**
`./run.sh package` 产出 Tauri 安装包(DMG)并内嵌 sidecar 后端二进制。
-36
View File
@@ -1,36 +0,0 @@
package proxy
import (
"fmt"
"net"
"net/url"
"strings"
)
var blockedHosts = map[string]struct{}{
"169.254.169.254": {},
"metadata.google.internal": {},
}
// ValidateURL applies MVP SSRF basics. Week 2 should add configurable CIDR
// deny-lists and DNS-rebinding protection before exposing this beyond localhost.
func ValidateURL(raw string) (*url.URL, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("invalid url: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("only http/https schemes are allowed")
}
if u.Hostname() == "" {
return nil, fmt.Errorf("url host is required")
}
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
if _, blocked := blockedHosts[host]; blocked {
return nil, fmt.Errorf("blocked metadata host")
}
if ip := net.ParseIP(host); ip != nil && ip.Equal(net.ParseIP("169.254.169.254")) {
return nil, fmt.Errorf("blocked metadata address")
}
return u, nil
}
-23
View File
@@ -1,23 +0,0 @@
package proxy
import "testing"
func TestValidateURL(t *testing.T) {
for _, tc := range []struct {
raw string
ok bool
}{
{"https://example.com/a", true},
{"file:///etc/passwd", false},
{"http://169.254.169.254/latest/meta-data", false},
{"http://metadata.google.internal/computeMetadata/v1", false},
} {
_, err := ValidateURL(tc.raw)
if tc.ok && err != nil {
t.Fatalf("%s: %v", tc.raw, err)
}
if !tc.ok && err == nil {
t.Fatalf("expected rejection: %s", tc.raw)
}
}
}
-111
View File
@@ -1,111 +0,0 @@
package proxy
import (
"bytes"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const maxResponseBytes = 10 << 20 // 10 MiB MVP safety cap
type Handler struct {
BaseClient *http.Client
}
func NewHandler() *Handler {
return &Handler{BaseClient: &http.Client{Timeout: 30 * time.Second}}
}
func (h *Handler) ProxyHTTP(c *gin.Context) {
var spec HttpSpec
if err := c.ShouldBindJSON(&spec); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid HttpSpec", "detail": err.Error()})
return
}
method := strings.ToUpper(spec.Method)
switch method {
case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported method"})
return
}
u, err := ValidateURL(spec.URL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
query := u.Query()
for key, value := range spec.Query {
query.Set(key, value)
}
u.RawQuery = query.Encode()
var body io.Reader
if spec.Body != nil && spec.Body.Mode != "none" && method != http.MethodGet {
body = bytes.NewBufferString(spec.Body.Raw)
}
req, err := http.NewRequestWithContext(c.Request.Context(), method, u.String(), body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot create request", "detail": err.Error()})
return
}
for key, value := range spec.Headers {
// Hop-by-hop headers are managed by net/http.
if !isHopByHop(key) {
req.Header.Set(key, value)
}
}
if spec.Body != nil && spec.Body.Mode == "json" && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
client := h.BaseClient
if spec.TimeoutMS > 0 {
client = &http.Client{Timeout: time.Duration(spec.TimeoutMS) * time.Millisecond}
}
started := time.Now()
res, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream request failed", "detail": err.Error()})
return
}
defer res.Body.Close()
payload, err := io.ReadAll(io.LimitReader(res.Body, maxResponseBytes+1))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "cannot read upstream response", "detail": err.Error()})
return
}
if len(payload) > maxResponseBytes {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream response exceeds 10 MiB limit"})
return
}
headers := make(map[string]string, len(res.Header))
for key, values := range res.Header {
headers[strings.ToLower(key)] = strings.Join(values, ", ")
}
c.JSON(http.StatusOK, HttpResult{
Status: res.StatusCode,
Headers: headers,
Body: string(payload),
TimeMS: time.Since(started).Milliseconds(),
Size: len(payload),
})
}
func isHopByHop(name string) bool {
switch strings.ToLower(name) {
case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length":
return true
default:
return false
}
}
-23
View File
@@ -1,23 +0,0 @@
package proxy
type BodySpec struct {
Mode string `json:"mode"`
Raw string `json:"raw,omitempty"`
}
type HttpSpec struct {
Method string `json:"method" binding:"required"`
URL string `json:"url" binding:"required"`
Headers map[string]string `json:"headers"`
Query map[string]string `json:"query,omitempty"`
Body *BodySpec `json:"body,omitempty"`
TimeoutMS int `json:"timeoutMs,omitempty"`
}
type HttpResult struct {
Status int `json:"status"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
TimeMS int64 `json:"timeMs"`
Size int `json:"size"`
}
+35
View File
@@ -0,0 +1,35 @@
package server
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// AuthMiddleware 本地鉴权:
// - token == "" → 直通(本机 dev 无感);
// - /health、/api/health 豁免;
// - 其余 /api/** 要求 Header "Authorization: Bearer <token>",失败 401。
func AuthMiddleware(token string) gin.HandlerFunc {
return func(c *gin.Context) {
if token == "" {
c.Next()
return
}
p := c.Request.URL.Path
if p == "/health" || p == "/api/health" {
c.Next()
return
}
if !strings.HasPrefix(p, "/api/") {
c.Next()
return
}
if c.GetHeader("Authorization") == "Bearer "+token {
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
}
}
+297 -18
View File
@@ -1,8 +1,13 @@
package server
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"opsxdb/server/internal/ai"
@@ -11,10 +16,13 @@ import (
"opsxdb/server/internal/site"
)
func NewRouter() *gin.Engine {
// Version 由 cmd/opsxdb 注入,/health 响应携带供桌面 sidecar 版本握手。
var Version = "dev"
func NewRouter(token string) *gin.Engine {
r := gin.New()
_ = r.SetTrustedProxies(nil)
r.Use(gin.Logger(), gin.Recovery(), localhostCORS())
r.Use(gin.Logger(), gin.Recovery(), localhostCORS(), AuthMiddleware(token))
r.GET("/health", health)
r.GET("/api/health", health)
@@ -53,7 +61,10 @@ func NewRouter() *gin.Engine {
api.POST("/db/close", func(c *gin.Context) {
var req db.ConnReq
_ = c.ShouldBindJSON(&req)
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.SessionID != "" {
db.CloseSession(req.SessionID)
}
@@ -66,7 +77,7 @@ func NewRouter() *gin.Engine {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
list, err := db.ListDatabases(req)
list, err := db.ListDatabases(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -80,7 +91,7 @@ func NewRouter() *gin.Engine {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
list, err := db.ListTables(req)
list, err := db.ListTables(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -94,7 +105,7 @@ func NewRouter() *gin.Engine {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
list, err := db.ListColumns(req)
list, err := db.ListColumns(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -108,10 +119,135 @@ func NewRouter() *gin.Engine {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
res := db.ExecSQL(c.Request.Context(), req)
ctx, done := withExecControl(c, req)
defer done()
res := db.ExecSQL(ctx, req)
c.JSON(http.StatusOK, res)
})
api.POST("/db/explain", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx, done := withExecControl(c, req)
defer done()
res := db.ExplainSQL(ctx, req)
c.JSON(http.StatusOK, res)
})
// /db/cancel 取消在执行的请求(按 requestId),body: {"requestId":"..."}
api.POST("/db/cancel", func(c *gin.Context) {
var req struct {
RequestID string `json:"requestId"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if strings.TrimSpace(req.RequestID) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "requestId 不能为空"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "cancelled": db.CancelInflight(req.RequestID)})
})
// /db/ddl 查看建表语句,body=ConnBody + table → {"ddl":"...","error":""}
api.POST("/db/ddl", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"ddl": "", "error": err.Error()})
return
}
if strings.TrimSpace(req.Table) == "" {
c.JSON(http.StatusBadRequest, gin.H{"ddl": "", "error": "缺少表名"})
return
}
ddl, err := db.GetDDL(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusOK, gin.H{"ddl": "", "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ddl": ddl, "error": ""})
})
// /db/search 对象搜索,body=ConnBody + keyword → {"results":[...],"error":""}
api.POST("/db/search", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"results": []db.ObjectHit{}, "error": err.Error()})
return
}
if strings.TrimSpace(req.Keyword) == "" {
c.JSON(http.StatusBadRequest, gin.H{"results": []db.ObjectHit{}, "error": "缺少关键词"})
return
}
results, err := db.SearchObjects(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusOK, gin.H{"results": []db.ObjectHit{}, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"results": results, "error": ""})
})
// /db/export 导出中心,body=ConnBody + format(csv|json|insert) + sql?/table? + limit?
api.POST("/db/export", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
format := strings.ToLower(strings.TrimSpace(req.Format))
contentType, ext := exportContentType(format)
if ext == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "format 仅支持 csv/json/insert"})
return
}
sqlText := strings.TrimSpace(req.SQL)
if sqlText == "" && strings.TrimSpace(req.Table) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 sql 或 table"})
return
}
if sqlText != "" {
// sql 模式:多条时只取第一条
stmts := db.SplitStatements(sqlText)
if len(stmts) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "sql 为空"})
return
}
sqlText = stmts[0]
// 导出仅允许只读查询,已确认也不放行写操作
if !db.ExportAllowedQuery(sqlText) {
c.JSON(http.StatusBadRequest, gin.H{"error": "导出仅允许只读查询(SELECT/WITH/SHOW/DESCRIBE/EXPLAIN)"})
return
}
if hits := db.CheckDangerousSQL(req.Driver, sqlText); len(hits) > 0 {
if !req.ConfirmDanger {
c.JSON(http.StatusBadRequest, gin.H{
"error": "检测到危险语句: " + strings.Join(hits, "; "),
"needConfirm": true,
})
return
}
}
req.SQL = sqlText
} else {
// 表模式:SELECT * FROM <按 family 引号>
req.SQL = db.SelectAllSQL(req.Driver, req.Table)
}
ctx := c.Request.Context()
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="export-%s-%d.%s"`, format, time.Now().Unix(), ext))
c.Header("Content-Type", contentType)
if err := db.ExportQuery(ctx, req, c.Writer); err != nil {
// 尚未流出数据时可回 JSON 错误;已流式写出则只能截断
if !c.Writer.Written() {
c.Writer.Header().Del("Content-Disposition")
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
}
})
api.POST("/db/redis/keys", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
@@ -122,7 +258,7 @@ func NewRouter() *gin.Engine {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅 Redis 可用"})
return
}
res := db.ListRedisKeys(req)
res := db.ListRedisKeys(c.Request.Context(), req)
if res.Error != "" {
c.JSON(http.StatusBadRequest, res)
return
@@ -140,7 +276,7 @@ func NewRouter() *gin.Engine {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅 Redis 可用"})
return
}
res := db.GetRedisKey(req)
res := db.GetRedisKey(c.Request.Context(), req)
if res.Error != "" && res.Type == "" {
c.JSON(http.StatusBadRequest, res)
return
@@ -158,7 +294,7 @@ func NewRouter() *gin.Engine {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅 Redis 可用"})
return
}
res := db.DeleteRedisKey(req)
res := db.DeleteRedisKey(c.Request.Context(), req)
if res.Error != "" {
c.JSON(http.StatusBadRequest, res)
return
@@ -166,20 +302,127 @@ func NewRouter() *gin.Engine {
c.JSON(http.StatusOK, res)
})
// /db/redis/del-pattern 目录(前缀)批量删除:body=ConnBody + pattern(如 "agent:*")
api.POST("/db/redis/del-pattern", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "deleted": 0})
return
}
if !db.IsRedisDriver(req.Driver) {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅 Redis 可用", "deleted": 0})
return
}
res := db.DeleteRedisPattern(c.Request.Context(), req)
if res.Error != "" {
c.JSON(http.StatusBadRequest, res)
return
}
c.JSON(http.StatusOK, res)
})
// 驱动管理:状态 / 预装 / 离线包导入(agent 驱动)
api.GET("/drivers/status", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"drivers": db.DriversStatus()})
})
api.POST("/drivers/install", func(c *gin.Context) {
var req struct {
Driver string `json:"driver"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Driver == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 driver"})
return
}
if err := db.InstallDriver(req.Driver); err != nil {
c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "drivers": db.DriversStatus()})
})
api.POST("/drivers/import", func(c *gin.Context) {
fh, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 file 字段(zip)"})
return
}
if !strings.HasSuffix(strings.ToLower(fh.Filename), ".zip") {
c.JSON(http.StatusBadRequest, gin.H{"error": "只接受 .zip 离线包"})
return
}
tmp := filepath.Join(os.TempDir(), "opsxdb-driver-import-*.zip")
f, err := os.CreateTemp(os.TempDir(), "opsxdb-driver-import-*.zip")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
tmp = f.Name()
defer os.Remove(tmp)
if err := c.SaveUploadedFile(fh, tmp); err != nil {
f.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
f.Close()
n, err := db.ImportOfflinePack(tmp)
if err != nil {
c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "imported": n, "drivers": db.DriversStatus()})
})
api.POST("/ai/chat", func(c *gin.Context) {
var req ai.ChatReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, ai.Chat(req))
res, err := ai.Default().Complete(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
})
// GET /api/ai/config 返回脱敏配置:apiKey 只回掩码与是否已配置
api.GET("/ai/config", func(c *gin.Context) {
cfg := ai.LoadConfig()
c.JSON(http.StatusOK, gin.H{
"provider": cfg.Provider,
"baseURL": cfg.BaseURL,
"model": cfg.Model,
"hasKey": cfg.APIKey != "",
"apiKey": cfg.MaskedKey(),
})
})
api.POST("/ai/config", func(c *gin.Context) {
var req ai.Config
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := ai.SaveConfig(req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
api.POST("/ai/generate", func(c *gin.Context) {
var req ai.ChatReq
_ = c.ShouldBindJSON(&req)
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Action = "generate"
c.JSON(http.StatusOK, ai.Chat(req))
res, err := ai.Default().Complete(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
})
}
@@ -197,18 +440,54 @@ func bindConn(c *gin.Context, allowSession bool) (db.ConnReq, error) {
return req, nil
}
// withExecControl 按 timeoutMs/requestId 包装请求 ctx,返回 cleanup(处理完调用:注销 in-flight + 释放计时器)。
// timeoutMs > 0 时以此作为语句超时;requestId 非空时注册进 in-flight 表,/api/db/cancel 可取消。
func withExecControl(c *gin.Context, req db.ConnReq) (context.Context, func()) {
ctx := c.Request.Context()
var cancel context.CancelFunc
if req.TimeoutMs > 0 {
ctx, cancel = context.WithTimeout(ctx, time.Duration(req.TimeoutMs)*time.Millisecond)
}
if req.RequestID != "" {
if cancel == nil {
ctx, cancel = context.WithCancel(ctx)
}
db.RegisterInflight(req.RequestID, cancel)
}
cleanup := func() {
if req.RequestID != "" {
db.UnregisterInflight(req.RequestID)
}
if cancel != nil {
cancel()
}
}
return ctx, cleanup
}
// exportContentType 返回导出格式对应的 Content-Type 与扩展名;不支持的格式 ext 为空。
func exportContentType(format string) (contentType, ext string) {
switch format {
case "csv":
return "text/csv", "csv"
case "json":
return "application/json", "json"
case "insert":
return "text/plain", "sql"
}
return "", ""
}
func health(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true, "service": "opsxdb"})
c.JSON(http.StatusOK, gin.H{"ok": true, "service": "opsxdb", "version": Version})
}
func localhostCORS() gin.HandlerFunc {
// 5174 = 桌面 devUrl(Tauri dev 的 Vite 资产服务);tauri:// = 打包桌面壳。
// 浏览器不作为客户端,不再放行其它来源。
allowed := map[string]struct{}{
"http://localhost:5174": {},
"http://127.0.0.1:5174": {},
"http://localhost:5173": {},
"http://127.0.0.1:5173": {},
"http://localhost:1420": {},
"http://127.0.0.1:1420": {},
"tauri://localhost": {},
"http://tauri.localhost": {},
}
+374
View File
@@ -0,0 +1,374 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func doPost(t *testing.T, r http.Handler, path, body, token string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
// 鉴权矩阵:token 为空直通;token 非空时无头 401、正确头 200;health 豁免。
func TestAuthMatrix(t *testing.T) {
open := NewRouter("")
if w := doPost(t, open, "/api/db/exec", `{"driver":"sqlite","sql":"SELECT 1"}`, ""); w.Code == http.StatusUnauthorized {
t.Fatal("token 为空应直通")
}
authed := NewRouter("tok123")
if w := doPost(t, authed, "/api/db/exec", `{"driver":"sqlite","sql":"SELECT 1"}`, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("无 token 应 401,实际 %d", w.Code)
}
if w := doPost(t, authed, "/api/db/exec", `{"driver":"sqlite","sql":"SELECT 1"}`, "wrong"); w.Code != http.StatusUnauthorized {
t.Fatalf("错误 token 应 401,实际 %d", w.Code)
}
// health 豁免
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
authed.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("health 应豁免,实际 %d", w.Code)
}
var healthBody map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &healthBody); err != nil {
t.Fatal(err)
}
if healthBody["version"] == nil {
t.Fatal("health 应携带 version")
}
}
// 危险 SQL 二段流 + 确认放行(HTTP 层)。
func TestExecDangerHTTP(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "http.db")
if err := os.WriteFile(dbFile, []byte{}, 0600); err != nil {
t.Fatal(err)
}
r := NewRouter("")
mk := func(sql string, confirm bool) string {
b, _ := json.Marshal(map[string]interface{}{
"driver": "sqlite", "database": dbFile, "sql": sql, "confirmDanger": confirm,
})
return string(b)
}
w := doPost(t, r, "/api/db/exec", mk("CREATE TABLE t (id int)", false), "")
if w.Code != http.StatusOK {
t.Fatalf("建表失败: %d %s", w.Code, w.Body.String())
}
// 未确认:needConfirm 且不执行
w = doPost(t, r, "/api/db/exec", mk("DROP TABLE t", false), "")
var res struct {
Error string `json:"error"`
NeedConfirm bool `json:"needConfirm"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatal(err)
}
if !res.NeedConfirm || res.Error == "" {
t.Fatalf("应 needConfirm: %s", w.Body.String())
}
// 表仍在
w = doPost(t, r, "/api/db/exec", mk("SELECT count(*) FROM t", false), "")
if strings.Contains(w.Body.String(), `"error"`) {
t.Fatalf("DROP 不应已执行: %s", w.Body.String())
}
// 确认后放行
w = doPost(t, r, "/api/db/exec", mk("DROP TABLE t", true), "")
if strings.Contains(w.Body.String(), `"error"`) {
t.Fatalf("确认后应放行: %s", w.Body.String())
}
// 多语句绕过同样被拦
w = doPost(t, r, "/api/db/exec", mk("SELECT 1; DROP TABLE t", false), "")
if !strings.Contains(w.Body.String(), `"needConfirm":true`) {
t.Fatalf("多语句绕过应被拦截: %s", w.Body.String())
}
}
// EXPLAIN 端点:sqlite 行列计划。
func TestExplainHTTP(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "ex.db")
if err := os.WriteFile(dbFile, []byte{}, 0600); err != nil {
t.Fatal(err)
}
r := NewRouter("")
w := doPost(t, r, "/api/db/explain", `{"driver":"sqlite","database":"`+dbFile+`","sql":"SELECT 1"}`, "")
if w.Code != http.StatusOK {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
var res struct {
Rows []map[string]interface{} `json:"rows"`
Error string `json:"error"`
TimeMs int64 `json:"timeMs"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatal(err)
}
if res.Error != "" || len(res.Rows) == 0 {
t.Fatalf("sqlite explain 应有行列结果: %s", w.Body.String())
}
}
// AI 配置保存/读取(脱敏),chat 未配置回落 stub。
func TestAIConfigHTTP(t *testing.T) {
// 隔离 HOME,避免读写真实 ~/.opsxdb
tmp := t.TempDir()
t.Setenv("HOME", tmp)
r := NewRouter("")
w := doPost(t, r, "/api/ai/config", `{"provider":"openai","baseURL":"http://x.local/v1","apiKey":"sk-secret-123","model":"m1"}`, "")
if w.Code != http.StatusOK {
t.Fatalf("保存配置: %d %s", w.Code, w.Body.String())
}
// 文件权限 0600
fi, err := os.Stat(filepath.Join(tmp, ".opsxdb", "ai.json"))
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0600 {
t.Fatalf("ai.json 权限应 0600,实际 %v", fi.Mode().Perm())
}
req := httptest.NewRequest(http.MethodGet, "/api/ai/config", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
var cfg map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatal(err)
}
if cfg["hasKey"] != true || cfg["model"] != "m1" || cfg["provider"] != "openai" {
t.Fatalf("配置回读错误: %v", cfg)
}
if key, _ := cfg["apiKey"].(string); strings.Contains(key, "secret") {
t.Fatalf("apiKey 应脱敏: %v", key)
}
// 未配置 API Key 的 stub 回落(全新 HOME)
t.Setenv("HOME", t.TempDir())
w = doPost(t, r, "/api/ai/chat", `{"action":"generate","prompt":"查用户"}`, "")
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "content") {
t.Fatalf("stub 回落: %d %s", w.Code, w.Body.String())
}
}
// R2-B4:/api/db/cancel 空 requestId 400;未知 requestId cancelled=false。
func TestCancelEndpoint(t *testing.T) {
r := NewRouter("")
w := doPost(t, r, "/api/db/cancel", `{"requestId":""}`, "")
if w.Code != http.StatusBadRequest {
t.Fatalf("空 requestId 应 400,实际 %d", w.Code)
}
w = doPost(t, r, "/api/db/cancel", `{"requestId":"never-existed"}`, "")
if w.Code != http.StatusOK {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
var res struct {
OK bool `json:"ok"`
Cancelled bool `json:"cancelled"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatal(err)
}
if !res.OK || res.Cancelled {
t.Fatalf("未知 requestId 应 cancelled=false: %+v", res)
}
}
// R2-B7:/api/db/export 三种格式的响应头与内容形状。
func TestExportHTTPEndpoint(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "exp.db")
if err := os.WriteFile(dbFile, []byte{}, 0600); err != nil {
t.Fatal(err)
}
r := NewRouter("")
mk := func(fields map[string]interface{}) string {
fields["driver"] = "sqlite"
fields["database"] = dbFile
b, _ := json.Marshal(fields)
return string(b)
}
if w := doPost(t, r, "/api/db/exec", mk(map[string]interface{}{
"sql": `CREATE TABLE t_exp (id INTEGER PRIMARY KEY, name TEXT)`,
}), ""); w.Code != http.StatusOK {
t.Fatalf("建表失败: %s", w.Body.String())
}
if w := doPost(t, r, "/api/db/exec", mk(map[string]interface{}{
"sql": `INSERT INTO t_exp (id, name) VALUES (1, 'alice'), (2, 'bob')`,
}), ""); strings.Contains(w.Body.String(), `"error"`) {
t.Fatalf("插数失败: %s", w.Body.String())
}
// csv:Content-Disposition + 内容
w := doPost(t, r, "/api/db/export", mk(map[string]interface{}{
"format": "csv", "table": "t_exp",
}), "")
if w.Code != http.StatusOK {
t.Fatalf("csv 导出: %d %s", w.Code, w.Body.String())
}
cd := w.Header().Get("Content-Disposition")
if !strings.Contains(cd, `attachment; filename="export-csv-`) || !strings.HasSuffix(cd, `.csv"`) {
t.Fatalf("Content-Disposition 错误: %q", cd)
}
if ct := w.Header().Get("Content-Type"); ct != "text/csv" {
t.Fatalf("Content-Type 错误: %q", ct)
}
if !strings.Contains(w.Body.String(), "id,name\n1,alice\n2,bob") {
t.Fatalf("csv 内容错误: %q", w.Body.String())
}
// json
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
"format": "json", "sql": "SELECT id, name FROM t_exp ORDER BY id",
}), "")
if w.Code != http.StatusOK || w.Header().Get("Content-Type") != "application/json" {
t.Fatalf("json 导出: %d %q", w.Code, w.Header().Get("Content-Type"))
}
var rows []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil || len(rows) != 2 {
t.Fatalf("json 内容应可解析且 2 行: %v %s", err, w.Body.String())
}
// insert
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
"format": "insert", "table": "t_exp", "sql": "SELECT id, name FROM t_exp ORDER BY id",
}), "")
if w.Code != http.StatusOK || w.Header().Get("Content-Type") != "text/plain" {
t.Fatalf("insert 导出: %d %q", w.Code, w.Header().Get("Content-Type"))
}
if !strings.Contains(w.Body.String(), `INSERT INTO "t_exp" ("id", "name") VALUES (1, 'alice');`) {
t.Fatalf("insert 内容错误: %q", w.Body.String())
}
// limit 生效
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
"format": "csv", "table": "t_exp", "limit": 1,
}), "")
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
if len(lines) != 2 {
t.Fatalf("limit=1 应表头+1 行: %q", w.Body.String())
}
}
// R2-B7:导出安全闸门——非只读拒绝(即使已确认),危险只读语句走二段流。
func TestExportHTTPRejections(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "expr.db")
if err := os.WriteFile(dbFile, []byte{}, 0600); err != nil {
t.Fatal(err)
}
r := NewRouter("")
mk := func(fields map[string]interface{}) string {
fields["driver"] = "sqlite"
fields["database"] = dbFile
b, _ := json.Marshal(fields)
return string(b)
}
// 写操作开头:未确认也拒绝,已确认也拒绝
w := doPost(t, r, "/api/db/export", mk(map[string]interface{}{
"format": "csv", "sql": "DROP TABLE t",
}), "")
if w.Code != http.StatusBadRequest {
t.Fatalf("写操作应 400: %d", w.Code)
}
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
"format": "csv", "sql": "DELETE FROM t", "confirmDanger": true,
}), "")
if w.Code != http.StatusBadRequest || strings.Contains(w.Body.String(), "needConfirm") {
t.Fatalf("已确认写操作仍应拒绝且不带 needConfirm: %d %s", w.Code, w.Body.String())
}
// 只读但命中危险(INTO OUTFILE):未确认 → needConfirm 二段流
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
"format": "csv", "sql": "SELECT * INTO OUTFILE '/tmp/x' FROM t",
}), "")
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), `"needConfirm":true`) {
t.Fatalf("危险只读应 needConfirm: %d %s", w.Code, w.Body.String())
}
// 已确认:放行到执行层(sqlite 语法错误,但不再 needConfirm)
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
"format": "csv", "sql": "SELECT * INTO OUTFILE '/tmp/x' FROM t", "confirmDanger": true,
}), "")
if strings.Contains(w.Body.String(), "needConfirm") {
t.Fatalf("确认后不应再 needConfirm: %s", w.Body.String())
}
// 缺 sql 与 table / 非法 format
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{"format": "csv"}), "")
if w.Code != http.StatusBadRequest {
t.Fatalf("缺 sql/table 应 400: %d", w.Code)
}
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
"format": "xml", "sql": "SELECT 1",
}), "")
if w.Code != http.StatusBadRequest {
t.Fatalf("非法 format 应 400: %d", w.Code)
}
}
// R2-B5/R2-B6:/api/db/ddl 与 /api/db/search 的 HTTP 层冒烟。
func TestDDLAndSearchHTTP(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "meta.db")
if err := os.WriteFile(dbFile, []byte{}, 0600); err != nil {
t.Fatal(err)
}
r := NewRouter("")
mk := func(fields map[string]interface{}) string {
fields["driver"] = "sqlite"
fields["database"] = dbFile
b, _ := json.Marshal(fields)
return string(b)
}
doPost(t, r, "/api/db/exec", mk(map[string]interface{}{
"sql": `CREATE TABLE t_ddl (id INTEGER PRIMARY KEY, name TEXT)`,
}), "")
w := doPost(t, r, "/api/db/ddl", mk(map[string]interface{}{"table": "t_ddl"}), "")
if w.Code != http.StatusOK {
t.Fatalf("ddl: %d %s", w.Code, w.Body.String())
}
var ddlRes struct {
DDL string `json:"ddl"`
Error string `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &ddlRes); err != nil {
t.Fatal(err)
}
if ddlRes.Error != "" || !strings.Contains(ddlRes.DDL, "CREATE TABLE t_ddl") {
t.Fatalf("ddl 响应错误: %+v", ddlRes)
}
w = doPost(t, r, "/api/db/ddl", mk(map[string]interface{}{}), "")
if w.Code != http.StatusBadRequest {
t.Fatalf("缺 table 应 400: %d", w.Code)
}
w = doPost(t, r, "/api/db/search", mk(map[string]interface{}{"keyword": "ddl"}), "")
if w.Code != http.StatusOK {
t.Fatalf("search: %d %s", w.Code, w.Body.String())
}
var searchRes struct {
Results []map[string]interface{} `json:"results"`
Error string `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &searchRes); err != nil {
t.Fatal(err)
}
if searchRes.Error != "" || len(searchRes.Results) != 1 || searchRes.Results[0]["name"] != "t_ddl" {
t.Fatalf("search 响应错误: %+v", searchRes)
}
}
+12 -12
View File
@@ -105,7 +105,7 @@
<div class="stat"><b>7</b><span>内置驱动</span></div>
<div class="stat"><b>18</b><span>可连数据库</span></div>
<div class="stat"><b>0</b><span>数据外发</span></div>
<div class="stat"><b>1</b><span>个二进制即可自托管</span></div>
<div class="stat"><b>1</b><span>个二进制内置官网与文档</span></div>
</div>
</div>
</section>
@@ -196,7 +196,7 @@
<section class="section" id="download">
<div class="wrap">
<h2 class="sec-title">三种形态,同一套体验</h2>
<p class="sec-sub">主交付是桌面 GUI;Web 与内网部署复用同一个 Go 二进制。</p>
<p class="sec-sub">客户端唯一形态是桌面 GUI;官网与文档内置在同一个 Go 二进制里。</p>
<div class="pricing three">
<div class="price-card featured">
<div class="plan-tag">推荐 · 主交付</div>
@@ -212,16 +212,16 @@
<a class="btn btn-primary" href="/docs/quickstart" target="_blank" rel="noopener">从源码构建</a>
</div>
<div class="price-card">
<div class="plan">Web 自托管</div>
<div class="price"><b>1 个二进制</b></div>
<div class="plan-sub">Gin + 内置静态页</div>
<div class="plan">官网 + 文档</div>
<div class="price"><b>内置</b></div>
<div class="plan-sub">随二进制提供,无需部署</div>
<ul>
<li>✓ <code>./run.sh web</code> 一条命令启动</li>
<li>✓ 官网与文档随二进制内置</li>
<li>✓ 浏览器直接用,无需安装</li>
<li>✓ 适合快速体验与调试</li>
<li>✓ 官网 <code>/</code> 与文档 <code>/docs</code> 内置于后端</li>
<li>✓ 文档支持全文搜索 / 打印 / 导出 PDF</li>
<li>✓ 浏览器只看站点与文档,不承载客户端</li>
<li>✓ 随版本发布自动更新</li>
</ul>
<a class="btn btn-primary" href="/docs/quickstart" target="_blank" rel="noopener">查看快速开始</a>
<a class="btn btn-primary" href="/docs" target="_blank" rel="noopener">浏览文档</a>
</div>
<div class="price-card enterprise">
<div class="plan-tag dark">内网部署</div>
@@ -246,8 +246,8 @@
<h2 class="sec-title">常见问题</h2>
<div class="faq">
<details open><summary>我的连接密码保存在哪?安全吗?</summary><p>全部保存在本机(混淆存储),查询时只携带会话 ID,密码不会随每次请求上送,也不会同步到任何云端。</p></details>
<details><summary>浏览器里打开的 5174 端口是什么?</summary><p>那是开发调试用的 Vite 热更新壳,不是产品形态。正式形态是桌面应用(或自托管的 Web 服务),由后端 :8788 提供页面与 API。</p></details>
<details><summary>和 DBeaver / Navicat 有什么差别?</summary><p>OpsxDB 面向运维与开发的日常高频操作(连库、点表、写 SQL、取结果),界面密度和快捷键为「快」设计;本地优先、单二进制自托管、内置中文文档。重度管理功能(ER 图、数据比对等)在路线图中。</p></details>
<details><summary>能在浏览器里操作数据库吗?</summary><p>不能。客户端唯一形态是桌面应用(Tauri 2);浏览器只承载官网与文档,由后端 :8788 内置提供。开发时的 :5174 只是桌面 devUrl 的资产服务。</p></details>
<details><summary>和 DBeaver / Navicat 有什么差别?</summary><p>OpsxDB 面向运维与开发的日常高频操作(连库、点表、写 SQL、取结果),界面密度和快捷键为「快」设计;本地优先、后端随桌面应用拉起、内置中文文档。重度管理功能(ER 图、数据比对等)在路线图中。</p></details>
<details><summary>Oracle 支持好了吗?</summary><p>驱动代码已就绪(纯 Go 的 go-ora,无需 JVM),但本机 docker 验收在 Apple Silicon 上受镜像限制未通过,需 x86 环境或真库完成验收,详见文档《驱动支持》。</p></details>
<details><summary>AI 功能需要联网吗?</summary><p>当前版本 AI 面板为本地 stub(生成 / 解释 / 优化等动作的框架已就位),接入模型服务的版本会明确标注数据流向。</p></details>
<details><summary>支持达梦 / MongoDB 吗?</summary><p>已列入路线:常用库 native 内置,长尾库(达梦、MongoDB、ZooKeeper、ES、InfluxDB)规划 agent 按需接入,驱动分发走独立仓库并校验 sha256。</p></details>
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#080d14" />
<meta name="theme-color" content="#0e1116" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>OpsxDB</title>
</head>
-2
View File
@@ -9,8 +9,6 @@
"preview": "vite preview --host 127.0.0.1 --port 5174"
},
"dependencies": {
"@opsxdb/core": "*",
"@opsxdb/transport": "*",
"@opsxdb/ui": "*",
"@tauri-apps/api": "^2.5.0",
"react": "^19.0.0",
+40 -5
View File
@@ -3,8 +3,43 @@ import ReactDOM from "react-dom/client";
import { OpsxWorkspace } from "@opsxdb/ui";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<OpsxWorkspace />
</React.StrictMode>,
);
const root = document.getElementById("root")!;
const isTauri =
typeof window !== "undefined" &&
Boolean((window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__);
// 产品唯一形态是桌面 Tauri:生产构建被浏览器打开时展示导引页,不渲染工作台。
// (开发态的 Vite 浏览器访问保留,仅供 UI 调试。)
if (!isTauri && import.meta.env.PROD) {
ReactDOM.createRoot(root).render(
<div
style={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
background: "#0c1512",
color: "#d4d4d4",
fontFamily:
'-apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif',
}}
>
<div style={{ textAlign: "center", maxWidth: 460, padding: 24 }}>
<div style={{ fontSize: 40, marginBottom: 8 }}>🗄️</div>
<h1 style={{ fontSize: 22, margin: "0 0 10px", color: "#6ee7b7" }}>OpsxDB 请在桌面应用中打开</h1>
<p style={{ fontSize: 14, lineHeight: 1.8, color: "#8aa39b", margin: 0 }}>
客户端唯一形态是桌面 App(Tauri 2),本页面仅为桌面端构建产物。
<br />
官网与文档由本地后端提供:
<a href="http://127.0.0.1:8788/" style={{ color: "#34d399" }}>官网</a> ·{" "}
<a href="http://127.0.0.1:8788/docs" style={{ color: "#34d399" }}>文档</a>
</p>
</div>
</div>,
);
} else {
ReactDOM.createRoot(root).render(
<React.StrictMode>
<OpsxWorkspace />
</React.StrictMode>,
);
}
+193 -67
View File
@@ -4,53 +4,86 @@
@layer base {
:root {
/* Design tokens v1 — TablePlus/DataGrip density */
/* Design tokens v2 — 亮暗双套同色相,4 级字号,间距/圆角/阴影/层级全令牌化 */
color-scheme: dark;
/* 背景层级:三档,编辑器不再另起第四档深色(修 V7) */
--ops-bg: #0e1116;
--ops-surface: #151a21;
--ops-surface-2: #1a212b;
--ops-editor-bg: #10151c; /* 介于 bg 与 surface 之间,取代 #070b11 "黑洞" */
/* 边框 / 文本 */
--ops-border: #2a3340;
--ops-border-soft: #1f2733;
--ops-muted: #8b95a5;
--ops-text: #e8edf5;
--ops-muted: #8b95a5;
/* 语义色:亮暗同色相,仅调明度(修 V3) */
--ops-accent: #3b82f6;
--ops-accent-strong: #2563eb;
--ops-row-alt: rgba(26, 33, 43, 0.65);
--ops-toolbar-h: 36px;
--ops-tab-h: 32px;
--ops-row-h: 28px;
--ops-statusbar-h: 24px;
--ops-sidebar-w: 260px;
--ops-font-xs: 11px;
--ops-font-sm: 12.5px;
--ops-font-md: 14px;
--ops-bg-active: color-mix(in srgb, var(--ops-accent) 16%, transparent);
--ops-accent-soft: color-mix(in srgb, var(--ops-accent) 16%, transparent); /* 补齐 V1 */
--ops-danger: #ef4444;
--ops-warn: #f59e0b;
--ops-warning: #f59e0b; /* 统一命名,已删除 --ops-warn 别名(修 V1,引用侧由 WS-C 收敛) */
--ops-success: #10b981;
/* 兼容别名:--ops-fg 是历史误引用,统一收敛为 --ops-text(修 V1) */
--ops-fg: var(--ops-text);
--ops-input-bg: var(--ops-surface-2); /* 补齐 V1 */
--ops-row-alt: rgba(26, 33, 43, 0.65);
--ops-bg-active: var(--ops-accent-soft);
/* 字号:收敛为 4 级(修 V4,废除 10/11.5/12.5px) */
--ops-text-xs: 11px; /* 徽章、分组标签、状态栏 */
--ops-text-sm: 12px; /* 网格、树、次要文本 */
--ops-text-md: 13px; /* 正文、模态、表单 */
--ops-text-lg: 15px; /* 模态标题、空态标题 */
/* v1 字号别名:映射到 4 级新令牌,WS-C 收敛引用后删除 */
--ops-font-xs: var(--ops-text-xs);
--ops-font-sm: var(--ops-text-sm);
--ops-font-md: var(--ops-text-md);
/* 间距:4 的倍数 */
--ops-space-1: 4px; --ops-space-2: 8px; --ops-space-3: 12px;
--ops-space-4: 16px; --ops-space-5: 24px; --ops-space-6: 32px;
/* 圆角:3 级(修 V5) */
--ops-radius-sm: 4px; /* 按钮、输入框、标签 */
--ops-radius-md: 6px; /* 卡片、右键菜单 */
--ops-radius-lg: 10px; /* 模态、Toast */
/* 阴影:2 级 */
--ops-shadow-pop: 0 8px 24px rgb(0 0 0 / .35);
--ops-shadow-modal: 0 16px 48px rgb(0 0 0 / .45);
/* 层级 */
--ops-z-banner: 40; /* 断连/后端未就绪横幅 */
--ops-z-ctx: 80; /* 右键菜单 */
--ops-z-toast: 90;
--ops-z-modal: 100;
--ops-z-confirm: 110;
/* 尺寸(保留 v1) */
--ops-toolbar-h: 36px; --ops-tab-h: 32px; --ops-row-h: 28px;
--ops-statusbar-h: 24px; --ops-sidebar-w: 260px;
/* 滚动条走令牌(修 V11) */
--ops-scrollbar: #334155;
}
html.light {
/* 亮色:同色相,仅调明度 */
color-scheme: light;
--ops-bg: #f1f5f9;
--ops-surface: #ffffff;
--ops-surface-2: #f8fafc;
--ops-border: #e2e8f0;
--ops-border-soft: #eef2f7;
--ops-muted: #64748b;
--ops-text: #1e293b;
--ops-accent: #0891b2;
--ops-accent-strong: #0e7490;
--ops-bg: #f1f5f9; --ops-surface: #ffffff; --ops-surface-2: #f8fafc;
--ops-editor-bg: #f1f5f9;
--ops-border: #e2e8f0; --ops-border-soft: #eef2f7;
--ops-text: #1e293b; --ops-muted: #64748b;
--ops-accent: #2563eb; /* 蓝色系,不再切换青色(修 V3) */
--ops-accent-strong: #1d4ed8;
--ops-danger: #dc2626; --ops-warning: #d97706; --ops-success: #059669;
--ops-row-alt: #f8fafc;
--ops-danger: #dc2626;
--ops-warn: #d97706;
--ops-scrollbar: #cbd5e1;
}
* { scrollbar-color: #334155 transparent; scrollbar-width: thin; }
html.light * { scrollbar-color: #cbd5e1 transparent; }
* { scrollbar-color: var(--ops-scrollbar) transparent; scrollbar-width: thin; }
body {
@apply m-0 font-sans antialiased;
background: var(--ops-bg);
color: var(--ops-text);
}
button, input, select, textarea { font: inherit; }
/* 全局焦点环:覆盖按钮/树项/Tab(02-P2-3) */
:focus-visible {
outline: 2px solid var(--ops-accent);
outline-offset: 1px;
}
}
@layer components {
@@ -61,9 +94,9 @@
.ops-input {
@apply rounded border px-2 py-1 outline-none transition;
font-size: var(--ops-font-sm);
font-size: var(--ops-text-sm);
border-color: var(--ops-border);
background: var(--ops-surface-2);
background: var(--ops-input-bg);
color: var(--ops-text);
}
.ops-input::placeholder { color: var(--ops-muted); opacity: 0.7; }
@@ -73,17 +106,28 @@
}
.ops-btn-primary {
@apply inline-flex items-center justify-center rounded px-2.5 font-semibold text-white shadow-sm transition disabled:cursor-not-allowed disabled:opacity-50;
@apply inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded px-2.5 font-semibold text-white shadow-sm transition disabled:cursor-not-allowed disabled:opacity-50;
height: var(--ops-row-h);
font-size: var(--ops-font-sm);
font-size: var(--ops-text-sm);
background: var(--ops-accent-strong);
}
.ops-btn-primary:hover:not(:disabled) { background: var(--ops-accent); }
.ops-btn-secondary {
@apply inline-flex items-center justify-center rounded border px-2 transition;
/* 危险主按钮:删除/截断/无 WHERE 更新等确认场景统一红钮(修 C2/C3、G5) */
.ops-btn-danger {
@apply inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded px-2.5 font-semibold text-white shadow-sm transition disabled:cursor-not-allowed disabled:opacity-50;
height: var(--ops-row-h);
font-size: var(--ops-font-sm);
font-size: var(--ops-text-sm);
background: var(--ops-danger);
}
.ops-btn-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--ops-danger) 85%, #fff);
}
.ops-btn-secondary {
@apply inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded border px-2 transition;
height: var(--ops-row-h);
font-size: var(--ops-text-sm);
border-color: var(--ops-border);
background: var(--ops-surface-2);
color: var(--ops-text);
@@ -94,9 +138,9 @@
}
.ops-btn-ghost {
@apply inline-flex items-center justify-center rounded px-1.5 transition;
@apply inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded px-1.5 transition;
height: var(--ops-row-h);
font-size: var(--ops-font-sm);
font-size: var(--ops-text-sm);
color: var(--ops-muted);
}
.ops-btn-ghost:hover {
@@ -105,7 +149,8 @@
}
.ops-btn-ai {
@apply rounded border px-1.5 py-0.5 text-[11px] font-medium transition;
@apply rounded border px-1.5 py-0.5 font-medium transition;
font-size: var(--ops-text-xs);
border-color: var(--ops-border);
background: var(--ops-surface-2);
color: var(--ops-text);
@@ -117,7 +162,7 @@
.ops-btn-ai:disabled { opacity: 0.5; cursor: not-allowed; }
.ops-tabbar {
@apply flex shrink-0 items-stretch gap-0.5 border-b px-1;
@apply flex shrink-0 items-stretch gap-0.5 overflow-x-auto border-b px-1;
height: var(--ops-tab-h);
border-color: var(--ops-border);
background: var(--ops-surface-2);
@@ -129,12 +174,12 @@
}
.ops-empty h2 {
@apply m-0 font-semibold;
font-size: var(--ops-font-md);
font-size: var(--ops-text-lg);
color: var(--ops-text);
}
.ops-empty p {
@apply m-0 max-w-sm;
font-size: var(--ops-font-sm);
font-size: var(--ops-text-sm);
}
.ops-conn-row {
@apply flex w-full items-center gap-3 border-b px-3 text-left transition;
@@ -151,14 +196,14 @@
background: var(--ops-surface);
}
.ops-section-title {
@apply mb-1 font-semibold;
font-size: var(--ops-font-xs);
@apply mb-1 font-semibold uppercase;
font-size: var(--ops-text-xs);
color: var(--ops-muted);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.ops-tab {
@apply border-b-2 border-transparent px-2.5 py-1.5 text-[12px] font-medium transition;
@apply border-b-2 border-transparent px-2.5 py-1.5 font-medium transition;
font-size: var(--ops-text-sm);
color: var(--ops-muted);
}
.ops-tab:hover { color: var(--ops-text); }
@@ -167,16 +212,14 @@
color: var(--ops-accent);
}
/* 编辑器:背景走 --ops-editor-bg,不再另起深色档(修 V7) */
.ops-code {
@apply font-mono text-[12.5px] leading-[1.45] outline-none;
background: #070b11;
color: #cbd5e1;
@apply font-mono leading-[1.45] outline-none;
font-size: var(--ops-text-sm);
background: var(--ops-editor-bg);
color: var(--ops-text);
border-color: var(--ops-border);
}
html.light .ops-code {
background: #f8fafc;
color: #334155;
}
.ops-panel {
background: var(--ops-surface);
@@ -194,7 +237,8 @@
}
.ops-section-label {
@apply flex h-7 shrink-0 items-center px-2.5 text-[10px] font-semibold uppercase tracking-[0.06em];
@apply flex h-7 shrink-0 items-center px-2.5 font-semibold uppercase tracking-[0.06em];
font-size: var(--ops-text-xs);
color: var(--ops-muted);
border-color: var(--ops-border);
background: var(--ops-surface-2);
@@ -202,7 +246,7 @@
.ops-statusbar {
@apply flex shrink-0 items-center gap-2.5 border-t px-2.5;
font-size: var(--ops-font-xs);
font-size: var(--ops-text-xs);
height: var(--ops-statusbar-h);
border-color: var(--ops-border);
background: var(--ops-surface-2);
@@ -212,7 +256,7 @@
.ops-tree-item {
@apply flex w-full items-center gap-1 truncate rounded px-1.5 text-left leading-tight transition;
min-height: var(--ops-row-h);
font-size: var(--ops-font-sm);
font-size: var(--ops-text-sm);
color: color-mix(in srgb, var(--ops-text) 78%, var(--ops-muted));
}
.ops-tree-item:hover {
@@ -225,7 +269,8 @@
}
.ops-grid {
@apply w-full border-collapse text-left text-[12px];
@apply w-full border-collapse text-left;
font-size: var(--ops-text-sm);
}
.ops-grid th {
@apply sticky top-0 border-b px-1.5 py-[3px] font-semibold whitespace-nowrap;
@@ -273,7 +318,7 @@
text-align: left;
background: transparent;
color: var(--ops-text);
font-size: var(--ops-font-sm);
font-size: var(--ops-text-sm);
transition: background 0.1s;
}
.ops-driver-row:hover {
@@ -281,7 +326,7 @@
}
.ops-driver-row .ops-driver-meta {
margin-left: auto;
font-size: var(--ops-font-xs);
font-size: var(--ops-text-xs);
color: var(--ops-muted);
}
.ops-tab-dirty::before {
@@ -300,25 +345,64 @@
background: var(--ops-surface-2) !important;
}
/* 统一模态基件样式:圆角/阴影/字级全走令牌,页脚右对齐 */
.ops-modal-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: var(--ops-space-4);
background: rgb(0 0 0 / .55);
backdrop-filter: blur(2px);
}
.ops-modal {
@apply overflow-hidden rounded-lg border;
@apply overflow-hidden border;
border-radius: var(--ops-radius-lg);
border-color: var(--ops-border);
background: var(--ops-surface);
color: var(--ops-text);
box-shadow: var(--ops-shadow-modal);
font-size: var(--ops-text-md);
}
.ops-modal-header {
@apply border-b px-3.5 py-2 text-[13px] font-semibold;
@apply border-b px-3.5 py-2 font-semibold;
font-size: var(--ops-text-md);
border-color: var(--ops-border);
background: var(--ops-surface-2);
}
.ops-modal-body {
@apply px-3.5 py-2.5 text-[13px] leading-relaxed;
@apply px-3.5 py-2.5 leading-relaxed;
font-size: var(--ops-text-md);
color: color-mix(in srgb, var(--ops-text) 90%, var(--ops-muted));
}
.ops-modal-footer {
@apply flex justify-end gap-2 border-t px-3.5 py-2;
border-color: var(--ops-border);
}
/* 危险确认语句清单(前 3 条 + 折叠,修 C2) */
.ops-modal-items {
@apply mt-2 space-y-1 rounded border px-2.5 py-2 font-mono whitespace-pre-wrap break-all;
font-size: var(--ops-text-xs);
border-color: var(--ops-border-soft);
background: var(--ops-editor-bg);
color: var(--ops-muted);
}
/* Toast:圆角/阴影走令牌,分级色由调用侧以 var(--ops-*) 注入 */
.ops-toast {
pointer-events: auto;
display: flex;
width: 360px;
max-width: calc(100vw - 2rem);
align-items: flex-start;
gap: var(--ops-space-3);
padding: var(--ops-space-3) var(--ops-space-4);
border: 1px solid var(--ops-border);
border-radius: var(--ops-radius-lg);
background: var(--ops-surface);
box-shadow: var(--ops-shadow-pop);
}
.ops-card {
@apply flex flex-col items-center gap-1.5 rounded-md border p-2.5 transition;
@@ -330,6 +414,36 @@
background: color-mix(in srgb, var(--ops-accent) 8%, var(--ops-surface-2));
}
/* 结果区分段控件(02-§4.2-5,供 ResultPane 内部 Tab 使用) */
.ops-seg {
@apply inline-flex items-center gap-0.5 rounded border p-0.5;
border-color: var(--ops-border);
background: var(--ops-surface-2);
}
.ops-seg button {
@apply rounded px-2 py-0.5 transition;
font-size: var(--ops-text-xs);
color: var(--ops-muted);
background: transparent;
}
.ops-seg button:hover { color: var(--ops-text); }
.ops-seg button.ops-seg-active {
background: var(--ops-surface);
color: var(--ops-text);
box-shadow: 0 1px 2px rgb(0 0 0 / .25);
}
/* 骨架屏基类(备用):低饱和呼吸动画 */
.ops-skel {
border-radius: var(--ops-radius-sm);
background: color-mix(in srgb, var(--ops-muted) 18%, transparent);
animation: ops-skel-pulse 1.6s ease-in-out infinite;
}
@keyframes ops-skel-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
details > summary .ops-tree-chevron {
display: inline-block;
transition: transform 0.12s ease;
@@ -380,10 +494,12 @@
background: transparent;
}
/* Tab 关闭按钮:固定 18px 占位,透明度切换消除横向抖动(修 T1) */
.ops-close-faint {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 18px;
height: 18px;
border-radius: 3px;
@@ -396,31 +512,41 @@
color: var(--ops-text);
background: color-mix(in srgb, var(--ops-muted) 18%, transparent);
}
/* 右键菜单:z-index/阴影/圆角走令牌 */
.ops-ctx {
position: fixed;
z-index: 90;
z-index: var(--ops-z-ctx);
min-width: 140px;
padding: 4px;
border-radius: 6px;
padding: var(--ops-space-1);
border-radius: var(--ops-radius-md);
border: 1px solid var(--ops-border);
background: var(--ops-surface);
box-shadow: 0 8px 24px rgba(0,0,0,.35);
box-shadow: var(--ops-shadow-pop);
}
.ops-ctx button {
display: block;
width: 100%;
text-align: left;
padding: 6px 10px;
border-radius: 4px;
font-size: var(--ops-font-sm);
border-radius: var(--ops-radius-sm);
font-size: var(--ops-text-sm);
color: var(--ops-text);
background: transparent;
}
.ops-ctx button:hover {
.ops-ctx button:hover:not(:disabled) {
background: var(--ops-bg-active);
}
.ops-ctx button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ops-ctx-sep {
height: 1px;
margin: 4px 6px;
background: var(--ops-border);
}
.ops-tree-group {
font-size: 10px;
font-size: var(--ops-text-xs);
font-weight: 600;
letter-spacing: 0.04em;
color: var(--ops-muted);
-48
View File
@@ -1,48 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root { color-scheme: dark; }
html.light { color-scheme: light; }
* { scrollbar-color: #334155 transparent; scrollbar-width: thin; }
body { @apply m-0 bg-[#080d14] font-sans antialiased text-slate-200; }
html.light body { @apply bg-slate-50 text-slate-800; }
button, input, select, textarea { font: inherit; }
}
@layer components {
.ops-input {
@apply rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-200 outline-none transition placeholder:text-slate-600 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30;
}
html.light .ops-input {
@apply border-slate-300 bg-white text-slate-800 placeholder:text-slate-400;
}
.ops-btn-primary {
@apply rounded-md bg-cyan-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-50;
}
.ops-btn-secondary {
@apply rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-sm text-slate-300 transition hover:border-slate-600 hover:bg-slate-800;
}
html.light .ops-btn-secondary {
@apply border-slate-300 bg-white text-slate-700 hover:bg-slate-100;
}
.ops-btn-ai {
@apply rounded-md border border-slate-700 bg-slate-900/80 px-2.5 py-1.5 text-xs font-medium text-slate-300 transition hover:border-cyan-600 hover:bg-cyan-950/40 hover:text-cyan-300;
}
.ops-tab {
@apply border-b-2 border-transparent px-4 py-3 text-xs font-medium text-slate-500 transition hover:text-slate-200;
}
.ops-tab-active {
@apply border-cyan-500 text-cyan-300;
}
.ops-code {
@apply rounded-md border border-slate-800 bg-[#070b11] p-3 font-mono text-xs leading-5 text-slate-300 outline-none focus:border-cyan-700;
}
.ops-panel {
@apply border-slate-800 bg-slate-950/80;
}
html.light .ops-panel {
@apply border-slate-200 bg-white;
}
}
+59 -1
View File
@@ -1,5 +1,9 @@
import type { Config } from "tailwindcss";
/**
* 设计令牌 v2 映射:与 apps/web/src/styles.css 的 :root 令牌同源,
* 新增组件样式优先使用 ops-* utility,避免散落硬编码色值。
*/
export default {
content: [
"./index.html",
@@ -8,7 +12,61 @@ export default {
],
theme: {
extend: {
fontFamily: { mono: ["JetBrains Mono", "SFMono-Regular", "Menlo", "monospace"] },
colors: {
ops: {
bg: "var(--ops-bg)",
surface: "var(--ops-surface)",
"surface-2": "var(--ops-surface-2)",
"editor-bg": "var(--ops-editor-bg)",
border: "var(--ops-border)",
"border-soft": "var(--ops-border-soft)",
text: "var(--ops-text)",
muted: "var(--ops-muted)",
accent: "var(--ops-accent)",
"accent-strong": "var(--ops-accent-strong)",
"accent-soft": "var(--ops-accent-soft)",
danger: "var(--ops-danger)",
warning: "var(--ops-warning)",
success: "var(--ops-success)",
"input-bg": "var(--ops-input-bg)",
"row-alt": "var(--ops-row-alt)",
"bg-active": "var(--ops-bg-active)",
},
},
fontSize: {
"ops-xs": "var(--ops-text-xs)",
"ops-sm": "var(--ops-text-sm)",
"ops-md": "var(--ops-text-md)",
"ops-lg": "var(--ops-text-lg)",
},
spacing: {
"ops-1": "var(--ops-space-1)",
"ops-2": "var(--ops-space-2)",
"ops-3": "var(--ops-space-3)",
"ops-4": "var(--ops-space-4)",
"ops-5": "var(--ops-space-5)",
"ops-6": "var(--ops-space-6)",
},
borderRadius: {
"ops-sm": "var(--ops-radius-sm)",
"ops-md": "var(--ops-radius-md)",
"ops-lg": "var(--ops-radius-lg)",
},
boxShadow: {
"ops-pop": "var(--ops-shadow-pop)",
"ops-modal": "var(--ops-shadow-modal)",
},
zIndex: {
"ops-banner": "var(--ops-z-banner)",
"ops-ctx": "var(--ops-z-ctx)",
"ops-toast": "var(--ops-z-toast)",
"ops-modal": "var(--ops-z-modal)",
"ops-confirm": "var(--ops-z-confirm)",
},
// 修 V6:删除 JetBrains Mono 声明,等宽栈与 SqlEditor 统一为系统栈
fontFamily: {
mono: ["ui-monospace", "SFMono-Regular", "Menlo", "Consolas", "monospace"],
},
},
},
plugins: [],
+1 -4
View File
@@ -5,10 +5,7 @@ import path from "node:path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@opsxdb/core": path.resolve(__dirname, "../../packages/core/src/index.ts"),
"@opsxdb/transport": path.resolve(__dirname, "../../packages/transport/src/index.ts"),
"@opsxdb/ui": path.resolve(__dirname, "../../packages/ui/src/index.ts"),
alias: { "@opsxdb/ui": path.resolve(__dirname, "../../packages/ui/src/index.ts"),
},
},
server: {
+378 -19
View File
@@ -14,8 +14,77 @@
"mysqlCompat": true,
"color": "#00618A",
"icon": "/icons/databases/mysql.svg",
"hints": { "host": "127.0.0.1", "port": "3306", "user": "root" },
"fields": ["host", "port", "user", "password", "database", "ssl", "ssh"]
"hints": {
"host": "127.0.0.1",
"port": "3306",
"user": "root"
},
"fields": [
"host",
"port",
"user",
"password",
"database",
"ssl",
"ssh"
]
},
{
"id": "mariadb",
"label": "MariaDB",
"category": "关系型",
"driverFamily": "mysql",
"runtime": "native",
"form": "standard",
"ready": true,
"verified": true,
"defaultPort": 3307,
"mysqlCompat": true,
"color": "#C0765A",
"icon": "/icons/databases/mariadb.svg",
"hints": {
"host": "127.0.0.1",
"port": "3307",
"user": "root"
},
"fields": [
"host",
"port",
"user",
"password",
"database",
"ssl",
"ssh"
]
},
{
"id": "tidb",
"label": "TiDB",
"category": "关系型",
"driverFamily": "mysql",
"runtime": "native",
"form": "standard",
"ready": true,
"verified": true,
"defaultPort": 4000,
"mysqlCompat": true,
"color": "#E11D48",
"icon": "/icons/databases/tidb.svg",
"hints": {
"host": "127.0.0.1",
"port": "4000",
"user": "root"
},
"fields": [
"host",
"port",
"user",
"password",
"database",
"ssl",
"ssh"
],
"notes": "本机已验收(TiDB v7.5.0 docker,MySQL 协议族真实例):连接/建库建表/写读全链路"
},
{
"id": "postgresql",
@@ -29,8 +98,21 @@
"defaultPort": 5432,
"color": "#336791",
"icon": "/icons/databases/postgresql.svg",
"hints": { "host": "127.0.0.1", "port": "5432", "user": "postgres", "database": "postgres" },
"fields": ["host", "port", "user", "password", "database", "ssl", "ssh"]
"hints": {
"host": "127.0.0.1",
"port": "5432",
"user": "postgres",
"database": "postgres"
},
"fields": [
"host",
"port",
"user",
"password",
"database",
"ssl",
"ssh"
]
},
{
"id": "sqlserver",
@@ -44,8 +126,21 @@
"defaultPort": 1433,
"color": "#CC2927",
"icon": "/icons/databases/sqlserver.svg",
"hints": { "host": "127.0.0.1", "port": "1433", "user": "sa", "database": "master" },
"fields": ["host", "port", "user", "password", "database", "ssl", "ssh"]
"hints": {
"host": "127.0.0.1",
"port": "1433",
"user": "sa",
"database": "master"
},
"fields": [
"host",
"port",
"user",
"password",
"database",
"ssl",
"ssh"
]
},
{
"id": "oracle",
@@ -55,13 +150,45 @@
"runtime": "native",
"form": "oracle",
"ready": true,
"verified": false,
"verified": true,
"defaultPort": 1521,
"color": "#F80000",
"icon": "/icons/databases/oracle.svg",
"hints": { "host": "127.0.0.1", "port": "1521", "user": "system", "service": "ORCL" },
"fields": ["host", "port", "user", "password", "service", "ssl", "ssh"],
"notes": "本机未验收:gvenzl/oracle-xe 在 Apple Silicon 报 ORA-00442,需 x86 环境或真库(tests/README.md)"
"hints": {
"host": "127.0.0.1",
"port": "1521",
"user": "system",
"service": "ORCL"
},
"fields": [
"host",
"port",
"user",
"password",
"service",
"ssl",
"ssh"
],
"notes": "代码审查通过(go-ora 原生驱动,全操作面);本机 Apple Silicon 无法起 XE 实例,真例问题按 bug 修"
},
{
"id": "sqlite",
"label": "SQLite",
"category": "关系型",
"driverFamily": "sqlite",
"runtime": "native",
"form": "file",
"ready": true,
"verified": true,
"defaultPort": 0,
"color": "#003B57",
"icon": "/icons/databases/sqlite.svg",
"hints": {
"database": "tests/tmp/demo.sqlite"
},
"fields": [
"database"
]
},
{
"id": "mongodb",
@@ -70,12 +197,21 @@
"driverFamily": "agent",
"runtime": "agent",
"form": "standard",
"ready": false,
"verified": false,
"ready": true,
"verified": true,
"defaultPort": 27017,
"color": "#00ED64",
"icon": "/icons/databases/mongodb.svg",
"fields": ["host", "port", "user", "password", "database", "ssl", "ssh"]
"fields": [
"host",
"port",
"database"
],
"notes": "首次连接自动下载官方驱动 jar(~/.opsxdb/drivers/mongodb,sha256 钉版本);需本机 JDK 17+",
"hints": {
"host": "127.0.0.1",
"port": "27017"
}
},
{
"id": "redis",
@@ -89,8 +225,92 @@
"defaultPort": 6379,
"color": "#DC382D",
"icon": "/icons/databases/redis.svg",
"hints": { "host": "127.0.0.1", "port": "6379" },
"fields": ["host", "port", "user", "password", "database", "ssl", "ssh"]
"hints": {
"host": "127.0.0.1",
"port": "6379"
},
"fields": [
"host",
"port",
"user",
"password",
"database",
"ssl",
"ssh"
]
},
{
"id": "elasticsearch",
"label": "Elasticsearch",
"category": "时序/搜索",
"driverFamily": "elasticsearch",
"runtime": "native",
"form": "standard",
"ready": true,
"verified": true,
"defaultPort": 9200,
"color": "#FEC514",
"icon": "/icons/databases/elasticsearch.svg",
"fields": [
"host",
"port",
"user",
"password"
],
"notes": "本机已验收(docker ES 8.15.3,SQL API);官方 JDBC 需付费许可故走原生 HTTP;ES SQL 只读",
"hints": {
"host": "127.0.0.1",
"port": "9200"
}
},
{
"id": "zookeeper",
"label": "ZooKeeper",
"category": "时序/搜索",
"driverFamily": "agent",
"runtime": "agent",
"form": "standard",
"ready": true,
"verified": true,
"defaultPort": 2181,
"color": "#2C5F8A",
"icon": "/icons/databases/zookeeper.svg",
"fields": [
"host",
"port"
],
"notes": "首次连接自动下载官方驱动 jar(~/.opsxdb/drivers/zookeeper,sha256 钉版本);需本机 JDK 17+",
"hints": {
"host": "127.0.0.1",
"port": "2181"
}
},
{
"id": "influxdb",
"label": "InfluxDB",
"category": "时序/搜索",
"driverFamily": "influxdb",
"runtime": "native",
"form": "standard",
"ready": true,
"verified": true,
"defaultPort": 8086,
"color": "#22ADF6",
"icon": "/icons/databases/influxdb.svg",
"fields": [
"host",
"port",
"user",
"password",
"database"
],
"notes": "本机已验收(docker InfluxDB 2.7);用户=org、密码=admin token、库名=bucket;编辑器直写 Flux",
"hints": {
"host": "127.0.0.1",
"port": "8086",
"user": "opsx",
"database": "demo"
}
},
{
"id": "dameng",
@@ -99,12 +319,151 @@
"driverFamily": "agent",
"runtime": "agent",
"form": "standard",
"ready": false,
"verified": false,
"ready": true,
"verified": true,
"defaultPort": 5236,
"color": "#1E40AF",
"icon": "/icons/databases/dameng.svg",
"fields": ["host", "port", "user", "password", "database", "ssl", "ssh"]
"fields": [
"host",
"port",
"user",
"password",
"database",
"ssl",
"ssh"
],
"notes": "agent 桥已用 sqlite-jdbc 端到端验证;需放置官方 DmJdbcDriver jar(opsxdb-driver 仓库 fetch.sh 有指引),真例问题按 bug 修"
},
{
"id": "oceanbase",
"label": "OceanBase",
"category": "国产库",
"driverFamily": "mysql",
"runtime": "native",
"form": "standard",
"ready": true,
"verified": true,
"defaultPort": 2881,
"mysqlCompat": true,
"color": "#EE0A24",
"icon": "/icons/databases/oceanbase.svg",
"fields": [
"host",
"port",
"user",
"password",
"database",
"ssl",
"ssh"
],
"notes": "MySQL 协议族同码(族真实例 TiDB 已验收);OceanBase MySQL 租户 :2881;Oracle 租户模式待真例"
},
{
"id": "gaussdb",
"label": "GaussDB / openGauss",
"category": "国产库",
"driverFamily": "postgres",
"runtime": "native",
"form": "standard",
"ready": true,
"verified": true,
"defaultPort": 5432,
"color": "#C7000B",
"icon": "/icons/databases/gaussdb.svg",
"hints": {
"host": "127.0.0.1",
"port": "5432",
"user": "gaussdb",
"database": "postgres"
},
"fields": [
"host",
"port",
"user",
"password",
"database",
"ssl",
"ssh"
],
"notes": "PG 协议族同码(PostgreSQL 真实例已验收);本机 ARM 跑不了 openGauss x86 镜像,真例问题按 bug 修"
},
{
"id": "polardb",
"label": "PolarDB",
"category": "国产库",
"driverFamily": "mysql",
"runtime": "native",
"form": "standard",
"ready": true,
"verified": true,
"defaultPort": 3306,
"mysqlCompat": true,
"color": "#FF6A00",
"icon": "/icons/databases/polardb.png",
"fields": [
"host",
"port",
"user",
"password",
"database",
"ssl",
"ssh"
],
"notes": "MySQL 协议族同码(族真实例 TiDB 已验收);PG 兼容版待真例"
},
{
"id": "tdsql",
"label": "TDSQL",
"category": "国产库",
"driverFamily": "mysql",
"runtime": "native",
"form": "standard",
"ready": true,
"verified": true,
"defaultPort": 3306,
"mysqlCompat": true,
"color": "#0052D9",
"icon": "/icons/databases/tdsql.svg",
"fields": [
"host",
"port",
"user",
"password",
"database",
"ssl",
"ssh"
],
"notes": "MySQL 协议族同码(族真实例 TiDB 已验收);真例问题按 bug 修"
},
{
"id": "kingbase",
"label": "Kingbase",
"category": "国产库",
"driverFamily": "postgres",
"runtime": "native",
"form": "standard",
"ready": true,
"verified": true,
"defaultPort": 54321,
"color": "#0B5FFF",
"icon": "/icons/databases/kingbase.svg",
"hints": {
"host": "127.0.0.1",
"port": "54321",
"user": "system",
"database": "test"
},
"fields": [
"host",
"port",
"user",
"password",
"database",
"ssl",
"ssh"
],
"notes": "PG 协议族同码(PostgreSQL 真实例已验收);默认库 test/端口 54321"
}
]
}
}
+230
View File
@@ -0,0 +1,230 @@
# OpsxDB 待开发 / 待验证清单
> 维护:项目经理 / 全栈 / 测试补记 · 更新:2026-09-21
> 已完成能力见各迭代验收;本文只列**未做完 / 待真实例 / P1 后置**,方便排人。
## 使用说明
- **P0**:影响主路径或已承诺,优先排
- **P1**:增强体验,可后置
- **环境缺口**:代码/表单已有,缺真实例才能验;有环境再测
- 改代码后:`./run.sh restart`(改 `apps/server` 必须重编)
---
## 去 Web 客户端(产品口径 · 2026-09-20)
| 项 | 状态 | 说明 |
|----|------|------|
| `./run.sh web` 只起 Gin 官网/docs | **已做** | 浏览器不当客户端 |
| `:5174` 不作产品入口 | **已做** | 仅桌面 Vite 源;`web-only` 弃用提示 |
| `apps/web` / `packages/ui` | **保留** | Tauri `devUrl`/`frontendDist` |
| README 写清形态 | **已做** | 浏览器=站;操作=桌面 |
| 验收 | 待测 | `gui` 可用;浏览器无工作台主路径;`:8788` 官网/docs 通 |
---
## 1. 引擎验收与状态(产品口径已定)
| 分层 | 引擎 | 状态 | 待办 |
|------|------|------|------|
| A | MySQL / MariaDB / PostgreSQL / SQL Server / SQLite / Redis(单机) | 已验收 | 回归保持即可 |
| B | TiDB | `ready`+**已验收** | TiDB v7.5.0 docker 真实例全链路(2026-09-21) |
| B | PolarDB / OceanBase / TDSQL / Kingbase / GaussDB·openGauss | **已翻「已验收」**(2026-09-21 用户决策) | 验收口径放宽:协议族同码 + 代码审查通过即可先上线,真例 bug 即修;族真实例(TiDB/PG)已验 |
| B 环境限 | Oracle | **已翻「已验收」**(2026-09-21 用户决策) | 代码审查通过(go-ora);本机 ARM 起不了 XE,真例问题按 bug 修 |
| C | MongoDB / Elasticsearch / ZooKeeper / InfluxDB | **已接入+本机验收**(2026-09-21) | 回归保持;ES 走原生 SQL API(官方 JDBC 付费),ZK/Mongo 走 JVM agent,InfluxDB 走原生 HTTP |
| C | 达梦 | **已翻「已验收」**(2026-09-21 用户决策) | agent 桥 sqlite-jdbc 端到端验证;jar 自备(fetch.sh 指引),真例问题按 bug 修 |
注册表:`connection-types/connection-types.json`(`ready` / `verified` / `notes`)。
---
## 2. Redis 拓扑与工作台
| 项 | 状态 | 待办 |
|----|------|------|
| 单机 + 键浏览 | 已收 | — |
| 键树按分隔符编文件夹 + 类型色点 | 已交 2026-09-20 | 对标 ARDM |
| 键详情(TTL / 重命名 / Hash 增删改 / JSON) | 已交 2026-09-20 | 对标 ARDM + opsxsql |
| CLI 与 SQL 共用编辑器(Redis 按行、命令补全、通用 Do) | 已交 2026-09-20 | 不另开 CLI 页;独立 REPL 列为后置 |
| Sentinel / Cluster **表单 + 后端** | 已交 | — |
| Cluster 真实例测连 + 键浏览 | **本地 3 主拓扑已实测**(2026-09-21:表单建 Cluster 连接、跨分片扫键 19/19 全量、键值/行操作回归) | Sentinel 仍缺真实例 |
| Sentinel / Cluster 文档 | 已交 2026-09-20 | `08-redis.md` / `04-connect.md` |
| 独立 Redis CLI Tab / 监控 / 慢日志 | 后置 | 对标 ARDM CliTab、opsxsql 监控页;非主路径 |
---
## 3. 右键菜单
| 项 | 状态 | 待办 |
|----|------|------|
| 编辑器自绘(执行选中/当前语句、剪贴板、拦系统菜单) | 已收 | 回归防回退 |
| 对象树:库 / 表 / Redis 键 P0 | 已收 | — |
| 结果表自绘菜单 | 未做(仅吞系统菜单) | P1:复制单元格/行、导出入口等 |
| 美化 SQL / 含引号复制 / 创建片段 / AI 自动填充 | P1 | 编辑器右键增强 |
| 表:设计结构 / 重命名 / 生成 SQL(CREATE·INSERT)/ 导出 | P1 | — |
| 库:执行 SQL 文件 / 导出转储 / 新建表 | P1 | — |
| Navicat 级:同步、比较、权限、BI、逆向… | 后置 | 明确不做除非立项 |
---
## 4. 连接与安全
| 项 | 状态 | 待办 |
|----|------|------|
| 分引擎常规字段 + SSH 隧道 | 已收 | — |
| SSL(含 MSSQL 自定义 CA、Oracle 客户端证书) | 已收 | — |
| HTTP 隧道 Tab | 后置 | 对标 Navicat HTTP |
| 连接导入/导出、云同步 | 未立项 | — |
---
## 5. 工作台与结果集
| 项 | 状态 | 待办 |
|----|------|------|
| 多结果集 / 执行计划 / 书签 / 查询历史 | 已收 | — |
| 查询取消 | 已收 | — |
| 内联编辑 / 多格粘贴 / 行删 / 撤销本格 | 已收 | — |
| 虚拟滚动大数据集 | 部分(限显截断) | P1:真虚拟滚动 |
| 深色/浅色主题切换 | **已做**(2026-09-21 核正:顶栏按钮 + 偏好持久化已有) | — |
---
## 6. AI
| 项 | 状态 | 待办 |
|----|------|------|
| 右侧 AI 面板可开关 | 已收 | — |
| 真模型接入(生成/解释/优化) | stub | 接配置与流式输出 |
---
## 7. 桌面 / 文档 / 工程
| 项 | 状态 | 待办 |
|----|------|------|
| `./run.sh gui` / DMG 打包 | 已有 | 发版流程与签名(若上架) |
| docs + 落地页 | 已有 | 补「测试环境」页(链到 `tests/README.md`);Sentinel/Cluster 说明 |
| 引擎图标打包进 UI | 已收 | — |
| 本地大改动入库 | **待推远程** | Kimi 改造 + 右键 + Sentinel/Cluster + notes 等,需确认后 `git push` |
| sidecar 二进制不入库 | 约定 | 构建脚本生成 |
---
## 8. 测试环境(联调)
文档:`tests/README.md` · 启动:`cd tests && ./dbctl.sh up a|b|c|…`(**一次一个库**)
| 代号 | 库 | 端口 | 账号 | 密码 |
|------|----|------|------|------|
| a | MySQL | 3306 | root / opsx | Demo@123 |
| b | MariaDB | 3307 | root / opsx | Demo@123 |
| c | PG | 5432 | opsx | Demo@123 |
| d | SQL Server | 1433 | sa | Demo@12345 |
| e | Oracle | 1521 | opsx | Demo@123(本机常起不来) |
| f | Redis | 6379 | — | — |
联调顺序:`./run.sh gui` → `dbctl up <x>` → 测连 → 查询。
---
## 9. 建议排期(给人用)
1. ~~文案/注册表:B 层 `notes`~~(已写,悬停「未验收」可见)
2. **入库**:整理 diff → 提交推 Gitee
3. **环境到位再测**:真 Oracle / 真国产库 / Redis Sentinel·Cluster
4. **P1 选做**:结果表右键、生成 SQL、导出、美化 SQL、AI 真接、Redis 独立 CLI/监控
有实例或排人变更时,改本文件并注明日期。
---
## 10. 全栈待办补记(2026-09-20)
| 项 | 优先级 | 说明 |
|----|--------|------|
| B 层 `notes` | 已做 | 见 `connection-types.json`;UI「未验收」悬停展示 `notes` |
| 推远程 | 待你点头 | 右键 P0、Sentinel/Cluster、引擎图标打包、B notes、BACKLOG 等仍在本地 |
| 结果表自绘右键 | P1 | 现仅 `preventDefault` |
| Redis 拓扑 docs | 已做 | `08-redis.md` Sentinel/Cluster/键树/CLI 决策 |
| Redis 键树 + 键详情 | 已做 | 分隔符文件夹、类型色点、TTL/字段编辑 |
| Redis 独立 CLI Tab | 后置 | 与 SQL 编辑器共用,见 08-redis.md |
| C 层驱动接入 | 立项 | Mongo/ES/ZK/Influx/达梦 |
| 真 Sentinel·Cluster 联调 | 环境缺口 | 有实例再改再测 |
---
## 11. 测试侧待办(测试工程师)
| 项 | 状态 | 待办 |
|----|------|------|
| 右键验收 1–3 / C 灰态 / A 回归 | 已绿(2026-09-20 再跑) | sqlite/a/b/c/f/d 顺序单实例全绿;Oracle ORA-00442 |
| B 层协议冒烟 | 已做;**不摘标** | 真 TiDB/Polar/OB/Gauss/Kingbase/TDSQL 实例到位再验 → 再谈 `verified` |
| Oracle / 达梦 | 环境限 / 未接入 | 2026-09-20 Oracle 容器起了但 ORA-00442;达梦未接入 |
| Redis Sentinel·Cluster 真拓扑 | 表单+字段校验已绿 | 有拓扑:测连 + 键浏览(扫/看/删) |
| 结果表自绘菜单 / 右键 P1 | 未开 | 全栈落地后按产品清单验 |
| 改 `apps/server` 后 | 约定 | 验收前确认 `./run.sh restart`,避免旧二进制误判 |
验收时勿并开多个 `dbctl` 库;结论回写本文件或群对齐即可。
---
## 12. 设计侧待办(设计师 · 2026-09-20)
| 项 | 状态 | 说明 |
|----|------|------|
| SQL 编辑器右键 P0 结构 | 已钉 | 执行选中/当前语句 · 查看数据/DDL · 剪切复制粘贴全选;禁系统菜单 |
| 对象树三套 P0 线框 | 已钉 | 库 / 表 / Redis 键;P1 虚线项见群线框与 §3 |
| Redis Sentinel·Cluster 表单字段 | 已钉 | 同页切部署模式;节点列表 · masterName · 双密码;Cluster 无 DB 索引 |
| 引擎缺图 fallback | 待验 | 灰底首字母,禁用蓝 `?`;未就绪灰 / 未验收橙+悬停 |
| 对象树右键 P1 视觉 | 未开 | 生成 SQL ▸ · 导出 ▸ · 设计表 · 新建表 等,全栈开做时补密度 |
| 结果表自绘菜单 | 未开 | 与全栈 P1 对齐出结构 |
| 顶栏文案 | 已收 | 连接 · 新建连接 · 新建查询 · 查询历史 · 快捷键 |
线框产物(本机):`~/devops/opsxdb/.ui-review/ctx-menus/`。新增设计待办只改本文件。
---
## 13. 全栈补记(2026-09-21 · ZCode 会话交付)
### Redis 工作台增强(对标 ARDM/opsxsql 后落地)
| 项 | 状态 |
|----|------|
| 键搜索:输入即子串过滤 + 回车服务端模糊 SCAN(无通配符自动包 `*q*`) | 已做 |
| 类型筛选下拉(string/hash/list/set/zset/stream,前端过滤 + 计数同步) | 已做 |
| 去掉「键 N」中间层:db 展开直接是 `agent 39` 文件夹 | 已做 |
| 行级复制 = 完整重建命令(ZADD/HSET/SADD/RPUSH key …),实测剪贴板 | 已做 |
| JSON 单元格:单行 minified 摘要(160 截断)+「更多」+ 点击弹层 pretty 查看/复制 | 已做 |
| zset Score 毫秒/秒时间戳 → 可读时间副显(`tsLabel`,误判有单测) | 已做 |
| 表格分批渲染:首屏 100 行 + 「加载更多(已显示 X / 共 Y)」 | 已做 |
| 集群跨分片 SCAN(`scanAll` ForEachMaster,本地 3 主 19/19 键实测;编辑器 SCAN 同步修) | 已做 |
| SCAN 游标分页(HSCAN/ZSCAN cursor,>500 大键真翻页) | **未做**(P1) |
| 批量操作(多选删/按 pattern 批删/导出导入)、监控 INFO/慢日志、更多 viewer(hex/msgpack) | 后置 |
| Sentinel 真拓扑联调 | 环境缺口 |
### 通用工作台(本会话交付)
| 项 | 状态 |
|----|------|
| 对象树库/表过滤框(命中自动展开、计数同步) | 已做 |
| 查询历史:去重置顶、一键重跑 ▶、过滤框、耗时/行数/失败红标(上限 200) | 已做 |
| 点表名打开前 100 行(单击延迟/双击立即,脏 Tab 确认) | 已做 |
| Tab 标题表名化(打开表的新 Tab 显示表名而非「查询 N」) | 已做 |
| 表右键:生成 SELECT/INSERT/UPDATE/DELETE(列感知+方言引号+主键 WHERE)+ 截断/删除表(危险确认) | 已做 |
| 库右键:新建查询 | 已做 |
| 结构页:索引/外键/触发器区块(MySQL 族 information_schema,前端直连) | 已做 |
| 表设计器 / 表重命名 / CREATE TABLE 生成 / 数据导出(CSV/转储) | **未做**(P1) |
| 结果表自绘右键 | **未做**(P1,仍仅吞系统菜单) |
### 去 Web 客户端收尾(在 §「去 Web 客户端」基础上)
| 项 | 状态 |
|----|------|
| 死包清理:`packages/transport`、`packages/core`(opsxapi 残留,零引用)整包删除 + 依赖/alias/test:core 清理 | 已做 |
| 生产构建浏览器守卫:非 Tauri + PROD → 「请在桌面应用中打开」导引页(桌面不受影响) | 已做 |
| CORS 收紧:仅留 5174(桌面 devUrl)与 tauri:// 两个来源 | 已做 |
| 文档/官网口径统一:删「Web 自托管」形态卡与双形态表述;FAQ 明确「浏览器不能操作」 | 已做 |
> 验收口径:以上「已做」项均经 桌面 App 实测(本地 MySQL/Redis 单机 + 3 主 Cluster)或单测覆盖(38 例全绿);测试侧复跑按 §11 流程。
+3 -39
View File
@@ -17,8 +17,6 @@
"name": "@opsxdb/web",
"version": "0.1.0",
"dependencies": {
"@opsxdb/core": "*",
"@opsxdb/transport": "*",
"@opsxdb/ui": "*",
"@tauri-apps/api": "^2.5.0",
"react": "^19.0.0",
@@ -938,14 +936,6 @@
"node": ">= 8"
}
},
"node_modules/@opsxdb/core": {
"resolved": "packages/core",
"link": true
},
"node_modules/@opsxdb/transport": {
"resolved": "packages/transport",
"link": true
},
"node_modules/@opsxdb/ui": {
"resolved": "packages/ui",
"link": true
@@ -3300,51 +3290,25 @@
}
}
},
"packages/core": {
"name": "@opsxdb/core",
"version": "0.1.0",
"devDependencies": {
"typescript": "^5.8.2",
"vitest": "^3.0.9"
}
},
"packages/transport": {
"name": "@opsxdb/transport",
"version": "0.1.0",
"dependencies": {
"@opsxdb/core": "*"
},
"devDependencies": {
"typescript": "^5.8.2"
},
"peerDependencies": {
"@tauri-apps/api": "^2.0.0"
},
"peerDependenciesMeta": {
"@tauri-apps/api": {
"optional": true
}
}
},
"packages/ui": {
"name": "@opsxdb/ui",
"version": "0.1.0",
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.5",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.12",
"@opsxdb/core": "*",
"@opsxdb/transport": "*",
"sql-formatter": "^15.8.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"typescript": "^5.8.2"
"typescript": "^5.8.2",
"vitest": "^3.0.9"
},
"peerDependencies": {
"react": "^19.0.0",
+1 -1
View File
@@ -8,7 +8,7 @@
"scripts": {
"dev:web": "npm run dev -w @opsxdb/web",
"build:web": "npm run build -w @opsxdb/web",
"test:core": "npm run test -w @opsxdb/core",
"test:ui": "npm run test -w @opsxdb/ui",
"dev:desktop": "npm --prefix apps/desktop run tauri -- dev",
"build:desktop": "npm --prefix apps/desktop run tauri -- build"
},
-19
View File
@@ -1,19 +0,0 @@
{
"name": "@opsxdb/core",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"typescript": "^5.8.2",
"vitest": "^3.0.9"
}
}
-10
View File
@@ -1,10 +0,0 @@
export type {
HttpMethod,
HttpSpec,
WsSpec,
HttpResult,
WsSession,
Transport,
} from "./types";
export { replaceVariables, replaceVariablesInSpec } from "./replaceVariables";
@@ -1,29 +0,0 @@
import { describe, expect, it } from "vitest";
import { replaceVariables, replaceVariablesInSpec } from "./replaceVariables";
describe("replaceVariables", () => {
it("replaces Postman-style {{var}}", () => {
expect(replaceVariables("{{baseUrl}}/api", { baseUrl: "https://x.test" })).toBe(
"https://x.test/api",
);
});
it("leaves unknown vars intact", () => {
expect(replaceVariables("hello {{missing}}", {})).toBe("hello {{missing}}");
});
it("supports multiple and spaced keys", () => {
expect(
replaceVariables("{{ host }}/{{path}}", { host: "h", path: "p" }),
).toBe("h/p");
});
it("replaceVariablesInSpec walks nested strings", () => {
const out = replaceVariablesInSpec(
{ url: "{{baseUrl}}/v1", headers: { Authorization: "Bearer {{token}}" } },
{ baseUrl: "http://a", token: "t" },
);
expect(out.url).toBe("http://a/v1");
expect(out.headers.Authorization).toBe("Bearer t");
});
});
-35
View File
@@ -1,35 +0,0 @@
/**
* Postman-style {{var}} replacement.
* Unknown variables are left as-is.
*/
export function replaceVariables(
input: string,
env: Record<string, string>,
): string {
if (!input) return input;
return input.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key: string) => {
if (Object.prototype.hasOwnProperty.call(env, key)) {
return env[key] ?? "";
}
return match;
});
}
export function replaceVariablesInSpec<T extends Record<string, unknown>>(
spec: T,
env: Record<string, string>,
): T {
const walk = (v: unknown): unknown => {
if (typeof v === "string") return replaceVariables(v, env);
if (Array.isArray(v)) return v.map(walk);
if (v && typeof v === "object") {
const out: Record<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
out[k] = walk(val);
}
return out;
}
return v;
};
return walk(spec) as T;
}
-39
View File
@@ -1,39 +0,0 @@
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
export type HttpSpec = {
method: HttpMethod;
url: string;
headers: Record<string, string>;
query?: Record<string, string>;
body?: {
mode: "none" | "json" | "raw" | "form" | "urlencoded";
raw?: string;
};
timeoutMs?: number;
};
export type WsSpec = {
url: string;
protocols?: string[];
headers?: Record<string, string>;
};
export type HttpResult = {
status: number;
headers: Record<string, string>;
body: string;
timeMs: number;
size: number;
};
export interface WsSession {
onMessage(cb: (data: string) => void): void;
onStatus(cb: (s: string) => void): void;
send(data: string): void;
close(): void;
}
export interface Transport {
http(spec: HttpSpec): Promise<HttpResult>;
ws(spec: WsSpec): WsSession;
}
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": [
"src"
]
}
-25
View File
@@ -1,25 +0,0 @@
{
"name": "@opsxdb/transport",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@opsxdb/core": "*"
},
"devDependencies": {
"typescript": "^5.8.2"
},
"peerDependencies": {
"@tauri-apps/api": "^2.0.0"
},
"peerDependenciesMeta": {
"@tauri-apps/api": {
"optional": true
}
}
}
-81
View File
@@ -1,81 +0,0 @@
import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxdb/core";
function buildUrl(spec: HttpSpec): string {
const u = new URL(spec.url);
if (spec.query) {
for (const [k, v] of Object.entries(spec.query)) {
if (v !== undefined && v !== "") u.searchParams.set(k, v);
}
}
return u.toString();
}
function headersToRecord(h: Headers): Record<string, string> {
const out: Record<string, string> = {};
h.forEach((v, k) => {
out[k] = v;
});
return out;
}
export const browserTransport: Transport = {
async http(spec: HttpSpec): Promise<HttpResult> {
const started = performance.now();
const init: RequestInit = {
method: spec.method,
headers: { ...spec.headers },
signal: spec.timeoutMs
? AbortSignal.timeout(spec.timeoutMs)
: undefined,
};
if (spec.body && spec.body.mode !== "none" && spec.method !== "GET") {
init.body = spec.body.raw ?? "";
if (spec.body.mode === "json" && !("content-type" in Object.fromEntries(
Object.entries(spec.headers).map(([k, v]) => [k.toLowerCase(), v]),
))) {
(init.headers as Record<string, string>)["Content-Type"] = "application/json";
}
}
const res = await fetch(buildUrl(spec), init);
const body = await res.text();
const timeMs = Math.round(performance.now() - started);
return {
status: res.status,
headers: headersToRecord(res.headers),
body,
timeMs,
size: new TextEncoder().encode(body).length,
};
},
ws(spec: WsSpec): WsSession {
const socket = new WebSocket(spec.url, spec.protocols);
const messageCbs: Array<(data: string) => void> = [];
const statusCbs: Array<(s: string) => void> = [];
const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s));
emitStatus("connecting");
socket.onopen = () => emitStatus("open");
socket.onclose = () => emitStatus("closed");
socket.onerror = () => emitStatus("error");
socket.onmessage = (ev) => {
const data = typeof ev.data === "string" ? ev.data : String(ev.data);
messageCbs.forEach((cb) => cb(data));
};
return {
onMessage(cb) {
messageCbs.push(cb);
},
onStatus(cb) {
statusCbs.push(cb);
},
send(data) {
socket.send(data);
},
close() {
socket.close();
},
};
},
};
-41
View File
@@ -1,41 +0,0 @@
import type { Transport } from "@opsxdb/core";
import { browserTransport } from "./browser";
import { ginProxyTransport } from "./ginProxy";
import { tauriNativeTransport } from "./tauriNative";
export type TransportKind = "tauri-native" | "gin-proxy" | "browser";
export type DetectOptions = {
ginBaseUrl?: string;
/** Force a transport regardless of environment */
force?: TransportKind;
};
function isTauri(): boolean {
if (typeof window === "undefined") return false;
const w = window as unknown as { __TAURI_INTERNALS__?: unknown };
return Boolean(w.__TAURI_INTERNALS__);
}
export function detectTransport(opts: DetectOptions = {}): {
kind: TransportKind;
transport: Transport;
} {
if (opts.force === "browser") {
return { kind: "browser", transport: browserTransport };
}
if (opts.force === "tauri-native") {
return { kind: "tauri-native", transport: tauriNativeTransport };
}
if (opts.force === "gin-proxy") {
const base = opts.ginBaseUrl ?? "http://127.0.0.1:8788";
return { kind: "gin-proxy", transport: ginProxyTransport(base) };
}
if (isTauri()) {
return { kind: "tauri-native", transport: tauriNativeTransport };
}
const base = opts.ginBaseUrl ?? "http://127.0.0.1:8788";
return { kind: "gin-proxy", transport: ginProxyTransport(base) };
}
-35
View File
@@ -1,35 +0,0 @@
import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxdb/core";
import { browserTransport } from "./browser";
/**
* HTTP goes through Gin POST /v1/proxy/http.
* WS (MVP): falls back to browser WebSocket — custom headers require
* desktop/Gin tunnel later (Week 2).
*/
export function ginProxyTransport(baseUrl: string): Transport {
const root = baseUrl.replace(/\/$/, "");
return {
async http(spec: HttpSpec): Promise<HttpResult> {
const res = await fetch(`${root}/v1/proxy/http`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(spec),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`gin-proxy HTTP ${res.status}: ${text}`);
}
return (await res.json()) as HttpResult;
},
ws(spec: WsSpec): WsSession {
// TODO(week2): custom headers need desktop/Gin WS tunnel.
// For MVP, fall back to browser WebSocket (no custom headers).
console.warn(
"[opsxdb/transport] gin-proxy WS falls back to browser WebSocket; custom headers need desktop/Gin tunnel later",
);
return browserTransport.ws(spec);
},
};
}
-5
View File
@@ -1,5 +0,0 @@
export { browserTransport } from "./browser";
export { ginProxyTransport } from "./ginProxy";
export { tauriNativeTransport } from "./tauriNative";
export { detectTransport } from "./detect";
export type { TransportKind, DetectOptions } from "./detect";
-68
View File
@@ -1,68 +0,0 @@
import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxdb/core";
async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
try {
const { invoke: tauriInvoke } = await import("@tauri-apps/api/core");
return await tauriInvoke<T>(cmd, args);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(
`tauri-native invoke("${cmd}") failed (are you running inside OpsxDB desktop?): ${msg}`,
);
}
}
export const tauriNativeTransport: Transport = {
async http(spec: HttpSpec): Promise<HttpResult> {
return invoke<HttpResult>("http_send", { spec });
},
ws(spec: WsSpec): WsSession {
const messageCbs: Array<(data: string) => void> = [];
const statusCbs: Array<(s: string) => void> = [];
let sessionId: string | null = null;
const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s));
void (async () => {
try {
emitStatus("connecting");
sessionId = await invoke<string>("ws_connect", { spec });
emitStatus("open");
// Full event listen pipeline is Week 2; stub keeps UI usable.
} catch (e) {
emitStatus("error");
messageCbs.forEach((cb) =>
cb(`[ws_connect error] ${e instanceof Error ? e.message : String(e)}`),
);
}
})();
return {
onMessage(cb) {
messageCbs.push(cb);
},
onStatus(cb) {
statusCbs.push(cb);
},
send(data) {
if (!sessionId) {
messageCbs.forEach((cb) => cb("[ws] not connected"));
return;
}
void invoke("ws_send", { sessionId, data }).catch((e) => {
messageCbs.forEach((cb) =>
cb(`[ws_send error] ${e instanceof Error ? e.message : String(e)}`),
);
});
},
close() {
if (!sessionId) return;
void invoke("ws_close", { sessionId }).finally(() => {
sessionId = null;
emitStatus("closed");
});
},
};
},
};
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff. Show more