diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3a80b64 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 1d4530a..a869f36 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3ec144b --- /dev/null +++ b/AGENTS.md @@ -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 内容保持同步即可。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..57fab9d --- /dev/null +++ b/CLAUDE.md @@ -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 当成必须在线的多人后端。 diff --git a/Makefile b/Makefile index 4679e29..a0ed23e 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 341694b..bf5bf21 100644 --- a/README.md +++ b/README.md @@ -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)。 diff --git a/agents/README.md b/agents/README.md new file mode 100644 index 0000000..9f13793 --- /dev/null +++ b/agents/README.md @@ -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//`,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` diff --git a/agents/jdbc/OpsxJdbcAgent.java b/agents/jdbc/OpsxJdbcAgent.java new file mode 100644 index 0000000..c57e1fb --- /dev/null +++ b/agents/jdbc/OpsxJdbcAgent.java @@ -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 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 cols = new ArrayList<>(n); + for (int i = 1; i <= n; i++) cols.add(md.getColumnLabel(i)); + List 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 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 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 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; + } +} diff --git a/agents/jdbc/OpsxMongoAgent.java b/agents/jdbc/OpsxMongoAgent.java new file mode 100644 index 0000000..54ab323 --- /dev/null +++ b/agents/jdbc/OpsxMongoAgent.java @@ -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..find([filter]) 查询(filter 为 JSON,可省略) +// db..count([filter]) 计数 +// db..insertOne({doc}) 插入 +// db..updateOne({f},{u}) 更新(u 用 $set 等操作符) +// db..deleteOne({f}) 删除单条 +// db..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 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 names = new ArrayList<>(); + zk(a).listDatabaseNames().into(names); + JsonObject o = new JsonObject(); + o.add("names", gson.toJsonTree(names)); + return o; + } + case "metaTables": { + List 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 cols = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + try (var it = coll(a).find().limit(5).iterator()) { + while (it.hasNext()) { + for (Map.Entry 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 cols = new ArrayList<>(); + cols.add("_id"); + Set seen = new LinkedHashSet<>(cols); + List 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 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 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 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..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 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 coll(JsonObject a) { + // metaColumns 等入口:schema=库,table=集合 + return zk(a).getDatabase(str(a, "schema")).getCollection(str(a, "table")); + } + + private MongoCollection 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; + } +} diff --git a/agents/jdbc/OpsxZkAgent.java b/agents/jdbc/OpsxZkAgent.java new file mode 100644 index 0000000..cab3abd --- /dev/null +++ b/agents/jdbc/OpsxZkAgent.java @@ -0,0 +1,252 @@ +// OpsxDB ZooKeeper Agent —— 与 OpsxJdbcAgent 同协议的 ZK 专属桥(默认包,单文件)。 +// +// 启动:java -cp opsx-agent-jdbc.jar:gson-2.12.1.jar OpsxZkAgent --jars +// +// SQL 约定(工作台编辑器直写): +// GET 读 znode:path/data/version/ctime/mtime/ephemeral/children +// LS 列子节点(带 stat) +// CREATE [data] 持久节点(父须存在);SET ;DELETE (须无子) +// 写动词(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 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 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 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; + } +} diff --git a/api/openapi.yaml b/api/openapi.yaml index 1f898ba..78448d2 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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 } diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 3b5714f..6ef6c04 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -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", +] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index d54d112..2513172 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -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" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index a1ee68a..ab248a4 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -6,6 +6,7 @@ "main" ], "permissions": [ - "core:default" + "core:default", + "window-state:default" ] } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index b3d004e..f25f3e2 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -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 { + 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::(); @@ -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") diff --git a/apps/desktop/src-tauri/src/sidecar.rs b/apps/desktop/src-tauri/src/sidecar.rs index 2d182a8..d5084b6 100644 --- a/apps/desktop/src-tauri/src/sidecar.rs +++ b/apps/desktop/src-tauri/src/sidecar.rs @@ -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>, + config: Mutex>, } 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 { + 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 { + 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 { + 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 { + // 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 { - 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 { + 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 { + 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) { diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index f870da9..e1c1df2 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -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": { diff --git a/apps/server/cmd/opsxdb/main.go b/apps/server/cmd/opsxdb/main.go index 4b2c887..756a95b 100644 --- a/apps/server/cmd/opsxdb/main.go +++ b/apps/server/cmd/opsxdb/main.go @@ -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 +} diff --git a/apps/server/go.mod b/apps/server/go.mod index d8c9a26..cc3b5df 100644 --- a/apps/server/go.mod +++ b/apps/server/go.mod @@ -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 diff --git a/apps/server/internal/ai/config.go b/apps/server/internal/ai/config.go new file mode 100644 index 0000000..dab3d60 --- /dev/null +++ b/apps/server/internal/ai/config.go @@ -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] + "***" +} diff --git a/apps/server/internal/ai/openai.go b/apps/server/internal/ai/openai.go new file mode 100644 index 0000000..7e1766a --- /dev/null +++ b/apps/server/internal/ai/openai.go @@ -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]) +} diff --git a/apps/server/internal/ai/prompt.go b/apps/server/internal/ai/prompt.go new file mode 100644 index 0000000..71e06e6 --- /dev/null +++ b/apps/server/internal/ai/prompt.go @@ -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()}, + } +} diff --git a/apps/server/internal/ai/provider.go b/apps/server/internal/ai/provider.go new file mode 100644 index 0000000..075874d --- /dev/null +++ b/apps/server/internal/ai/provider.go @@ -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{} +} diff --git a/apps/server/internal/ai/stub.go b/apps/server/internal/ai/stub.go index 53eee33..b302d26 100644 --- a/apps/server/internal/ai/stub.go +++ b/apps/server/internal/ai/stub.go @@ -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" diff --git a/apps/server/internal/db/agent.go b/apps/server/internal/db/agent.go new file mode 100644 index 0000000..e9bc862 --- /dev/null +++ b/apps/server/internal/db/agent.go @@ -0,0 +1,1211 @@ +package db + +// agent.go —— JDBC agent 桥(Go 侧)。 +// +// 架构:每种 agent 驱动(如达梦)由本机 JVM 进程承载官方 JDBC 驱动, +// Go 服务端通过 stdin/stdout 的 JSON Lines 协议与其通信(协议见 agents/README.md)。 +// 目标:不引入 CGO/JNI,商用驱动 jar 由用户自备(许可合规),产品侧只做发现与编排。 +// +// 安全约定(与 Java 侧一致): +// - query 必带 maxRows 硬截断(JDBC Statement.setMaxRows),深分页超过 agentMaxRows 直接拒绝 +// - 每条语句 setQueryTimeout 兜底,timeoutSec 取自请求 ctx deadline +// - v1 限制:ctx 取消不会真正掐断 JDBC 执行(靠 timeoutSec 兜底),见 agents/README.md 已知限制 + +import ( + "archive/zip" + "bufio" + "context" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "hash" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" +) + +const ( + agentProtocolVersion = 1 + agentHelloTimeout = 30 * time.Second + agentMaxRows = 20000 // 与 Java 侧 setMaxRows 上限一致 + // gson 2.12.1 固定版本 + sha1 钉死(下载自 Maven Central / 阿里云镜像,校验失败即拒用) + gsonVersion = "2.12.1" + gsonSHA1 = "4e773a317740b83b43cfc3d652962856041697cb" + + // agent jar 与开源驱动 jar:首次连接时按需下载到 ~/.opsxdb/(DMG 不打包任何 Java 产物)。 + // 下载源双托管(Gitee 主 / GitHub 备),sha256 钉版本;商用驱动(达梦)不代下。 + agentJarName = "opsx-agent-jdbc.jar" + agentJarSHA256 = "6efc3c71965729f1fe09e6912bcdcd713d5e9d7a4ca87227eaa7a13af2b14227" + agentJarURLs = "https://gitee.com/attacker/opsxdb-driver/raw/main/dist/opsx-agent-jdbc.jar,https://raw.githubusercontent.com/attacker/opsxdb-driver/main/dist/opsx-agent-jdbc.jar" +) + +// agentArtifact 驱动 jar 清单(开源、可再分发)。Maven 坐标 + sha256 与 opsxdb-driver +// 仓库 drivers/*.yaml 一致;商用驱动(达梦)不在此表,连接时给 manualSource 指引。 +type agentArtifact struct { + File string // 落盘文件名 + Maven string // group:artifact:version;空 = 不自动下载 + SHA256 string +} + +var agentDriverArtifacts = map[string][]agentArtifact{ + "mongodb": { + {"mongodb-driver-sync-5.2.1.jar", "org.mongodb:mongodb-driver-sync:5.2.1", "c10ad97ab8a148c53ab8377a3fa89e49c2f9be277f1216c7f69967b7cc1e1699"}, + {"mongodb-driver-core-5.2.1.jar", "org.mongodb:mongodb-driver-core:5.2.1", "5ae1d49fc6b1b42046cb16c1ce9f6952e36d0203f085d9fe045266460c51da19"}, + {"bson-5.2.1.jar", "org.mongodb:bson:5.2.1", "5bca0022f4a933795788244b0ba3d7d92d6969730cf3b279bb37f9f7ffba5f35"}, + }, + "zookeeper": { + {"zookeeper-3.9.3.jar", "org.apache.zookeeper:zookeeper:3.9.3", "ad6570105768d18382a762325b4460368b7089a5a77c053e682b17942e0e72b5"}, + {"zookeeper-jute-3.9.3.jar", "org.apache.zookeeper:zookeeper-jute:3.9.3", "be72e2c9099413a9878ce830b65bb22e0be755dc2a22b290550ab9e03b5b4cfa"}, + {"slf4j-api-2.0.16.jar", "org.slf4j:slf4j-api:2.0.16", "a12578dde1ba00bd9b816d388a0b879928d00bab3c83c240f7013bf4196c579a"}, + {"netty-common-4.1.113.Final.jar", "io.netty:netty-common:4.1.113.Final", "c0fb22d47111cb06aac2af67fe55e2e216a49fd00e767f4acb7488b280f8c327"}, + {"netty-buffer-4.1.113.Final.jar", "io.netty:netty-buffer:4.1.113.Final", "a85c198180a8de997e8f64a62e054946a39af0708466c1bd67747d393d2feee1"}, + {"netty-transport-4.1.113.Final.jar", "io.netty:netty-transport:4.1.113.Final", "cb8b97ff77d7c5f1c591c84d2dee3389a0eaa63a3137b7b8c0c64e1dbada6688"}, + {"netty-resolver-4.1.113.Final.jar", "io.netty:netty-resolver:4.1.113.Final", "b2c110a547e7dc6e2b27017ea4ef98416d7d832d2cf40625d0273b90e61df8ab"}, + {"netty-codec-4.1.113.Final.jar", "io.netty:netty-codec:4.1.113.Final", "c7669cbeda2b5c6284627b7ffafd6f1014a96639013b26ebb9d6bcb828f76542"}, + {"netty-handler-4.1.113.Final.jar", "io.netty:netty-handler:4.1.113.Final", "7a583c4fe5880504d1257a4a6bfe6a635cd34ffe18aab0e4caabbf17d104f172"}, + }, +} + +// AgentSpec 描述一种 agent 驱动的接入方式。 +type AgentSpec struct { + DriverKey string // 与前端 DriverId 一致(dameng、elasticsearch…) + DriverClass string // JDBC 驱动类名(报错定位;Java 侧按 META-INF/services 自动注册) + URLTemplate string // jdbc url 模板,占位 {{.Host}}/{{.Port}}/{{.Database}} + JarsDirEnv string // 用户放置官方驱动 jar 的目录环境变量;缺省 ~/.opsxdb/agents/jdbc/drivers/ + MainClass string // agent 主类,缺省 OpsxJdbcAgent(JDBC 桥);非 JDBC 驱动(如 ZK)给专属主类 + // TablesSQL 非空时:列表不走 DatabaseMetaData,改跑这条 SQL 并取首列(如 ES 的 SHOW TABLES)。 + TablesSQL string + // DefaultDatabase 非空时:对象树只挂这一个库节点(ES 无多库概念,恒为 default)。 + DefaultDatabase string + // JVMArgs 追加给 agent 进程的 JVM 参数(如 ZK 用 NIO socket 避开 netty 依赖)。 + JVMArgs []string +} + +// agentDriverSpecs —— agent 驱动注册表。新增驱动在此登记即可(达梦先行)。 +// 注意:仅注册"官方只提供 JDBC、且 JDBC 不锁付费许可"的驱动。Elasticsearch 曾评估走 +// x-pack-sql-jdbc,但其 JDBC 客户端要求付费许可(basic 下报 non-compliant),故 ES 走原生 HTTP(es.go)。 +var agentDriverSpecs = map[string]AgentSpec{ + "dameng": { + DriverKey: "dameng", + DriverClass: "dm.jdbc.driver.DmDriver", + URLTemplate: "jdbc:dm://{{.Host}}:{{.Port}}", + JarsDirEnv: "OPSXDB_DAMENG_JARS", + }, + "zookeeper": { + DriverKey: "zookeeper", + DriverClass: "org.apache.zookeeper.ZooKeeper", + URLTemplate: "{{.Host}}:{{.Port}}", + JarsDirEnv: "OPSXDB_ZK_JARS", + MainClass: "OpsxZkAgent", + // 3.9 默认 Netty socket 需额外 6 个 netty jar;NIO 实现仍内置,零额外依赖 + JVMArgs: []string{"-Dzookeeper.clientCnxnSocket=org.apache.zookeeper.ClientCnxnSocketNIO"}, + }, + "mongodb": { + DriverKey: "mongodb", + DriverClass: "com.mongodb.client.MongoClients", + URLTemplate: "mongodb://{{.Host}}:{{.Port}}", + JarsDirEnv: "OPSXDB_MONGO_JARS", + MainClass: "OpsxMongoAgent", + }, +} + +func agentSpec(d string) (AgentSpec, bool) { + s, ok := agentDriverSpecs[driverKey(d)] + return s, ok +} + +// IsAgentDriver 该驱动是否走 JDBC agent 桥(前端据此展示"未就绪/需配置")。 +func IsAgentDriver(d string) bool { + _, ok := agentSpec(d) + return ok +} + +// isAgentDriver 包内简写(ops/open/session 调用点使用)。 +func isAgentDriver(d string) bool { return IsAgentDriver(d) } + +// AgentConfigured 驱动 jar 目录已配置且存在(未配置时给出许可友好的提示)。 +func AgentConfigured(d string) (bool, string) { + spec, ok := agentSpec(d) + if !ok { + return false, "" + } + dir := agentJarsDir(spec) + entries, err := os.ReadDir(dir) + if err != nil { + return false, dir + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".jar") { + return true, dir + } + } + return false, dir +} + +// ---------- 协议信封 ---------- + +type agentRequest struct { + ID uint64 `json:"id"` + Op string `json:"op"` + Args map[string]any `json:"args"` +} + +type agentResponse struct { + ID uint64 `json:"id"` + OK bool `json:"ok"` + Result json.RawMessage `json:"result"` + Error string `json:"error"` +} + +// ---------- AgentClient(一个 JVM 进程一个实例,多连接复用) ---------- + +type pendingCall struct { + ch chan agentResponse +} + +// AgentClient 是与单个 JVM agent 进程的会话。 +type AgentClient struct { + spec AgentSpec + cmd *exec.Cmd + stdin io.WriteCloser + pendingMu sync.Mutex + pending map[uint64]pendingCall + idGen atomic.Uint64 + + stderrTail *cappedBuffer // 排障用:JVM stderr 末尾若干 KB + + readerDone chan struct{} + closeOnce sync.Once +} + +// cappedBuffer 并发安全的环形缓冲,只保留末尾 cap 字节。 +type cappedBuffer struct { + mu sync.Mutex + buf []byte + capN int +} + +func newCappedBuffer(n int) *cappedBuffer { return &cappedBuffer{capN: n} } + +func (b *cappedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + b.buf = append(b.buf, p...) + if len(b.buf) > b.capN { + b.buf = append([]byte(nil), b.buf[len(b.buf)-b.capN:]...) + } + return len(p), nil +} + +func (b *cappedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return string(b.buf) +} + +// StartAgent 按 spec 启动(或复用)JVM agent 进程并完成握手。 +// agent jar 与开源驱动 jar 在首次连接时按需下载(DMG 不打包 Java 产物,保持安装包瘦身)。 +func StartAgent(spec AgentSpec) (*AgentClient, error) { + javaBin := os.Getenv("OPSXDB_JAVA_BIN") + if javaBin == "" { + p, err := exec.LookPath("java") + if err != nil { + return nil, errors.New("未找到 java:请安装 JDK 17+ 或设置 OPSXDB_JAVA_BIN") + } + javaBin = p + } + if err := ensureAgentRuntime(spec); err != nil { + return nil, err + } + home := agentHome() + gsonJar, err := ensureGson(home) + if err != nil { + return nil, err + } + agentJar := os.Getenv("OPSXDB_AGENT_JAR") + if agentJar == "" { + agentJar = filepath.Join(agentDir(), agentJarName) + } + jarsDir := agentJarsDir(spec) + mainClass := spec.MainClass + if mainClass == "" { + mainClass = "OpsxJdbcAgent" + } + classPath := agentJar + string(os.PathListSeparator) + gsonJar + if mainClass != "OpsxJdbcAgent" { + // 专属 agent(如 OpsxZkAgent)的主类直接依赖厂商 jar,须上应用类路径; + // JDBC 桥则用 --jars 隔离加载驱动,避免与 gson 等类冲突。 + if jars, _ := filepath.Glob(filepath.Join(jarsDir, "*.jar")); len(jars) > 0 { + classPath += string(os.PathListSeparator) + strings.Join(jars, string(os.PathListSeparator)) + } + } + args := append([]string{}, spec.JVMArgs...) + args = append(args, "-cp", classPath, mainClass, "--jars", jarsDir) + cmd := exec.Command(javaBin, args...) + return StartAgentFromCmd(cmd, spec) +} + +// StartAgentFromCmd 以自定义命令启动 agent(测试注入 stub 进程用)。 +func StartAgentFromCmd(cmd *exec.Cmd, spec AgentSpec) (*AgentClient, error) { + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + cli := &AgentClient{ + spec: spec, + cmd: cmd, + stdin: stdin, + pending: map[uint64]pendingCall{}, + stderrTail: newCappedBuffer(16 * 1024), + readerDone: make(chan struct{}), + } + cmd.Stderr = cli.stderrTail + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("启动 agent 进程失败: %w", err) + } + go cli.readerLoop(bufio.NewReader(stdout)) + + // 握手:Java 侧启动即推送一行 hello 公告 + type hello struct { + Agent string `json:"agent"` + Version string `json:"version"` + Protocol int `json:"protocol"` + } + ctx, cancel := context.WithTimeout(context.Background(), agentHelloTimeout) + defer cancel() + var h hello + if err := cli.call(ctx, "hello", nil, &h); err != nil { + cli.Kill() + return nil, fmt.Errorf("agent 握手失败: %w(stderr: %s)", err, cli.stderrTail.String()) + } + if h.Agent != "opsx-agent-jdbc" || h.Protocol != agentProtocolVersion { + cli.Kill() + return nil, fmt.Errorf("agent 协议不匹配: agent=%s protocol=%d(期望 protocol=%d)", h.Agent, h.Protocol, agentProtocolVersion) + } + return cli, nil +} + +// readerLoop 单 goroutine 读 stdout,按 id 路由响应。 +func (c *AgentClient) readerLoop(r *bufio.Reader) { + defer close(c.readerDone) + for { + line, err := r.ReadBytes('\n') + if len(line) > 0 { + var resp agentResponse + if uerr := json.Unmarshal(line, &resp); uerr == nil { + c.pendingMu.Lock() + pc, ok := c.pending[resp.ID] + if ok { + delete(c.pending, resp.ID) + } + c.pendingMu.Unlock() + if ok { + pc.ch <- resp + } + } + } + if err != nil { + c.pendingMu.Lock() + for id, pc := range c.pending { + delete(c.pending, id) + pc.ch <- agentResponse{ID: id, OK: false, Error: "agent 进程已退出"} + } + c.pendingMu.Unlock() + return + } + } +} + +// call 串行写请求 + 等待响应;读由 readerLoop 并发分发。 +func (c *AgentClient) call(ctx context.Context, op string, args map[string]any, out any) error { + id := c.idGen.Add(1) + ch := make(chan agentResponse, 1) + c.pendingMu.Lock() + c.pending[id] = pendingCall{ch: ch} + c.pendingMu.Unlock() + + payload, err := json.Marshal(agentRequest{ID: id, Op: op, Args: args}) + if err != nil { + return err + } + writeMu.Lock() + _, werr := c.stdin.Write(append(payload, '\n')) + writeMu.Unlock() + if werr != nil { + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return fmt.Errorf("写入 agent 失败: %w", werr) + } + + select { + case resp := <-ch: + if !resp.OK { + return fmt.Errorf("agent: %s", resp.Error) + } + if out != nil && len(resp.Result) > 0 { + if uerr := json.Unmarshal(resp.Result, out); uerr != nil { + return fmt.Errorf("解析 agent 响应失败: %w", uerr) + } + } + return nil + case <-ctx.Done(): + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return ctx.Err() + } +} + +// 全进程共享的写锁:多个 AgentClient(不同 JVM)并发写各自 stdin 不需要互斥, +// 但同一客户端的并发 call 必须串行写,避免行交错。 +var writeMu sync.Mutex + +// Kill 立即终止 JVM(不优雅 shutdown;句柄 close 由 withAgent 负责)。 +func (c *AgentClient) Kill() { + c.closeOnce.Do(func() { + _ = c.stdin.Close() + if c.cmd.Process != nil { + _ = c.cmd.Process.Kill() + } + <-c.readerDone + }) +} + +// Shutdown 优雅退出(op shutdown 后进程自退)。 +func (c *AgentClient) Shutdown(ctx context.Context) { + ctx2, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + _ = c.call(ctx2, "shutdown", nil, nil) + c.Kill() +} + +// ---------- agent 进程池(每驱动一个 JVM,长驻复用) ---------- + +var agentPool = struct { + sync.Mutex + m map[string]*AgentClient +}{m: map[string]*AgentClient{}} + +func getAgentClient(d string) (*AgentClient, error) { + spec, ok := agentSpec(d) + if !ok { + return nil, unsupportedDriverErr(ConnReq{Driver: d}) + } + agentPool.Lock() + defer agentPool.Unlock() + if cli, ok := agentPool.m[spec.DriverKey]; ok { + return cli, nil + } + cli, err := StartAgent(spec) + if err != nil { + return nil, err + } + agentPool.m[spec.DriverKey] = cli + return cli, nil +} + +// ---------- URL 与连接打开 ---------- + +// renderAgentURL 按模板渲染 jdbc url(达梦: jdbc:dm://host:port)。 +func renderAgentURL(spec AgentSpec, c ConnReq) (string, error) { + repl := strings.NewReplacer( + "{{.Host}}", c.Host, + "{{.Port}}", fmt.Sprintf("%d", c.Port), + "{{.Database}}", c.Database, + ) + url := repl.Replace(spec.URLTemplate) + if c.Host == "" { + return "", fmt.Errorf("%s 需要主机地址", spec.DriverKey) + } + if c.Port == 0 { + return "", fmt.Errorf("%s 需要端口", spec.DriverKey) + } + return url, nil +} + +// agentOpen 在 agent 进程内打开一个 JDBC 连接,返回句柄。 +func agentOpen(ctx context.Context, cli *AgentClient, c ConnReq) (string, error) { + spec := cli.spec + url, err := renderAgentURL(spec, c) + if err != nil { + return "", err + } + args := map[string]any{ + "url": url, + "user": c.User, + "password": c.Password, + "readOnly": false, + "db": c.Database, // 非 JDBC 桥(Mongo DSL)用它路由库;JDBC 桥忽略 + } + var res struct { + Handle string `json:"handle"` + } + if err := cli.call(ctx, "open", args, &res); err != nil { + return "", err + } + if res.Handle == "" { + return "", errors.New("agent 未返回句柄") + } + return res.Handle, nil +} + +// ---------- 与 withDB 对称的 agent 执行入口 ---------- + +// withAgent 取(或建)agent 连接并执行 fn;会话内按 database 缓存句柄,直连模式用完即关。 +func withAgent(c ConnReq, fn func(cli *AgentClient, handle string) error) error { + if c.SessionID != "" { + s, err := GetSession(c.SessionID) + if err != nil { + return err + } + cli, handle, err := s.agentConn(c) + if err != nil { + return err + } + return fn(cli, handle) + } + cli, err := getAgentClient(c.Driver) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + handle, err := agentOpen(ctx, cli, c) + if err != nil { + return err + } + defer func() { + ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel2() + _ = cli.call(ctx2, "close", map[string]any{"handle": handle}, nil) + }() + return fn(cli, handle) +} + +// agentTimeoutSec 从 ctx deadline 推算语句超时(兜底,防慢查询挂死业务库)。 +func agentTimeoutSec(ctx context.Context, def int) int { + if dl, ok := ctx.Deadline(); ok { + sec := int(time.Until(dl).Seconds()) + 1 + if sec > 0 && sec < 3600 { + return sec + } + } + return def +} + +// ---------- agent 版元数据 / 查询(ops.go 分支调用) ---------- + +func agentListDatabases(ctx context.Context, c ConnReq) ([]string, error) { + spec, _ := agentSpec(c.Driver) + // 无多库概念的驱动(ES):对象树只挂一个固定库节点 + if spec.DefaultDatabase != "" { + return []string{spec.DefaultDatabase}, nil + } + var out []string + err := withAgent(c, func(cli *AgentClient, handle string) error { + var res struct { + Names []string `json:"names"` + } + if err := cli.call(ctx, "metaDatabases", map[string]any{"handle": handle}, &res); err != nil { + return err + } + out = res.Names + return nil + }) + if out == nil { + out = []string{} + } + return out, err +} + +func agentListTables(ctx context.Context, c ConnReq) ([]string, error) { + spec, _ := agentSpec(c.Driver) + // 专属列表 SQL(ES SHOW TABLES):跑查询取首列 + if spec.TablesSQL != "" { + var out []string + err := withAgent(c, func(cli *AgentClient, handle string) error { + 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": spec.TablesSQL, "maxRows": 10000, "timeoutSec": agentTimeoutSec(ctx, 30), + }, &res); err != nil { + return err + } + if len(res.Columns) == 0 { + return nil + } + first := res.Columns[0] + for _, row := range res.Rows { + if v, ok := row[first].(string); ok && v != "" { + out = append(out, v) + } + } + return nil + }) + if out == nil { + out = []string{} + } + return out, err + } + var out []string + err := withAgent(c, func(cli *AgentClient, handle string) error { + var res struct { + Names []string `json:"names"` + } + if err := cli.call(ctx, "metaTables", map[string]any{"handle": handle, "schema": c.Database}, &res); err != nil { + return err + } + out = res.Names + return nil + }) + if out == nil { + out = []string{} + } + return out, err +} + +func agentListColumns(ctx context.Context, c ConnReq) ([]ColumnInfo, error) { + var out []ColumnInfo + err := withAgent(c, func(cli *AgentClient, handle string) error { + var res struct { + Columns []ColumnInfo `json:"columns"` + } + if err := cli.call(ctx, "metaColumns", map[string]any{"handle": handle, "schema": c.Database, "table": c.Table}, &res); err != nil { + return err + } + out = res.Columns + return nil + }) + if out == nil { + out = []ColumnInfo{} + } + return out, err +} + +// agentVerb 提取语句动词:ZK 风格 "GET /path" 取首词;Mongo DSL "db.c.find({...})" 取第二段。 +func agentVerb(sql string) string { + first := strings.TrimSpace(sql) + if fields := strings.Fields(first); len(fields) > 0 { + first = fields[0] + } + if strings.HasPrefix(first, "db.") { + parts := strings.SplitN(first, ".", 3) + if len(parts) == 3 { + v := parts[2] + if i := strings.IndexAny(v, "({"); i >= 0 { + v = v[:i] + } + return strings.ToUpper(v) + } + } + if i := strings.IndexAny(first, "({"); i >= 0 { + first = first[:i] + } + return strings.ToUpper(first) +} + +// agentExecSQL 对应 execOnDB:查询带 maxRows 硬截断 + offset 分页;非查询返回 affectedRows。 +func agentExecSQL(ctx context.Context, c ConnReq, start time.Time) ExecResult { + var res ExecResult + err := withAgent(c, func(cli *AgentClient, handle string) error { + timeout := agentTimeoutSec(ctx, 60) + // ZK 专属动词路由:GET/LS 走 query(返回行列),CREATE/SET/DELETE 走 exec + useQuery := isQuerySQL(c.SQL) + if driverKey(c.Driver) == "zookeeper" { + verb := agentVerb(c.SQL) + useQuery = verb == "GET" || verb == "LS" + } + if driverKey(c.Driver) == "mongodb" { + verb := agentVerb(c.SQL) + useQuery = verb == "FIND" || verb == "COUNT" + } + if !useQuery { + var r struct { + AffectedRows int64 `json:"affectedRows"` + } + if err := cli.call(ctx, "exec", map[string]any{"handle": handle, "sql": c.SQL, "timeoutSec": timeout, "db": c.Database}, &r); err != nil { + return err + } + res = ExecResult{ + Columns: []string{"affected_rows"}, + Rows: []map[string]interface{}{{"affected_rows": r.AffectedRows}}, + RowCount: 1, + TimeMs: time.Since(start).Milliseconds(), + } + return nil + } + limit := rowLimit(c) + offset := c.Offset + if offset < 0 { + offset = 0 + } + if offset+limit > agentMaxRows { + return fmt.Errorf("agent 驱动暂不支持深分页(offset+limit ≤ %d)", agentMaxRows) + } + var r struct { + Columns []string `json:"columns"` + Rows []map[string]interface{} `json:"rows"` + Truncated bool `json:"truncated"` + } + if err := cli.call(ctx, "query", map[string]any{ + "handle": handle, "sql": c.SQL, + "maxRows": offset + limit, "timeoutSec": timeout, "db": c.Database, + }, &r); err != nil { + return err + } + rows := r.Rows + truncated := r.Truncated + if offset > 0 { + if len(rows) > offset { + rows = rows[offset:] + } else { + rows = []map[string]interface{}{} + } + } + if rows == nil { + rows = []map[string]interface{}{} + } + res = ExecResult{ + Columns: r.Columns, + Rows: rows, + RowCount: len(rows), + TimeMs: time.Since(start).Milliseconds(), + Truncated: truncated, + Limit: limit, + Offset: offset, + } + return nil + }) + if err != nil { + return execFail(start, err) + } + return res +} + +func agentExplain(ctx context.Context, c ConnReq, sqlText string) ExplainResult { + // v1:通用 EXPLAIN 前缀(达梦支持);方言化计划格式后续按驱动补。 + cc := c + cc.SQL = "EXPLAIN " + sqlText + cc.Limit = 200 + var res ExplainResult + err := withAgent(cc, func(cli *AgentClient, handle string) error { + var r struct { + Columns []string `json:"columns"` + Rows []map[string]interface{} `json:"rows"` + } + if err := cli.call(ctx, "query", map[string]any{ + "handle": handle, "sql": cc.SQL, "maxRows": 200, "timeoutSec": agentTimeoutSec(ctx, 30), + }, &r); err != nil { + return err + } + res = ExplainResult{Columns: r.Columns, Rows: r.Rows} + return nil + }) + if err != nil { + return ExplainResult{Error: errMsg(err)} + } + return res +} + +// agentPing 供 TestConnection / OpenSession 探活。 +func agentPing(c ConnReq) error { + return withAgent(c, func(cli *AgentClient, handle string) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return cli.call(ctx, "ping", map[string]any{"handle": handle}, nil) + }) +} + +// ---------- agent 运行环境(全部在用户家目录,连接时按需落盘) ---------- + +// agentHome 根目录(~/.opsxdb) +func agentHome() string { + if h := os.Getenv("OPSXDB_HOME"); h != "" { + return h + } + home, err := os.UserHomeDir() + if err != nil { + return ".opsxdb" + } + return filepath.Join(home, ".opsxdb") +} + +// agentDir agent jar 目录:~/.opsxdb/agent +func agentDir() string { + return filepath.Join(agentHome(), "agent") +} + +// agentJarsDir 厂商驱动 jar 目录:~/.opsxdb/drivers/(用户家目录,DMG 不打包) +func agentJarsDir(spec AgentSpec) string { + if v := os.Getenv(spec.JarsDirEnv); v != "" { + return v + } + return filepath.Join(agentHome(), "drivers", spec.DriverKey) +} + +// ensureAgentRuntime 连接时按需下载:agent jar(双托管源 + sha256)与开源驱动 jar(Maven 镜像 + sha256)。 +// 商用驱动不在下载范围,由后续 AgentConfigured 给出 manualSource 指引。 +// 网络失败时回退离线驱动包(~/.opsxdb/offline-driver-pack.zip,pack.sh 产物)。 +func ensureAgentRuntime(spec AgentSpec) error { + if err := ensureAgentJar(); err != nil { + return err + } + artifacts, ok := agentDriverArtifacts[spec.DriverKey] + if !ok { + return nil // 商用/未知驱动:不代下,由 AgentConfigured 校验并给指引 + } + dir := agentJarsDir(spec) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + for _, a := range artifacts { + dest := filepath.Join(dir, a.File) + if _, err := os.Stat(dest); err == nil { + continue + } + if a.Maven == "" || a.SHA256 == "" { + continue + } + if err := downloadMavenAllMirrors(a, dest); err != nil { + // 离线/内网:从离线驱动包导入 + if impErr := importFromOfflinePack(map[string]string{ + "drivers/" + spec.DriverKey + "/" + a.File: dest, + }); impErr == nil { + continue + } + return fmt.Errorf("%s 驱动 jar 下载失败: %w(离线环境可放置离线包 ~/.opsxdb/offline-driver-pack.zip 或手动放 %s)", + spec.DriverKey, err, dir) + } + } + return nil +} + +// downloadMavenAllMirrors 按镜像顺序下载并校验;全部失败返回最后一个错误。 +func downloadMavenAllMirrors(a agentArtifact, dest string) error { + mirrors := []string{ + os.Getenv("MAVEN_MIRROR"), + "https://maven.aliyun.com/repository/central", + "https://repo1.maven.org/maven2", + } + var lastErr error + for _, base := range mirrors { + if base == "" { + continue + } + if err := downloadPinned(base+"/"+mavenPath(a.Maven), dest, a.SHA256); err == nil { + return nil + } else { + lastErr = err + } + } + return lastErr +} + +// offlinePackPath 离线驱动包位置(OPSXDB_OFFLINE_ZIP 可覆盖)。 +func offlinePackPath() string { + if v := os.Getenv("OPSXDB_OFFLINE_ZIP"); v != "" { + return v + } + return filepath.Join(agentHome(), "offline-driver-pack.zip") +} + +// importFromOfflinePack 从离线包按 manifest 校验后提取文件。 +// files: zip 内相对路径 → 本地目标路径。 +func importFromOfflinePack(files map[string]string) error { + zr, err := zip.OpenReader(offlinePackPath()) + if err != nil { + return err + } + defer zr.Close() + // 先读 manifest 建 hash 表 + hashes := map[string]string{} + for _, f := range zr.File { + if f.Name != "pack/manifest.json" { + continue + } + rc, err := f.Open() + if err != nil { + return err + } + var m struct { + Files []struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + } `json:"files"` + } + err = json.NewDecoder(rc).Decode(&m) + rc.Close() + if err != nil { + return err + } + for _, e := range m.Files { + hashes["pack/"+e.Path] = e.SHA256 + } + break + } + for rel, dest := range files { + rel = "pack/" + strings.TrimPrefix(rel, "pack/") + want, ok := hashes[rel] + if !ok { + return fmt.Errorf("离线包 %s 中不存在 %s", offlinePackPath(), rel) + } + for _, f := range zr.File { + if f.Name != rel { + continue + } + rc, err := f.Open() + if err != nil { + return err + } + data, err := io.ReadAll(io.LimitReader(rc, 64<<20)) + rc.Close() + if err != nil { + return err + } + if got := fmt.Sprintf("%x", sha256.Sum256(data)); got != want { + return fmt.Errorf("离线包 %s 校验失败: %s", rel, got) + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + if err := os.WriteFile(dest, data, 0o644); err != nil { + return err + } + return nil + } + return fmt.Errorf("离线包 %s 中找不到 %s", offlinePackPath(), rel) + } + return nil +} + +// ensureAgentJar agent jar:本地缺时先网络下载,失败回退离线包。 +func ensureAgentJar() error { + agentJar := os.Getenv("OPSXDB_AGENT_JAR") + if agentJar != "" { + return nil // 显式指定,由调用方负责存在性 + } + agentJar = filepath.Join(agentDir(), agentJarName) + if _, err := os.Stat(agentJar); err == nil { + return nil + } + if err := os.MkdirAll(agentDir(), 0o755); err != nil { + return err + } + var lastErr error + for _, u := range strings.Split(agentJarURLs, ",") { + if err := downloadPinned(u, agentJar, agentJarSHA256); err == nil { + return nil + } else { + lastErr = err + } + } + if err := importFromOfflinePack(map[string]string{"pack/agent/" + agentJarName: agentJar}); err == nil { + return nil + } + return fmt.Errorf("agent jar 获取失败: %w(联网下载与离线包均未成功;可手动放置到 %s 或设置 OPSXDB_AGENT_JAR)", lastErr, agentJar) +} + +// mavenPath group:artifact:version → org/mongodb/bson/5.2.1/bson-5.2.1.jar +func mavenPath(gav string) string { + parts := strings.Split(gav, ":") + if len(parts) != 3 { + return "" + } + return strings.ReplaceAll(parts[0], ".", "/") + "/" + parts[1] + "/" + parts[2] + "/" + parts[1] + "-" + parts[2] + ".jar" +} + +// ensureGson 下载并校验 gson(不存在时)。多源回退:Maven Central → 阿里云镜像。 +func ensureGson(home string) (string, error) { + libs := filepath.Join(home, "libs") + if err := os.MkdirAll(libs, 0o755); err != nil { + return "", err + } + name := fmt.Sprintf("gson-%s.jar", gsonVersion) + dest := filepath.Join(libs, name) + if _, err := os.Stat(dest); err == nil { + return dest, nil + } + urls := []string{ + fmt.Sprintf("https://repo1.maven.org/maven2/com/google/code/gson/gson/%s/%s", gsonVersion, name), + fmt.Sprintf("https://maven.aliyun.com/repository/central/com/google/code/gson/gson/%s/%s", gsonVersion, name), + } + var lastErr error + for _, u := range urls { + if err := downloadPinned(u, dest, gsonSHA1); err != nil { + lastErr = err + continue + } + return dest, nil + } + return "", fmt.Errorf("gson 下载失败(已按 sha1 钉版本校验): %w", lastErr) +} + +// downloadPinned 下载到临时文件并校验 sha1,通过后原子改名。 +func downloadPinned(url, dest, wantHash string) error { + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s → HTTP %d", url, resp.StatusCode) + } + tmp := dest + ".tmp" + f, err := os.Create(tmp) + if err != nil { + return err + } + // 按期望哈希长度自动选算法:64 位 hex = sha256(驱动/agent 清单),40 位 = sha1(gson 的 .sha1 惯例) + var h hash.Hash + if len(wantHash) == 64 { + h = sha256.New() + } else { + h = sha1.New() + } + _, copyErr := io.Copy(io.MultiWriter(f, h), resp.Body) + closeErr := f.Close() + if copyErr != nil { + _ = os.Remove(tmp) + return copyErr + } + if closeErr != nil { + _ = os.Remove(tmp) + return closeErr + } + if got := hex.EncodeToString(h.Sum(nil)); got != wantHash { + _ = os.Remove(tmp) + return fmt.Errorf("%s 校验失败: got=%s 期望=%s", url, got, wantHash) + } + return os.Rename(tmp, dest) +} + +// mongoVerbAsSQL 把 Mongo DSL 写动词映射为 SQL 危险词,复用既有词表与确认流: +// deleteOne/deleteMany → DELETE(无 WHERE 必触发确认),updateOne/updateMany → UPDATE。 +func mongoVerbAsSQL(sql string) string { + repl := strings.NewReplacer( + "deleteOne", "DELETE", + "deleteMany", "DELETE", + "updateOne", "UPDATE", + "updateMany", "UPDATE", + ) + return repl.Replace(sql) +} + +// ---------- 驱动管理(UI 状态/预装/离线导入) ---------- + +type JarStatus struct { + File string `json:"file"` + Size int64 `json:"size"` + Installed bool `json:"installed"` +} + +type DriverStatus struct { + ID string `json:"id"` + Label string `json:"label"` + Runtime string `json:"runtime"` // agent + License string `json:"license"` // oss / proprietary + AutoFetch bool `json:"autoFetch"` // 开源可自动下载 + Jars []JarStatus `json:"jars"` + AllInstalled bool `json:"allInstalled"` + JDKRequired bool `json:"jdkRequired"` // agent 驱动恒 true + JDKOK bool `json:"jdkOk"` + JavaVersion string `json:"javaVersion"` // 探测到的版本(如 21.0.12) + AgentInstalled bool `json:"agentInstalled"` // agent jar 是否就位 + OfflinePack bool `json:"offlinePack"` // 离线驱动包是否就位 +} + +// DriversStatus 汇总所有 agent 驱动的安装状态,供驱动管理界面展示。 +func DriversStatus() []DriverStatus { + javaBin, _ := exec.LookPath("java") + javaVersion := "" + if javaBin != "" { + if out, err := exec.Command(javaBin, "-version").CombinedOutput(); err == nil { + javaVersion = parseJavaVersion(string(out)) + } + } + _, offlineErr := os.Stat(offlinePackPath()) + + out := []DriverStatus{} + // 按注册表顺序输出(map 遍历乱序,用固定顺序) + keys := []string{"mongodb", "zookeeper", "dameng"} + for _, k := range keys { + spec, ok := agentDriverSpecs[k] + if !ok { + continue + } + artifacts, auto := agentDriverArtifacts[k] + st := DriverStatus{ + ID: spec.DriverKey, + Label: spec.DriverKey, + Runtime: "agent", + AutoFetch: auto, + JDKRequired: true, + JDKOK: javaBin != "", + JavaVersion: javaVersion, + OfflinePack: offlineErr == nil, + AgentInstalled: fileExists(filepath.Join(agentDir(), agentJarName)), + } + if os.Getenv("OPSXDB_AGENT_JAR") != "" { + st.AgentInstalled = fileExists(os.Getenv("OPSXDB_AGENT_JAR")) + } + if st.AutoFetch { + st.License = "oss" + dir := agentJarsDir(spec) + for _, a := range artifacts { + p := filepath.Join(dir, a.File) + js := JarStatus{File: a.File, Installed: fileExists(p)} + if fi, err := os.Stat(p); err == nil { + js.Size = fi.Size() + } + st.Jars = append(st.Jars, js) + } + st.AllInstalled = st.AgentInstalled && everyJar(st.Jars) + } else { + st.License = "proprietary" + // 商用:只报告目录里是否已有 jar + dir := agentJarsDir(spec) + entries, _ := os.ReadDir(dir) + n := 0 + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".jar") { + n++ + } + } + st.License = "proprietary" + st.Jars = []JarStatus{} + st.AllInstalled = st.AgentInstalled && n > 0 + } + out = append(out, st) + } + return out +} + +// InstallDriver 预装指定驱动的运行时(agent jar + 开源驱动 jar),供驱动管理界面"安装"按钮。 +func InstallDriver(driverKey string) error { + spec, ok := agentDriverSpecs[driverKey] + if !ok { + return fmt.Errorf("未知 agent 驱动: %s", driverKey) + } + if _, err := exec.LookPath(os.Getenv("OPSXDB_JAVA_BIN")); err != nil && os.Getenv("OPSXDB_JAVA_BIN") == "" { + if _, err := exec.LookPath("java"); err != nil { + return fmt.Errorf("未找到 java,请先安装 JDK 17+") + } + } + return ensureAgentRuntime(spec) +} + +// ImportOfflinePack 全量导入离线驱动包(上传 zip → 校验 manifest → 落盘 ~/.opsxdb/)。 +func ImportOfflinePack(zipPath string) (int, error) { + zr, err := zip.OpenReader(zipPath) + if err != nil { + return 0, err + } + defer zr.Close() + hashes := map[string]string{} + var entries []*zip.File + for _, f := range zr.File { + entries = append(entries, f) + if f.Name != "pack/manifest.json" { + continue + } + rc, err := f.Open() + if err != nil { + return 0, err + } + var m struct { + Files []struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + } `json:"files"` + } + err = json.NewDecoder(rc).Decode(&m) + rc.Close() + if err != nil { + return 0, err + } + for _, e := range m.Files { + hashes["pack/"+e.Path] = e.SHA256 + } + } + if len(hashes) == 0 { + return 0, fmt.Errorf("离线包缺 manifest.json") + } + home := agentHome() + imported := 0 + for _, f := range entries { + want, ok := hashes[f.Name] + if !ok || strings.HasSuffix(f.Name, "/") { + continue + } + rel := strings.TrimPrefix(f.Name, "pack/") + // 只允许写入白名单前缀,防 zip 路径穿越 + if !strings.HasPrefix(rel, "agent/") && !strings.HasPrefix(rel, "libs/") && !strings.HasPrefix(rel, "drivers/") { + continue + } + rc, err := f.Open() + if err != nil { + return imported, err + } + data, err := io.ReadAll(io.LimitReader(rc, 64<<20)) + rc.Close() + if err != nil { + return imported, err + } + if got := fmt.Sprintf("%x", sha256.Sum256(data)); got != want { + return imported, fmt.Errorf("%s 校验失败", rel) + } + dest := filepath.Join(home, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return imported, err + } + if err := os.WriteFile(dest, data, 0o644); err != nil { + return imported, err + } + imported++ + } + return imported, nil +} + +func fileExists(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +func everyJar(js []JarStatus) bool { + for _, j := range js { + if !j.Installed { + return false + } + } + return true +} + +// parseJavaVersion 从 `java -version` 输出提取版本号(输出在 stderr/stdout 第一行)。 +func parseJavaVersion(out string) string { + for _, line := range strings.Split(out, "\n") { + if i := strings.Index(line, "version \""); i >= 0 { + rest := line[i+len("version \""):] + if j := strings.Index(rest, "\""); j > 0 { + return rest[:j] + } + } + } + return "" +} diff --git a/apps/server/internal/db/agent_e2e_test.go b/apps/server/internal/db/agent_e2e_test.go new file mode 100644 index 0000000..d3f38e0 --- /dev/null +++ b/apps/server/internal/db/agent_e2e_test.go @@ -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"]) + } +} diff --git a/apps/server/internal/db/agent_test.go b/apps/server/internal/db/agent_test.go new file mode 100644 index 0000000..97e2c82 --- /dev/null +++ b/apps/server/internal/db/agent_test.go @@ -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 +} diff --git a/apps/server/internal/db/danger.go b/apps/server/internal/db/danger.go index ce6cf0c..ad7691f 100644 --- a/apps/server/internal/db/danger.go +++ b/apps/server/internal/db/danger.go @@ -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 { diff --git a/apps/server/internal/db/danger_test.go b/apps/server/internal/db/danger_test.go new file mode 100644 index 0000000..62e1b9e --- /dev/null +++ b/apps/server/internal/db/danger_test.go @@ -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") + } +} diff --git a/apps/server/internal/db/ddl.go b/apps/server/internal/db/ddl.go new file mode 100644 index 0000000..f2222e7 --- /dev/null +++ b/apps/server/internal/db/ddl.go @@ -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 +} diff --git a/apps/server/internal/db/ddl_test.go b/apps/server/internal/db/ddl_test.go new file mode 100644 index 0000000..b90572b --- /dev/null +++ b/apps/server/internal/db/ddl_test.go @@ -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("无列应报错") + } +} diff --git a/apps/server/internal/db/es.go b/apps/server/internal/db/es.go new file mode 100644 index 0000000..a1e7c37 --- /dev/null +++ b/apps/server/internal/db/es.go @@ -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 +} diff --git a/apps/server/internal/db/export.go b/apps/server/internal/db/export.go new file mode 100644 index 0000000..5ba253e --- /dev/null +++ b/apps/server/internal/db/export.go @@ -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) + } +} diff --git a/apps/server/internal/db/export_test.go b/apps/server/internal/db/export_test.go new file mode 100644 index 0000000..a2da092 --- /dev/null +++ b/apps/server/internal/db/export_test.go @@ -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("未知格式应报错") + } +} diff --git a/apps/server/internal/db/family_e2e_test.go b/apps/server/internal/db/family_e2e_test.go new file mode 100644 index 0000000..f2798fb --- /dev/null +++ b/apps/server/internal/db/family_e2e_test.go @@ -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) + } +} diff --git a/apps/server/internal/db/ident.go b/apps/server/internal/db/ident.go index 683a3d3..1cbdfa6 100644 --- a/apps/server/internal/db/ident.go +++ b/apps/server/internal/db/ident.go @@ -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 { diff --git a/apps/server/internal/db/inflight.go b/apps/server/internal/db/inflight.go new file mode 100644 index 0000000..d468671 --- /dev/null +++ b/apps/server/internal/db/inflight.go @@ -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 +} diff --git a/apps/server/internal/db/inflight_test.go b/apps/server/internal/db/inflight_test.go new file mode 100644 index 0000000..5170765 --- /dev/null +++ b/apps/server/internal/db/inflight_test.go @@ -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 不应注册成功") + } +} diff --git a/apps/server/internal/db/influx.go b/apps/server/internal/db/influx.go new file mode 100644 index 0000000..803790f --- /dev/null +++ b/apps/server/internal/db/influx.go @@ -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 +} diff --git a/apps/server/internal/db/mssql.go b/apps/server/internal/db/mssql.go index e7400a3..6f5fbe6 100644 --- a/apps/server/internal/db/mssql.go +++ b/apps/server/internal/db/mssql.go @@ -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 } diff --git a/apps/server/internal/db/mysql.go b/apps/server/internal/db/mysql.go index 1be4788..490efe8 100644 --- a/apps/server/internal/db/mysql.go +++ b/apps/server/internal/db/mysql.go @@ -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 } diff --git a/apps/server/internal/db/open.go b/apps/server/internal/db/open.go index 6d03ffb..a5a5ff0 100644 --- a/apps/server/internal/db/open.go +++ b/apps/server/internal/db/open.go @@ -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 diff --git a/apps/server/internal/db/ops.go b/apps/server/internal/db/ops.go index 40177fc..75857fd 100644 --- a/apps/server/internal/db/ops.go +++ b/apps/server/internal/db/ops.go @@ -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 == "" { diff --git a/apps/server/internal/db/oracle.go b/apps/server/internal/db/oracle.go index d71d878..247d74d 100644 --- a/apps/server/internal/db/oracle.go +++ b/apps/server/internal/db/oracle.go @@ -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 } diff --git a/apps/server/internal/db/postgres.go b/apps/server/internal/db/postgres.go index f92aefb..171d48c 100644 --- a/apps/server/internal/db/postgres.go +++ b/apps/server/internal/db/postgres.go @@ -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 } diff --git a/apps/server/internal/db/redis.go b/apps/server/internal/db/redis.go index f20a59f..1002763 100644 --- a/apps/server/internal/db/redis.go +++ b/apps/server/internal/db/redis.go @@ -3,6 +3,7 @@ package db import ( "context" "fmt" + "sort" "strconv" "strings" "time" @@ -10,7 +11,53 @@ import ( "github.com/redis/go-redis/v9" ) -func redisOpts(c ConnReq) (*redis.Options, error) { +func redisDBIndex(c ConnReq) int { + db := 0 + if c.Database != "" { + if n, err := strconv.Atoi(c.Database); err == nil { + db = n + } + } + return db +} + +func normalizeRedisMode(c ConnReq) string { + m := strings.ToLower(strings.TrimSpace(c.RedisMode)) + if m == "" { + return "standalone" + } + return m +} + +// parseRedisAddrs 解析 host:port 列表(换行 / 逗号 / 分号 / 空白分隔)。 +func parseRedisAddrs(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + raw = strings.ReplaceAll(raw, "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + raw = strings.ReplaceAll(raw, ",", " ") + raw = strings.ReplaceAll(raw, ";", " ") + raw = strings.ReplaceAll(raw, "\n", " ") + parts := strings.Fields(raw) + out := make([]string, 0, len(parts)) + seen := map[string]struct{}{} + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + out = append(out, p) + } + return out +} + +func redisStandaloneOpts(c ConnReq) (*redis.Options, error) { host := c.Host if host == "" { host = "127.0.0.1" @@ -19,17 +66,11 @@ func redisOpts(c ConnReq) (*redis.Options, error) { if port == 0 { port = 6379 } - db := 0 - if c.Database != "" { - if n, err := strconv.Atoi(c.Database); err == nil { - db = n - } - } opts := &redis.Options{ Addr: fmt.Sprintf("%s:%d", host, port), Password: c.Password, Username: c.User, - DB: db, + DB: redisDBIndex(c), DialTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, @@ -44,12 +85,94 @@ func redisOpts(c ConnReq) (*redis.Options, error) { return opts, nil } -func openRedis(c ConnReq) (*redis.Client, error) { - opts, err := redisOpts(c) - if err != nil { - return nil, err +// openRedis 按 redisMode 建 UniversalClient(单机 / Sentinel / Cluster)。 +func openRedis(c ConnReq) (redis.UniversalClient, error) { + mode := normalizeRedisMode(c) + switch mode { + case "sentinel": + addrs := parseRedisAddrs(c.RedisNodes) + if len(addrs) == 0 { + return nil, fmt.Errorf("Sentinel 模式请填写至少一个 Sentinel 节点(host:port)") + } + master := strings.TrimSpace(c.MasterName) + if master == "" { + return nil, fmt.Errorf("Sentinel 模式请填写主节点名 masterName") + } + opts := &redis.FailoverOptions{ + MasterName: master, + SentinelAddrs: addrs, + Password: c.Password, + Username: c.User, + SentinelPassword: c.SentinelPassword, + DB: redisDBIndex(c), + DialTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + if SSLEnabled(c) { + tlsCfg, err := tlsConfigFrom(c) + if err != nil { + return nil, err + } + opts.TLSConfig = tlsCfg + } + return redis.NewFailoverClient(opts), nil + case "cluster": + addrs := parseRedisAddrs(c.RedisNodes) + if len(addrs) == 0 { + return nil, fmt.Errorf("Cluster 模式请填写至少一个种子节点(host:port)") + } + opts := &redis.ClusterOptions{ + Addrs: addrs, + Password: c.Password, + Username: c.User, + DialTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + if SSLEnabled(c) { + tlsCfg, err := tlsConfigFrom(c) + if err != nil { + return nil, err + } + opts.TLSConfig = tlsCfg + } + return redis.NewClusterClient(opts), nil + default: + opts, err := redisStandaloneOpts(c) + if err != nil { + return nil, err + } + return redis.NewClient(opts), nil } - return redis.NewClient(opts), nil +} + +// redisClientFor:会话请求复用会话级单例,非会话请求新建并随请求关闭。 +func redisClientFor(c ConnReq) (cli redis.UniversalClient, done func(), err error) { + if c.SessionID != "" { + s, err := GetSession(c.SessionID) + if err != nil { + return nil, nil, err + } + // 请求携带的 Database 决定用哪个 DB 的客户端(对象树切库时逐请求变化) + cli, err := s.redisClientFor(c) + if err != nil { + return nil, nil, err + } + return cli, func() {}, nil + } + cli, err = openRedis(c) + if err != nil { + return nil, nil, err + } + return cli, func() { _ = cli.Close() }, nil +} + +func reqCtxRedis(parent context.Context, d time.Duration) (context.Context, context.CancelFunc) { + if parent == nil { + parent = context.Background() + } + return context.WithTimeout(parent, d) } func testRedis(c ConnReq) error { @@ -63,16 +186,22 @@ func testRedis(c ConnReq) error { return cli.Ping(ctx).Err() } -func listDatabasesRedis(c ConnReq) ([]string, error) { - cli, err := openRedis(c) +func listDatabasesRedis(parent context.Context, c ConnReq) ([]string, error) { + // INFO keyspace 可拿已用 DB;统一暴露 0-15 便于切换,先 Ping 探活 + cli, done, err := redisClientFor(c) if err != nil { return nil, err } - defer cli.Close() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer done() + ctx, cancel := reqCtxRedis(parent, 5*time.Second) defer cancel() - // INFO keyspace 可拿已用 DB;统一暴露 0-15 便于切换 - _ = ctx + if err := cli.Ping(ctx).Err(); err != nil { + return nil, err + } + // Cluster 不支持 SELECT 多库,只暴露 0 + if normalizeRedisMode(c) == "cluster" { + return []string{"0"}, nil + } out := make([]string, 16) for i := 0; i < 16; i++ { out[i] = strconv.Itoa(i) @@ -80,13 +209,13 @@ func listDatabasesRedis(c ConnReq) ([]string, error) { return out, nil } -func listTablesRedis(c ConnReq) ([]string, error) { - cli, err := openRedis(c) +func listTablesRedis(parent context.Context, c ConnReq) ([]string, error) { + cli, done, err := redisClientFor(c) if err != nil { return nil, err } - defer cli.Close() - ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer done() + ctx, cancel := reqCtxRedis(parent, 8*time.Second) defer cancel() var keys []string var cursor uint64 @@ -107,23 +236,47 @@ func listTablesRedis(c ConnReq) ([]string, error) { return keys, nil } +// redisDangerCmds 需要 confirmDanger 确认后才执行的命令组: +// FLUSHALL/FLUSHDB/CONFIG 破坏性;KEYS 大库阻塞。 +var redisDangerCmds = map[string]bool{ + "FLUSHALL": true, + "FLUSHDB": true, + "CONFIG": true, + "KEYS": true, +} + +func redisOK(start time.Time) ExecResult { + return ExecResult{ + Columns: []string{"affected_rows"}, + Rows: []map[string]interface{}{{"affected_rows": 1}}, + RowCount: 1, + TimeMs: time.Since(start).Milliseconds(), + } +} + +func redisAffected(start time.Time, n int64) ExecResult { + return ExecResult{ + Columns: []string{"affected_rows"}, + Rows: []map[string]interface{}{{"affected_rows": n}}, + RowCount: 1, + TimeMs: time.Since(start).Milliseconds(), + } +} + +func redisErr(start time.Time, err error) ExecResult { + return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} +} + +func redisUsage(start time.Time, usage string) ExecResult { + return ExecResult{Error: usage, TimeMs: time.Since(start).Milliseconds()} +} + func execRedis(parent context.Context, c ConnReq) ExecResult { start := time.Now() sql := strings.TrimSpace(c.SQL) if sql == "" { return ExecResult{Error: "empty sql", TimeMs: 0} } - cli, err := openRedis(c) - if err != nil { - return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} - } - defer cli.Close() - if parent == nil { - parent = context.Background() - } - ctx, cancel := context.WithTimeout(parent, 10*time.Second) - defer cancel() - fields := splitRedisCmd(sql) if len(fields) == 0 { return ExecResult{Error: "empty command", TimeMs: time.Since(start).Milliseconds()} @@ -131,130 +284,475 @@ func execRedis(parent context.Context, c ConnReq) ExecResult { cmd := strings.ToUpper(fields[0]) args := fields[1:] + // 危险命令二段确认:未确认不执行 + if redisDangerCmds[cmd] && !c.ConfirmDanger { + return ExecResult{ + Error: "检测到危险命令: " + cmd, + NeedConfirm: true, + TimeMs: time.Since(start).Milliseconds(), + } + } + + cli, done, err := redisClientFor(c) + if err != nil { + return redisErr(start, err) + } + defer done() + ctx, cancel := reqCtxRedis(parent, 10*time.Second) + defer cancel() + switch cmd { case "PING": val, err := cli.Ping(ctx).Result() if err != nil { - return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} + return redisErr(start, err) } return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": val}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()} case "GET": if len(args) < 1 { - return ExecResult{Error: "GET key", TimeMs: time.Since(start).Milliseconds()} + return redisUsage(start, "GET key") } val, err := cli.Get(ctx, args[0]).Result() if err == redis.Nil { return ExecResult{Columns: []string{"key", "value"}, Rows: []map[string]interface{}{}, RowCount: 0, TimeMs: time.Since(start).Milliseconds()} } if err != nil { - return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} + return redisErr(start, err) } return ExecResult{Columns: []string{"key", "value"}, Rows: []map[string]interface{}{{"key": args[0], "value": val}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()} case "SET": + // 参数已按 shell 词法切分,value 即 args[1](不再 Join 破坏引号分组); + // 支持可选 EX 秒数:SET key value [EX n] if len(args) < 2 { - return ExecResult{Error: "SET key value", TimeMs: time.Since(start).Milliseconds()} + return redisUsage(start, "SET key value [EX seconds]") } - if err := cli.Set(ctx, args[0], strings.Join(args[1:], " "), 0).Err(); err != nil { - return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} + exp := time.Duration(0) + if len(args) >= 4 && strings.ToUpper(args[2]) == "EX" { + n, err := strconv.Atoi(args[3]) + if err != nil || n <= 0 { + return redisUsage(start, "SET key value EX seconds(秒数需为正整数)") + } + exp = time.Duration(n) * time.Second } - return ExecResult{Columns: []string{"affected_rows"}, Rows: []map[string]interface{}{{"affected_rows": 1}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()} + if err := cli.Set(ctx, args[0], args[1], exp).Err(); err != nil { + return redisErr(start, err) + } + return redisOK(start) case "DEL": if len(args) < 1 { - return ExecResult{Error: "DEL key [key...]", TimeMs: time.Since(start).Milliseconds()} + return redisUsage(start, "DEL key [key...]") } n, err := cli.Del(ctx, args...).Result() if err != nil { - return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} + return redisErr(start, err) } - return ExecResult{Columns: []string{"affected_rows"}, Rows: []map[string]interface{}{{"affected_rows": n}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()} + return redisAffected(start, n) case "KEYS": + // 危险组已在上面对 confirmDanger 把关,此处直接执行 pat := "*" if len(args) > 0 { pat = args[0] } keys, err := cli.Keys(ctx, pat).Result() if err != nil { - return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} + return redisErr(start, err) } rows := make([]map[string]interface{}, 0, len(keys)) for _, k := range keys { rows = append(rows, map[string]interface{}{"key": k}) } return ExecResult{Columns: []string{"key"}, Rows: rows, RowCount: len(rows), TimeMs: time.Since(start).Milliseconds()} - case "SCAN": - keys, err := listTablesRedis(c) + case "FLUSHALL", "FLUSHDB": + // 危险组,已经过 confirmDanger 把关 + var err error + if cmd == "FLUSHALL" { + err = cli.FlushAll(ctx).Err() + } else { + err = cli.FlushDB(ctx).Err() + } if err != nil { - return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} + return redisErr(start, err) } - rows := make([]map[string]interface{}, 0, len(keys)) - for _, k := range keys { + return redisOK(start) + case "CONFIG": + // 危险组,已经过 confirmDanger 把关;通用透传(CONFIG GET/SET ...) + if len(args) < 1 { + return redisUsage(start, "CONFIG GET|SET parameter [value]") + } + doArgs := append([]interface{}{"CONFIG"}, strsToAnys(args)...) + val, err := cli.Do(ctx, doArgs...).Result() + if err != nil { + return redisErr(start, err) + } + return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": normalize(fmt.Sprintf("%v", val))}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()} + case "SCAN": + pat := "*" + count := int64(200) + i := 0 + if len(args) > 0 { + // SCAN cursor [MATCH pattern] [COUNT n] —— 忽略 cursor,内部自扫 + i = 1 + } + for i < len(args) { + up := strings.ToUpper(args[i]) + if up == "MATCH" && i+1 < len(args) { + pat = args[i+1] + i += 2 + continue + } + if up == "COUNT" && i+1 < len(args) { + if n, err := strconv.ParseInt(args[i+1], 10, 64); err == nil && n > 0 { + count = n + } + i += 2 + continue + } + i++ + } + names, truncated, err := scanAll(ctx, cli, pat, count, redisKeyListCap) + if err != nil { + return redisErr(start, err) + } + if names == nil { + names = []string{} + } + rows := make([]map[string]interface{}, 0, len(names)) + for _, k := range names { rows = append(rows, map[string]interface{}{"key": k}) } - return ExecResult{Columns: []string{"key"}, Rows: rows, RowCount: len(rows), TimeMs: time.Since(start).Milliseconds()} + return ExecResult{Columns: []string{"key"}, Rows: rows, RowCount: len(rows), TimeMs: time.Since(start).Milliseconds(), Truncated: truncated} + case "EXPIRE": + if len(args) < 2 { + return redisUsage(start, "EXPIRE key seconds") + } + sec, err := strconv.Atoi(args[1]) + if err != nil { + return redisUsage(start, "EXPIRE key seconds(秒数需为整数)") + } + ok, err := cli.Expire(ctx, args[0], time.Duration(sec)*time.Second).Result() + if err != nil { + return redisErr(start, err) + } + if !ok { + return redisAffected(start, 0) + } + return redisAffected(start, 1) + case "PERSIST": + if len(args) < 1 { + return redisUsage(start, "PERSIST key") + } + ok, err := cli.Persist(ctx, args[0]).Result() + if err != nil { + return redisErr(start, err) + } + if !ok { + return redisAffected(start, 0) + } + return redisAffected(start, 1) + case "HSET": + if len(args) < 3 || len(args)%2 == 0 { + return redisUsage(start, "HSET key field value [field value...]") + } + n, err := cli.HSet(ctx, args[0], strsToAnys(args[1:])...).Result() + if err != nil { + return redisErr(start, err) + } + return redisAffected(start, n) + case "HDEL": + if len(args) < 2 { + return redisUsage(start, "HDEL key field [field...]") + } + n, err := cli.HDel(ctx, args[0], args[1:]...).Result() + if err != nil { + return redisErr(start, err) + } + return redisAffected(start, n) + case "SADD": + if len(args) < 2 { + return redisUsage(start, "SADD key member [member...]") + } + n, err := cli.SAdd(ctx, args[0], strsToAnys(args[1:])...).Result() + if err != nil { + return redisErr(start, err) + } + return redisAffected(start, n) + case "SREM": + if len(args) < 2 { + return redisUsage(start, "SREM key member [member...]") + } + n, err := cli.SRem(ctx, args[0], strsToAnys(args[1:])...).Result() + if err != nil { + return redisErr(start, err) + } + return redisAffected(start, n) + case "ZADD": + // ZADD key score member [score member...] + if len(args) < 3 || len(args)%2 == 0 { + return redisUsage(start, "ZADD key score member [score member...]") + } + var zs []redis.Z + for i := 1; i+1 < len(args); i += 2 { + score, err := strconv.ParseFloat(args[i], 64) + if err != nil { + return redisUsage(start, "ZADD score 需为数字: "+args[i]) + } + zs = append(zs, redis.Z{Score: score, Member: args[i+1]}) + } + n, err := cli.ZAdd(ctx, args[0], zs...).Result() + if err != nil { + return redisErr(start, err) + } + return redisAffected(start, n) + case "ZREM": + if len(args) < 2 { + return redisUsage(start, "ZREM key member [member...]") + } + n, err := cli.ZRem(ctx, args[0], strsToAnys(args[1:])...).Result() + if err != nil { + return redisErr(start, err) + } + return redisAffected(start, n) + case "LPUSH", "RPUSH": + if len(args) < 2 { + return redisUsage(start, cmd+" key value [value...]") + } + var n int64 + var err error + if cmd == "LPUSH" { + n, err = cli.LPush(ctx, args[0], strsToAnys(args[1:])...).Result() + } else { + n, err = cli.RPush(ctx, args[0], strsToAnys(args[1:])...).Result() + } + if err != nil { + return redisErr(start, err) + } + return redisAffected(start, n) + case "LSET": + if len(args) < 3 { + return redisUsage(start, "LSET key index value") + } + idx, err := strconv.ParseInt(args[1], 10, 64) + if err != nil { + return redisUsage(start, "LSET key index value(index 需为整数)") + } + if err := cli.LSet(ctx, args[0], idx, args[2]).Err(); err != nil { + return redisErr(start, err) + } + return redisOK(start) + case "INCR", "DECR": + if len(args) < 1 { + return redisUsage(start, cmd+" key") + } + var n int64 + var err error + if cmd == "INCR" { + n, err = cli.Incr(ctx, args[0]).Result() + } else { + n, err = cli.Decr(ctx, args[0]).Result() + } + if err != nil { + return redisErr(start, err) + } + return ExecResult{Columns: []string{"value"}, Rows: []map[string]interface{}{{"value": n}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()} + case "MGET": + if len(args) < 1 { + return redisUsage(start, "MGET key [key...]") + } + vals, err := cli.MGet(ctx, args...).Result() + if err != nil { + return redisErr(start, err) + } + rows := make([]map[string]interface{}, 0, len(args)) + for i, v := range vals { + rows = append(rows, map[string]interface{}{"key": args[i], "value": normalize(v)}) + } + return ExecResult{Columns: []string{"key", "value"}, Rows: rows, RowCount: len(rows), TimeMs: time.Since(start).Milliseconds()} + case "MSET": + if len(args) < 2 || len(args)%2 != 0 { + return redisUsage(start, "MSET key value [key value...]") + } + if err := cli.MSet(ctx, strsToAnys(args)...).Err(); err != nil { + return redisErr(start, err) + } + return redisOK(start) + case "RENAME": + if len(args) < 2 { + return redisUsage(start, "RENAME key newkey") + } + if err := cli.Rename(ctx, args[0], args[1]).Err(); err != nil { + return redisErr(start, err) + } + return redisOK(start) case "HGETALL": if len(args) < 1 { - return ExecResult{Error: "HGETALL key", TimeMs: time.Since(start).Milliseconds()} + return redisUsage(start, "HGETALL key") } c2 := c c2.Key = args[0] c2.Limit = redisValueRowCap - v := GetRedisKey(c2) + v := GetRedisKey(ctx, c2) if v.Error != "" && v.Type == "" { return ExecResult{Error: v.Error, TimeMs: time.Since(start).Milliseconds()} } - return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error} + return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, NextCursor: v.NextCursor, Error: v.Error} case "LRANGE": if len(args) < 1 { - return ExecResult{Error: "LRANGE key [start stop]", TimeMs: time.Since(start).Milliseconds()} + return redisUsage(start, "LRANGE key [start stop]") } c2 := c c2.Key = args[0] c2.Limit = redisValueRowCap - v := GetRedisKey(c2) - return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error} + v := GetRedisKey(ctx, c2) + return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, NextCursor: v.NextCursor, Error: v.Error} case "SMEMBERS": if len(args) < 1 { - return ExecResult{Error: "SMEMBERS key", TimeMs: time.Since(start).Milliseconds()} + return redisUsage(start, "SMEMBERS key") } c2 := c c2.Key = args[0] - v := GetRedisKey(c2) - return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error} + v := GetRedisKey(ctx, c2) + return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, NextCursor: v.NextCursor, Error: v.Error} case "ZRANGE": if len(args) < 1 { - return ExecResult{Error: "ZRANGE key [start stop]", TimeMs: time.Since(start).Milliseconds()} + return redisUsage(start, "ZRANGE key [start stop]") } c2 := c c2.Key = args[0] - v := GetRedisKey(c2) - return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error} + v := GetRedisKey(ctx, c2) + return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, NextCursor: v.NextCursor, Error: v.Error} case "TYPE": if len(args) < 1 { - return ExecResult{Error: "TYPE key", TimeMs: time.Since(start).Milliseconds()} + return redisUsage(start, "TYPE key") } t, err := cli.Type(ctx, args[0]).Result() if err != nil { - return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} + return redisErr(start, err) } return ExecResult{Columns: []string{"key", "type"}, Rows: []map[string]interface{}{{"key": args[0], "type": t}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()} case "TTL": if len(args) < 1 { - return ExecResult{Error: "TTL key", TimeMs: time.Since(start).Milliseconds()} + return redisUsage(start, "TTL key") } n, err := cli.TTL(ctx, args[0]).Result() if err != nil { - return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} + return redisErr(start, err) } return ExecResult{Columns: []string{"key", "ttl_sec"}, Rows: []map[string]interface{}{{"key": args[0], "ttl_sec": int64(n.Seconds())}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()} default: - return ExecResult{Error: fmt.Sprintf("暂不支持命令 %s(可用: PING/GET/SET/DEL/KEYS/SCAN/TYPE/TTL/HGETALL/LRANGE/SMEMBERS/ZRANGE)", cmd), TimeMs: time.Since(start).Milliseconds()} + // 未点名的命令走通用 Do,避免 CLI 只能跑白名单。 + doArgs := append([]interface{}{cmd}, strsToAnys(args)...) + val, err := cli.Do(ctx, doArgs...).Result() + if err == redis.Nil { + return ExecResult{ + Columns: []string{"result"}, + Rows: []map[string]interface{}{{"result": "(nil)"}}, + RowCount: 1, + TimeMs: time.Since(start).Milliseconds(), + } + } + if err != nil { + return redisErr(start, err) + } + return formatRedisReply(start, val) } } +func formatRedisReply(start time.Time, val interface{}) ExecResult { + ms := time.Since(start).Milliseconds() + switch v := val.(type) { + case nil: + return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": "(nil)"}}, RowCount: 1, TimeMs: ms} + case string: + return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": v}}, RowCount: 1, TimeMs: ms} + case []byte: + return formatRedisReply(start, string(v)) + case int64: + return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": v}}, RowCount: 1, TimeMs: ms} + case float64: + return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": v}}, RowCount: 1, TimeMs: ms} + case bool: + return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": v}}, RowCount: 1, TimeMs: ms} + case error: + return ExecResult{Error: v.Error(), TimeMs: ms} + case []interface{}: + if len(v) == 0 { + return ExecResult{Columns: []string{"value"}, Rows: []map[string]interface{}{}, RowCount: 0, TimeMs: ms} + } + if kvRows := redisPairRows(v); kvRows != nil { + return ExecResult{Columns: []string{"field", "value"}, Rows: kvRows, RowCount: len(kvRows), TimeMs: ms} + } + rows := make([]map[string]interface{}, 0, len(v)) + for i, item := range v { + rows = append(rows, map[string]interface{}{"index": i, "value": normalizeRedisReply(item)}) + } + return ExecResult{Columns: []string{"index", "value"}, Rows: rows, RowCount: len(rows), TimeMs: ms} + case map[string]interface{}: + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + rows := make([]map[string]interface{}, 0, len(keys)) + for _, k := range keys { + rows = append(rows, map[string]interface{}{"field": k, "value": normalizeRedisReply(v[k])}) + } + return ExecResult{Columns: []string{"field", "value"}, Rows: rows, RowCount: len(rows), TimeMs: ms} + default: + return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": normalizeRedisReply(v)}}, RowCount: 1, TimeMs: ms} + } +} + +func redisPairRows(v []interface{}) []map[string]interface{} { + if len(v) < 2 || len(v)%2 != 0 { + return nil + } + for _, item := range v { + switch item.(type) { + case string, []byte: + default: + return nil + } + } + rows := make([]map[string]interface{}, 0, len(v)/2) + for i := 0; i+1 < len(v); i += 2 { + rows = append(rows, map[string]interface{}{ + "field": normalizeRedisReply(v[i]), + "value": normalizeRedisReply(v[i+1]), + }) + } + return rows +} + +func normalizeRedisReply(v interface{}) interface{} { + switch t := v.(type) { + case nil: + return "(nil)" + case []byte: + return string(t) + case []interface{}: + parts := make([]string, len(t)) + for i, x := range t { + parts[i] = fmt.Sprintf("%v", normalizeRedisReply(x)) + } + return strings.Join(parts, " ") + default: + return normalize(t) + } +} + +func strsToAnys(ss []string) []interface{} { + out := make([]interface{}, len(ss)) + for i, s := range ss { + out[i] = s + } + return out +} + +// splitRedisCmd 按 shell 词法切分:空格分隔,单/双引号分组,反斜杠转义。 func splitRedisCmd(s string) []string { var out []string var cur strings.Builder - inQ := false + inS, inD := false, false esc := false for _, r := range s { switch { @@ -263,9 +761,11 @@ func splitRedisCmd(s string) []string { esc = false case r == '\\': esc = true - case r == '"': - inQ = !inQ - case !inQ && (r == ' ' || r == '\t' || r == '\n'): + case r == '"' && !inS: + inD = !inD + case r == '\'' && !inD: + inS = !inS + case !inS && !inD && (r == ' ' || r == '\t' || r == '\n'): if cur.Len() > 0 { out = append(out, cur.String()) cur.Reset() @@ -289,13 +789,13 @@ func IsRedisDriver(d string) bool { return isRedis(d) } -func listColumnsRedis(c ConnReq, key string) ([]ColumnInfo, error) { - cli, err := openRedis(c) +func listColumnsRedis(parent context.Context, c ConnReq, key string) ([]ColumnInfo, error) { + cli, done, err := redisClientFor(c) if err != nil { return nil, err } - defer cli.Close() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer done() + ctx, cancel := reqCtxRedis(parent, 5*time.Second) defer cancel() typ, err := cli.Type(ctx, key).Result() if err != nil { @@ -348,36 +848,64 @@ func ttlSeconds(d time.Duration) int64 { return int64(d / time.Second) } +// scanAll 全量 SCAN 到 limit 截断。集群模式遍历所有 master 分片 —— +// ClusterClient.Scan 只命中单个节点,会漏掉其他分片的键。 +func scanAll(ctx context.Context, cli redis.UniversalClient, pat string, count int64, limit int) (names []string, truncated bool, err error) { + scanOne := func(c scanClient) error { + var cursor uint64 + for { + batch, next, err := c.Scan(ctx, cursor, pat, count).Result() + if err != nil { + return err + } + names = append(names, batch...) + cursor = next + if len(names) >= limit { + truncated = true + return nil + } + if cursor == 0 { + return nil + } + } + } + if cc, ok := cli.(*redis.ClusterClient); ok { + err = cc.ForEachMaster(ctx, func(ctx context.Context, mc *redis.Client) error { + return scanOne(mc) + }) + if err == nil { + // 跨分片归并排序,保证键树稳定 + sort.Strings(names) + if len(names) > limit { + names = names[:limit] + } + } + return names, truncated, err + } + err = scanOne(cli) + return names, truncated, err +} + +// scanClient Scan 的最小接口(*redis.Client 与 UniversalClient 都满足) +type scanClient interface { + Scan(ctx context.Context, cursor uint64, match string, count int64) *redis.ScanCmd +} + // ListRedisKeys SCAN + pipeline TYPE/TTL. Cap ~500. -func ListRedisKeys(c ConnReq) RedisKeysResult { +func ListRedisKeys(parent context.Context, c ConnReq) RedisKeysResult { start := time.Now() - cli, err := openRedis(c) + cli, done, err := redisClientFor(c) if err != nil { return RedisKeysResult{Keys: []RedisKeyInfo{}, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} } - defer cli.Close() - ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer done() + ctx, cancel := reqCtxRedis(parent, 12*time.Second) defer cancel() pat := redisScanPattern(c) - var names []string - var cursor uint64 - truncated := false - for { - batch, next, err := cli.Scan(ctx, cursor, pat, 200).Result() - if err != nil { - return RedisKeysResult{Keys: []RedisKeyInfo{}, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} - } - names = append(names, batch...) - cursor = next - if len(names) >= redisKeyListCap { - truncated = true - names = names[:redisKeyListCap] - break - } - if cursor == 0 { - break - } + names, truncated, err := scanAll(ctx, cli, pat, 200, redisKeyListCap) + if err != nil { + return RedisKeysResult{Keys: []RedisKeyInfo{}, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} } if names == nil { names = []string{} @@ -412,18 +940,18 @@ func ListRedisKeys(c ConnReq) RedisKeysResult { } // GetRedisKey typed value → ResultPane columns/rows. -func GetRedisKey(c ConnReq) RedisKeyValue { +func GetRedisKey(parent context.Context, c ConnReq) RedisKeyValue { start := time.Now() key := redisKeyName(c) if key == "" { return RedisKeyValue{Error: "缺少 key", TimeMs: time.Since(start).Milliseconds()} } - cli, err := openRedis(c) + cli, done, err := redisClientFor(c) if err != nil { return RedisKeyValue{Name: key, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} } - defer cli.Close() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer done() + ctx, cancel := reqCtxRedis(parent, 10*time.Second) defer cancel() typ, err := cli.Type(ctx, key).Result() @@ -465,90 +993,86 @@ func GetRedisKey(c ConnReq) RedisKeyValue { base.TimeMs = time.Since(start).Milliseconds() return base case "hash": - fields, err := cli.HGetAll(ctx, key).Result() + // HSCAN 游标分页:cursor 回传续载(0=起点/到底),页内按 field 排序保持观感 + pairs, next, err := cli.HScan(ctx, key, uint64(c.Cursor), "*", int64(limit)).Result() if err != nil { base.Error = errMsg(err) base.TimeMs = time.Since(start).Milliseconds() return base } base.Columns = []string{"field", "value"} - rows := make([]map[string]interface{}, 0, len(fields)) - for f, v := range fields { - rows = append(rows, map[string]interface{}{"field": f, "value": v}) - if len(rows) >= limit { - base.Truncated = true - break - } + rows := make([]map[string]interface{}, 0, len(pairs)/2) + for i := 0; i+1 < len(pairs); i += 2 { + rows = append(rows, map[string]interface{}{"field": pairs[i], "value": pairs[i+1]}) } + sort.Slice(rows, func(i, j int) bool { return fmt.Sprint(rows[i]["field"]) < fmt.Sprint(rows[j]["field"]) }) base.Rows = rows base.RowCount = len(rows) + base.NextCursor = int64(next) + base.Truncated = next != 0 base.TimeMs = time.Since(start).Milliseconds() return base case "list": - vals, err := cli.LRange(ctx, key, 0, int64(limit)-1).Result() + // 偏移分页:cursor = 起始下标 + offset := c.Cursor + if offset < 0 { + offset = 0 + } + vals, err := cli.LRange(ctx, key, offset, offset+int64(limit)-1).Result() if err != nil { base.Error = errMsg(err) base.TimeMs = time.Since(start).Milliseconds() return base } - truncated := false n, _ := cli.LLen(ctx, key).Result() - if n > int64(len(vals)) { - truncated = true - } base.Columns = []string{"index", "value"} rows := make([]map[string]interface{}, 0, len(vals)) for i, v := range vals { - rows = append(rows, map[string]interface{}{"index": i, "value": v}) + rows = append(rows, map[string]interface{}{"index": offset + int64(i), "value": v}) } base.Rows = rows base.RowCount = len(rows) - base.Truncated = truncated + if offset+int64(len(vals)) < n { + base.NextCursor = offset + int64(len(vals)) + base.Truncated = true + } base.TimeMs = time.Since(start).Milliseconds() return base case "set": + // SSCAN 游标分页:每页 limit,cursor 回传续载 + batch, next, err := cli.SScan(ctx, key, uint64(c.Cursor), "*", int64(limit)).Result() + if err != nil { + base.Error = errMsg(err) + base.TimeMs = time.Since(start).Milliseconds() + return base + } base.Columns = []string{"member"} - var rows []map[string]interface{} - var scursor uint64 - truncated := false - for { - batch, next, err := cli.SScan(ctx, key, scursor, "*", 200).Result() - if err != nil { - base.Error = errMsg(err) - base.TimeMs = time.Since(start).Milliseconds() - return base - } - for _, m := range batch { - rows = append(rows, map[string]interface{}{"member": m}) - if len(rows) >= limit { - truncated = true - break - } - } - scursor = next - if truncated || scursor == 0 { - break - } - } - if rows == nil { - rows = []map[string]interface{}{} + rows := make([]map[string]interface{}, 0, len(batch)) + for _, m := range batch { + rows = append(rows, map[string]interface{}{"member": m}) } base.Rows = rows base.RowCount = len(rows) - base.Truncated = truncated + base.NextCursor = int64(next) + base.Truncated = next != 0 base.TimeMs = time.Since(start).Milliseconds() return base case "zset": - vals, err := cli.ZRangeWithScores(ctx, key, 0, int64(limit-1)).Result() + // 偏移分页(按 score 升序):cursor = 起始排名 + zoffset := c.Cursor + if zoffset < 0 { + zoffset = 0 + } + vals, err := cli.ZRangeWithScores(ctx, key, zoffset, zoffset+int64(limit)-1).Result() if err != nil { base.Error = errMsg(err) base.TimeMs = time.Since(start).Milliseconds() return base } - truncated := false n, _ := cli.ZCard(ctx, key).Result() - if n > int64(len(vals)) { - truncated = true + if zoffset+int64(len(vals)) < n { + base.NextCursor = zoffset + int64(len(vals)) + base.Truncated = true } base.Columns = []string{"score", "member"} rows := make([]map[string]interface{}, 0, len(vals)) @@ -557,7 +1081,6 @@ func GetRedisKey(c ConnReq) RedisKeyValue { } base.Rows = rows base.RowCount = len(rows) - base.Truncated = truncated base.TimeMs = time.Since(start).Milliseconds() return base default: @@ -571,27 +1094,82 @@ func GetRedisKey(c ConnReq) RedisKeyValue { } // DeleteRedisKey DEL one key. -func DeleteRedisKey(c ConnReq) ExecResult { +func DeleteRedisKey(parent context.Context, c ConnReq) ExecResult { start := time.Now() key := redisKeyName(c) if key == "" { return ExecResult{Error: "缺少 key", TimeMs: time.Since(start).Milliseconds()} } - cli, err := openRedis(c) + cli, done, err := redisClientFor(c) if err != nil { - return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} + return redisErr(start, err) } - defer cli.Close() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer done() + ctx, cancel := reqCtxRedis(parent, 5*time.Second) defer cancel() n, err := cli.Del(ctx, key).Result() if err != nil { - return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} + return redisErr(start, err) } - return ExecResult{ - Columns: []string{"affected_rows"}, - Rows: []map[string]interface{}{{"affected_rows": n}}, - RowCount: 1, - TimeMs: time.Since(start).Milliseconds(), + return redisAffected(start, n) +} + +// RedisDelPatternResult 按模式批量删除(目录删除)结果。 +type RedisDelPatternResult struct { + Deleted int `json:"deleted"` + Scanned int `json:"scanned"` + Truncated bool `json:"truncated"` + Error string `json:"error"` + TimeMs int64 `json:"timeMs"` +} + +// 目录删除安全上限:超出即拒绝,防止误配 pattern 造成删库风暴。 +const redisDelPatternCap = 10000 + +// DeleteRedisPattern 按模式批量删除键(SCAN + UNLINK 分批)。用于「删除目录」: +// 前端传 `目录前缀:*`。拒绝全库 `*`;扫描量超上限拒绝。 +func DeleteRedisPattern(parent context.Context, c ConnReq) RedisDelPatternResult { + start := time.Now() + fail := func(msg string) RedisDelPatternResult { + return RedisDelPatternResult{Error: msg, TimeMs: time.Since(start).Milliseconds()} + } + pat := redisScanPattern(c) + if pat == "*" || pat == "" { + return fail("拒绝全库模式删除(*),请指定目录前缀") + } + cli, done, err := redisClientFor(c) + if err != nil { + return fail(errMsg(err)) + } + defer done() + ctx, cancel := reqCtxRedis(parent, 60*time.Second) + defer cancel() + + names, truncated, err := scanAll(ctx, cli, pat, 500, redisDelPatternCap+1) + if err != nil { + return fail(errMsg(err)) + } + if len(names) > redisDelPatternCap { + return fail(fmt.Sprintf("匹配键 %d 个超过上限 %d,请缩小目录范围", len(names), redisDelPatternCap)) + } + total := 0 + for i := 0; i < len(names); i += 500 { + end := i + 500 + if end > len(names) { + end = len(names) + } + n, err := cli.Unlink(ctx, names[i:end]...).Result() + if err != nil { + return RedisDelPatternResult{ + Deleted: total, Scanned: len(names), Truncated: truncated, + Error: errMsg(err), TimeMs: time.Since(start).Milliseconds(), + } + } + total += int(n) + } + return RedisDelPatternResult{ + Deleted: total, Scanned: len(names), Truncated: truncated, + TimeMs: time.Since(start).Milliseconds(), } } + diff --git a/apps/server/internal/db/redis_exec_test.go b/apps/server/internal/db/redis_exec_test.go new file mode 100644 index 0000000..cb542ea --- /dev/null +++ b/apps/server/internal/db/redis_exec_test.go @@ -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) + } +} diff --git a/apps/server/internal/db/redis_keys_test.go b/apps/server/internal/db/redis_keys_test.go index 32c06bb..7f32e79 100644 --- a/apps/server/internal/db/redis_keys_test.go +++ b/apps/server/internal/db/redis_keys_test.go @@ -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) } diff --git a/apps/server/internal/db/redis_mode_test.go b/apps/server/internal/db/redis_mode_test.go new file mode 100644 index 0000000..b097f8f --- /dev/null +++ b/apps/server/internal/db/redis_mode_test.go @@ -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) + } +} diff --git a/apps/server/internal/db/search.go b/apps/server/internal/db/search.go new file mode 100644 index 0000000..d45364b --- /dev/null +++ b/apps/server/internal/db/search.go @@ -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() +} diff --git a/apps/server/internal/db/search_test.go b/apps/server/internal/db/search_test.go new file mode 100644 index 0000000..ee8fbaf --- /dev/null +++ b/apps/server/internal/db/search_test.go @@ -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) + } +} diff --git a/apps/server/internal/db/session.go b/apps/server/internal/db/session.go index ee560f0..6f42dde 100644 --- a/apps/server/internal/db/session.go +++ b/apps/server/internal/db/session.go @@ -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 } diff --git a/apps/server/internal/db/session_test.go b/apps/server/internal/db/session_test.go new file mode 100644 index 0000000..682402c --- /dev/null +++ b/apps/server/internal/db/session_test.go @@ -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 路径应报错") + } +} diff --git a/apps/server/internal/db/sqlite.go b/apps/server/internal/db/sqlite.go index 44ba578..bb79876 100644 --- a/apps/server/internal/db/sqlite.go +++ b/apps/server/internal/db/sqlite.go @@ -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 } diff --git a/apps/server/internal/db/sqlparse.go b/apps/server/internal/db/sqlparse.go new file mode 100644 index 0000000..399a956 --- /dev/null +++ b/apps/server/internal/db/sqlparse.go @@ -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() +} diff --git a/apps/server/internal/db/sqlparse_test.go b/apps/server/internal/db/sqlparse_test.go new file mode 100644 index 0000000..43f491d --- /dev/null +++ b/apps/server/internal/db/sqlparse_test.go @@ -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) + } + } +} diff --git a/apps/server/internal/db/ssh_tunnel.go b/apps/server/internal/db/ssh_tunnel.go index b9cef49..6e9d61f 100644 --- a/apps/server/internal/db/ssh_tunnel.go +++ b/apps/server/internal/db/ssh_tunnel.go @@ -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:)。 +// 改写会丢失真实 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 } diff --git a/apps/server/internal/db/ssh_tunnel_test.go b/apps/server/internal/db/ssh_tunnel_test.go new file mode 100644 index 0000000..f4314bd --- /dev/null +++ b/apps/server/internal/db/ssh_tunnel_test.go @@ -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) + } +} diff --git a/apps/server/internal/db/tls.go b/apps/server/internal/db/tls.go index 3d1e131..de5003a 100644 --- a/apps/server/internal/db/tls.go +++ b/apps/server/internal/db/tls.go @@ -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("客户端证书与密钥需同时填写") } diff --git a/apps/server/internal/db/tls_test.go b/apps/server/internal/db/tls_test.go index 4d7ebe4..ca0977e 100644 --- a/apps/server/internal/db/tls_test.go +++ b/apps/server/internal/db/tls_test.go @@ -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 { diff --git a/apps/server/internal/db/types.go b/apps/server/internal/db/types.go index 74ac725..403863d 100644 --- a/apps/server/internal/db/types.go +++ b/apps/server/internal/db/types.go @@ -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"` } diff --git a/apps/server/internal/db/types_test.go b/apps/server/internal/db/types_test.go new file mode 100644 index 0000000..eaaa2f7 --- /dev/null +++ b/apps/server/internal/db/types_test.go @@ -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 应报错") + } +} diff --git a/apps/server/internal/docs/content/01-intro.md b/apps/server/internal/docs/content/01-intro.md index 18016cb..beb50bc 100644 --- a/apps/server/internal/docs/content/01-intro.md +++ b/apps/server/internal/docs/content/01-intro.md @@ -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 二进制 + 一份前端,内网可部署,无遥测无账号。 diff --git a/apps/server/internal/docs/content/02-quickstart.md b/apps/server/internal/docs/content/02-quickstart.md index f21079a..3115bd4 100644 --- a/apps/server/internal/docs/content/02-quickstart.md +++ b/apps/server/internal/docs/content/02-quickstart.md @@ -4,17 +4,18 @@ 桌面版(主交付形态)双击 OpsxDB 应用即可,本地后端随应用自动拉起。 -从源码跑 Web / 桌面形态: +从源码运行(开发): ```bash git clone 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. 新建连接 diff --git a/apps/server/internal/docs/content/03-interface.md b/apps/server/internal/docs/content/03-interface.md index 1a0a30f..a26b026 100644 --- a/apps/server/internal/docs/content/03-interface.md +++ b/apps/server/internal/docs/content/03-interface.md @@ -24,6 +24,7 @@ OpsxDB 的界面为「高频操作」设计:从打开应用到看到数据, - 点**表名**:直接打开前 100 行数据; - 展开**表**:查看列结构; +- Redis:按键分隔符编文件夹,点键打开类型化详情(TTL / JSON / 增删字段); - 库列表懒加载,大实例也不卡。 ## 编辑器与结果 diff --git a/apps/server/internal/docs/content/04-connect.md b/apps/server/internal/docs/content/04-connect.md index 75f5e76..9fca014 100644 --- a/apps/server/internal/docs/content/04-connect.md +++ b/apps/server/internal/docs/content/04-connect.md @@ -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`,账密不会随每次请求上送;密码在本机混淆存储。 diff --git a/apps/server/internal/docs/content/06-workbench.md b/apps/server/internal/docs/content/06-workbench.md index c858fac..3251b09 100644 --- a/apps/server/internal/docs/content/06-workbench.md +++ b/apps/server/internal/docs/content/06-workbench.md @@ -4,6 +4,7 @@ SQL 工作台是日常使用率最高的界面,所有交互围绕「快」设 - **⌘/Ctrl + Enter**:执行光标所在的**当前语句**;有选区时执行**选区**; - 不是「整页脚本」模式 —— 多条语句互不干扰,想跑哪句把光标放哪; +- Redis 连接共用同一编辑器,按**行**执行命令(见 [Redis 工作台](redis)),不另开 CLI; - 执行在会话上运行,连接断开后自动提示。 ## 多 Tab 与分栏 diff --git a/apps/server/internal/docs/content/08-redis.md b/apps/server/internal/docs/content/08-redis.md index f6b8e97..850a1a9 100644 --- a/apps/server/internal/docs/content/08-redis.md +++ b/apps/server/internal/docs/content/08-redis.md @@ -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` 环境缺口。 diff --git a/apps/server/internal/docs/content/11-faq.md b/apps/server/internal/docs/content/11-faq.md index 43721c3..53e7e8a 100644 --- a/apps/server/internal/docs/content/11-faq.md +++ b/apps/server/internal/docs/content/11-faq.md @@ -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 后端二进制。 diff --git a/apps/server/internal/proxy/guard.go b/apps/server/internal/proxy/guard.go deleted file mode 100644 index fc2184e..0000000 --- a/apps/server/internal/proxy/guard.go +++ /dev/null @@ -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 -} diff --git a/apps/server/internal/proxy/guard_test.go b/apps/server/internal/proxy/guard_test.go deleted file mode 100644 index 2750500..0000000 --- a/apps/server/internal/proxy/guard_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/apps/server/internal/proxy/handler.go b/apps/server/internal/proxy/handler.go deleted file mode 100644 index 8fa1a87..0000000 --- a/apps/server/internal/proxy/handler.go +++ /dev/null @@ -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 - } -} diff --git a/apps/server/internal/proxy/types.go b/apps/server/internal/proxy/types.go deleted file mode 100644 index e945925..0000000 --- a/apps/server/internal/proxy/types.go +++ /dev/null @@ -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"` -} diff --git a/apps/server/internal/server/auth.go b/apps/server/internal/server/auth.go new file mode 100644 index 0000000..afbe310 --- /dev/null +++ b/apps/server/internal/server/auth.go @@ -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 ",失败 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": "未授权"}) + } +} diff --git a/apps/server/internal/server/router.go b/apps/server/internal/server/router.go index eb8d024..acf5d20 100644 --- a/apps/server/internal/server/router.go +++ b/apps/server/internal/server/router.go @@ -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": {}, } diff --git a/apps/server/internal/server/router_test.go b/apps/server/internal/server/router_test.go new file mode 100644 index 0000000..2f59f5d --- /dev/null +++ b/apps/server/internal/server/router_test.go @@ -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) + } +} diff --git a/apps/server/internal/site/template.html b/apps/server/internal/site/template.html index 0922041..5e9cb96 100644 --- a/apps/server/internal/site/template.html +++ b/apps/server/internal/site/template.html @@ -105,7 +105,7 @@
7内置驱动
18可连数据库
0数据外发
-
1个二进制即可自托管
+
1个二进制内置官网与文档
@@ -196,7 +196,7 @@

三种形态,同一套体验

-

主交付是桌面 GUI;Web 与内网部署复用同一个 Go 二进制。

+

客户端唯一形态是桌面 GUI;官网与文档内置在同一个 Go 二进制里。

-
Web 自托管
-
1 个二进制
-
Gin + 内置静态页
+
官网 + 文档
+
内置
+
随二进制提供,无需部署
    -
  • ✓ ./run.sh web 一条命令启动
  • -
  • ✓ 官网与文档随二进制内置
  • -
  • ✓ 浏览器直接用,无需安装
  • -
  • ✓ 适合快速体验与调试
  • +
  • ✓ 官网 / 与文档 /docs 内置于后端
  • +
  • ✓ 文档支持全文搜索 / 打印 / 导出 PDF
  • +
  • ✓ 浏览器只看站点与文档,不承载客户端
  • +
  • ✓ 随版本发布自动更新
- 查看快速开始 + 浏览文档
内网部署
@@ -246,8 +246,8 @@

常见问题

我的连接密码保存在哪?安全吗?

全部保存在本机(混淆存储),查询时只携带会话 ID,密码不会随每次请求上送,也不会同步到任何云端。

-
浏览器里打开的 5174 端口是什么?

那是开发调试用的 Vite 热更新壳,不是产品形态。正式形态是桌面应用(或自托管的 Web 服务),由后端 :8788 提供页面与 API。

-
和 DBeaver / Navicat 有什么差别?

OpsxDB 面向运维与开发的日常高频操作(连库、点表、写 SQL、取结果),界面密度和快捷键为「快」设计;本地优先、单二进制自托管、内置中文文档。重度管理功能(ER 图、数据比对等)在路线图中。

+
能在浏览器里操作数据库吗?

不能。客户端唯一形态是桌面应用(Tauri 2);浏览器只承载官网与文档,由后端 :8788 内置提供。开发时的 :5174 只是桌面 devUrl 的资产服务。

+
和 DBeaver / Navicat 有什么差别?

OpsxDB 面向运维与开发的日常高频操作(连库、点表、写 SQL、取结果),界面密度和快捷键为「快」设计;本地优先、后端随桌面应用拉起、内置中文文档。重度管理功能(ER 图、数据比对等)在路线图中。

Oracle 支持好了吗?

驱动代码已就绪(纯 Go 的 go-ora,无需 JVM),但本机 docker 验收在 Apple Silicon 上受镜像限制未通过,需 x86 环境或真库完成验收,详见文档《驱动支持》。

AI 功能需要联网吗?

当前版本 AI 面板为本地 stub(生成 / 解释 / 优化等动作的框架已就位),接入模型服务的版本会明确标注数据流向。

支持达梦 / MongoDB 吗?

已列入路线:常用库 native 内置,长尾库(达梦、MongoDB、ZooKeeper、ES、InfluxDB)规划 agent 按需接入,驱动分发走独立仓库并校验 sha256。

diff --git a/apps/web/index.html b/apps/web/index.html index 5d0b739..da0ae42 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,7 +3,7 @@ - + OpsxDB diff --git a/apps/web/package.json b/apps/web/package.json index 6c52170..15a1a13 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 71f95e6..5f42dc1 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -3,8 +3,43 @@ import ReactDOM from "react-dom/client"; import { OpsxWorkspace } from "@opsxdb/ui"; import "./styles.css"; -ReactDOM.createRoot(document.getElementById("root")!).render( - - - , -); +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( +
+
+
🗄️
+

OpsxDB 请在桌面应用中打开

+

+ 客户端唯一形态是桌面 App(Tauri 2),本页面仅为桌面端构建产物。 +
+ 官网与文档由本地后端提供: + 官网 ·{" "} + 文档 +

+
+
, + ); +} else { + ReactDOM.createRoot(root).render( + + + , + ); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index b868a70..61deba2 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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); diff --git a/apps/web/src/styles.css.bak b/apps/web/src/styles.css.bak deleted file mode 100644 index 8cebfc3..0000000 --- a/apps/web/src/styles.css.bak +++ /dev/null @@ -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; - } -} diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index 6250766..db2c1dd 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -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: [], diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a698be4..696f6b6 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -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: { diff --git a/connection-types/connection-types.json b/connection-types/connection-types.json index 254dc4c..50a4b47 100644 --- a/connection-types/connection-types.json +++ b/connection-types/connection-types.json @@ -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" } ] -} +} \ No newline at end of file diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md new file mode 100644 index 0000000..b08a3db --- /dev/null +++ b/docs/BACKLOG.md @@ -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 ` → 测连 → 查询。 + +--- + +## 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 流程。 diff --git a/package-lock.json b/package-lock.json index 92cad05..4f6e7ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a877840..104f44a 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/packages/core/package.json b/packages/core/package.json deleted file mode 100644 index 8f5adcf..0000000 --- a/packages/core/package.json +++ /dev/null @@ -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" - } -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts deleted file mode 100644 index 9849430..0000000 --- a/packages/core/src/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type { - HttpMethod, - HttpSpec, - WsSpec, - HttpResult, - WsSession, - Transport, -} from "./types"; - -export { replaceVariables, replaceVariablesInSpec } from "./replaceVariables"; diff --git a/packages/core/src/replaceVariables.test.ts b/packages/core/src/replaceVariables.test.ts deleted file mode 100644 index b27a932..0000000 --- a/packages/core/src/replaceVariables.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/packages/core/src/replaceVariables.ts b/packages/core/src/replaceVariables.ts deleted file mode 100644 index 4ce99f5..0000000 --- a/packages/core/src/replaceVariables.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Postman-style {{var}} replacement. - * Unknown variables are left as-is. - */ -export function replaceVariables( - input: string, - env: Record, -): 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>( - spec: T, - env: Record, -): 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 = {}; - for (const [k, val] of Object.entries(v as Record)) { - out[k] = walk(val); - } - return out; - } - return v; - }; - return walk(spec) as T; -} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts deleted file mode 100644 index e7f3ca9..0000000 --- a/packages/core/src/types.ts +++ /dev/null @@ -1,39 +0,0 @@ -export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - -export type HttpSpec = { - method: HttpMethod; - url: string; - headers: Record; - query?: Record; - body?: { - mode: "none" | "json" | "raw" | "form" | "urlencoded"; - raw?: string; - }; - timeoutMs?: number; -}; - -export type WsSpec = { - url: string; - protocols?: string[]; - headers?: Record; -}; - -export type HttpResult = { - status: number; - headers: Record; - 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; - ws(spec: WsSpec): WsSession; -} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json deleted file mode 100644 index a47cede..0000000 --- a/packages/core/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": [ - "src" - ] -} diff --git a/packages/transport/package.json b/packages/transport/package.json deleted file mode 100644 index b50a84b..0000000 --- a/packages/transport/package.json +++ /dev/null @@ -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 - } - } -} diff --git a/packages/transport/src/browser.ts b/packages/transport/src/browser.ts deleted file mode 100644 index 2189ebd..0000000 --- a/packages/transport/src/browser.ts +++ /dev/null @@ -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 { - const out: Record = {}; - h.forEach((v, k) => { - out[k] = v; - }); - return out; -} - -export const browserTransport: Transport = { - async http(spec: HttpSpec): Promise { - 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)["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(); - }, - }; - }, -}; diff --git a/packages/transport/src/detect.ts b/packages/transport/src/detect.ts deleted file mode 100644 index 68e7be5..0000000 --- a/packages/transport/src/detect.ts +++ /dev/null @@ -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) }; -} diff --git a/packages/transport/src/ginProxy.ts b/packages/transport/src/ginProxy.ts deleted file mode 100644 index 6ffb04c..0000000 --- a/packages/transport/src/ginProxy.ts +++ /dev/null @@ -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 { - 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); - }, - }; -} diff --git a/packages/transport/src/index.ts b/packages/transport/src/index.ts deleted file mode 100644 index f1b2e22..0000000 --- a/packages/transport/src/index.ts +++ /dev/null @@ -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"; diff --git a/packages/transport/src/tauriNative.ts b/packages/transport/src/tauriNative.ts deleted file mode 100644 index 29a7744..0000000 --- a/packages/transport/src/tauriNative.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxdb/core"; - -async function invoke(cmd: string, args?: Record): Promise { - try { - const { invoke: tauriInvoke } = await import("@tauri-apps/api/core"); - return await tauriInvoke(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 { - return invoke("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("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"); - }); - }, - }; - }, -}; diff --git a/packages/transport/tsconfig.json b/packages/transport/tsconfig.json deleted file mode 100644 index a47cede..0000000 --- a/packages/transport/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": [ - "src" - ] -} diff --git a/packages/ui/assets/databases/dameng.svg b/packages/ui/assets/databases/dameng.svg new file mode 100644 index 0000000..5315156 --- /dev/null +++ b/packages/ui/assets/databases/dameng.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/assets/databases/elasticsearch.svg b/packages/ui/assets/databases/elasticsearch.svg new file mode 100644 index 0000000..0da0d70 --- /dev/null +++ b/packages/ui/assets/databases/elasticsearch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/assets/databases/gaussdb.svg b/packages/ui/assets/databases/gaussdb.svg new file mode 100644 index 0000000..f115a85 --- /dev/null +++ b/packages/ui/assets/databases/gaussdb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/assets/databases/influxdb.svg b/packages/ui/assets/databases/influxdb.svg new file mode 100644 index 0000000..a6ed507 --- /dev/null +++ b/packages/ui/assets/databases/influxdb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/assets/databases/kingbase.svg b/packages/ui/assets/databases/kingbase.svg new file mode 100644 index 0000000..9d7f076 --- /dev/null +++ b/packages/ui/assets/databases/kingbase.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/assets/databases/mariadb.svg b/packages/ui/assets/databases/mariadb.svg new file mode 100644 index 0000000..33794b1 --- /dev/null +++ b/packages/ui/assets/databases/mariadb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/assets/databases/mongodb.svg b/packages/ui/assets/databases/mongodb.svg new file mode 100644 index 0000000..8660ced --- /dev/null +++ b/packages/ui/assets/databases/mongodb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/assets/databases/mysql.svg b/packages/ui/assets/databases/mysql.svg new file mode 100644 index 0000000..853c55f --- /dev/null +++ b/packages/ui/assets/databases/mysql.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/assets/databases/oceanbase.svg b/packages/ui/assets/databases/oceanbase.svg new file mode 100644 index 0000000..2ace405 --- /dev/null +++ b/packages/ui/assets/databases/oceanbase.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/assets/databases/opengauss.svg b/packages/ui/assets/databases/opengauss.svg new file mode 100644 index 0000000..6f46971 --- /dev/null +++ b/packages/ui/assets/databases/opengauss.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/assets/databases/oracle.svg b/packages/ui/assets/databases/oracle.svg new file mode 100644 index 0000000..f5449b2 --- /dev/null +++ b/packages/ui/assets/databases/oracle.svg @@ -0,0 +1 @@ +Oracle \ No newline at end of file diff --git a/packages/ui/assets/databases/polardb.png b/packages/ui/assets/databases/polardb.png new file mode 100644 index 0000000..197ba75 Binary files /dev/null and b/packages/ui/assets/databases/polardb.png differ diff --git a/packages/ui/assets/databases/postgresql.svg b/packages/ui/assets/databases/postgresql.svg new file mode 100644 index 0000000..abacb8f --- /dev/null +++ b/packages/ui/assets/databases/postgresql.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/assets/databases/redis.svg b/packages/ui/assets/databases/redis.svg new file mode 100644 index 0000000..1981b34 --- /dev/null +++ b/packages/ui/assets/databases/redis.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/assets/databases/sqlite.svg b/packages/ui/assets/databases/sqlite.svg new file mode 100644 index 0000000..dde0558 --- /dev/null +++ b/packages/ui/assets/databases/sqlite.svg @@ -0,0 +1,7 @@ + + SQLite + + + + + diff --git a/packages/ui/assets/databases/sqlserver.svg b/packages/ui/assets/databases/sqlserver.svg new file mode 100644 index 0000000..b23e83d --- /dev/null +++ b/packages/ui/assets/databases/sqlserver.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/assets/databases/tdsql.svg b/packages/ui/assets/databases/tdsql.svg new file mode 100644 index 0000000..65627ff --- /dev/null +++ b/packages/ui/assets/databases/tdsql.svg @@ -0,0 +1,15 @@ + + + + + + + Tencent Cloud + + + + + + + + diff --git a/packages/ui/assets/databases/tidb.svg b/packages/ui/assets/databases/tidb.svg new file mode 100644 index 0000000..ee0b723 --- /dev/null +++ b/packages/ui/assets/databases/tidb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/assets/databases/zookeeper.svg b/packages/ui/assets/databases/zookeeper.svg new file mode 100644 index 0000000..a281bab --- /dev/null +++ b/packages/ui/assets/databases/zookeeper.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/package.json b/packages/ui/package.json index 7900fe2..ad67c57 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -9,14 +9,13 @@ ".": "./src/index.ts" }, "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" }, @@ -24,9 +23,14 @@ "react": "^19.0.0", "react-dom": "^19.0.0" }, + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, "devDependencies": { "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", - "typescript": "^5.8.2" + "typescript": "^5.8.2", + "vitest": "^3.0.9" } } diff --git a/packages/ui/src/AiPanel.tsx b/packages/ui/src/AiPanel.tsx index 44e2c21..06c2235 100644 --- a/packages/ui/src/AiPanel.tsx +++ b/packages/ui/src/AiPanel.tsx @@ -1,5 +1,5 @@ -import { callAi, type AiAction } from "./api"; -import { driverById } from "./catalog"; +import { useEffect, useState } from "react"; +import { callAi, getAiConfig, saveAiConfig, schemaContextFor, type AiAction } from "./api"; import { useWorkspaceStore } from "./store"; const ACTIONS: { id: AiAction; label: string }[] = [ @@ -11,6 +11,205 @@ const ACTIONS: { id: AiAction; label: string }[] = [ { id: "sample", label: "样例" }, ]; +type ContentSeg = { kind: "text" | "code"; text: string }; + +/** R2-F7:拆分 AI 回复中的 ``` 代码块(通常 sql);块外文本维持纯文本 */ +function splitContent(content: string): ContentSeg[] { + const segs: ContentSeg[] = []; + const re = /```[a-zA-Z]*\r?\n?([\s\S]*?)```/g; + let last = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(content))) { + if (m.index > last) segs.push({ kind: "text", text: content.slice(last, m.index) }); + segs.push({ kind: "code", text: m[1].replace(/\n$/, "") }); + last = m.index + m[0].length; + } + if (last < content.length) segs.push({ kind: "text", text: content.slice(last) }); + return segs.length ? segs : [{ kind: "text", text: content }]; +} + +/** R2-F7:等宽代码块 + 顶部「复制」「插入编辑器」小按钮 */ +function CodeBlock({ code }: { code: string }) { + const notify = useWorkspaceStore((s) => s.notify); + const copy = async () => { + await navigator.clipboard.writeText(code); + notify({ kind: "success", title: "已复制", message: "SQL 已复制", mode: "toast", durationMs: 1200 }); + }; + const insertToEditor = () => { + const st = useWorkspaceStore.getState(); + const tab = st.tabs.find((t) => t.id === st.activeTabId); + const cur = tab?.sql ?? ""; + st.updateActiveSql(cur.trim() ? `${cur.replace(/\s+$/, "")}\n\n${code}` : code); + notify({ kind: "success", title: "已插入编辑器", message: "SQL 已追加到当前查询", mode: "toast", durationMs: 1400 }); + }; + return ( +
+
+ + +
+
+        {code}
+      
+
+ ); +} + +/** R2-F7:AI 消息渲染 —— 代码块走等宽块,其余纯文本(无代码块时整体不变) */ +function MessageContent({ content }: { content: string }) { + const segs = splitContent(content); + if (segs.length === 1 && segs[0].kind === "text") return <>{content}; + return ( + <> + {segs.map((s, i) => + s.kind === "code" ? : {s.text}, + )} + + ); +} + +/** C4:AI 设置(baseURL / API Key / model + 保存/测试),配置持久化在服务端 ~/.opsxdb/ai.json */ +function AiSettings({ onClose }: { onClose: () => void }) { + const notify = useWorkspaceStore((s) => s.notify); + const set = useWorkspaceStore((s) => s.set); + const [provider, setProvider] = useState("openai"); + const [baseURL, setBaseURL] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [model, setModel] = useState(""); + const [hasKey, setHasKey] = useState(false); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let alive = true; + getAiConfig() + .then((cfg) => { + if (!alive) return; + setProvider(cfg.provider || "stub"); + setBaseURL(cfg.baseURL || ""); + setModel(cfg.model || ""); + setHasKey(!!cfg.hasKey); + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, []); + + const save = async () => { + setBusy(true); + try { + await saveAiConfig({ + provider, + baseURL: baseURL.trim(), + // 留空表示不改动已保存的 key(服务端掩码不回传明文) + apiKey: apiKey.trim() || undefined, + model: model.trim(), + }); + if (model.trim()) set("aiModel", model.trim()); + setHasKey(hasKey || !!apiKey.trim()); + notify({ kind: "success", title: "AI 配置", message: "已保存", mode: "toast", durationMs: 1400 }); + return true; + } catch (e) { + notify({ kind: "error", title: "AI 配置", message: e instanceof Error ? e.message : String(e), mode: "modal" }); + return false; + } finally { + setBusy(false); + } + }; + + const test = async () => { + if (!(await save())) return; + setBusy(true); + try { + const res = await callAi({ action: "ask", prompt: "用一句话回答:连接正常", model: model.trim() || undefined }); + notify({ + kind: "success", + title: "AI 连接正常", + message: res.content.slice(0, 120) || "(空响应)", + mode: "toast", + durationMs: 4000, + }); + } catch (e) { + notify({ kind: "error", title: "AI 连接失败", message: e instanceof Error ? e.message : String(e), mode: "modal" }); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ + setModel(e.target.value)} + aria-label="模型" + /> +
+ setBaseURL(e.target.value)} + aria-label="Base URL" + /> + setApiKey(e.target.value)} + aria-label="API Key" + /> +
+ + + +
+
+ ); +} + export function AiPanel() { const messages = useWorkspaceStore((s) => s.aiMessages); const ask = useWorkspaceStore((s) => s.aiAsk); @@ -24,6 +223,26 @@ export function AiPanel() { const set = useWorkspaceStore((s) => s.set); const pushAi = useWorkspaceStore((s) => s.pushAi); const updateActiveSql = useWorkspaceStore((s) => s.updateActiveSql); + const [showSettings, setShowSettings] = useState(false); + /** C4:真实配置的模型清单(来自 /api/ai/config) */ + const [cfgModel, setCfgModel] = useState(""); + + useEffect(() => { + let alive = true; + getAiConfig() + .then((cfg) => { + if (!alive) return; + if (cfg.model) { + setCfgModel(cfg.model); + const cur = useWorkspaceStore.getState().aiModel; + if (!cur || cur === "opsxdb-stub") set("aiModel", cfg.model); + } + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, [set]); const conn = connections.find((c) => c.id === activeConnectionId); const tab = tabs.find((t) => t.id === activeTabId); @@ -39,6 +258,8 @@ export function AiPanel() { pushAi("user", `${ACTIONS.find((a) => a.id === action)?.label || action}`); } try { + // C8:注入当前连接的库表列上下文(按 connId 隔离,截断 4000 字符,见 api.ts) + const schema = schemaContextFor(conn?.id, conn?.database || tab?.database); const res = await callAi({ action, prompt: text, @@ -46,6 +267,8 @@ export function AiPanel() { driver: conn?.driver, database: conn?.database || tab?.database, error: result?.error, + schema: schema || undefined, + model: model && model !== "opsxdb-stub" ? model : undefined, }); pushAi("assistant", res.content); if (res.sql) updateActiveSql(res.sql); @@ -57,6 +280,8 @@ export function AiPanel() { } }; + const modelOptions = Array.from(new Set([cfgModel, model, "opsxdb-stub"].filter(Boolean))); + return (