驱动接入(全部本机真实例验收): - 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 同步
509 lines
15 KiB
Go
509 lines
15 KiB
Go
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()
|
||
}
|
||
}
|