diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index d0c1bc2..8950629 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -17,18 +17,33 @@ type ViewerPaginationState = { totalCountCancelled: boolean; }; +const JS_MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); + +const isIntegerText = (text: string): boolean => /^[+-]?\d+$/.test(text); + const toNonNegativeFiniteNumber = (value: unknown): number | null => { if (typeof value === 'number') { - return Number.isFinite(value) && value >= 0 ? value : null; + return Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? value : null; } if (typeof value === 'bigint') { - return value >= 0n ? Number(value) : null; + return value >= 0n && value <= JS_MAX_SAFE_INTEGER_BIGINT ? Number(value) : null; } if (typeof value === 'string') { const text = value.trim(); if (!text) return null; + if (isIntegerText(text)) { + try { + const parsedBigInt = BigInt(text); + if (parsedBigInt < 0n || parsedBigInt > JS_MAX_SAFE_INTEGER_BIGINT) { + return null; + } + return Number(parsedBigInt); + } catch { + return null; + } + } const parsed = Number(text); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; + return Number.isFinite(parsed) && parsed >= 0 && parsed <= Number.MAX_SAFE_INTEGER ? parsed : null; } return null; }; @@ -560,11 +575,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { if (!resCount.success) return; if (!Array.isArray(resCount.data) || resCount.data.length === 0) return; - let total: number | null = null; - const parsed = Number(resCount.data[0]?.['total']); - if (Number.isFinite(parsed) && parsed >= 0) { - total = parsed; - } + const total = parseTotalFromCountRow(resCount.data[0]); if (total === null) return; setPagination(prev => ({ diff --git a/internal/db/json_decode.go b/internal/db/json_decode.go new file mode 100644 index 0000000..e4f3edc --- /dev/null +++ b/internal/db/json_decode.go @@ -0,0 +1,53 @@ +package db + +import ( + "bytes" + "encoding/json" +) + +func decodeJSONWithUseNumber(data []byte, out interface{}) error { + if out == nil { + return nil + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := decoder.Decode(out); err != nil { + return err + } + normalizeDecodedJSONNumbers(out) + return nil +} + +func normalizeDecodedJSONNumbers(out interface{}) { + switch typed := out.(type) { + case *[]map[string]interface{}: + if typed == nil { + return + } + for i := range *typed { + row := (*typed)[i] + for key, value := range row { + row[key] = normalizeQueryValue(value) + } + } + case *map[string]interface{}: + if typed == nil || *typed == nil { + return + } + for key, value := range *typed { + (*typed)[key] = normalizeQueryValue(value) + } + case *[]interface{}: + if typed == nil { + return + } + for i, item := range *typed { + (*typed)[i] = normalizeQueryValue(item) + } + case *interface{}: + if typed == nil { + return + } + *typed = normalizeQueryValue(*typed) + } +} diff --git a/internal/db/json_decode_test.go b/internal/db/json_decode_test.go new file mode 100644 index 0000000..785a1ba --- /dev/null +++ b/internal/db/json_decode_test.go @@ -0,0 +1,58 @@ +package db + +import "testing" + +func TestDecodeJSONWithUseNumber_QueryRowsPreserveUnsafeInteger(t *testing.T) { + raw := []byte(`[{"id":9007199254740993,"safe":123,"nested":{"n":9007199254740992},"arr":[9007199254740992,1],"decimal":1.25}]`) + var out []map[string]interface{} + + if err := decodeJSONWithUseNumber(raw, &out); err != nil { + t.Fatalf("解码失败: %v", err) + } + if len(out) != 1 { + t.Fatalf("期望 1 行,实际 %d", len(out)) + } + + row := out[0] + if got, ok := row["id"].(string); !ok || got != "9007199254740993" { + t.Fatalf("id 应为 string 且保持精度,实际=%v(%T)", row["id"], row["id"]) + } + if got, ok := row["safe"].(int64); !ok || got != 123 { + t.Fatalf("safe 应为 int64(123),实际=%v(%T)", row["safe"], row["safe"]) + } + nested, ok := row["nested"].(map[string]interface{}) + if !ok { + t.Fatalf("nested 类型异常:%T", row["nested"]) + } + if got, ok := nested["n"].(string); !ok || got != "9007199254740992" { + t.Fatalf("nested.n 应为 string 且保持精度,实际=%v(%T)", nested["n"], nested["n"]) + } + arr, ok := row["arr"].([]interface{}) + if !ok || len(arr) != 2 { + t.Fatalf("arr 类型异常:%v(%T)", row["arr"], row["arr"]) + } + if got, ok := arr[0].(string); !ok || got != "9007199254740992" { + t.Fatalf("arr[0] 应为 string 且保持精度,实际=%v(%T)", arr[0], arr[0]) + } + if got, ok := arr[1].(int64); !ok || got != 1 { + t.Fatalf("arr[1] 应为 int64(1),实际=%v(%T)", arr[1], arr[1]) + } + if got, ok := row["decimal"].(float64); !ok || got != 1.25 { + t.Fatalf("decimal 应为 float64(1.25),实际=%v(%T)", row["decimal"], row["decimal"]) + } +} + +func TestDecodeJSONWithUseNumber_TypedStruct(t *testing.T) { + type item struct { + ID int64 `json:"id"` + Name string `json:"name"` + } + + var out []item + if err := decodeJSONWithUseNumber([]byte(`[{"id":7,"name":"ok"}]`), &out); err != nil { + t.Fatalf("解码失败: %v", err) + } + if len(out) != 1 || out[0].ID != 7 || out[0].Name != "ok" { + t.Fatalf("结构体解码结果异常:%+v", out) + } +} diff --git a/internal/db/mysql_agent_impl.go b/internal/db/mysql_agent_impl.go index 3ad540d..84f5233 100644 --- a/internal/db/mysql_agent_impl.go +++ b/internal/db/mysql_agent_impl.go @@ -174,7 +174,7 @@ func (c *mysqlAgentClient) call(req mysqlAgentRequest, out interface{}, fields * *rowsAffected = resp.RowsAffected } if out != nil && len(resp.Data) > 0 { - if err := json.Unmarshal(resp.Data, out); err != nil { + if err := decodeJSONWithUseNumber(resp.Data, out); err != nil { return fmt.Errorf("解析 MySQL 驱动代理数据失败:%w", err) } } diff --git a/internal/db/optional_driver_agent_impl.go b/internal/db/optional_driver_agent_impl.go index 58bc1dc..1b83902 100644 --- a/internal/db/optional_driver_agent_impl.go +++ b/internal/db/optional_driver_agent_impl.go @@ -179,7 +179,7 @@ func (c *optionalDriverAgentClient) call(req optionalAgentRequest, out interface *rowsAffected = resp.RowsAffected } if out != nil && len(resp.Data) > 0 { - if err := json.Unmarshal(resp.Data, out); err != nil { + if err := decodeJSONWithUseNumber(resp.Data, out); err != nil { return fmt.Errorf("解析 %s 驱动代理数据失败:%w", driverDisplayName(c.driver), err) } } diff --git a/internal/db/query_value.go b/internal/db/query_value.go index 36e9744..83fdf7f 100644 --- a/internal/db/query_value.go +++ b/internal/db/query_value.go @@ -2,13 +2,27 @@ package db import ( "encoding/hex" + "encoding/json" "fmt" + "math/big" "reflect" + "strconv" "strings" "unicode" "unicode/utf8" ) +const ( + jsMaxSafeInteger int64 = 9007199254740991 + jsMinSafeInteger int64 = -9007199254740991 + jsMaxSafeUint uint64 = 9007199254740991 +) + +var ( + jsMaxSafeBigInt = big.NewInt(jsMaxSafeInteger) + jsMinSafeBigInt = big.NewInt(jsMinSafeInteger) +) + // normalizeQueryValue normalizes driver-returned values for UI/JSON transport. // 当前主要处理 []byte:如果是可读文本则转为 string,否则转为十六进制字符串,避免前端出现“空白值”。 func normalizeQueryValue(v interface{}) interface{} { @@ -40,6 +54,8 @@ func normalizeCompositeQueryValue(v interface{}) interface{} { out[key] = normalizeQueryValue(value) } return out + case json.Number: + return normalizeJSONNumberForJS(typed) } rv := reflect.ValueOf(v) @@ -71,10 +87,52 @@ func normalizeCompositeQueryValue(v interface{}) interface{} { } return items default: - return v + return normalizeUnsafeIntegerForJS(rv, v) } } +func normalizeJSONNumberForJS(n json.Number) interface{} { + text := strings.TrimSpace(n.String()) + if text == "" { + return "" + } + + if integer, ok := parseJSONInteger(text); ok { + if integer.Cmp(jsMaxSafeBigInt) > 0 || integer.Cmp(jsMinSafeBigInt) < 0 { + return text + } + return integer.Int64() + } + + if f, err := n.Float64(); err == nil { + return f + } + return text +} + +func parseJSONInteger(text string) (*big.Int, bool) { + if text == "" { + return nil, false + } + start := 0 + if text[0] == '+' || text[0] == '-' { + if len(text) == 1 { + return nil, false + } + start = 1 + } + for i := start; i < len(text); i++ { + if text[i] < '0' || text[i] > '9' { + return nil, false + } + } + value, ok := new(big.Int).SetString(text, 10) + if !ok { + return nil, false + } + return value, true +} + func mapKeyToString(key interface{}) string { if key == nil { return "null" @@ -97,8 +155,7 @@ func bytesToDisplayValue(b []byte, databaseTypeName string) interface{} { if isBitLikeDBType(dbType) { if u, ok := bytesToUint64(b); ok { // JS number precision is limited; keep large bitmasks as string. - const maxSafeInteger = 9007199254740991 // 2^53 - 1 - if u <= maxSafeInteger { + if u <= jsMaxSafeUint { return int64(u) } return fmt.Sprintf("%d", u) @@ -153,6 +210,25 @@ func bytesToUint64(b []byte) (uint64, bool) { return u, true } +func normalizeUnsafeIntegerForJS(rv reflect.Value, original interface{}) interface{} { + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + n := rv.Int() + if n > jsMaxSafeInteger || n < jsMinSafeInteger { + return strconv.FormatInt(n, 10) + } + return original + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + u := rv.Uint() + if u > jsMaxSafeUint { + return strconv.FormatUint(u, 10) + } + return original + default: + return original + } +} + func isMostlyPrintable(s string) bool { if s == "" { return true diff --git a/internal/db/query_value_test.go b/internal/db/query_value_test.go index a19fa26..b05977e 100644 --- a/internal/db/query_value_test.go +++ b/internal/db/query_value_test.go @@ -1,6 +1,9 @@ package db -import "testing" +import ( + "encoding/json" + "testing" +) type duckMapLike map[any]any @@ -81,3 +84,84 @@ func TestNormalizeQueryValueWithDBType_MapAnyAnyForJSON(t *testing.T) { t.Fatalf("嵌套 map 数字 key 未转换,实际=%v(%T)", nested["2"], nested["2"]) } } + +func TestNormalizeQueryValueWithDBType_UnsafeIntegersAsString(t *testing.T) { + cases := []struct { + name string + input interface{} + want string + }{ + {name: "int64 overflow", input: int64(9007199254740992), want: "9007199254740992"}, + {name: "int64 underflow", input: int64(-9007199254740992), want: "-9007199254740992"}, + {name: "uint64 overflow", input: uint64(9007199254740992), want: "9007199254740992"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := normalizeQueryValueWithDBType(tc.input, "") + if got != tc.want { + t.Fatalf("期望=%q,实际=%v(%T)", tc.want, got, got) + } + }) + } +} + +func TestNormalizeQueryValueWithDBType_SafeIntegersKeepType(t *testing.T) { + got := normalizeQueryValueWithDBType(int64(9007199254740991), "") + if _, ok := got.(int64); !ok { + t.Fatalf("安全范围 int64 应保持数字类型,实际=%v(%T)", got, got) + } + + got = normalizeQueryValueWithDBType(uint64(9007199254740991), "") + if _, ok := got.(uint64); !ok { + t.Fatalf("安全范围 uint64 应保持数字类型,实际=%v(%T)", got, got) + } +} + +func TestNormalizeQueryValueWithDBType_JSONNumber(t *testing.T) { + cases := []struct { + name string + input json.Number + wantType string + wantValue string + }{ + {name: "safe integer", input: json.Number("9007199254740991"), wantType: "int64", wantValue: "9007199254740991"}, + {name: "unsafe integer", input: json.Number("9007199254740992"), wantType: "string", wantValue: "9007199254740992"}, + {name: "unsafe negative integer", input: json.Number("-9007199254740992"), wantType: "string", wantValue: "-9007199254740992"}, + {name: "decimal", input: json.Number("12.5"), wantType: "float64", wantValue: "12.5"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := normalizeQueryValueWithDBType(tc.input, "") + switch tc.wantType { + case "int64": + v, ok := got.(int64) + if !ok { + t.Fatalf("期望 int64,实际=%T", got) + } + if v != 9007199254740991 { + t.Fatalf("期望值=%s,实际=%d", tc.wantValue, v) + } + case "string": + v, ok := got.(string) + if !ok { + t.Fatalf("期望 string,实际=%T", got) + } + if v != tc.wantValue { + t.Fatalf("期望值=%s,实际=%s", tc.wantValue, v) + } + case "float64": + v, ok := got.(float64) + if !ok { + t.Fatalf("期望 float64,实际=%T", got) + } + if v != 12.5 { + t.Fatalf("期望值=%s,实际=%v", tc.wantValue, v) + } + default: + t.Fatalf("未知断言类型:%s", tc.wantType) + } + }) + } +}