同步完整源码至远程:扁平化重构现状 + 未入库源文件

- 补入库: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:
logan committed 2026-10-16 03:47:33 +08:00
1 parent 5c0be49217
commit 2c0bfafd8d
269 files changed
+10372 -16435

No files matched your search

+239 -11
View File
@@ -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
}