From 36724bba949037b00c75c587cc34c1fc5fbd4202 Mon Sep 17 00:00:00 2001 From: opsx Date: Tue, 27 Oct 2026 00:58:22 +0000 Subject: [PATCH] =?UTF-8?q?fix(queries):=20MySQL=20=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E8=AF=AF=E8=B0=83=20Redis=20=E6=8E=A5=E5=8F=A3=20500=20+=20?= =?UTF-8?q?=E7=82=B9=E8=A1=A8=E7=94=9F=E6=88=90=20SELECT=20+=20=E5=85=89?= =?UTF-8?q?=E6=A0=87=E5=A4=9A=E8=A1=8C=E9=97=AA=E7=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === 一、/queries 打开 MySQL 实例报 500 === 现象:GET /api/v1/redis/5/dbs → 500 后端日志 redis: invalid reply: "J\x00\x00\x00\n" ("J\x00\x00\x00" 是 MySQL 握手包,说明用 Redis 协议连了 3306) 根因:SchemaSidebar 是 mysql/redis 共用的组件,其中 const redisDbs = useRedisDbs(Number(instanceId)); 是无条件调用(React hooks 不能条件调用),而 useRedisDbs 内部 只看 instanceId 就请求 /redis/{id}/dbs → MySQL 实例也发 Redis 请求 → 后端用 Redis 协议连 MySQL → 500。 修复(前后端双保险): 1. useRedisDbs 增加 enabled 参数(默认 true),false 时不发请求并复位 [0]/false 2. SchemaSidebar 传 isRedis:useRedisDbs(Number(instanceId), isRedis) 3. redis.go getRedisClient 前置校验 DBType != "redis" 直接返回 4. resp 包新增 UserFacingError / UserFacing(),InternalErr 识别后返回 400 + 可读原因(不再把所有错误笼统包装成 500 "服务器内部错误") 验证: GET /redis/5/dbs(MySQL)→ 400 {"code":"bad_request","detail":"实例「pre-mysql-只读」的类型是 mysql, 不是 redis。Redis 接口只适用于 redis 实例;MySQL/PostgreSQL 请使用 /api/v1/instances/5/databases 等对应接口"} GET /instances/5/databases → 200 GET /redis/4/dbs(Redis 集群)→ 200 {"cluster":true,"dbs":[0]} === 二、左侧栏点表不生成 SELECT === 原交互:单击=展开/折叠,双击=执行 SELECT,Alt+点击=插入表名。 需求:单击表 → 在右侧编辑器生成 SELECT * FROM . 修复: 1. SchemaSidebar 表节点:箭头图标包 data-chevron 单独触发展开/折叠; 点表名区域 → onInsert(previewSelectSql(...) + ';\n') Alt+点击仍为只插表名;双击仍为直接执行 2. QueriesPage onInsert:生成的文本以 SELECT 开头时「替换」编辑器内容 (原来一律追加,会和上一条语句拼成非法 SQL);表名/字段名仍追加 === 三、SQL 编辑器光标同时在多行闪烁 === 根因:.cm-cursor 强制了 height:1.25em !important + marginTop:0.15em, 而 .cm-line 行高是 1.55em,光标高度溢出到相邻行,视觉上像两行同时闪。 修复:光标样式只保留颜色与 1.5px 宽度,height/marginTop 交回 CodeMirror 自行计算。 镜像:lghost/opsxsql:latest + v0.1 --- backend/internal/handler/redis.go | 12 ++++++++ backend/internal/handler/resp/resp.go | 19 ++++++++++++ frontend/src/components/SchemaSidebar.tsx | 29 +++++++++++++------ frontend/src/components/SqlEditor.tsx | 6 ++-- .../redis/keyBrowser/useRedisKeyBrowser.ts | 21 +++++++++++--- frontend/src/pages/QueriesPage.tsx | 5 ++++ 6 files changed, 76 insertions(+), 16 deletions(-) diff --git a/backend/internal/handler/redis.go b/backend/internal/handler/redis.go index f931b5e..f4b806f 100644 --- a/backend/internal/handler/redis.go +++ b/backend/internal/handler/redis.go @@ -351,6 +351,18 @@ func writeRedisKeyValue(ctx context.Context, client *redisClientWrapper, key str } func getRedisClient(instance *model.Instance, db int) (*redisClientWrapper, error) { + // 防御:非 Redis 实例不能走 Redis 协议。 + // 若前端误调 /api/v1/redis/:id/*(如共用组件的 MySQL 实例), + // 用 Redis 协议连 3306 会收到 MySQL 握手包,报 + // `redis: invalid reply: "J\x00\x00\x00\n"` 并抛 500。 + // 这里提前拦截,给出可读错误。 + if instance.DBType != "redis" { + return nil, resp.UserFacing(fmt.Sprintf( + "实例「%s」的类型是 %s,不是 redis。Redis 接口只适用于 redis 实例;"+ + "MySQL/PostgreSQL 请使用 /api/v1/instances/%d/databases 等对应接口", + instance.Name, instance.DBType, instance.ID)) + } + password := instance.Password // Decrypt if encrypted if strings.HasPrefix(password, "enc:") { diff --git a/backend/internal/handler/resp/resp.go b/backend/internal/handler/resp/resp.go index 099371c..5a384fa 100644 --- a/backend/internal/handler/resp/resp.go +++ b/backend/internal/handler/resp/resp.go @@ -7,6 +7,7 @@ package resp import ( + "errors" "log" "net/http" @@ -54,7 +55,25 @@ func Forbidden(c *gin.Context, detail string) { Fail(c, http.StatusForbidden, func NotFound(c *gin.Context, detail string) { Fail(c, http.StatusNotFound, detail) } // InternalErr 500:服务端 log 完整错误,客户端只收通用文案(防 DB/内部错误原文泄漏) +// UserFacingError 标记「可以展示给用户」的错误。 +// +// 典型场景:调用方用错了接口/参数不满足前置条件(例如拿 MySQL 实例去调 +// /api/v1/redis/:id/*),这既不是服务端故障也不该泄漏内部细节, +// 用 400 + 明确原因比 500 + "服务器内部错误" 更有助于定位。 +type UserFacingError struct{ Msg string } + +func (e *UserFacingError) Error() string { return e.Msg } + +// UserFacing 构造一个可展示给用户的错误 +func UserFacing(msg string) error { return &UserFacingError{Msg: msg} } + func InternalErr(c *gin.Context, err error) { + var uf *UserFacingError + if errors.As(err, &uf) { + log.Printf("[resp] bad request on %s %s: %v", c.Request.Method, c.Request.URL.Path, err) + c.JSON(http.StatusBadRequest, gin.H{"detail": uf.Msg, "code": "bad_request"}) + return + } log.Printf("[resp] internal error on %s %s: %v", c.Request.Method, c.Request.URL.Path, err) c.JSON(http.StatusInternalServerError, gin.H{"detail": "服务器内部错误,请稍后重试", "code": "internal"}) } diff --git a/frontend/src/components/SchemaSidebar.tsx b/frontend/src/components/SchemaSidebar.tsx index 0299dfd..3d1f9d0 100644 --- a/frontend/src/components/SchemaSidebar.tsx +++ b/frontend/src/components/SchemaSidebar.tsx @@ -93,7 +93,8 @@ export default function SchemaSidebar({ // -- Redis State(浏览逻辑在共享 RedisKeyBrowser,这里只留库选择与详情预览) -- const [redisDb, setRedisDb] = useState(0); - const redisDbs = useRedisDbs(Number(instanceId)); + // 仅 Redis 实例才拉库列表,避免 MySQL/Mongo 也打到 /redis/:id/dbs + const redisDbs = useRedisDbs(Number(instanceId), isRedis); const ctxMenu = useContextMenu(); const dialect: SqlDialect = dbType === 'postgresql' ? 'postgresql' : 'mysql'; @@ -407,22 +408,32 @@ export default function SchemaSidebar({
; 到右侧查询编辑器 + onInsert(`${previewSelectSql(t.name, dialect, dbName)};\n`); } }} onDoubleClick={() => onExecuteSql?.(previewSelectSql(t.name, dialect, dbName))} onContextMenu={(e) => ctxMenu.open(e, tableMenuItems(dbName, t.name))} className="flex items-center gap-1.5 w-full px-2 py-1 text-[11px] hover:bg-[var(--dg-bg-hover)] transition-colors group" - title={`单击展开/折叠 · 双击查看数据 · Alt+点击插入表名 · 右键更多`} - > - {isTableExpanded ? ( - - ) : ( - - )} + title={`单击生成 SELECT · 点箭头展开字段 · 双击直接查数据 · Alt+点击只插表名 · 右键更多`} + > + + {isTableExpanded ? ( + + ) : ( + + )} + {t.name} diff --git a/frontend/src/components/SqlEditor.tsx b/frontend/src/components/SqlEditor.tsx index da4b505..f8f3135 100644 --- a/frontend/src/components/SqlEditor.tsx +++ b/frontend/src/components/SqlEditor.tsx @@ -306,12 +306,12 @@ const appTheme = EditorView.theme({ fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace", lineHeight: '1.55', }, + // 光标只改颜色/粗细,不要强改 height 与 marginTop: + // CodeMirror 按行高自行计算光标高度,强制 1.25em + marginTop 会溢出到相邻行, + // 视觉上表现为「光标同时在两行闪烁」。 '.cm-cursor': { borderLeftColor: 'var(--dg-text-primary)', borderLeftWidth: '1.5px', - height: '1.25em !important', - marginTop: '0.15em', - boxSizing: 'border-box', }, '.cm-line': { lineHeight: '1.55', diff --git a/frontend/src/features/redis/keyBrowser/useRedisKeyBrowser.ts b/frontend/src/features/redis/keyBrowser/useRedisKeyBrowser.ts index 1e0002f..d07dea6 100644 --- a/frontend/src/features/redis/keyBrowser/useRedisKeyBrowser.ts +++ b/frontend/src/features/redis/keyBrowser/useRedisKeyBrowser.ts @@ -151,13 +151,26 @@ export function useRedisKeyBrowser(instanceId: number, db: number): UseRedisKeyB }; } -/** 库列表(集群恒 [0]);实例变化自动重拉 */ -export function useRedisDbs(instanceId: number): { dbs: number[]; cluster: boolean } { +/** 库列表(集群恒 [0]);实例变化自动重拉。 + * + * enabled=false 时不发请求 —— React hooks 不能条件调用,而 SchemaSidebar + * 是 mysql/redis 共用的组件,若不加此开关,打开 MySQL 实例也会去请求 + * /redis/{id}/dbs,后端拿 Redis 协议连 MySQL 会收到 MySQL 握手包, + * 直接 500(redis: invalid reply)。调用方应传 dbType === 'redis'。 + */ +export function useRedisDbs( + instanceId: number, + enabled = true, +): { dbs: number[]; cluster: boolean } { const [dbs, setDbs] = useState([0]); const [cluster, setCluster] = useState(false); useEffect(() => { - if (!instanceId) return; + if (!instanceId || !enabled) { + setDbs([0]); + setCluster(false); + return; + } let cancelled = false; redisApi .getDbs(instanceId) @@ -172,7 +185,7 @@ export function useRedisDbs(instanceId: number): { dbs: number[]; cluster: boole return () => { cancelled = true; }; - }, [instanceId]); + }, [instanceId, enabled]); return { dbs, cluster }; } diff --git a/frontend/src/pages/QueriesPage.tsx b/frontend/src/pages/QueriesPage.tsx index 715fd00..883cf96 100644 --- a/frontend/src/pages/QueriesPage.tsx +++ b/frontend/src/pages/QueriesPage.tsx @@ -456,7 +456,12 @@ export default function QueriesPage() { onInsert={(text) => { if (dbType === 'redis') { setSql(text); + } else if (/^\s*SELECT\b/i.test(text)) { + // 左侧栏点表生成的 SELECT 语句:直接替换编辑器内容, + // 避免和上一条语句拼接成非法 SQL + setSql(text); } else { + // 插入表名/字段名:追加到现有内容后 setSql((prev) => prev + (prev.trim() ? ' ' : '') + text + ' '); } }}