chore: 恢复旧布局基线(迁移改动丢失后重建,apps/packages 布局暂存)
This commit is contained in:
1 parent
a9510235bb
commit
456fa6954b
203 files changed
+18787
-48
No files matched your search
@@ -0,0 +1,35 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthMiddleware 本地鉴权:
|
||||
// - token == "" → 直通(本机 dev 无感);
|
||||
// - /health、/api/health 豁免;
|
||||
// - 其余 /api/** 要求 Header "Authorization: Bearer <token>",失败 401。
|
||||
func AuthMiddleware(token string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if token == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
p := c.Request.URL.Path
|
||||
if p == "/health" || p == "/api/health" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(p, "/api/") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if c.GetHeader("Authorization") == "Bearer "+token {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user