=== 一、/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 <db>.<table>
修复:
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
90 lines
3.4 KiB
Go
90 lines
3.4 KiB
Go
// Package resp 统一 API 响应助手(渐进式契约统一):
|
|
// - 成功:直接返回业务数据;分页统一 {items, total, page, page_size} 信封
|
|
// - 失败:HTTP 状态码语义保持不变,错误体 {detail, code?}(兼容既有前端与测试)
|
|
// - 内部错误:服务端记录完整 error,客户端只收通用文案,杜绝 DB 错误原文泄漏
|
|
//
|
|
// 新代码禁止直接 c.JSON 错误响应;存量调用点在迭代中逐步迁移到本包。
|
|
package resp
|
|
|
|
import (
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// OK 200:直接返回业务数据
|
|
func OK(c *gin.Context, data any) {
|
|
c.JSON(http.StatusOK, data)
|
|
}
|
|
|
|
// Created 201:资源创建成功
|
|
func Created(c *gin.Context, data any) {
|
|
c.JSON(http.StatusCreated, data)
|
|
}
|
|
|
|
// Page 200:统一分页信封 {items, total, page, page_size}
|
|
func Page(c *gin.Context, items any, total int64, page, pageSize int) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"items": items,
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": pageSize,
|
|
})
|
|
}
|
|
|
|
// Message 200:仅含 {message} 的轻量确认(删除/测试类端点)
|
|
func Message(c *gin.Context, msg string) {
|
|
c.JSON(http.StatusOK, gin.H{"message": msg})
|
|
}
|
|
|
|
// Fail 通用失败:{detail}
|
|
func Fail(c *gin.Context, status int, detail string) {
|
|
c.JSON(status, gin.H{"detail": detail})
|
|
}
|
|
|
|
// FailCode 失败并携带机器可读错误码:{detail, code}
|
|
func FailCode(c *gin.Context, status int, code, detail string) {
|
|
c.JSON(status, gin.H{"detail": detail, "code": code})
|
|
}
|
|
|
|
func BadRequest(c *gin.Context, detail string) { Fail(c, http.StatusBadRequest, detail) }
|
|
func Unauthorized(c *gin.Context, detail string) { Fail(c, http.StatusUnauthorized, detail) }
|
|
func Forbidden(c *gin.Context, detail string) { Fail(c, http.StatusForbidden, detail) }
|
|
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"})
|
|
}
|
|
|
|
// InternalMsg 500:无底层 error、需要给用户可读文案时使用
|
|
func InternalMsg(c *gin.Context, userMsg string) {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": userMsg})
|
|
}
|
|
|
|
// TooManyRequests 429:携带 retry_after_minutes
|
|
func TooManyRequests(c *gin.Context, detail string, retryAfterMinutes int) {
|
|
c.JSON(http.StatusTooManyRequests, gin.H{"detail": detail, "retry_after_minutes": retryAfterMinutes})
|
|
}
|