Files
MyGoNavi/frontend/src/utils/redisValueDisplay.ts
Syngnat 21222cf9f4 🐛 fix(redis): 修复自动模式 JSON 大整数精度丢失
- 保留 Redis JSON 值中的大整数原始字面量
- 避免自动格式化时通过 JSON.stringify 改写超出安全范围的数字
- 补充自动模式大整数与字符串转义展示回归测试
Refs #400
2026-04-26 20:15:13 +08:00

276 lines
7.2 KiB
TypeScript

const hasDecodedUnicodeText = (value: string): boolean => {
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) > 0xFF) {
return true;
}
}
return false;
};
const toByteArray = (value: string): Uint8Array => {
const bytes = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) {
bytes[i] = value.charCodeAt(i) & 0xFF;
}
return bytes;
};
const decodeUtf8Bytes = (value: string): string => (
new TextDecoder('utf-8', { fatal: false }).decode(toByteArray(value))
);
const tryDecodeValue = (value: string): { displayValue: string; encoding: string; needsHex: boolean } => {
if (!value || value.length === 0) {
return { displayValue: '', encoding: 'UTF-8', needsHex: false };
}
if (hasDecodedUnicodeText(value)) {
return { displayValue: value, encoding: 'UTF-8', needsHex: false };
}
let nullCount = 0;
let printableCount = 0;
let highByteCount = 0;
const sampleSize = Math.min(value.length, 200);
for (let i = 0; i < sampleSize; i++) {
const code = value.charCodeAt(i);
if (code === 0) {
nullCount++;
} else if (code >= 32 && code < 127) {
printableCount++;
} else if (code >= 128) {
highByteCount++;
}
}
if (nullCount / sampleSize > 0.3) {
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
}
if (highByteCount === 0 && printableCount / sampleSize > 0.7) {
return { displayValue: value, encoding: 'UTF-8', needsHex: false };
}
if (highByteCount > 0) {
try {
const decoded = decodeUtf8Bytes(value);
let validChars = 0;
let replacementChars = 0;
let controlChars = 0;
for (let i = 0; i < Math.min(decoded.length, 200); i++) {
const code = decoded.charCodeAt(i);
if (code === 0xFFFD) {
replacementChars++;
} else if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
controlChars++;
} else if ((code >= 32 && code < 127) || (code >= 0x4E00 && code <= 0x9FFF) || (code >= 0x3000 && code <= 0x303F)) {
validChars++;
}
}
const totalChecked = Math.max(1, Math.min(decoded.length, 200));
if (replacementChars / totalChecked > 0.1 || controlChars / totalChecked > 0.2) {
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
}
if (validChars / totalChecked > 0.5) {
return { displayValue: decoded, encoding: 'UTF-8', needsHex: false };
}
} catch {
// ignore decode failure
}
}
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
};
const findNextNonWhitespace = (value: string, startIndex: number): string => {
for (let i = startIndex; i < value.length; i++) {
if (!/\s/.test(value[i])) {
return value[i];
}
}
return '';
};
const readJsonStringToken = (value: string, startIndex: number): { token: string; nextIndex: number } => {
let index = startIndex + 1;
let escaped = false;
while (index < value.length) {
const char = value[index];
if (escaped) {
escaped = false;
index++;
continue;
}
if (char === '\\') {
escaped = true;
index++;
continue;
}
if (char === '"') {
return { token: value.slice(startIndex, index + 1), nextIndex: index + 1 };
}
index++;
}
return { token: value.slice(startIndex), nextIndex: value.length };
};
const readJsonPrimitiveToken = (value: string, startIndex: number): { token: string; nextIndex: number } => {
let index = startIndex;
while (index < value.length && !/[\s,\]}]/.test(value[index])) {
index++;
}
return { token: value.slice(startIndex, index), nextIndex: index };
};
const formatJsonStringToken = (token: string): string => {
try {
return JSON.stringify(JSON.parse(token));
} catch {
return token;
}
};
const formatJsonPreservingNumberLiterals = (value: string): string | null => {
try {
JSON.parse(value);
} catch {
return null;
}
const indentUnit = ' ';
const indent = (depth: number) => indentUnit.repeat(Math.max(0, depth));
let result = '';
let depth = 0;
let index = 0;
let lastToken: 'open' | 'value' | 'close' | 'comma' | 'colon' | '' = '';
while (index < value.length) {
const char = value[index];
if (/\s/.test(char)) {
index++;
continue;
}
if (char === '"') {
const { token, nextIndex } = readJsonStringToken(value, index);
result += formatJsonStringToken(token);
lastToken = 'value';
index = nextIndex;
continue;
}
if (char === '{' || char === '[') {
const closeChar = char === '{' ? '}' : ']';
result += char;
depth++;
lastToken = 'open';
if (findNextNonWhitespace(value, index + 1) !== closeChar) {
result += `\n${indent(depth)}`;
}
index++;
continue;
}
if (char === '}' || char === ']') {
depth--;
if (lastToken !== 'open') {
result += `\n${indent(depth)}`;
}
result += char;
lastToken = 'close';
index++;
continue;
}
if (char === ',') {
result += `,\n${indent(depth)}`;
lastToken = 'comma';
index++;
continue;
}
if (char === ':') {
result += ': ';
lastToken = 'colon';
index++;
continue;
}
const { token, nextIndex } = readJsonPrimitiveToken(value, index);
result += token;
lastToken = 'value';
index = nextIndex;
}
return result;
};
const tryFormatJson = (value: string): { isJson: boolean; formatted: string } => {
const formatted = formatJsonPreservingNumberLiterals(value);
if (formatted !== null) {
return { isJson: true, formatted };
}
return { isJson: false, formatted: value };
};
export const toHexDisplay = (value: string): string => {
const bytes: string[] = [];
const ascii: string[] = [];
let result = '';
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
bytes.push(code.toString(16).padStart(2, '0').toUpperCase());
ascii.push(code >= 32 && code < 127 ? value[i] : '.');
if (bytes.length === 16 || i === value.length - 1) {
const offset = (Math.floor(i / 16) * 16).toString(16).padStart(8, '0').toUpperCase();
const hexPart = bytes.join(' ').padEnd(47, ' ');
const asciiPart = ascii.join('');
result += `${offset} ${hexPart} |${asciiPart}|\n`;
bytes.length = 0;
ascii.length = 0;
}
}
return result;
};
export const decodeRedisUtf8Value = (value: string): string => {
if (!value || value.length === 0) {
return '';
}
if (hasDecodedUnicodeText(value)) {
return value;
}
try {
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) > 0x7F) {
return decodeUtf8Bytes(value);
}
}
return value;
} catch {
return value;
}
};
export const formatRedisStringValue = (value: string): { displayValue: string; isBinary: boolean; isJson: boolean; encoding: string } => {
const { displayValue, encoding, needsHex } = tryDecodeValue(value);
if (needsHex) {
return { displayValue, isBinary: true, isJson: false, encoding };
}
const { isJson, formatted } = tryFormatJson(displayValue);
return { displayValue: formatted, isBinary: false, isJson, encoding };
};