Files
opsxdb/apps/server/internal/server/router.go
T
logan a9510235bb
CI / Go server (push) Canceled after 0s
CI / Web + core packages (push) Canceled after 0s
CI / Desktop (Tauri) cargo check (push) Canceled after 0s
feat: 18 引擎驱动全家桶 + 驱动管理/离线包 + 设置中心 + 工作台交互打磨
驱动接入(全部本机真实例验收):
- Elasticsearch 原生 SQL API、InfluxDB 原生 HTTP+Flux(官方 JDBC 付费许可,实测弃用)
- ZooKeeper/MongoDB JVM agent(连接时自动下载官方驱动 jar,sha256 钉版本到 ~/.opsxdb/drivers/)
- 达梦 JDBC agent 桥就绪(待厂商 jar+实例);TiDB 真实例验收
- 协议族代码审查放行 PolarDB/TDSQL/OceanBase/Kingbase/GaussDB/Oracle
- 驱动能力模型:按驱动开关对象树分组/右键菜单/行编辑,NoSQL 树拍平(对象直挂库节点)

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

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

质量:
- Go 协议层/离线回退单测、E2E 门控测试(ES/ZK/Influx/Mongo/TiDB/JDBC 桥)
- 驱动注册表单一事实来源 connection-types.json,README/BACKLOG/AGENTS 同步
2026-09-22 01:07:46 +08:00

509 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"opsxdb/server/internal/ai"
"opsxdb/server/internal/db"
"opsxdb/server/internal/docs"
"opsxdb/server/internal/site"
)
// 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(), AuthMiddleware(token))
r.GET("/health", health)
r.GET("/api/health", health)
docs.New().Register(r)
site.New().Register(r)
api := r.Group("/api")
{
api.POST("/db/test-connection", func(c *gin.Context) {
req, err := bindConn(c, false)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": err.Error()})
return
}
if err := db.TestConnection(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "message": "connected"})
})
api.POST("/db/open", func(c *gin.Context) {
req, err := bindConn(c, false)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
id, err := db.OpenSession(req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "sessionId": id, "message": "connected"})
})
api.POST("/db/close", func(c *gin.Context) {
var req db.ConnReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.SessionID != "" {
db.CloseSession(req.SessionID)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
api.POST("/db/databases", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
list, err := db.ListDatabases(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"databases": list})
})
api.POST("/db/tables", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
list, err := db.ListTables(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"tables": list})
})
api.POST("/db/columns", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
list, err := db.ListColumns(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"columns": list})
})
api.POST("/db/exec", 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.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 {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !db.IsRedisDriver(req.Driver) {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅 Redis 可用"})
return
}
res := db.ListRedisKeys(c.Request.Context(), req)
if res.Error != "" {
c.JSON(http.StatusBadRequest, res)
return
}
c.JSON(http.StatusOK, res)
})
api.POST("/db/redis/key", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !db.IsRedisDriver(req.Driver) {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅 Redis 可用"})
return
}
res := db.GetRedisKey(c.Request.Context(), req)
if res.Error != "" && res.Type == "" {
c.JSON(http.StatusBadRequest, res)
return
}
c.JSON(http.StatusOK, res)
})
api.POST("/db/redis/key/delete", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !db.IsRedisDriver(req.Driver) {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅 Redis 可用"})
return
}
res := db.DeleteRedisKey(c.Request.Context(), req)
if res.Error != "" {
c.JSON(http.StatusBadRequest, res)
return
}
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
}
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
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Action = "generate"
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)
})
}
return r
}
func bindConn(c *gin.Context, allowSession bool) (db.ConnReq, error) {
var req db.ConnReq
if err := c.ShouldBindJSON(&req); err != nil {
return req, err
}
if allowSession && req.SessionID != "" {
return db.ResolveConn(req)
}
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", "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": {},
"tauri://localhost": {},
"http://tauri.localhost": {},
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if _, ok := allowed[strings.TrimSuffix(origin, "/")]; ok {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Vary", "Origin")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}