chore: 恢复旧布局基线(迁移改动丢失后重建,apps/packages 布局暂存)

This commit is contained in:
logan committed 2026-10-14 20:31:17 +08:00
1 parent a9510235bb
commit 456fa6954b
203 files changed
+18787 -48

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
package server
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// AuthMiddleware 本地鉴权:
// - token == "" → 直通(本机 dev 无感);
// - /health、/api/health 豁免;
// - 其余 /api/** 要求 Header "Authorization: Bearer <token>",失败 401。
func AuthMiddleware(token string) gin.HandlerFunc {
return func(c *gin.Context) {
if token == "" {
c.Next()
return
}
p := c.Request.URL.Path
if p == "/health" || p == "/api/health" {
c.Next()
return
}
if !strings.HasPrefix(p, "/api/") {
c.Next()
return
}
if c.GetHeader("Authorization") == "Bearer "+token {
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
}
}
+508
View File
@@ -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()
}
}
+374
View File
@@ -0,0 +1,374 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func doPost(t *testing.T, r http.Handler, path, body, token string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
// 鉴权矩阵:token 为空直通;token 非空时无头 401、正确头 200;health 豁免。
func TestAuthMatrix(t *testing.T) {
open := NewRouter("")
if w := doPost(t, open, "/api/db/exec", `{"driver":"sqlite","sql":"SELECT 1"}`, ""); w.Code == http.StatusUnauthorized {
t.Fatal("token 为空应直通")
}
authed := NewRouter("tok123")
if w := doPost(t, authed, "/api/db/exec", `{"driver":"sqlite","sql":"SELECT 1"}`, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("无 token 应 401,实际 %d", w.Code)
}
if w := doPost(t, authed, "/api/db/exec", `{"driver":"sqlite","sql":"SELECT 1"}`, "wrong"); w.Code != http.StatusUnauthorized {
t.Fatalf("错误 token 应 401,实际 %d", w.Code)
}
// health 豁免
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
authed.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("health 应豁免,实际 %d", w.Code)
}
var healthBody map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &healthBody); err != nil {
t.Fatal(err)
}
if healthBody["version"] == nil {
t.Fatal("health 应携带 version")
}
}
// 危险 SQL 二段流 + 确认放行(HTTP 层)。
func TestExecDangerHTTP(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "http.db")
if err := os.WriteFile(dbFile, []byte{}, 0600); err != nil {
t.Fatal(err)
}
r := NewRouter("")
mk := func(sql string, confirm bool) string {
b, _ := json.Marshal(map[string]interface{}{
"driver": "sqlite", "database": dbFile, "sql": sql, "confirmDanger": confirm,
})
return string(b)
}
w := doPost(t, r, "/api/db/exec", mk("CREATE TABLE t (id int)", false), "")
if w.Code != http.StatusOK {
t.Fatalf("建表失败: %d %s", w.Code, w.Body.String())
}
// 未确认:needConfirm 且不执行
w = doPost(t, r, "/api/db/exec", mk("DROP TABLE t", false), "")
var res struct {
Error string `json:"error"`
NeedConfirm bool `json:"needConfirm"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatal(err)
}
if !res.NeedConfirm || res.Error == "" {
t.Fatalf("应 needConfirm: %s", w.Body.String())
}
// 表仍在
w = doPost(t, r, "/api/db/exec", mk("SELECT count(*) FROM t", false), "")
if strings.Contains(w.Body.String(), `"error"`) {
t.Fatalf("DROP 不应已执行: %s", w.Body.String())
}
// 确认后放行
w = doPost(t, r, "/api/db/exec", mk("DROP TABLE t", true), "")
if strings.Contains(w.Body.String(), `"error"`) {
t.Fatalf("确认后应放行: %s", w.Body.String())
}
// 多语句绕过同样被拦
w = doPost(t, r, "/api/db/exec", mk("SELECT 1; DROP TABLE t", false), "")
if !strings.Contains(w.Body.String(), `"needConfirm":true`) {
t.Fatalf("多语句绕过应被拦截: %s", w.Body.String())
}
}
// EXPLAIN 端点:sqlite 行列计划。
func TestExplainHTTP(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "ex.db")
if err := os.WriteFile(dbFile, []byte{}, 0600); err != nil {
t.Fatal(err)
}
r := NewRouter("")
w := doPost(t, r, "/api/db/explain", `{"driver":"sqlite","database":"`+dbFile+`","sql":"SELECT 1"}`, "")
if w.Code != http.StatusOK {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
var res struct {
Rows []map[string]interface{} `json:"rows"`
Error string `json:"error"`
TimeMs int64 `json:"timeMs"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatal(err)
}
if res.Error != "" || len(res.Rows) == 0 {
t.Fatalf("sqlite explain 应有行列结果: %s", w.Body.String())
}
}
// AI 配置保存/读取(脱敏),chat 未配置回落 stub。
func TestAIConfigHTTP(t *testing.T) {
// 隔离 HOME,避免读写真实 ~/.opsxdb
tmp := t.TempDir()
t.Setenv("HOME", tmp)
r := NewRouter("")
w := doPost(t, r, "/api/ai/config", `{"provider":"openai","baseURL":"http://x.local/v1","apiKey":"sk-secret-123","model":"m1"}`, "")
if w.Code != http.StatusOK {
t.Fatalf("保存配置: %d %s", w.Code, w.Body.String())
}
// 文件权限 0600
fi, err := os.Stat(filepath.Join(tmp, ".opsxdb", "ai.json"))
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0600 {
t.Fatalf("ai.json 权限应 0600,实际 %v", fi.Mode().Perm())
}
req := httptest.NewRequest(http.MethodGet, "/api/ai/config", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
var cfg map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatal(err)
}
if cfg["hasKey"] != true || cfg["model"] != "m1" || cfg["provider"] != "openai" {
t.Fatalf("配置回读错误: %v", cfg)
}
if key, _ := cfg["apiKey"].(string); strings.Contains(key, "secret") {
t.Fatalf("apiKey 应脱敏: %v", key)
}
// 未配置 API Key 的 stub 回落(全新 HOME)
t.Setenv("HOME", t.TempDir())
w = doPost(t, r, "/api/ai/chat", `{"action":"generate","prompt":"查用户"}`, "")
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "content") {
t.Fatalf("stub 回落: %d %s", w.Code, w.Body.String())
}
}
// R2-B4:/api/db/cancel 空 requestId 400;未知 requestId cancelled=false。
func TestCancelEndpoint(t *testing.T) {
r := NewRouter("")
w := doPost(t, r, "/api/db/cancel", `{"requestId":""}`, "")
if w.Code != http.StatusBadRequest {
t.Fatalf("空 requestId 应 400,实际 %d", w.Code)
}
w = doPost(t, r, "/api/db/cancel", `{"requestId":"never-existed"}`, "")
if w.Code != http.StatusOK {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
var res struct {
OK bool `json:"ok"`
Cancelled bool `json:"cancelled"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatal(err)
}
if !res.OK || res.Cancelled {
t.Fatalf("未知 requestId 应 cancelled=false: %+v", res)
}
}
// R2-B7:/api/db/export 三种格式的响应头与内容形状。
func TestExportHTTPEndpoint(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "exp.db")
if err := os.WriteFile(dbFile, []byte{}, 0600); err != nil {
t.Fatal(err)
}
r := NewRouter("")
mk := func(fields map[string]interface{}) string {
fields["driver"] = "sqlite"
fields["database"] = dbFile
b, _ := json.Marshal(fields)
return string(b)
}
if w := doPost(t, r, "/api/db/exec", mk(map[string]interface{}{
"sql": `CREATE TABLE t_exp (id INTEGER PRIMARY KEY, name TEXT)`,
}), ""); w.Code != http.StatusOK {
t.Fatalf("建表失败: %s", w.Body.String())
}
if w := doPost(t, r, "/api/db/exec", mk(map[string]interface{}{
"sql": `INSERT INTO t_exp (id, name) VALUES (1, 'alice'), (2, 'bob')`,
}), ""); strings.Contains(w.Body.String(), `"error"`) {
t.Fatalf("插数失败: %s", w.Body.String())
}
// csv:Content-Disposition + 内容
w := doPost(t, r, "/api/db/export", mk(map[string]interface{}{
"format": "csv", "table": "t_exp",
}), "")
if w.Code != http.StatusOK {
t.Fatalf("csv 导出: %d %s", w.Code, w.Body.String())
}
cd := w.Header().Get("Content-Disposition")
if !strings.Contains(cd, `attachment; filename="export-csv-`) || !strings.HasSuffix(cd, `.csv"`) {
t.Fatalf("Content-Disposition 错误: %q", cd)
}
if ct := w.Header().Get("Content-Type"); ct != "text/csv" {
t.Fatalf("Content-Type 错误: %q", ct)
}
if !strings.Contains(w.Body.String(), "id,name\n1,alice\n2,bob") {
t.Fatalf("csv 内容错误: %q", w.Body.String())
}
// json
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
"format": "json", "sql": "SELECT id, name FROM t_exp ORDER BY id",
}), "")
if w.Code != http.StatusOK || w.Header().Get("Content-Type") != "application/json" {
t.Fatalf("json 导出: %d %q", w.Code, w.Header().Get("Content-Type"))
}
var rows []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil || len(rows) != 2 {
t.Fatalf("json 内容应可解析且 2 行: %v %s", err, w.Body.String())
}
// insert
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
"format": "insert", "table": "t_exp", "sql": "SELECT id, name FROM t_exp ORDER BY id",
}), "")
if w.Code != http.StatusOK || w.Header().Get("Content-Type") != "text/plain" {
t.Fatalf("insert 导出: %d %q", w.Code, w.Header().Get("Content-Type"))
}
if !strings.Contains(w.Body.String(), `INSERT INTO "t_exp" ("id", "name") VALUES (1, 'alice');`) {
t.Fatalf("insert 内容错误: %q", w.Body.String())
}
// limit 生效
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
"format": "csv", "table": "t_exp", "limit": 1,
}), "")
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
if len(lines) != 2 {
t.Fatalf("limit=1 应表头+1 行: %q", w.Body.String())
}
}
// R2-B7:导出安全闸门——非只读拒绝(即使已确认),危险只读语句走二段流。
func TestExportHTTPRejections(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "expr.db")
if err := os.WriteFile(dbFile, []byte{}, 0600); err != nil {
t.Fatal(err)
}
r := NewRouter("")
mk := func(fields map[string]interface{}) string {
fields["driver"] = "sqlite"
fields["database"] = dbFile
b, _ := json.Marshal(fields)
return string(b)
}
// 写操作开头:未确认也拒绝,已确认也拒绝
w := doPost(t, r, "/api/db/export", mk(map[string]interface{}{
"format": "csv", "sql": "DROP TABLE t",
}), "")
if w.Code != http.StatusBadRequest {
t.Fatalf("写操作应 400: %d", w.Code)
}
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
"format": "csv", "sql": "DELETE FROM t", "confirmDanger": true,
}), "")
if w.Code != http.StatusBadRequest || strings.Contains(w.Body.String(), "needConfirm") {
t.Fatalf("已确认写操作仍应拒绝且不带 needConfirm: %d %s", w.Code, w.Body.String())
}
// 只读但命中危险(INTO OUTFILE):未确认 → needConfirm 二段流
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
"format": "csv", "sql": "SELECT * INTO OUTFILE '/tmp/x' FROM t",
}), "")
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), `"needConfirm":true`) {
t.Fatalf("危险只读应 needConfirm: %d %s", w.Code, w.Body.String())
}
// 已确认:放行到执行层(sqlite 语法错误,但不再 needConfirm)
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
"format": "csv", "sql": "SELECT * INTO OUTFILE '/tmp/x' FROM t", "confirmDanger": true,
}), "")
if strings.Contains(w.Body.String(), "needConfirm") {
t.Fatalf("确认后不应再 needConfirm: %s", w.Body.String())
}
// 缺 sql 与 table / 非法 format
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{"format": "csv"}), "")
if w.Code != http.StatusBadRequest {
t.Fatalf("缺 sql/table 应 400: %d", w.Code)
}
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
"format": "xml", "sql": "SELECT 1",
}), "")
if w.Code != http.StatusBadRequest {
t.Fatalf("非法 format 应 400: %d", w.Code)
}
}
// R2-B5/R2-B6:/api/db/ddl 与 /api/db/search 的 HTTP 层冒烟。
func TestDDLAndSearchHTTP(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "meta.db")
if err := os.WriteFile(dbFile, []byte{}, 0600); err != nil {
t.Fatal(err)
}
r := NewRouter("")
mk := func(fields map[string]interface{}) string {
fields["driver"] = "sqlite"
fields["database"] = dbFile
b, _ := json.Marshal(fields)
return string(b)
}
doPost(t, r, "/api/db/exec", mk(map[string]interface{}{
"sql": `CREATE TABLE t_ddl (id INTEGER PRIMARY KEY, name TEXT)`,
}), "")
w := doPost(t, r, "/api/db/ddl", mk(map[string]interface{}{"table": "t_ddl"}), "")
if w.Code != http.StatusOK {
t.Fatalf("ddl: %d %s", w.Code, w.Body.String())
}
var ddlRes struct {
DDL string `json:"ddl"`
Error string `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &ddlRes); err != nil {
t.Fatal(err)
}
if ddlRes.Error != "" || !strings.Contains(ddlRes.DDL, "CREATE TABLE t_ddl") {
t.Fatalf("ddl 响应错误: %+v", ddlRes)
}
w = doPost(t, r, "/api/db/ddl", mk(map[string]interface{}{}), "")
if w.Code != http.StatusBadRequest {
t.Fatalf("缺 table 应 400: %d", w.Code)
}
w = doPost(t, r, "/api/db/search", mk(map[string]interface{}{"keyword": "ddl"}), "")
if w.Code != http.StatusOK {
t.Fatalf("search: %d %s", w.Code, w.Body.String())
}
var searchRes struct {
Results []map[string]interface{} `json:"results"`
Error string `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &searchRes); err != nil {
t.Fatal(err)
}
if searchRes.Error != "" || len(searchRes.Results) != 1 || searchRes.Results[0]["name"] != "t_ddl" {
t.Fatalf("search 响应错误: %+v", searchRes)
}
}