mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 07:39:40 +08:00
🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失
- 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度 - 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串 - 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页 - refs #142
This commit is contained in:
@@ -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 => ({
|
||||
|
||||
53
internal/db/json_decode.go
Normal file
53
internal/db/json_decode.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
58
internal/db/json_decode_test.go
Normal file
58
internal/db/json_decode_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user