同步完整源码至远程:扁平化重构现状 + 未入库源文件
- 补入库:server/internal/db/dump.go、redis 分页/扫描预算测试、opsxdb-mcp main_test、 packages/opsxdb-mcp package-lock、docs 审计与对比文档等 37 个新文件 - 含结构扁平化进行中的 80 处修改与 155 处旧结构删除(server/ src/ src-tauri/ 为当前有效源) - 构建产物(bin/target/dist)与签名私钥仍按规则排除
This commit is contained in:
1 parent
5c0be49217
commit
2c0bfafd8d
269 files changed
+10372
-16435
No files matched your search
+28
-20
@@ -34,17 +34,17 @@ type AgentReq struct {
|
||||
|
||||
// AgentStep 挂在回复下的步骤时间线一项。
|
||||
type AgentStep struct {
|
||||
Tool string `json:"tool"`
|
||||
Args string `json:"args,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Summary string `json:"summary"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Ms int64 `json:"ms"`
|
||||
NeedConfirm bool `json:"needConfirm,omitempty"`
|
||||
ConfirmSQL string `json:"confirmSql,omitempty"`
|
||||
ConfirmBind string `json:"confirmBind,omitempty"`
|
||||
Pending bool `json:"pending,omitempty"`
|
||||
Result *AgentQueryResult `json:"result,omitempty"`
|
||||
Tool string `json:"tool"`
|
||||
Args string `json:"args,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Summary string `json:"summary"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Ms int64 `json:"ms"`
|
||||
NeedConfirm bool `json:"needConfirm,omitempty"`
|
||||
ConfirmSQL string `json:"confirmSql,omitempty"`
|
||||
ConfirmBind string `json:"confirmBind,omitempty"`
|
||||
Pending bool `json:"pending,omitempty"`
|
||||
Result *AgentQueryResult `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// AgentRes Agent 最终回答 + 步骤 + 运行结果契约。
|
||||
@@ -60,7 +60,7 @@ type AgentRes struct {
|
||||
Resumable bool `json:"resumable,omitempty"`
|
||||
StepsBudget int `json:"stepsBudget,omitempty"`
|
||||
// Trajectory 含 system/user/assistant.tool_calls/tool 的完整对话(供落盘续跑;JSON 默认省略大字段可选)
|
||||
Trajectory []Message `json:"trajectory,omitempty"`
|
||||
Trajectory []Message `json:"trajectory,omitempty"`
|
||||
}
|
||||
|
||||
// RunAgent 多轮:LLM tool_call → aitools.Runtime.Call → 回灌。
|
||||
@@ -98,6 +98,10 @@ func runAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentR
|
||||
req.SessionID = sessionID
|
||||
}
|
||||
rt.RunID = runID
|
||||
if req.ConnectionID != "" {
|
||||
rt.BoundConnectionID = req.ConnectionID
|
||||
rt.BoundDatabase = req.Database
|
||||
}
|
||||
// Agent 路径禁止模型自行带确认令牌
|
||||
rt.RejectCallerConfirm = true
|
||||
|
||||
@@ -106,14 +110,14 @@ func runAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentR
|
||||
if req.Continue && len(req.Prior) > 0 {
|
||||
msgs = append([]Message{}, req.Prior...)
|
||||
hasSystem := false
|
||||
for _, m := range msgs {
|
||||
for i, m := range msgs {
|
||||
if m.Role == "system" {
|
||||
msgs[i].Content = buildAgentSystem(req, rt.Policy)
|
||||
hasSystem = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasSystem {
|
||||
msgs = append([]Message{{Role: "system", Content: buildAgentSystem(req)}}, msgs...)
|
||||
msgs = append([]Message{{Role: "system", Content: buildAgentSystem(req, rt.Policy)}}, msgs...)
|
||||
}
|
||||
if prompt != "" {
|
||||
msgs = append(msgs, Message{Role: "user", Content: prompt})
|
||||
@@ -121,7 +125,7 @@ func runAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentR
|
||||
msgs = TrimMessages(msgs, h)
|
||||
} else {
|
||||
msgs = []Message{
|
||||
{Role: "system", Content: buildAgentSystem(req)},
|
||||
{Role: "system", Content: buildAgentSystem(req, rt.Policy)},
|
||||
{Role: "user", Content: prompt},
|
||||
}
|
||||
}
|
||||
@@ -258,18 +262,22 @@ func runAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentR
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func buildAgentSystem(req AgentReq) string {
|
||||
func buildAgentSystem(req AgentReq, policy aitools.Policy) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("你是 OpsxDB 数据库 Agent。你可以通过工具查询用户已白名单的连接。\n")
|
||||
b.WriteString("规则:\n")
|
||||
b.WriteString("1. 需要查库时必须调用工具;不要假装已执行 SQL。\n")
|
||||
b.WriteString("2. 默认只读:execute_query 以 SELECT/SHOW/EXPLAIN/DESCRIBE/WITH…SELECT 为主;Redis 以读命令为主。\n")
|
||||
if policy.WritesEnabled() {
|
||||
b.WriteString("2. 当前允许申请写操作:有写入意图时调用对应工具生成待确认命令;工具要求确认后必须停下,等待用户在界面确认,不能自行确认。\n")
|
||||
} else {
|
||||
b.WriteString("2. 当前策略为只读:仅能查询,写操作会被拒绝,也不会出现写操作确认。用户需要写入时,说明需先在设置 → MCP 选择使用默认权限,再提交任务并逐次确认。\n")
|
||||
}
|
||||
b.WriteString("3. 写操作若被拒绝并提示需要确认,向用户说明须在界面确认后执行;不要声称已写入。\n")
|
||||
b.WriteString("4. 先确认 connection_id(可用 opsxdb_list_connections);用户给出偏好连接时优先用它。\n")
|
||||
b.WriteString("4. 只使用当前会话绑定的 connection_id 和数据库;调用工具时传入它们,不要切换到其他连接或库。\n")
|
||||
b.WriteString("5. 用中文简明回答;可执行 SQL 放在 ```sql 代码块中。\n")
|
||||
b.WriteString("6. 工具结果可能截断,注意说明。\n")
|
||||
if req.ConnectionID != "" {
|
||||
b.WriteString(fmt.Sprintf("\n用户当前偏好连接 id: %s", req.ConnectionID))
|
||||
b.WriteString(fmt.Sprintf("\n当前会话绑定连接 id: %s", req.ConnectionID))
|
||||
if req.Driver != "" {
|
||||
b.WriteString(fmt.Sprintf("(%s)", req.Driver))
|
||||
}
|
||||
|
||||
@@ -183,6 +183,121 @@ func TestRunAgentRejectsWriteViaRuntime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentCannotLeaveCurrentMySQLConnectionOrDatabase(t *testing.T) {
|
||||
rt := aitools.NewRuntime(aitools.Policy{Mode: aitools.ModeReadOnly, AllowedConnectionIDs: []string{"current", "other"}, DefaultLimit: 10, MaxLimit: 20})
|
||||
rt.FindConn = func(id string) (*aitools.SavedConnection, error) {
|
||||
return &aitools.SavedConnection{ID: id, Driver: "mysql", Database: "demo"}, nil
|
||||
}
|
||||
var queries int
|
||||
rt.ExecSQL = func(_ context.Context, req db.ConnReq) db.ExecResult {
|
||||
queries++
|
||||
if req.Database != "demo" {
|
||||
t.Fatalf("wrong database: %s", req.Database)
|
||||
}
|
||||
return db.ExecResult{Columns: []string{"id"}, Rows: []map[string]interface{}{{"id": 1}}, RowCount: 1}
|
||||
}
|
||||
rt.Audit = func(aitools.AuditEntry) {}
|
||||
call := func(id, conn, database string) ToolCall {
|
||||
args, _ := json.Marshal(map[string]any{"connection_id": conn, "database": database, "sql": "SELECT id FROM users LIMIT 1"})
|
||||
return ToolCall{ID: id, Type: "function", Function: ToolCallFunc{Name: aitools.ToolExecuteQuery, Arguments: string(args)}}
|
||||
}
|
||||
llm := &scriptedLLM{turns: []ToolsTurn{
|
||||
{ToolCalls: []ToolCall{call("other", "other", "demo"), call("db", "current", "secret"), call("ok", "current", "demo")}},
|
||||
{Content: "只查询了当前库。"},
|
||||
}}
|
||||
res, err := RunAgent(context.Background(), llm, rt, AgentReq{Prompt: "查询当前库", ConnectionID: "current", Driver: "mysql", Database: "demo"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Steps) != 3 || res.Steps[0].OK || res.Steps[1].OK || !res.Steps[2].OK || queries != 1 {
|
||||
t.Fatalf("Agent escaped scope or rejected valid query: steps=%+v queries=%d", res.Steps, queries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRedisKeepOneKeyNeedsConfirmation(t *testing.T) {
|
||||
rt := aitools.NewRuntime(aitools.Policy{Mode: aitools.ModeReadWrite, AllowedConnectionIDs: []string{"redis"}})
|
||||
rt.RejectCallerConfirm = true
|
||||
rt.FindConn = func(id string) (*aitools.SavedConnection, error) {
|
||||
return &aitools.SavedConnection{ID: id, Driver: "redis", Database: "0"}, nil
|
||||
}
|
||||
var executed []string
|
||||
rt.ExecSQL = func(_ context.Context, req db.ConnReq) db.ExecResult {
|
||||
executed = append(executed, req.SQL)
|
||||
return db.ExecResult{Columns: []string{"value"}, Rows: []map[string]interface{}{{"value": 3}}, RowCount: 1}
|
||||
}
|
||||
rt.Audit = func(aitools.AuditEntry) {}
|
||||
call := func(id, command string) ToolCall {
|
||||
args, _ := json.Marshal(map[string]any{"connection_id": "redis", "command": command, "confirm_danger": true})
|
||||
return ToolCall{ID: id, Type: "function", Function: ToolCallFunc{Name: aitools.ToolExecuteRedisCommand, Arguments: string(args)}}
|
||||
}
|
||||
llm := &scriptedLLM{turns: []ToolsTurn{
|
||||
{ToolCalls: []ToolCall{call("read", "EXISTS key1 key2 key3")}},
|
||||
{ToolCalls: []ToolCall{call("delete", "DEL key2 key3")}},
|
||||
}}
|
||||
res, err := RunAgent(context.Background(), llm, rt, AgentReq{Prompt: "key1 key2 key3 只保留 key1", ConnectionID: "redis", Driver: "redis", Database: "0"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(executed) != 1 || executed[0] != "EXISTS key1 key2 key3" || len(res.Steps) != 2 || !res.Steps[1].NeedConfirm {
|
||||
t.Fatalf("删除必须停在用户确认前: executed=%v steps=%+v", executed, res.Steps)
|
||||
}
|
||||
if err := aitools.VerifyAgentBind("redis", "0", "DEL key2 key3", res.Steps[1].ConfirmBind); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentContinueRefreshesWritePolicy(t *testing.T) {
|
||||
rt := aitools.NewRuntime(aitools.Policy{Mode: aitools.ModeReadOnly})
|
||||
llm := &scriptedLLM{turns: []ToolsTurn{{Content: "当前只读"}}}
|
||||
_, err := RunAgent(context.Background(), llm, rt, AgentReq{
|
||||
Prompt: "继续", Continue: true,
|
||||
Prior: []Message{{Role: "system", Content: "旧策略:允许写入"}, {Role: "user", Content: "只保留 key1"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(llm.seen) != 1 || !strings.Contains(llm.seen[0][0].Content, "当前策略为只读") || strings.Contains(llm.seen[0][0].Content, "旧策略") {
|
||||
t.Fatalf("续聊必须使用当前权限: %+v", llm.seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRedisReadThenWriteNeedsCurrentDBConfirmation(t *testing.T) {
|
||||
rt := aitools.NewRuntime(aitools.Policy{Mode: aitools.ModeReadWrite, AllowedConnectionIDs: []string{"redis"}, DefaultLimit: 10, MaxLimit: 20})
|
||||
rt.FindConn = func(id string) (*aitools.SavedConnection, error) {
|
||||
return &aitools.SavedConnection{ID: id, Driver: "redis", Database: "0"}, nil
|
||||
}
|
||||
var commands []string
|
||||
rt.ExecSQL = func(_ context.Context, req db.ConnReq) db.ExecResult {
|
||||
if req.Database != "1" {
|
||||
t.Fatalf("Redis DB drifted: %s", req.Database)
|
||||
}
|
||||
commands = append(commands, req.SQL)
|
||||
return db.ExecResult{Columns: []string{"value"}, Rows: []map[string]interface{}{{"value": "old"}}, RowCount: 1}
|
||||
}
|
||||
rt.Audit = func(aitools.AuditEntry) {}
|
||||
call := func(id, cmd string) ToolCall {
|
||||
args, _ := json.Marshal(map[string]any{"connection_id": "redis", "command": cmd})
|
||||
return ToolCall{ID: id, Type: "function", Function: ToolCallFunc{Name: aitools.ToolExecuteRedisCommand, Arguments: string(args)}}
|
||||
}
|
||||
llm := &scriptedLLM{turns: []ToolsTurn{
|
||||
{ToolCalls: []ToolCall{call("read", "GET smoke:key")}},
|
||||
{ToolCalls: []ToolCall{call("write", "SET smoke:key new")}},
|
||||
}}
|
||||
res, err := RunAgent(context.Background(), llm, rt, AgentReq{Prompt: "读取然后修改键", ConnectionID: "redis", Driver: "redis", Database: "1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(commands) != 1 || commands[0] != "GET smoke:key" || len(res.Steps) != 2 || !res.Steps[0].OK || !res.Steps[1].NeedConfirm {
|
||||
t.Fatalf("unexpected Redis execution/confirmation: commands=%v steps=%+v", commands, res.Steps)
|
||||
}
|
||||
if err := aitools.VerifyAgentBind("redis", "1", "SET smoke:key new", res.Steps[1].ConfirmBind); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := aitools.VerifyAgentBind("redis", "0", "SET smoke:key new", res.Steps[1].ConfirmBind); err == nil {
|
||||
t.Fatal("other DB must not reuse the confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolsTurn(t *testing.T) {
|
||||
content := "hi"
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||
|
||||
func TestValidateEndpoint(t *testing.T) {
|
||||
if err := validateEndpoint("https://api.deepseek.com/v1", false); err != nil {
|
||||
t.Fatalf("https 应直接放行: %v", err)
|
||||
@@ -98,3 +104,49 @@ func TestBuildMessagesCoversActions(t *testing.T) {
|
||||
t.Fatal("user 消息应包含 SQL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskChatsWithoutClaimingDatabaseExecution(t *testing.T) {
|
||||
request := ChatReq{Action: "ask", Prompt: "这个库里有哪些表?", Driver: "mysql", Database: "demo"}
|
||||
msgs := BuildMessages(request)
|
||||
if !strings.Contains(msgs[0].Content, "demo(mysql)") || !strings.Contains(msgs[0].Content, "不要声称已经查询") {
|
||||
t.Fatalf("Ask should carry context without pretending to query: %s", msgs[0].Content)
|
||||
}
|
||||
if strings.Contains(msgs[0].Content, "只输出可执行 SQL") {
|
||||
t.Fatal("Ask must not force a SQL response")
|
||||
}
|
||||
generate := BuildMessages(ChatReq{Action: "generate", Prompt: "查 users", Driver: "mysql", Database: "demo"})
|
||||
if !strings.Contains(generate[0].Content, "生成一条 mysql 方言的 SQL") {
|
||||
t.Fatalf("SQL generation lost its dedicated instruction: %s", generate[0].Content)
|
||||
}
|
||||
offline := chatStub(request)
|
||||
if offline.SQL != "" || !strings.Contains(offline.Content, "离线体验无法回答") {
|
||||
t.Fatalf("offline Ask must not pretend to generate or query: %+v", offline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskRemoteRequestIsChatAndReturnsNoUnrequestedSQL(t *testing.T) {
|
||||
p := NewRemoteProvider(Config{Provider: "deepseek", Protocol: ProtoChat, BaseURL: "https://api.example.test/v1", APIKey: "test-secret", Model: "test-model"})
|
||||
p.hc = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path != "/v1/chat/completions" {
|
||||
t.Fatalf("unexpected endpoint: %s", req.URL)
|
||||
}
|
||||
if req.Header.Get("Authorization") != "Bearer test-secret" {
|
||||
t.Fatal("missing auth")
|
||||
}
|
||||
var payload struct {
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []any `json:"tools"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(payload.Messages) != 2 || len(payload.Tools) != 0 || !strings.Contains(payload.Messages[0].Content, "demo(mysql)") || strings.Contains(payload.Messages[0].Content, "只输出可执行 SQL") {
|
||||
t.Fatalf("Ask should be contextual chat without tools: %+v", payload)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"当前表结构上下文不足,无法确认表名。"}}]}`)), Header: make(http.Header)}, nil
|
||||
})}
|
||||
res, err := p.Complete(context.Background(), ChatReq{Action: "ask", Prompt: "有哪些表?", Driver: "mysql", Database: "demo"})
|
||||
if err != nil || res.SQL != "" || !strings.Contains(res.Content, "无法确认表名") {
|
||||
t.Fatalf("Ask must return a plain answer without invented SQL: %+v err=%v", res, err)
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,10 @@ func BuildMessages(req ChatReq) []Message {
|
||||
sys := "你是资深数据库工程师助手,服务于数据库客户端 OpsxDB。回答使用中文,SQL 代码放在 ```sql 代码块中。"
|
||||
var task string
|
||||
switch strings.ToLower(req.Action) {
|
||||
case "generate", "ask":
|
||||
case "generate":
|
||||
task = fmt.Sprintf("根据用户描述生成一条 %s 方言的 SQL。只输出可执行 SQL 与极简说明。", driver)
|
||||
case "ask":
|
||||
task = "回答用户关于当前数据库、数据或 SQL 的问题;只有用户明确要求编写 SQL 时才给出 SQL。你没有调用数据库工具,不要声称已经查询或修改数据。"
|
||||
case "explain":
|
||||
task = "逐部分解释用户给出的 SQL:访问路径、索引使用、潜在代价。"
|
||||
case "optimize":
|
||||
|
||||
@@ -28,7 +28,12 @@ func chatStub(req ChatReq) ChatRes {
|
||||
prompt := strings.TrimSpace(req.Prompt)
|
||||
|
||||
switch strings.ToLower(req.Action) {
|
||||
case "generate", "ask":
|
||||
case "ask":
|
||||
if prompt == "" {
|
||||
prompt = "当前问题"
|
||||
}
|
||||
return ChatRes{Content: fmt.Sprintf("离线体验无法回答「%s」。请在设置 → 大模型配置 AI 供应商;如需试写 SQL,可切换为「生成 SQL」。", prompt)}
|
||||
case "generate":
|
||||
q := prompt
|
||||
if q == "" {
|
||||
q = "示例查询"
|
||||
|
||||
@@ -15,6 +15,18 @@ func ConfirmBindHash(connectionID, statement string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Agent 写操作还绑定数据库,避免确认时切换到同连接的其他库。
|
||||
func ConfirmAgentBindHash(connectionID, database, statement string) string {
|
||||
return ConfirmBindHash(connectionID, strings.TrimSpace(database)+"\n"+statement)
|
||||
}
|
||||
|
||||
func VerifyAgentBind(connectionID, database, statement, bind string) error {
|
||||
if strings.TrimSpace(bind) == "" || !strings.EqualFold(bind, ConfirmAgentBindHash(connectionID, database, statement)) {
|
||||
return fmt.Errorf("confirmBind mismatch: statement, connection or database changed; re-confirm required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyConfirmBind 校验客户端带回的 bind;空 bind 视为不合法(桌面路径必须带)。
|
||||
func VerifyConfirmBind(connectionID, statement, bind string) error {
|
||||
bind = strings.TrimSpace(bind)
|
||||
|
||||
@@ -23,6 +23,19 @@ func TestConfirmBindHashStableAndSensitive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentConfirmBindIncludesDatabase(t *testing.T) {
|
||||
bind := ConfirmAgentBindHash("c1", "demo", "DELETE FROM users WHERE id=1")
|
||||
if err := VerifyAgentBind("c1", "demo", "DELETE FROM users WHERE id=1", bind); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := VerifyAgentBind("c1", "other", "DELETE FROM users WHERE id=1", bind); err == nil {
|
||||
t.Fatal("database switch must invalidate confirmation")
|
||||
}
|
||||
if err := VerifyAgentBind("c2", "demo", "DELETE FROM users WHERE id=1", bind); err == nil {
|
||||
t.Fatal("connection switch must invalidate confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedConfirmIncludesBind(t *testing.T) {
|
||||
rt := NewRuntime(Policy{
|
||||
Mode: ModeReadWrite,
|
||||
@@ -55,3 +68,23 @@ func TestNeedConfirmIncludesBind(t *testing.T) {
|
||||
t.Fatalf("bind %s want %s", bind, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentWriteProposalBindsCurrentDatabase(t *testing.T) {
|
||||
rt := NewRuntime(Policy{Mode: ModeReadWrite, AllowedConnectionIDs: []string{"c1"}})
|
||||
rt.BoundConnectionID = "c1"
|
||||
rt.BoundDatabase = "demo"
|
||||
rt.RejectCallerConfirm = true
|
||||
rt.Audit = func(AuditEntry) {}
|
||||
statement := "DELETE FROM users WHERE id=1"
|
||||
out, err := rt.Call(nil, ToolExecuteQuery, map[string]any{"connection_id": "c1", "sql": statement, "database": "demo"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bind := out.(map[string]any)["confirmBind"].(string)
|
||||
if err := VerifyAgentBind("c1", "demo", statement, bind); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := VerifyAgentBind("c1", "other", statement, bind); err == nil {
|
||||
t.Fatal("confirmation must be scoped to demo")
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,19 @@ func SaveConnections(list []SavedConnection) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, b, 0600)
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".connections-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
if _, err := f.Write(b); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(f.Name(), path)
|
||||
}
|
||||
|
||||
func FindConnection(id string) (*SavedConnection, error) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package aitools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -101,7 +102,9 @@ func findToolVersion(name string, versionArg string) (bin, version string) {
|
||||
if bin == "" {
|
||||
return "", ""
|
||||
}
|
||||
out, err := exec.Command(bin, versionArg).CombinedOutput()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, bin, versionArg).CombinedOutput()
|
||||
if err != nil {
|
||||
return bin, ""
|
||||
}
|
||||
@@ -130,14 +133,7 @@ func lookPathAugmented(name string) string {
|
||||
"/usr/local/bin",
|
||||
)
|
||||
}
|
||||
pathEnv := os.Getenv("PATH")
|
||||
aug := strings.Join(extras, string(os.PathListSeparator))
|
||||
if pathEnv != "" {
|
||||
aug = aug + string(os.PathListSeparator) + pathEnv
|
||||
}
|
||||
cmd := exec.Command(name, "--version")
|
||||
cmd.Env = append(os.Environ(), "PATH="+aug)
|
||||
// Prefer LookPath with augmented PATH
|
||||
// 检查常见安装路径,不执行探测命令。
|
||||
for _, dir := range extras {
|
||||
cand := filepath.Join(dir, name)
|
||||
if runtime.GOOS == "windows" {
|
||||
@@ -147,15 +143,13 @@ func lookPathAugmented(name string) string {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p
|
||||
}
|
||||
_ = cmd
|
||||
return ""
|
||||
}
|
||||
|
||||
func npmGlobalPackageVersion(npmBin, pkg string) string {
|
||||
out, err := exec.Command(npmBin, "list", "-g", pkg, "--depth=0", "--json").CombinedOutput()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, npmBin, "list", "-g", pkg, "--depth=0", "--json").CombinedOutput()
|
||||
if err != nil && len(out) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Mode P0 仅支持 read_only;read_write 留给 P1(写 + 确认)。 // Logan.Li
|
||||
// Mode 控制读写权限;写操作仍须确认。
|
||||
const (
|
||||
ModeReadOnly = "read_only"
|
||||
ModeReadWrite = "read_write" // P1 才启用写路径;P0 加载后仍强制只读执行
|
||||
ModeReadWrite = "read_write"
|
||||
)
|
||||
|
||||
// Policy ~/.opsxdb/mcp.json
|
||||
@@ -19,8 +19,7 @@ type Policy struct {
|
||||
AllowedConnectionIDs []string `json:"allowed_connection_ids"`
|
||||
Mode string `json:"mode"` // read_only | read_write
|
||||
// AllowDangerous 为 true 时,在 mode=read_write 且已确认后允许 DROP/TRUNCATE/ALTER 等危险 SQL。
|
||||
// 默认 false:普通 INSERT/UPDATE/DELETE(有 WHERE)仍可写,DDL/无 WHERE 等仍拒。
|
||||
AllowDangerous bool `json:"allow_dangerous,omitempty"`
|
||||
AllowDangerous bool `json:"allow_dangerous"`
|
||||
DefaultLimit int `json:"default_limit,omitempty"`
|
||||
MaxLimit int `json:"max_limit,omitempty"`
|
||||
}
|
||||
@@ -28,7 +27,8 @@ type Policy struct {
|
||||
func DefaultPolicy() Policy {
|
||||
return Policy{
|
||||
AllowedConnectionIDs: nil,
|
||||
Mode: ModeReadOnly,
|
||||
Mode: ModeReadWrite,
|
||||
AllowDangerous: true,
|
||||
DefaultLimit: 100,
|
||||
MaxLimit: 500,
|
||||
}
|
||||
@@ -64,6 +64,9 @@ func LoadPolicy() (Policy, error) {
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
// 已有文件沿用显式配置;旧版缺少危险 SQL 字段时仍视为禁用。
|
||||
p.Mode = ModeReadOnly
|
||||
p.AllowDangerous = false
|
||||
if err := json.Unmarshal(b, &p); err != nil {
|
||||
return DefaultPolicy(), fmt.Errorf("mcp.json: %w", err)
|
||||
}
|
||||
|
||||
@@ -65,4 +65,30 @@ func TestDangerousSQLRequiresAllowFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultWritePolicyStillRequiresConfirmation(t *testing.T) {
|
||||
p := DefaultPolicy()
|
||||
if !p.WritesEnabled() || !p.AllowsDangerousDDL() {
|
||||
t.Fatal("新配置应默认允许读写和危险 SQL")
|
||||
}
|
||||
if err := p.AllowConnection("c1"); err == nil {
|
||||
t.Fatal("默认权限不能自动授权连接")
|
||||
}
|
||||
p.AllowedConnectionIDs = []string{"c1"}
|
||||
rt := NewRuntime(p)
|
||||
rt.RejectCallerConfirm = true
|
||||
rt.Audit = func(AuditEntry) {}
|
||||
rt.FindConn = func(id string) (*SavedConnection, error) {
|
||||
return &SavedConnection{ID: id, Driver: "mysql"}, nil
|
||||
}
|
||||
out, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
|
||||
"connection_id": "c1", "sql": "DROP TABLE demo", "confirm_danger": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.(map[string]any)["needConfirm"] != true {
|
||||
t.Fatalf("默认允许危险 SQL 后,Agent 仍必须等待用户确认: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool { return strings.Contains(s, sub) }
|
||||
@@ -37,6 +37,9 @@ func ToolNames() []string {
|
||||
// Runtime 可被 MCP stdio 与将来侧栏 Agent 进程内共用。 // Logan.Li
|
||||
type Runtime struct {
|
||||
Policy Policy
|
||||
// Agent 会话限定到当前编辑器上下文;MCP 外部调用保持独立的白名单策略。
|
||||
BoundConnectionID string
|
||||
BoundDatabase string
|
||||
// LoadConns / FindConn 可注入,便于单测;nil 则读磁盘。
|
||||
LoadConns func() ([]SavedConnection, error)
|
||||
FindConn func(id string) (*SavedConnection, error)
|
||||
@@ -144,6 +147,31 @@ func (r *Runtime) listCol(ctx context.Context, c db.ConnReq) ([]db.ColumnInfo, e
|
||||
|
||||
// Call 统一入口:MCP 与 Agent 都走这里。args 为 JSON object。
|
||||
func (r *Runtime) Call(ctx context.Context, tool string, args map[string]any) (any, error) {
|
||||
if r.BoundConnectionID != "" {
|
||||
if tool == ToolListConnections {
|
||||
return r.listBoundConnection(ctx)
|
||||
}
|
||||
if argString(args, "connection_id") != r.BoundConnectionID {
|
||||
return nil, fmt.Errorf("tool connection differs from the current Agent connection")
|
||||
}
|
||||
if tool == ToolListDatabases && r.BoundDatabase != "" {
|
||||
if _, err := r.resolveAllowed(r.BoundConnectionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"databases": []string{r.BoundDatabase}}, nil
|
||||
}
|
||||
if database := argString(args, "database"); database != "" && r.BoundDatabase != "" && database != r.BoundDatabase {
|
||||
return nil, fmt.Errorf("tool database differs from the current Agent database")
|
||||
}
|
||||
if r.BoundDatabase != "" {
|
||||
clone := make(map[string]any, len(args)+1)
|
||||
for k, v := range args {
|
||||
clone[k] = v
|
||||
}
|
||||
clone["database"] = r.BoundDatabase
|
||||
args = clone
|
||||
}
|
||||
}
|
||||
switch tool {
|
||||
case ToolListConnections:
|
||||
return r.listConnections(ctx)
|
||||
@@ -164,6 +192,15 @@ func (r *Runtime) Call(ctx context.Context, tool string, args map[string]any) (a
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runtime) listBoundConnection(ctx context.Context) (any, error) {
|
||||
_ = ctx
|
||||
c, err := r.resolveAllowed(r.BoundConnectionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"connections": []ConnectionSummary{c.Summary()}}, nil
|
||||
}
|
||||
|
||||
func (r *Runtime) listConnections(ctx context.Context) (any, error) {
|
||||
_ = ctx
|
||||
list, err := r.loadConns()
|
||||
@@ -250,7 +287,6 @@ func argBool(args map[string]any, key string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func (r *Runtime) listDatabases(ctx context.Context, args map[string]any) (any, error) {
|
||||
id := argString(args, "connection_id")
|
||||
c, err := r.resolveAllowed(id)
|
||||
@@ -409,12 +445,15 @@ func (r *Runtime) executeQuery(ctx context.Context, args map[string]any) (any, e
|
||||
if !confirmed {
|
||||
r.audit(AuditEntry{Tool: ToolExecuteQuery, ConnectionID: id, OK: false, Error: "needConfirm", Summary: truncate(sqlText, 80)})
|
||||
bind := ConfirmBindHash(id, sqlText)
|
||||
if r.BoundConnectionID != "" {
|
||||
bind = ConfirmAgentBindHash(id, database, sqlText)
|
||||
}
|
||||
return map[string]any{
|
||||
"needConfirm": true,
|
||||
"sql": sqlText,
|
||||
"confirmBind": bind,
|
||||
"message": "write requires explicit user confirmation",
|
||||
"reason": readReason,
|
||||
"needConfirm": true,
|
||||
"sql": sqlText,
|
||||
"confirmBind": bind,
|
||||
"message": "write requires explicit user confirmation",
|
||||
"reason": readReason,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -483,12 +522,15 @@ func (r *Runtime) executeRedis(ctx context.Context, args map[string]any) (any, e
|
||||
if !confirmed {
|
||||
r.audit(AuditEntry{Tool: ToolExecuteRedisCommand, ConnectionID: id, OK: false, Error: "needConfirm", Summary: truncate(command, 80)})
|
||||
bind := ConfirmBindHash(id, command)
|
||||
if r.BoundConnectionID != "" {
|
||||
bind = ConfirmAgentBindHash(id, database, command)
|
||||
}
|
||||
return map[string]any{
|
||||
"needConfirm": true,
|
||||
"command": command,
|
||||
"confirmBind": bind,
|
||||
"message": "redis write requires explicit user confirmation",
|
||||
"reason": rerr.Error(),
|
||||
"needConfirm": true,
|
||||
"command": command,
|
||||
"confirmBind": bind,
|
||||
"message": "redis write requires explicit user confirmation",
|
||||
"reason": rerr.Error(),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,43 @@ func TestWhitelistEmptyRejects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentBoundToolsStayOnCurrentConnectionAndDatabase(t *testing.T) {
|
||||
rt := NewRuntime(Policy{AllowedConnectionIDs: []string{"current", "other"}, Mode: ModeReadOnly})
|
||||
rt.BoundConnectionID = "current"
|
||||
rt.BoundDatabase = "demo"
|
||||
rt.Audit = func(AuditEntry) {}
|
||||
rt.FindConn = func(id string) (*SavedConnection, error) {
|
||||
return &SavedConnection{ID: id, Name: id, Driver: "mysql", Database: "default"}, nil
|
||||
}
|
||||
rt.LoadConns = func() ([]SavedConnection, error) {
|
||||
return []SavedConnection{{ID: "current"}, {ID: "other"}}, nil
|
||||
}
|
||||
var actualDatabase string
|
||||
rt.ListTbl = func(_ context.Context, req db.ConnReq) ([]string, error) {
|
||||
actualDatabase = req.Database
|
||||
return []string{"users"}, nil
|
||||
}
|
||||
res, err := rt.Call(context.Background(), ToolListConnections, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := res.(map[string]any)["connections"].([]ConnectionSummary); len(got) != 1 || got[0].ID != "current" {
|
||||
t.Fatalf("Agent should only see the bound connection: %+v", got)
|
||||
}
|
||||
for _, args := range []map[string]any{
|
||||
{"connection_id": "other", "database": "demo"},
|
||||
{"connection_id": "current", "database": "secret"},
|
||||
} {
|
||||
if _, err := rt.Call(context.Background(), ToolListTables, args); err == nil {
|
||||
t.Fatalf("out-of-scope tool call was allowed: %+v", args)
|
||||
}
|
||||
}
|
||||
_, err = rt.Call(context.Background(), ToolListTables, map[string]any{"connection_id": "current"})
|
||||
if err != nil || actualDatabase != "demo" {
|
||||
t.Fatalf("current database was not applied: database=%q err=%v", actualDatabase, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDangerousSQLBlocked(t *testing.T) {
|
||||
rt := NewRuntime(Policy{
|
||||
AllowedConnectionIDs: []string{"c1"},
|
||||
|
||||
+44
-26
@@ -18,10 +18,10 @@ import (
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"hash"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -215,13 +215,9 @@ func (b *cappedBuffer) String() string {
|
||||
// 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
|
||||
javaBin, _, err := agentJavaRuntime()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureAgentRuntime(spec); err != nil {
|
||||
return nil, err
|
||||
@@ -1019,27 +1015,21 @@ type JarStatus struct {
|
||||
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"` // 开源可自动下载
|
||||
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
|
||||
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"` // 离线驱动包是否就位
|
||||
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))
|
||||
}
|
||||
}
|
||||
_, javaVersion, javaErr := agentJavaRuntime()
|
||||
_, offlineErr := os.Stat(offlinePackPath())
|
||||
|
||||
out := []DriverStatus{}
|
||||
@@ -1057,7 +1047,7 @@ func DriversStatus() []DriverStatus {
|
||||
Runtime: "agent",
|
||||
AutoFetch: auto,
|
||||
JDKRequired: true,
|
||||
JDKOK: javaBin != "",
|
||||
JDKOK: javaErr == nil,
|
||||
JavaVersion: javaVersion,
|
||||
OfflinePack: offlineErr == nil,
|
||||
AgentInstalled: fileExists(filepath.Join(agentDir(), agentJarName)),
|
||||
@@ -1103,10 +1093,8 @@ func InstallDriver(driverKey string) error {
|
||||
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+")
|
||||
}
|
||||
if _, _, err := agentJavaRuntime(); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureAgentRuntime(spec)
|
||||
}
|
||||
@@ -1159,6 +1147,9 @@ func ImportOfflinePack(zipPath string) (int, error) {
|
||||
if !strings.HasPrefix(rel, "agent/") && !strings.HasPrefix(rel, "libs/") && !strings.HasPrefix(rel, "drivers/") {
|
||||
continue
|
||||
}
|
||||
if !filepath.IsLocal(filepath.FromSlash(rel)) {
|
||||
return imported, fmt.Errorf("离线包包含非法路径: %s", rel)
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return imported, err
|
||||
@@ -1209,3 +1200,30 @@ func parseJavaVersion(out string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// agentJavaRuntime 与实际启动使用同一 Java 路径,避免设置页把旧版 Java 误报为就绪。
|
||||
func agentJavaRuntime() (string, string, error) {
|
||||
javaBin := os.Getenv("OPSXDB_JAVA_BIN")
|
||||
if javaBin == "" {
|
||||
javaBin = "java"
|
||||
}
|
||||
path, err := exec.LookPath(javaBin)
|
||||
if err != nil {
|
||||
return "", "", errors.New("未找到 Java,请安装 Java 17+ 或设置 OPSXDB_JAVA_BIN")
|
||||
}
|
||||
out, err := exec.Command(path, "-version").CombinedOutput()
|
||||
version := parseJavaVersion(string(out))
|
||||
if err != nil {
|
||||
return path, version, fmt.Errorf("Java 运行失败: %w", err)
|
||||
}
|
||||
major := 0
|
||||
if strings.HasPrefix(version, "1.") {
|
||||
fmt.Sscanf(version, "1.%d", &major)
|
||||
} else {
|
||||
fmt.Sscanf(version, "%d", &major)
|
||||
}
|
||||
if major < 17 {
|
||||
return path, version, fmt.Errorf("Java %s 不受支持,agent 需要 Java 17+", version)
|
||||
}
|
||||
return path, version, nil
|
||||
}
|
||||
@@ -102,6 +102,46 @@ func runAgentStub() {
|
||||
// 不会走到这里(TestMain 已拦截),但名字必须存在以匹配 -test.run。
|
||||
func TestAgentStubHelper(t *testing.T) {}
|
||||
|
||||
func TestAgentJavaRuntimeVersion(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "java")
|
||||
t.Setenv("OPSXDB_JAVA_BIN", path)
|
||||
for _, tc := range []struct {
|
||||
version string
|
||||
wantOK bool
|
||||
}{
|
||||
{"1.8.0_401", false},
|
||||
{"17.0.12", true},
|
||||
{"21.0.2", true},
|
||||
} {
|
||||
script := "#!/bin/sh\necho 'openjdk version \"" + tc.version + "\"' >&2\n"
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, gotVersion, err := agentJavaRuntime()
|
||||
if (err == nil) != tc.wantOK || gotVersion != tc.version {
|
||||
t.Fatalf("Java %s: version=%q err=%v", tc.version, gotVersion, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportOfflinePackRejectsTraversal(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("OPSXDB_HOME", home)
|
||||
content := "unsafe"
|
||||
rel := "drivers/../../escape.jar"
|
||||
manifest := []map[string]string{{"path": rel, "sha256": fmt.Sprintf("%x", sha256.Sum256([]byte(content)))}}
|
||||
path := filepath.Join(home, "malicious.zip")
|
||||
if err := writeTestZip(path, manifest, map[string]string{rel: content}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ImportOfflinePack(path); err == nil {
|
||||
t.Fatal("离线驱动包不得写到驱动目录之外")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "escape.jar")); !os.IsNotExist(err) {
|
||||
t.Fatalf("导入越界文件: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func startStubAgent(t *testing.T) *AgentClient {
|
||||
t.Helper()
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestAgentStubHelper", "--", "-agent-stub")
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DumpMode 表转储范围:仅结构 / 仅数据 / 结构+数据(对齐 Navicat「转储 SQL 文件」三态)。
|
||||
const (
|
||||
DumpStructure = "structure"
|
||||
DumpData = "data"
|
||||
DumpBoth = "structure_data"
|
||||
)
|
||||
|
||||
// DumpTable 把单表转储为 .sql 写入 w(流式,数据部分复用 ExportQuery 的 insert 编码)。
|
||||
// 不自动附带 DROP TABLE,避免导入时误删既有表;注释头标注来源与时间便于追溯。
|
||||
func DumpTable(ctx context.Context, c ConnReq, w io.Writer) error {
|
||||
mode := strings.ToLower(strings.TrimSpace(c.Format))
|
||||
if mode == "" {
|
||||
mode = DumpBoth
|
||||
}
|
||||
if mode != DumpStructure && mode != DumpData && mode != DumpBoth {
|
||||
return fmt.Errorf("mode 仅支持 structure/data/structure_data")
|
||||
}
|
||||
if strings.TrimSpace(c.Table) == "" {
|
||||
return fmt.Errorf("缺少表名")
|
||||
}
|
||||
// table 支持 "库.表" 形式:拆出 database,避免整体被引号包住导致 No database selected
|
||||
if i := strings.Index(c.Table, "."); i > 0 {
|
||||
if strings.TrimSpace(c.Database) == "" {
|
||||
c.Database = c.Table[:i]
|
||||
}
|
||||
c.Table = c.Table[i+1:]
|
||||
}
|
||||
// 数据部分复用 insert 编码:值拷贝整个 ConnReq,只覆盖导出相关字段
|
||||
dataReq := c
|
||||
dataReq.Format = "insert"
|
||||
dataReq.SQL = SelectAllSQL(c.Driver, c.Table)
|
||||
if mode == DumpData {
|
||||
return ExportQuery(ctx, dataReq, w)
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "-- OpsxDB 表转储 %s\n-- 表: %s\n-- 时间: %s\n\n",
|
||||
modeLabel(mode), c.Table, time.Now().Format("2006-01-02 15:04:05"))
|
||||
ddl, err := GetDDL(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.WriteString(strings.TrimRight(ddl, ";\n"))
|
||||
b.WriteString(";\n")
|
||||
if mode == DumpBoth {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if _, err := io.WriteString(w, b.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
if mode == DumpBoth {
|
||||
if err := ExportQuery(ctx, dataReq, w); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func modeLabel(mode string) string {
|
||||
switch mode {
|
||||
case DumpStructure:
|
||||
return "· 仅结构"
|
||||
case DumpData:
|
||||
return "· 仅数据"
|
||||
default:
|
||||
return "· 结构和数据"
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func listColumnsMySQL(ctx context.Context, db *sql.DB, database, table string) (
|
||||
if err := rows.Scan(&field, &typ, &collation, &null, &key, &def, &extra, &priv, &comment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
col := ColumnInfo{Name: field, Type: typ, Nullable: null == "YES", Key: key, Comment: comment.String}
|
||||
col := ColumnInfo{Name: field, Type: typ, Nullable: null == "YES", Key: key, Comment: comment.String, Extra: extra.String}
|
||||
if def.Valid {
|
||||
col.Default = def.String
|
||||
}
|
||||
|
||||
+239
-11
@@ -2,10 +2,15 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -24,6 +29,11 @@ func redisDBIndex(c ConnReq) int {
|
||||
func normalizeRedisMode(c ConnReq) string {
|
||||
m := strings.ToLower(strings.TrimSpace(c.RedisMode))
|
||||
if m == "" {
|
||||
// 未显式选模式但填了多个节点:按 Cluster 处理(Sentinel 必须显式选并填 masterName,
|
||||
// 不会误伤)。避免"填了种子节点但忘切模式"时按单机连首节点、单键操作 MOVED 直抛。
|
||||
if len(parseRedisAddrs(c.RedisNodes)) > 1 {
|
||||
return "cluster"
|
||||
}
|
||||
return "standalone"
|
||||
}
|
||||
return m
|
||||
@@ -183,7 +193,7 @@ func testRedis(c ConnReq) error {
|
||||
defer cli.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return cli.Ping(ctx).Err()
|
||||
return redisConnectionError(c, cli.Ping(ctx).Err())
|
||||
}
|
||||
|
||||
func listDatabasesRedis(parent context.Context, c ConnReq) ([]string, error) {
|
||||
@@ -196,7 +206,7 @@ func listDatabasesRedis(parent context.Context, c ConnReq) ([]string, error) {
|
||||
ctx, cancel := reqCtxRedis(parent, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := cli.Ping(ctx).Err(); err != nil {
|
||||
return nil, err
|
||||
return nil, redisConnectionError(c, err)
|
||||
}
|
||||
// Cluster 不支持 SELECT 多库,只暴露 0
|
||||
if normalizeRedisMode(c) == "cluster" {
|
||||
@@ -264,7 +274,12 @@ func redisAffected(start time.Time, n int64) ExecResult {
|
||||
}
|
||||
|
||||
func redisErr(start time.Time, err error) ExecResult {
|
||||
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||
msg := errMsg(err)
|
||||
// 单机模式客户端不做 MOVED 重定向:给出可操作提示,引导切换部署模式
|
||||
if strings.Contains(msg, "MOVED ") || strings.Contains(msg, "ASK ") {
|
||||
msg += ";该键属于集群其他分片,当前按单机模式连接。请在连接表单把部署模式切为 Cluster 并填写种子节点。"
|
||||
}
|
||||
return ExecResult{Error: msg, TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
|
||||
func redisUsage(start time.Time, usage string) ExecResult {
|
||||
@@ -322,7 +337,7 @@ func execRedis(parent context.Context, c ConnReq) ExecResult {
|
||||
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]
|
||||
// 支持 EX 或 KEEPTTL;详情页保存需保持原键的过期时间。
|
||||
if len(args) < 2 {
|
||||
return redisUsage(start, "SET key value [EX seconds]")
|
||||
}
|
||||
@@ -334,10 +349,54 @@ func execRedis(parent context.Context, c ConnReq) ExecResult {
|
||||
}
|
||||
exp = time.Duration(n) * time.Second
|
||||
}
|
||||
if len(args) == 3 && strings.EqualFold(args[2], "KEEPTTL") {
|
||||
if err := cli.SetArgs(ctx, args[0], args[1], redis.SetArgs{KeepTTL: true}).Err(); err != nil {
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "syntax error") {
|
||||
return redisErr(start, err)
|
||||
}
|
||||
// Redis <6 无 KEEPTTL:原子读取 PTTL 再写回,避免分开的命令丢失过期时间。
|
||||
const keepTTLScript = `local kind = redis.call('TYPE', KEYS[1])
|
||||
if kind.ok ~= 'string' then return redis.error_reply('键已变化,请刷新后重试') end
|
||||
local ttl = redis.call('PTTL', KEYS[1])
|
||||
redis.call('SET', KEYS[1], ARGV[1])
|
||||
if ttl > 0 then redis.call('PEXPIRE', KEYS[1], ttl) end
|
||||
return 'OK'`
|
||||
if scriptErr := redis.NewScript(keepTTLScript).Run(ctx, cli, []string{args[0]}, args[1]).Err(); scriptErr != nil {
|
||||
return redisErr(start, scriptErr)
|
||||
}
|
||||
}
|
||||
return redisOK(start)
|
||||
}
|
||||
if len(args) > 2 && !(len(args) == 4 && strings.EqualFold(args[2], "EX")) {
|
||||
return redisUsage(start, "SET key value [EX seconds | KEEPTTL]")
|
||||
}
|
||||
if err := cli.Set(ctx, args[0], args[1], exp).Err(); err != nil {
|
||||
return redisErr(start, err)
|
||||
}
|
||||
return redisOK(start)
|
||||
case "LDELETEINDEX":
|
||||
// 内部单行操作:按索引和当前值校验后,在同一脚本中移除该位置。
|
||||
// LREM 1 value 会误删前面同值元素;随机标记避免与现有列表值冲突。
|
||||
if len(args) != 3 {
|
||||
return redisUsage(start, "LDELETEINDEX key index expectedValue")
|
||||
}
|
||||
index, parseErr := strconv.ParseInt(args[1], 10, 64)
|
||||
if parseErr != nil || index < 0 {
|
||||
return redisUsage(start, "LDELETEINDEX index 需为非负整数")
|
||||
}
|
||||
random := make([]byte, 24)
|
||||
if _, err := rand.Read(random); err != nil {
|
||||
return redisErr(start, err)
|
||||
}
|
||||
marker := "\x00opsxdb-delete:" + hex.EncodeToString(random)
|
||||
const script = `local value = redis.call('LINDEX', KEYS[1], ARGV[1])
|
||||
if value == false or value ~= ARGV[2] then return redis.error_reply('列表已变化,请刷新后重试') end
|
||||
redis.call('LSET', KEYS[1], ARGV[1], ARGV[3])
|
||||
return redis.call('LREM', KEYS[1], 1, ARGV[3])`
|
||||
if err := redis.NewScript(script).Run(ctx, cli, []string{args[0]}, index, args[2], marker).Err(); err != nil {
|
||||
return redisErr(start, err)
|
||||
}
|
||||
return redisOK(start)
|
||||
case "DEL":
|
||||
if len(args) < 1 {
|
||||
return redisUsage(start, "DEL key [key...]")
|
||||
@@ -825,6 +884,17 @@ func redisScanPattern(c ConnReq) string {
|
||||
return p
|
||||
}
|
||||
|
||||
func redisPatHasGlob(p string) bool {
|
||||
return strings.ContainsAny(p, "*?[")
|
||||
}
|
||||
|
||||
func redisBrowsePattern(p string) string {
|
||||
if p == "*" || redisPatHasGlob(p) {
|
||||
return p
|
||||
}
|
||||
return "*" + p + "*"
|
||||
}
|
||||
|
||||
func redisValueLimit(c ConnReq) int {
|
||||
n := c.Limit
|
||||
if n <= 0 {
|
||||
@@ -850,10 +920,23 @@ func ttlSeconds(d time.Duration) int64 {
|
||||
|
||||
// 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) {
|
||||
func scanAll(ctx context.Context, cli redis.UniversalClient, pat string, count int64, limit int, scanBudget ...int) (names []string, truncated bool, err error) {
|
||||
var mu sync.Mutex
|
||||
calls := 0
|
||||
scanOne := func(c scanClient) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(names) >= limit {
|
||||
truncated = true
|
||||
return nil
|
||||
}
|
||||
var cursor uint64
|
||||
for {
|
||||
if len(scanBudget) > 0 && calls >= scanBudget[0] {
|
||||
truncated = true
|
||||
return nil
|
||||
}
|
||||
calls++
|
||||
batch, next, err := c.Scan(ctx, cursor, pat, count).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -883,6 +966,9 @@ func scanAll(ctx context.Context, cli redis.UniversalClient, pat string, count i
|
||||
return names, truncated, err
|
||||
}
|
||||
err = scanOne(cli)
|
||||
if len(names) > limit {
|
||||
names = names[:limit]
|
||||
}
|
||||
return names, truncated, err
|
||||
}
|
||||
|
||||
@@ -891,7 +977,62 @@ type scanClient interface {
|
||||
Scan(ctx context.Context, cursor uint64, match string, count int64) *redis.ScanCmd
|
||||
}
|
||||
|
||||
type keyScanPosition struct {
|
||||
Pattern string `json:"p"`
|
||||
DB string `json:"d"`
|
||||
Nodes []string `json:"n"`
|
||||
Index int `json:"i"`
|
||||
Cursor uint64 `json:"c"`
|
||||
}
|
||||
|
||||
// Each page carries its shard and SCAN cursor. A complete SCAN batch is kept
|
||||
// together so a server-side COUNT hint returning >limit keys cannot drop keys.
|
||||
func scanKeyPage(ctx context.Context, clients []scanClient, pos keyScanPosition) ([]string, keyScanPosition, error) {
|
||||
names := make([]string, 0, redisKeyListCap)
|
||||
calls := 0
|
||||
for pos.Index < len(clients) && calls < 20 && len(names) < redisKeyListCap {
|
||||
batch, next, err := clients[pos.Index].Scan(ctx, pos.Cursor, pos.Pattern, 200).Result()
|
||||
if err != nil {
|
||||
return nil, pos, err
|
||||
}
|
||||
calls++
|
||||
names = append(names, batch...)
|
||||
if next == 0 {
|
||||
pos.Index++
|
||||
pos.Cursor = 0
|
||||
} else {
|
||||
pos.Cursor = next
|
||||
}
|
||||
}
|
||||
return names, pos, nil
|
||||
}
|
||||
|
||||
func keyScanClients(ctx context.Context, cli redis.UniversalClient) ([]scanClient, []string, error) {
|
||||
if cc, ok := cli.(*redis.ClusterClient); ok {
|
||||
masters := map[string]*redis.Client{}
|
||||
err := cc.ForEachMaster(ctx, func(_ context.Context, mc *redis.Client) error {
|
||||
masters[mc.Options().Addr] = mc
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
addresses := make([]string, 0, len(masters))
|
||||
for addr := range masters {
|
||||
addresses = append(addresses, addr)
|
||||
}
|
||||
sort.Strings(addresses)
|
||||
clients := make([]scanClient, 0, len(addresses))
|
||||
for _, addr := range addresses {
|
||||
clients = append(clients, masters[addr])
|
||||
}
|
||||
return clients, addresses, nil
|
||||
}
|
||||
return []scanClient{cli}, []string{"single"}, nil
|
||||
}
|
||||
|
||||
// ListRedisKeys SCAN + pipeline TYPE/TTL. Cap ~500.
|
||||
// 完整键名优先展示,同时按 *关键词* 做有预算的模糊 SCAN;截断不代表完整搜索。
|
||||
func ListRedisKeys(parent context.Context, c ConnReq) RedisKeysResult {
|
||||
start := time.Now()
|
||||
cli, done, err := redisClientFor(c)
|
||||
@@ -903,17 +1044,67 @@ func ListRedisKeys(parent context.Context, c ConnReq) RedisKeysResult {
|
||||
defer cancel()
|
||||
|
||||
pat := redisScanPattern(c)
|
||||
names, truncated, err := scanAll(ctx, cli, pat, 200, redisKeyListCap)
|
||||
scanPat := pat
|
||||
var exact *RedisKeyInfo
|
||||
exactHit := false
|
||||
if pat != "*" && pat != "" && !redisPatHasGlob(pat) {
|
||||
if typ, err := cli.Type(ctx, pat).Result(); err == nil && typ != "none" {
|
||||
ttl := int64(-2)
|
||||
if d, err := cli.TTL(ctx, pat).Result(); err == nil {
|
||||
ttl = ttlSeconds(d)
|
||||
}
|
||||
exact = &RedisKeyInfo{Name: pat, Type: typ, TTL: ttl, Exact: true}
|
||||
exactHit = true
|
||||
}
|
||||
// 无通配符按包含匹配;精确 Key 单独置顶。
|
||||
scanPat = redisBrowsePattern(pat)
|
||||
}
|
||||
|
||||
clients, nodes, err := keyScanClients(ctx, cli)
|
||||
if err != nil {
|
||||
return RedisKeysResult{Keys: []RedisKeyInfo{}, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
pos := keyScanPosition{Pattern: scanPat, DB: c.Database, Nodes: nodes}
|
||||
if c.KeyCursor != "" {
|
||||
decoded, decodeErr := base64.RawURLEncoding.DecodeString(c.KeyCursor)
|
||||
if decodeErr != nil || len(decoded) > 4096 || json.Unmarshal(decoded, &pos) != nil ||
|
||||
pos.Pattern != scanPat || pos.DB != c.Database || !equalScanNodes(pos.Nodes, nodes) ||
|
||||
pos.Index < 0 || pos.Index >= len(clients) {
|
||||
return RedisKeysResult{Keys: []RedisKeyInfo{}, Error: "搜索位置已失效,请重新搜索", TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
}
|
||||
names, pos, err := scanKeyPage(ctx, clients, pos)
|
||||
if err != nil {
|
||||
return RedisKeysResult{Keys: []RedisKeyInfo{}, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
truncated := pos.Index < len(clients)
|
||||
nextCursor := ""
|
||||
if truncated {
|
||||
encoded, _ := json.Marshal(pos)
|
||||
nextCursor = base64.RawURLEncoding.EncodeToString(encoded)
|
||||
}
|
||||
sort.Strings(names)
|
||||
if names == nil {
|
||||
names = []string{}
|
||||
}
|
||||
|
||||
out := make([]RedisKeyInfo, len(names))
|
||||
// 精确命中去重:SCAN 也可能扫到同一键
|
||||
if exact != nil && c.KeyCursor == "" {
|
||||
filtered := names[:0]
|
||||
for _, n := range names {
|
||||
if n != exact.Name {
|
||||
filtered = append(filtered, n)
|
||||
}
|
||||
}
|
||||
names = filtered
|
||||
}
|
||||
|
||||
out := make([]RedisKeyInfo, 0, len(names)+1)
|
||||
if exact != nil && c.KeyCursor == "" {
|
||||
out = append(out, *exact)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return RedisKeysResult{Keys: out, Truncated: truncated, TimeMs: time.Since(start).Milliseconds()}
|
||||
return RedisKeysResult{Keys: out, ExactHit: exactHit, Truncated: truncated, NextCursor: nextCursor, TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
|
||||
// Pipeline TYPE + TTL (avoid N+1)
|
||||
@@ -934,9 +1125,21 @@ func ListRedisKeys(parent context.Context, c ConnReq) RedisKeysResult {
|
||||
if d, err := ttlCmds[i].Result(); err == nil {
|
||||
ttl = ttlSeconds(d)
|
||||
}
|
||||
out[i] = RedisKeyInfo{Name: k, Type: typ, TTL: ttl}
|
||||
out = append(out, RedisKeyInfo{Name: k, Type: typ, TTL: ttl})
|
||||
}
|
||||
return RedisKeysResult{Keys: out, Truncated: truncated, TimeMs: time.Since(start).Milliseconds()}
|
||||
return RedisKeysResult{Keys: out, ExactHit: exactHit, Truncated: truncated, NextCursor: nextCursor, TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
|
||||
func equalScanNodes(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GetRedisKey typed value → ResultPane columns/rows.
|
||||
@@ -1152,13 +1355,29 @@ func DeleteRedisPattern(parent context.Context, c ConnReq) RedisDelPatternResult
|
||||
if len(names) > redisDelPatternCap {
|
||||
return fail(fmt.Sprintf("匹配键 %d 个超过上限 %d,请缩小目录范围", len(names), redisDelPatternCap))
|
||||
}
|
||||
// 集群下一次 UNLINK 多 key 会 CROSSSLOT;按单 key 删除。单机仍可批量。
|
||||
_, isCluster := cli.(*redis.ClusterClient)
|
||||
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()
|
||||
batch := names[i:end]
|
||||
if isCluster {
|
||||
for _, k := range batch {
|
||||
n, err := cli.Unlink(ctx, k).Result()
|
||||
if err != nil {
|
||||
return RedisDelPatternResult{
|
||||
Deleted: total, Scanned: len(names), Truncated: truncated,
|
||||
Error: errMsg(err), TimeMs: time.Since(start).Milliseconds(),
|
||||
}
|
||||
}
|
||||
total += int(n)
|
||||
}
|
||||
continue
|
||||
}
|
||||
n, err := cli.Unlink(ctx, batch...).Result()
|
||||
if err != nil {
|
||||
return RedisDelPatternResult{
|
||||
Deleted: total, Scanned: len(names), Truncated: truncated,
|
||||
@@ -1173,3 +1392,12 @@ func DeleteRedisPattern(parent context.Context, c ConnReq) RedisDelPatternResult
|
||||
}
|
||||
}
|
||||
|
||||
func redisConnectionError(c ConnReq, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if normalizeRedisMode(c) == "cluster" && strings.Contains(err.Error(), "connection refused") {
|
||||
return fmt.Errorf("Redis 集群节点拒绝连接:%w;请确认节点已启动,种子地址及 cluster-announce-ip/port 均能从当前电脑访问;远程集群返回 127.0.0.1 时需修正节点公布地址", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type pageScanner struct {
|
||||
steps map[uint64]struct {
|
||||
keys []string
|
||||
next uint64
|
||||
}
|
||||
visited []uint64
|
||||
}
|
||||
|
||||
func (s *pageScanner) Scan(ctx context.Context, cursor uint64, _ string, _ int64) *redis.ScanCmd {
|
||||
s.visited = append(s.visited, cursor)
|
||||
step := s.steps[cursor]
|
||||
cmd := redis.NewScanCmd(ctx, nil, "scan")
|
||||
cmd.SetVal(step.keys, step.next)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestKeyPageResumesAcrossMastersWithoutDroppingBatch(t *testing.T) {
|
||||
first := &pageScanner{steps: map[uint64]struct {
|
||||
keys []string
|
||||
next uint64
|
||||
}{
|
||||
0: {[]string{"a", "b"}, 17},
|
||||
17: {[]string{"c"}, 0},
|
||||
}}
|
||||
second := &pageScanner{steps: map[uint64]struct {
|
||||
keys []string
|
||||
next uint64
|
||||
}{
|
||||
0: {[]string{"d"}, 0},
|
||||
}}
|
||||
clients := []scanClient{first, second}
|
||||
// Simulate a page already at the next cursor of the first shard.
|
||||
keys, pos, err := scanKeyPage(context.Background(), clients, keyScanPosition{Pattern: "*", Index: 0, Cursor: 17})
|
||||
if err != nil || len(keys) != 2 || keys[0] != "c" || keys[1] != "d" || pos.Index != 2 || pos.Cursor != 0 {
|
||||
t.Fatalf("keys=%v pos=%+v err=%v", keys, pos, err)
|
||||
}
|
||||
if len(first.visited) != 1 || first.visited[0] != 17 || len(second.visited) != 1 || second.visited[0] != 0 {
|
||||
t.Fatalf("unexpected scan positions first=%v second=%v", first.visited, second.visited)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyPageStopsAtBudgetWithResumableCursor(t *testing.T) {
|
||||
s := &pageScanner{steps: map[uint64]struct {
|
||||
keys []string
|
||||
next uint64
|
||||
}{
|
||||
0: {nil, 1}, 1: {nil, 1},
|
||||
}}
|
||||
keys, pos, err := scanKeyPage(context.Background(), []scanClient{s}, keyScanPosition{Pattern: "rare*"})
|
||||
if err != nil || len(keys) != 0 || len(s.visited) != 20 || pos.Index != 0 || pos.Cursor != 1 {
|
||||
t.Fatalf("keys=%v calls=%d pos=%+v err=%v", keys, len(s.visited), pos, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyPageKeepsOversizedScanBatch(t *testing.T) {
|
||||
batch := make([]string, redisKeyListCap+3)
|
||||
for i := range batch {
|
||||
batch[i] = "key"
|
||||
}
|
||||
s := &pageScanner{steps: map[uint64]struct {
|
||||
keys []string
|
||||
next uint64
|
||||
}{
|
||||
0: {batch, 9}, 9: {[]string{"last"}, 0},
|
||||
}}
|
||||
keys, pos, err := scanKeyPage(context.Background(), []scanClient{s}, keyScanPosition{Pattern: "*"})
|
||||
if err != nil || len(keys) != len(batch) || pos.Cursor != 9 || pos.Index != 0 {
|
||||
t.Fatalf("first page keys=%d pos=%+v err=%v", len(keys), pos, err)
|
||||
}
|
||||
nextKeys, end, err := scanKeyPage(context.Background(), []scanClient{s}, pos)
|
||||
if err != nil || len(nextKeys) != 1 || nextKeys[0] != "last" || end.Index != 1 {
|
||||
t.Fatalf("second page keys=%v pos=%+v err=%v", nextKeys, end, err)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseRedisAddrs(t *testing.T) {
|
||||
@@ -26,3 +28,32 @@ func TestOpenRedisModeValidation(t *testing.T) {
|
||||
t.Fatalf("cluster without nodes: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRedisModeInfersClusterFromNodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
c ConnReq
|
||||
want string
|
||||
}{
|
||||
{"空配置回单机", ConnReq{}, "standalone"},
|
||||
{"显式 sentinel 保留", ConnReq{RedisMode: "Sentinel", RedisNodes: "127.0.0.1:26379"}, "sentinel"},
|
||||
{"多节点未选模式推断 cluster", ConnReq{RedisNodes: "127.0.0.1:7101,127.0.0.1:7102"}, "cluster"},
|
||||
{"单节点未选模式保持单机", ConnReq{Host: "127.0.0.1", Port: 6379, RedisNodes: "127.0.0.1:6379"}, "standalone"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := normalizeRedisMode(tc.c); got != tc.want {
|
||||
t.Fatalf("%s: want %s, got %s", tc.name, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisErrMovedHint(t *testing.T) {
|
||||
res := redisErr(time.Now(), errors.New("MOVED 12503 127.0.0.1:7103"))
|
||||
if !strings.Contains(res.Error, "部署模式切为 Cluster") {
|
||||
t.Fatalf("MOVED 错误应带模式切换提示: %q", res.Error)
|
||||
}
|
||||
res = redisErr(time.Now(), errors.New("WRONGTYPE Operation against"))
|
||||
if strings.Contains(res.Error, "Cluster") {
|
||||
t.Fatalf("普通错误不应带集群提示: %q", res.Error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type budgetRedis struct {
|
||||
redis.UniversalClient
|
||||
calls int
|
||||
batch []string
|
||||
}
|
||||
|
||||
func (c *budgetRedis) Scan(ctx context.Context, cursor uint64, match string, count int64) *redis.ScanCmd {
|
||||
c.calls++
|
||||
cmd := redis.NewScanCmd(ctx, nil, "scan")
|
||||
cmd.SetVal(c.batch, uint64(c.calls))
|
||||
return cmd
|
||||
}
|
||||
func TestRedisScanBudgetSparseMatches(t *testing.T) {
|
||||
c := &budgetRedis{}
|
||||
names, truncated, err := scanAll(context.Background(), c, "missing*", 200, 500, 20)
|
||||
if err != nil || !truncated || len(names) != 0 || c.calls != 20 {
|
||||
t.Fatalf("calls=%d names=%v truncated=%v err=%v", c.calls, names, truncated, err)
|
||||
}
|
||||
}
|
||||
func TestRedisScanCountIsNotHardLimit(t *testing.T) {
|
||||
c := &budgetRedis{batch: []string{"a", "b", "c"}}
|
||||
names, truncated, err := scanAll(context.Background(), c, "*", 1, 2, 20)
|
||||
if err != nil || !truncated || len(names) != 2 || c.calls != 1 {
|
||||
t.Fatalf("names=%v calls=%d truncated=%v err=%v", names, c.calls, truncated, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisBrowsePattern(t *testing.T) {
|
||||
for input, want := range map[string]string{"order": "*order*", "order:*": "order:*", "*": "*"} {
|
||||
if got := redisBrowsePattern(input); got != want {
|
||||
t.Fatalf("input=%q got=%q want=%q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
-37
@@ -12,19 +12,19 @@ const MaxRowLimit = 10000
|
||||
|
||||
// ConnReq 连接 / 执行请求。sessionId 有值时服务端用会话里的账密,请求里的 password 可空。
|
||||
type ConnReq struct {
|
||||
Driver string `json:"driver"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
Database string `json:"database"`
|
||||
SQL string `json:"sql"`
|
||||
Service string `json:"service"`
|
||||
SSL bool `json:"ssl"`
|
||||
SSLMode string `json:"sslMode"` // disable|require|skip-verify|verify-ca
|
||||
SSLCA string `json:"sslCA"`
|
||||
SSLCert string `json:"sslCert"`
|
||||
SSLKey string `json:"sslKey"`
|
||||
Driver string `json:"driver"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
Database string `json:"database"`
|
||||
SQL string `json:"sql"`
|
||||
Service string `json:"service"`
|
||||
SSL bool `json:"ssl"`
|
||||
SSLMode string `json:"sslMode"` // disable|require|skip-verify|verify-ca
|
||||
SSLCA string `json:"sslCA"`
|
||||
SSLCert string `json:"sslCert"`
|
||||
SSLKey string `json:"sslKey"`
|
||||
// 证书 PEM 内容,优先于上面的文件路径(客户端与服务端分离部署时免共享文件)
|
||||
SSLCAContent string `json:"sslCAContent"`
|
||||
SSLCertContent string `json:"sslCertContent"`
|
||||
@@ -36,12 +36,13 @@ type ConnReq struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Table string `json:"table"`
|
||||
Limit int `json:"limit"`
|
||||
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
|
||||
Offset int `json:"offset"` // 结果分页:跳过前 offset 行再按 limit 收集
|
||||
Key string `json:"key"` // Redis key (get/delete)
|
||||
Pattern string `json:"pattern"` // Redis SCAN match
|
||||
KeyCursor string `json:"keyCursor"` // Redis 键列表的跨主节点续扫位置
|
||||
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"`
|
||||
@@ -77,6 +78,7 @@ type ColumnInfo struct {
|
||||
Key string `json:"key,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Extra string `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
// ExecResult 与前端 QueryResult 字段对齐
|
||||
@@ -129,29 +131,32 @@ func normalize(v interface{}) interface{} {
|
||||
|
||||
// Redis key browse (dedicated endpoints; do not reshape SQL tables []).
|
||||
type RedisKeyInfo struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
TTL int64 `json:"ttl"` // seconds; -1 no expire; -2 missing
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
TTL int64 `json:"ttl"` // seconds; -1 no expire; -2 missing
|
||||
Exact bool `json:"exact,omitempty"` // TYPE 精确命中置顶
|
||||
}
|
||||
|
||||
type RedisKeysResult struct {
|
||||
Keys []RedisKeyInfo `json:"keys"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
TimeMs int64 `json:"timeMs"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Keys []RedisKeyInfo `json:"keys"`
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
ExactHit bool `json:"exactHit,omitempty"` // 无通配符时 TYPE 直接命中
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
TimeMs int64 `json:"timeMs"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RedisKeyValue feeds ResultPane (columns/rows) plus type/ttl meta.
|
||||
type RedisKeyValue struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
TTL int64 `json:"ttl"`
|
||||
Columns []string `json:"columns"`
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
RowCount int `json:"rowCount"`
|
||||
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"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
TTL int64 `json:"ttl"`
|
||||
Columns []string `json:"columns"`
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
RowCount int `json:"rowCount"`
|
||||
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"`
|
||||
}
|
||||
@@ -15,6 +15,12 @@ SQL 工作台是日常使用率最高的界面,所有交互围绕「快」设
|
||||
|
||||
## 对象树联动
|
||||
|
||||
库节点右键「新建查询」会把该库绑定到新标签。表右键可打开前 N 行(1–10000)、复制限定名、生成 SELECT / INSERT;模板打开在新查询中,不覆盖已有草稿。视图不提供 INSERT 模板。
|
||||
|
||||
执行未限定表名的 SQL 时,使用当前查询标签工具条选中的库;各标签独立保存库上下文。MySQL 系列与 SQL Server 执行 `USE 库名` 会先验证目标库,再切换该标签,脚本中的后续语句也使用新库。目标库不存在或无权限时保持原库不变,并停止该批次的后续语句。PostgreSQL 请通过工具条切换数据库;Redis 单机用 DB 选择器或 `SELECT 0`–`SELECT 15`,集群固定 DB0。
|
||||
|
||||
编辑器右键支持格式化选区或全文、注释切换、查找替换,以及复制纯文本或带连接摘要的 SQL。连接摘要不含账号密码。点击已加载元数据中的明确表名,可跳转到 DDL;无法唯一识别、别名或未加载对象会禁用跳转。
|
||||
|
||||
- 左侧对象树懒加载库 / 表 / 列;
|
||||
- **点表名 = 查询前 100 行**,自动生成对应 SQL;
|
||||
- 展开表看列名与类型,写 SQL 时可对照。
|
||||
@@ -45,3 +51,16 @@ SQL 工作台是日常使用率最高的界面,所有交互围绕「快」设
|
||||
| ⌘/Ctrl + Enter | 执行当前语句 / 选区 |
|
||||
| 双击结果单元格 | 复制单元格内容 |
|
||||
| Esc | 关闭弹层 / 取消确认 |
|
||||
|
||||
## 按对象浏览结构
|
||||
|
||||
关系库按能力显示「表、视图、存储过程、函数」。展开表可浏览字段,并按需展开索引、外键、触发器;视图可展开字段。元数据分组支持右键刷新,具体对象支持复制名称、定义或详情;失败时可重试,未适配的引擎会明确说明。当前目录查询已接入 MySQL 系列、PostgreSQL 系列和 SQLite。
|
||||
|
||||
表右键的「查看结构」打开字段结构页,「查看 DDL」查看定义,「复制建表 / 视图 SQL」直接复制定义。SELECT / INSERT 模板在新查询中打开;清空、删除仍须危险确认。
|
||||
|
||||
Redis:SQL 工具条可切换单机的 DB0–DB15,展开左侧 DB 后可在该库内搜索;底部搜索框查询当前库。输入普通关键词自动按 `*关键词*` 包含匹配,完整同名 Key 优先显示;也可输入模式(如 `user:*`)。回车或点击搜索才会请求服务端,最多 20 次 SCAN(COUNT 200、最多返回 500 项),不会使用 KEYS 枚举浏览。SCAN 仍有开销;有限搜索无结果不等于不存在,当前暂不提供续扫。集群只有 DB0,左侧直接显示 Key,隐藏多余的库层级。
|
||||
|
||||
Key 树仅在有过期时间时显示 TTL;点开 Key 的底部详情可输入正整数秒保存 TTL,`-1` 设为永久。`0` 秒会立即删除 Key,界面要求使用明确的删除操作。
|
||||
从 SQL 输入区执行 `GET`、`HGETALL`、`TTL` 等单键读取时也会打开键详情,可在底部查看并修改 TTL。
|
||||
|
||||
Redis 集群所有种子及集群公布的节点地址都需要从当前电脑可达。`127.0.0.1:7001 connection refused` 表示本机该端口拒绝连接;检查节点是否启动。若远程集群返回本机地址,应修正服务端 `cluster-announce-ip/port` 与端口映射。
|
||||
@@ -10,15 +10,23 @@
|
||||
|
||||
## 单元格操作
|
||||
|
||||
- **双击单元格**:复制该单元格内容;
|
||||
结果上方可筛选已加载的数据,并显示匹配数;筛选不会向数据库发起查询。先显示 500 行,底部「继续显示」可展开更多已加载记录。服务端仍有更多数据时使用分页或完整查询导出。
|
||||
|
||||
NULL、空字符串、空白文本分别展示。右键「查看完整值」可查看和复制长文本或 JSON;只读单元格也可双击查看详情。列头右键可排序、复制列名、适应列宽,双击列边界也可适应内容。选中单元格后可用方向键移动、Ctrl/Cmd+C 复制、Shift+F10 打开菜单。
|
||||
|
||||
右键单元格、行号或列头可复制对应单元格、行或列;行列使用不含表头的 TSV,列复制包含本地筛选后的已加载行并遵循当前排序。NULL 显示为 `NULL`,含制表符/换行的字段会加引号。导出当前结果仍包含当前页全部已加载行,不受本地筛选影响。
|
||||
|
||||
右键还提供当前行 JSON、当前结果 JSON,以及 CSV / JSON 导出。当前行 INSERT 仅在结果明确来自表预览时启用,任意查询不会猜测目标表。导出当前结果不等于导出整张表。
|
||||
|
||||
- **双击单元格**:可写表进入编辑,只读结果打开完整值详情;复制使用右键或 Ctrl/Cmd+C。
|
||||
- 列头即字段名,长文本自动截断显示,完整内容在复制时保留。
|
||||
|
||||
## 截断提示
|
||||
|
||||
为保护界面与内存,超大结果集会按阈值截断展示:
|
||||
结果展示区分已加载记录与数据库中尚未取回的记录:
|
||||
|
||||
- 结果面板顶部显示「已截断」提示与实际行数;
|
||||
- 需要完整数据时,缩小查询范围(加 LIMIT / WHERE)或使用导出。
|
||||
- 已加载记录分批显示,底部提示已显示数量,可点击「继续显示」。
|
||||
- 服务端返回更多行提示时,使用分页继续查询;需要完整数据可使用完整查询导出,也可通过 WHERE 缩小查询范围。导出当前结果仅导出已加载的当前页。
|
||||
|
||||
> **提示**:默认「点表名看前 100 行」就是截断策略的一部分 —— 快速浏览用 100 行,精确取数写 WHERE。
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ SQL 工作台已经有:多 Tab、⌘/Ctrl+Enter、查询历史、书签、危
|
||||
| 执行 | ⌘/Ctrl+Enter 当前语句或选区 | ⌘/Ctrl+Enter 当前行或选区 |
|
||||
| 结果 | 结果表 | 命令结果表;从对象树打开键则进入键详情 |
|
||||
|
||||
Another Redis Desktop Manager 的独立 CLI Tab、opsxsql 的单行命令 + 输出日志,列为后续 P1,不是现在的主路径。
|
||||
独立 CLI Tab、单行命令与输出日志可在后续评估,不属于当前主路径。
|
||||
|
||||
## 键空间浏览
|
||||
|
||||
@@ -28,7 +28,7 @@ Another Redis Desktop Manager 的独立 CLI Tab、opsxsql 的单行命令 + 输
|
||||
|
||||
## 键详情
|
||||
|
||||
对标 Another Redis Desktop Manager 的 Hash/List/Set 视图:
|
||||
Hash/List/Set 键详情支持:
|
||||
|
||||
- 类型徽章、键名(回车重命名)、TTL(回车或「保存 TTL」,`-1` 永久)、刷新、导出还原命令、删除;
|
||||
- **结构**表:Hash 为 ID / Field / Value,JSON 值自动美化;可新增字段、编辑、删除;
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
AI 面板的目标:让「写不出的 SQL」和「看不懂的 SQL」不再阻塞你。
|
||||
|
||||
## 六种动作
|
||||
## Ask 的操作
|
||||
|
||||
| 动作 | 用途 |
|
||||
| --- | --- |
|
||||
| 生成 | 自然语言 → SQL(「查最近 7 天下单但未支付的用户」) |
|
||||
| 自由提问 | 结合当前 SQL 与库结构回答问题 |
|
||||
| 生成 SQL | 自然语言 → SQL(「查最近 7 天下单但未支付的用户」) |
|
||||
| 解释 | 把一段复杂 SQL 翻译成中文说明 |
|
||||
| 优化 | 分析慢查询,给出索引 / 改写建议 |
|
||||
| 修复 | 报错信息 + SQL → 修正版本 |
|
||||
| 方言 | MySQL ↔ PostgreSQL 等跨方言转换 |
|
||||
| 样例 | 按表结构生成示例查询 |
|
||||
|
||||
|
||||
## Ask | Agent
|
||||
|
||||
侧栏输入框上方可切换模式:
|
||||
侧栏输入框下方的下拉框切换模式,默认 Ask:
|
||||
|
||||
| 模式 | 行为 |
|
||||
| --- | --- |
|
||||
| **Ask** | 只生成/解释/优化 SQL,**不执行**数据库工具 |
|
||||
| **Agent** | 多轮调用与 MCP 同源工具;步骤时间线边跑边推(SSE);可「插入编辑器」「在结果区打开」 |
|
||||
| **Ask** | 回答问题或建议 SQL;不执行数据库工具,也不自动改动 SQL 窗口。点击回复里的「复制到 SQL 窗口」后可自行执行 |
|
||||
| **Agent** | 使用与 MCP 同源的工具查询、分析当前编辑器绑定的连接与库(Redis 为当前 DB);展示步骤与结果,可在结果区打开 |
|
||||
|
||||
Agent 支持 openai-chat(DeepSeek 等)、Anthropic messages、OpenAI responses。连接须在 `~/.opsxdb/mcp.json` 白名单内。
|
||||
Agent 支持 openai-chat(DeepSeek 等)、Anthropic messages、OpenAI responses。当前连接须在 `~/.opsxdb/mcp.json` 白名单内;Agent 的工具调用限定在当前连接/库,外部 MCP 客户端仍按自己的白名单运行。数据库账号本身的跨库权限仍由数据库控制。
|
||||
|
||||
默认 **只读**。若将 `mode` 设为 `read_write`,写操作仍须在气泡确认条点「执行」(一次一刀),模型不能自行带确认令牌。
|
||||
默认 **只读**。若将 `mode` 设为 `read_write`,写操作仍须在气泡确认条核对连接、库、语句后点「执行」(一次一刀);切库后原确认失效,模型不能自行带确认令牌。
|
||||
|
||||
## MCP
|
||||
|
||||
@@ -45,13 +45,13 @@ npx -y @rlwj-lab/opsxdb-mcp
|
||||
|
||||
1. 连接库后打开右侧 AI 面板;
|
||||
2. 选择动作,输入需求或粘贴 SQL(可附上对象树上下文);
|
||||
3. 生成结果一键放回编辑器,⌘/Ctrl + Enter 执行。
|
||||
3. 核对生成结果,点击「复制到 SQL 窗口」,再按 ⌘/Ctrl + Enter 执行。
|
||||
|
||||
> **提示**:生成的 SQL 依旧走危险语句确认流程 —— AI 产出与手写 SQL 同等对待,不放宽防护。
|
||||
|
||||
## 配置服务
|
||||
|
||||
点击桌面右上角的设置按钮,在「AI 助手」中选择服务类型,填写服务地址、模型和 API Key,点击「保存」或「测试」(测试会先保存配置)。也可以从 AI 面板的设置入口配置。
|
||||
点击桌面右上角「设置」,在「大模型 → AI 供应商」选择服务,填写模型、API 密钥和接口地址,再点击「保存」或「测试连接」(测试会先保存配置)。也可以从 AI 面板的设置入口配置。
|
||||
|
||||
支持离线演示和 OpenAI 兼容服务。已保存的 API Key 不回显,留空保存表示保留原密钥。配置加载失败时可重试。
|
||||
|
||||
@@ -63,4 +63,4 @@ npx -y @rlwj-lab/opsxdb-mcp
|
||||
- 可完全关闭 AI 功能;
|
||||
- 使用云端模型时在界面上明确标识。
|
||||
|
||||
AI 自动跟随 SQL 编辑器当前连接与数据库(Redis 显示 DB 编号,集群固定 DB0),输入框上方可核对目标。直接点选生成 SQL、解释、优化、修复或样例后发送;Shift+Enter 换行。切换标签不会把晚到的生成结果写入新标签,等待期间修改过的 SQL 也不会被自动覆盖。
|
||||
AI 自动跟随 SQL 编辑器当前连接与数据库(Redis 显示 DB 编号,集群固定 DB0),输入框上方可核对目标。Ask 模式下选择自由提问、生成 SQL、解释、优化、修复或样例后发送;Shift+Enter 换行。切换标签时,晚到的建议只留在回复里,不会改写 SQL 窗口。
|
||||
@@ -48,4 +48,4 @@
|
||||
npx @rlwj-lab/opsxdb-mcp
|
||||
```
|
||||
|
||||
在 Cursor 的 `mcp.json` 中指向该命令(或本机构建的 `bin/opsxdb-mcp`),并在 `~/.opsxdb/mcp.json` 填写允许的连接 id。默认只读;详见《AI 助手》。
|
||||
在 Cursor 的 `mcp.json` 中指向该命令(或本机构建的 `bin/opsxdb-mcp`),并在设置 → MCP 勾选允许访问的连接。新配置默认允许读写与危险 SQL,写入须确认;详见《MCP 接入》。
|
||||
@@ -38,7 +38,7 @@ export OPSXDB_MCP_BIN=/绝对路径/opsxdb/bin/opsxdb-mcp
|
||||
|
||||
- 查看 Node / npm / 包版本 / Go 二进制是否就绪
|
||||
- 复制安装命令与各客户端配置片段
|
||||
- 配置 **只读模式**、**允许危险 SQL**、**连接白名单**
|
||||
- 查看读写权限,并勾选允许访问的连接;新配置默认允许读写和危险 SQL
|
||||
|
||||
## 配置 AI 客户端
|
||||
|
||||
@@ -66,13 +66,14 @@ Claude Code 使用项目根目录 `.mcp.json`(结构相同)。VS Code / Wind
|
||||
|
||||
| 项 | 说明 |
|
||||
| --- | --- |
|
||||
| 默认只读 | `mode=read_only`:仅 SELECT/SHOW/EXPLAIN 与 Redis 读命令 |
|
||||
| 可写 | 设置中关闭「只读模式」→ `mode=read_write`;每次写仍须确认 |
|
||||
| 危险 SQL | 「允许危险 SQL」对应 `allow_dangerous`;DROP/TRUNCATE/ALTER/无 WHERE 更新删除等 |
|
||||
| 默认读写 | 新配置使用 `mode=read_write`;Agent 每次写入前显示确认弹框 |
|
||||
| 危险 SQL | 新配置默认 `allow_dangerous=true`,DROP/TRUNCATE/ALTER/无 WHERE 更新删除等仍须确认 |
|
||||
| 旧配置 | 沿用已保存的权限;设置中选择「使用默认权限」可启用读写与危险 SQL |
|
||||
| 连接白名单 | `allowed_connection_ids`;**空=拒绝全部** |
|
||||
| 审计 | `~/.opsxdb/logs/mcp-audit.jsonl`(含 tool、connection_id、run_id) |
|
||||
|
||||
外部客户端若在 `read_write` 下自行传 `confirm_danger`,视为操作者授权;桌面侧栏 Agent **不会**让模型静默带确认令牌。
|
||||
保存白名单或权限后,外部 MCP 进程在下一次工具调用时读取新策略,无需重启桌面或 AI 客户端;策略文件读取失败时该次调用会被拒绝。
|
||||
|
||||
## 工具列表
|
||||
|
||||
@@ -83,8 +84,8 @@ Claude Code 使用项目根目录 `.mcp.json`(结构相同)。VS Code / Wind
|
||||
| `opsxdb_list_tables` | 表/视图 |
|
||||
| `opsxdb_describe_table` | 列定义 |
|
||||
| `opsxdb_get_schema_context` | 紧凑 schema 文本 |
|
||||
| `opsxdb_execute_query` | SQL(默认只读,行数上限) |
|
||||
| `opsxdb_execute_redis_command` | Redis(默认只读;FLUSH*/KEYS 永拒) |
|
||||
| `opsxdb_execute_query` | SQL(写入需确认,查询有行数上限) |
|
||||
| `opsxdb_execute_redis_command` | Redis(写入需确认;FLUSH*/KEYS 永拒) |
|
||||
|
||||
## 侧栏 Ask | Agent
|
||||
|
||||
|
||||
@@ -28,8 +28,12 @@ func NewRouter(token string) *gin.Engine {
|
||||
r.GET("/health", health)
|
||||
r.GET("/api/health", health)
|
||||
|
||||
docs.New().Register(r)
|
||||
site.New().Register(r)
|
||||
// homepage/dist 存在时整站走静态(落地页 + 文档,对齐 opsxapi/opsxshell);
|
||||
// 否则回退内嵌 SSR 文档与官网模板。
|
||||
if !mountHomepage(r) {
|
||||
docs.New().Register(r)
|
||||
site.New().Register(r)
|
||||
}
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
@@ -442,7 +446,6 @@ func NewRouter(token string) *gin.Engine {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
|
||||
// 桌面连接落盘双写:供 MCP 进程读 ~/.opsxdb/connections.json(Logan.Li)
|
||||
api.GET("/db/connections-store", func(c *gin.Context) {
|
||||
list, err := aitools.LoadConnections()
|
||||
@@ -474,7 +477,6 @@ func NewRouter(token string) *gin.Engine {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Connections)})
|
||||
})
|
||||
|
||||
|
||||
// AI 会话落盘(真·续跑):~/.opsxdb/ai-sessions/
|
||||
api.GET("/ai/sessions", func(c *gin.Context) {
|
||||
list, err := ai.ListSessions(50)
|
||||
@@ -593,7 +595,7 @@ func NewRouter(token string) *gin.Engine {
|
||||
if stmt == "" {
|
||||
stmt = strings.TrimSpace(req.Command)
|
||||
}
|
||||
if err := aitools.VerifyConfirmBind(req.ConnectionID, stmt, req.ConfirmBind); err != nil {
|
||||
if err := aitools.VerifyAgentBind(req.ConnectionID, req.Database, stmt, req.ConfirmBind); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -754,12 +756,10 @@ func sanitizeFileToken(s string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
||||
// runAgentHTTP 共用 Agent JSON / SSE;结束后落盘轨迹(真·续跑)。
|
||||
func runAgentHTTP(c *gin.Context, req ai.AgentReq) {
|
||||
llm, err := ai.DefaultToolChat()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
if strings.TrimSpace(req.ConnectionID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请先选择当前数据库连接,再使用 Agent"})
|
||||
return
|
||||
}
|
||||
rt, err := aitools.DefaultRuntime()
|
||||
@@ -767,6 +767,19 @@ func runAgentHTTP(c *gin.Context, req ai.AgentReq) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := rt.Policy.AllowConnection(req.ConnectionID); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "当前连接未授权 Agent 使用,请在设置 → MCP 中勾选此连接"})
|
||||
return
|
||||
}
|
||||
if _, err := aitools.FindConnection(req.ConnectionID); err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "当前连接尚未同步至本机服务或已被删除,请重新保存连接后重试 Agent"})
|
||||
return
|
||||
}
|
||||
llm, err := ai.DefaultToolChat()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
rt.Source = "agent"
|
||||
rt.RejectCallerConfirm = true
|
||||
|
||||
@@ -821,14 +834,14 @@ func runAgentHTTP(c *gin.Context, req ai.AgentReq) {
|
||||
}
|
||||
// 补发 session 元数据(轻量),避免改动 loop 内已发的 done
|
||||
_ = ai.WriteAgentSSE(c.Writer, flusher, ai.AgentEvent{
|
||||
Type: "session",
|
||||
Content: final.SessionID,
|
||||
RunID: final.RunID,
|
||||
Outcome: final.Outcome,
|
||||
Type: "session",
|
||||
Content: final.SessionID,
|
||||
RunID: final.RunID,
|
||||
Outcome: final.Outcome,
|
||||
IncompleteReason: final.IncompleteReason,
|
||||
Resumable: final.Resumable,
|
||||
StepsBudget: final.StepsBudget,
|
||||
Rounds: final.Rounds,
|
||||
Resumable: final.Resumable,
|
||||
StepsBudget: final.StepsBudget,
|
||||
Rounds: final.Rounds,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -846,11 +859,50 @@ func runAgentHTTP(c *gin.Context, req ai.AgentReq) {
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
|
||||
func health(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "service": "opsxdb", "version": Version})
|
||||
}
|
||||
|
||||
// mountHomepage 本地预览 homepage/dist(落地页 + 文档静态站)。不改 homepage/ 源。
|
||||
func mountHomepage(r *gin.Engine) bool {
|
||||
for _, dir := range []string{
|
||||
os.Getenv("OPSXDB_HOMEPAGE"),
|
||||
"../../homepage/dist",
|
||||
"../homepage/dist",
|
||||
"homepage/dist",
|
||||
} {
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
if st, err := os.Stat(dir); err == nil && st.IsDir() {
|
||||
fs := http.FileServer(http.Dir(dir))
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
p := c.Request.URL.Path
|
||||
if strings.HasPrefix(p, "/api/") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "接口不存在"})
|
||||
return
|
||||
}
|
||||
upath := strings.TrimPrefix(p, "/")
|
||||
if upath == "home" || upath == "index.html" {
|
||||
c.Request.URL.Path = "/" // 落地页别名:导航里的 /home 链接
|
||||
}
|
||||
if upath != "" {
|
||||
if st2, err2 := os.Stat(filepath.Join(dir, upath)); err2 == nil && st2.IsDir() {
|
||||
c.Request.URL.Path = p + "/"
|
||||
} else if !strings.Contains(filepath.Base(upath), ".") {
|
||||
if _, err3 := os.Stat(filepath.Join(dir, upath+".html")); err3 == nil {
|
||||
c.Request.URL.Path = p + ".html"
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func localhostCORS() gin.HandlerFunc {
|
||||
// 5174 = 桌面 devUrl(Tauri dev 的 Vite 资产服务);tauri:// = 打包桌面壳。
|
||||
// 浏览器不作为客户端,不再放行其它来源。
|
||||
@@ -866,7 +918,7 @@ func localhostCORS() gin.HandlerFunc {
|
||||
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")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
|
||||
}
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
|
||||
@@ -22,6 +22,32 @@ func doPost(t *testing.T, r http.Handler, path, body, token string) *httptest.Re
|
||||
return w
|
||||
}
|
||||
|
||||
// 桌面 WebView 跨端口保存 MCP 白名单前会先发送 PUT 预检请求。
|
||||
func TestMcpPolicyPutPreflight(t *testing.T) {
|
||||
r := NewRouter("local-token")
|
||||
for _, origin := range []string{"http://127.0.0.1:5174", "tauri://localhost", "http://tauri.localhost"} {
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/ai/mcp-policy", nil)
|
||||
req.Header.Set("Origin", origin)
|
||||
req.Header.Set("Access-Control-Request-Method", http.MethodPut)
|
||||
req.Header.Set("Access-Control-Request-Headers", "authorization,content-type")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNoContent || w.Header().Get("Access-Control-Allow-Origin") != origin ||
|
||||
!strings.Contains(w.Header().Get("Access-Control-Allow-Methods"), http.MethodPut) ||
|
||||
!strings.Contains(w.Header().Get("Access-Control-Allow-Headers"), "Authorization") {
|
||||
t.Errorf("%s: MCP PUT 预检未放行: code=%d headers=%v", origin, w.Code, w.Header())
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/ai/mcp-policy", nil)
|
||||
req.Header.Set("Origin", "https://example.com")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Fatal("非桌面来源不应被放行")
|
||||
}
|
||||
}
|
||||
|
||||
// 鉴权矩阵:token 为空直通;token 非空时无头 401、正确头 200;health 豁免。
|
||||
func TestAuthMatrix(t *testing.T) {
|
||||
open := NewRouter("")
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-table"/></svg></div><div><h3>结果随手取</h3><p>结果集一键复制 / 导出 CSV / 生成 INSERT;双击单元格即复制,截断行数有提示。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-tree"/></svg></div><div><h3>对象树与表数据</h3><p>懒加载库 / 表 / 列;点表名直接看前 100 行,展开表即看列结构。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-database"/></svg></div><div><h3>Redis 工作台</h3><p>编辑器直接执行 <code>PING</code> / <code>GET</code> / <code>SET</code> / <code>KEYS</code>,键空间浏览、TTL 管理。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-sparkles"/></svg></div><div><h3>AI / MCP</h3><p>Ask 写 SQL;Agent 真查白名单库。设置 → MCP 一键复制 Cursor / Claude 配置;默认只读。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-sparkles"/></svg></div><div><h3>AI / MCP</h3><p>Ask 写 SQL;Agent 操作授权连接,写入前逐次确认。设置 → MCP 复制 Cursor / Claude 配置。</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -271,9 +271,9 @@
|
||||
<div class="faq">
|
||||
<details open><summary>我的连接密码保存在哪?安全吗?</summary><p>全部保存在本机(混淆存储),查询时只携带会话 ID,密码不会随每次请求上送,也不会同步到任何云端。</p></details>
|
||||
<details><summary>能在浏览器里操作数据库吗?</summary><p>不能。客户端唯一形态是桌面应用(Tauri 2);浏览器只承载官网与文档,由后端 :8788 内置提供。开发时的 :5174 只是桌面 devUrl 的资产服务。</p></details>
|
||||
<details><summary>和 DBeaver / Navicat 有什么差别?</summary><p>OpsxDB 面向运维与开发的日常高频操作(连库、点表、写 SQL、取结果),界面密度和快捷键为「快」设计;本地优先、后端随桌面应用拉起、内置中文文档。重度管理功能(ER 图、数据比对等)在路线图中。</p></details>
|
||||
<details><summary>OpsxDB 适合哪些场景?</summary><p>OpsxDB 面向运维与开发的日常高频操作(连库、点表、写 SQL、取结果),界面密度和快捷键为「快」设计;本地优先、后端随桌面应用拉起、内置中文文档。重度管理功能(ER 图、数据比对等)在路线图中。</p></details>
|
||||
<details><summary>Oracle 支持好了吗?</summary><p>驱动代码已就绪(纯 Go 的 go-ora,无需 JVM),但本机 docker 验收在 Apple Silicon 上受镜像限制未通过,需 x86 环境或真库完成验收,详见文档《驱动支持》。</p></details>
|
||||
<details><summary>AI 功能需要联网吗?</summary><p>配置云端模型(如 DeepSeek)时,提问与结构上下文会发往该服务;可改用本地兼容端点。MCP / Agent 查库始终在本机执行,默认只读。</p></details>
|
||||
<details><summary>AI 功能需要联网吗?</summary><p>配置云端模型(如 DeepSeek)时,提问与结构上下文会发往该服务;可改用本地兼容端点。MCP / Agent 查库在本机执行;Agent 写入前需确认具体命令。</p></details>
|
||||
<details><summary>如何把 Cursor / Claude 接到 OpsxDB?</summary><p>安装 <code>@rlwj-lab/opsxdb-mcp</code> 并构建 Go 二进制,在桌面 <strong>设置 → MCP</strong> 勾选连接白名单、复制对应客户端配置。完整步骤见 <a href="/docs/mcp" target="_blank" rel="noopener">MCP 接入</a>。</p></details>
|
||||
|
||||
<details><summary>支持达梦 / MongoDB 吗?</summary><p>已列入路线:常用库 native 内置,长尾库(达梦、MongoDB、ZooKeeper、ES、InfluxDB)规划 agent 按需接入,驱动分发走独立仓库并校验 sha256。</p></details>
|
||||
|
||||
Reference in new issue
Block a user