fix: harden publish snapshot diff normalization and error handling
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6ec72d8635
commit
81dd2f05d1
@ -6,98 +6,196 @@ function canonicalKey(key: string): string {
|
|||||||
return key.charAt(0).toLowerCase() + key.slice(1)
|
return key.charAt(0).toLowerCase() + key.slice(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUDIT_KEYS = new Set([
|
/** 对比时可忽略的审计/元数据字段 */
|
||||||
|
const NOISE_KEYS = new Set([
|
||||||
'createtime',
|
'createtime',
|
||||||
'updatetime',
|
'updatetime',
|
||||||
'createby',
|
'createby',
|
||||||
'updateby',
|
'updateby',
|
||||||
'createtimeutc',
|
'createtimeutc',
|
||||||
'updatetimeutc',
|
'updatetimeutc',
|
||||||
|
'conurrencystamp',
|
||||||
|
'isdeleted',
|
||||||
|
'extraproperties',
|
||||||
|
'deleterid',
|
||||||
|
'deletiontime',
|
||||||
|
'lastmodificationtime',
|
||||||
|
'lastmodifierid',
|
||||||
|
'creatorid',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const ARRAY_SORT_KEYS = ['id', 'serialNumber', 'priceId', 'factor', 'name', 'version'] as const
|
||||||
|
|
||||||
|
const DEFAULT_DATE_PREFIXES = ['0001-01-01', '1970-01-01']
|
||||||
|
|
||||||
function isDefaultDateString(value: string): boolean {
|
function isDefaultDateString(value: string): boolean {
|
||||||
return /^0001-01-01[ T]00:00:00/.test(value.trim())
|
const trimmed = value.trim()
|
||||||
|
return DEFAULT_DATE_PREFIXES.some((prefix) => trimmed.startsWith(prefix))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDateTimeString(value: string): boolean {
|
||||||
|
return /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/.test(value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDateString(value: string): string {
|
||||||
|
return value.trim().replace('T', ' ').slice(0, 19)
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeScalar(value: unknown): unknown {
|
function normalizeScalar(value: unknown): unknown {
|
||||||
if (typeof value !== 'string') return value
|
if (value === null || value === undefined) return undefined
|
||||||
if (isDefaultDateString(value)) return undefined
|
if (typeof value === 'string') {
|
||||||
if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/.test(value)) {
|
if (value.trim() === '') return undefined
|
||||||
return value.replace('T', ' ').slice(0, 19)
|
if (isDefaultDateString(value)) return undefined
|
||||||
|
if (isDateTimeString(value)) return normalizeDateString(value)
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (!Number.isFinite(value)) return undefined
|
||||||
|
if (Number.isInteger(value)) return value
|
||||||
|
const rounded = Math.round(value * 1_000_000) / 1_000_000
|
||||||
|
return Object.is(rounded, -0) ? 0 : rounded
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') return value
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value != null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectArraySortKey(items: unknown[]): (typeof ARRAY_SORT_KEYS)[number] | null {
|
||||||
|
const records = items.filter(isPlainObject) as Record<string, unknown>[]
|
||||||
|
if (records.length === 0) return null
|
||||||
|
for (const key of ARRAY_SORT_KEYS) {
|
||||||
|
const canonical = canonicalKey(key)
|
||||||
|
if (
|
||||||
|
records.every((item) => {
|
||||||
|
const raw = item[key] ?? item[canonical]
|
||||||
|
return raw !== undefined && raw !== null && raw !== ''
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSortValue(record: Record<string, unknown>, key: string): number | string {
|
||||||
|
const raw = record[key] ?? record[canonicalKey(key)]
|
||||||
|
if (typeof raw === 'number') return raw
|
||||||
|
if (typeof raw === 'string') return raw
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
function sortArrayItems(items: unknown[]): unknown[] {
|
function sortArrayItems(items: unknown[]): unknown[] {
|
||||||
if (items.length === 0) return items
|
if (items.length <= 1) return items
|
||||||
const first = items[0]
|
const sortKey = detectArraySortKey(items)
|
||||||
if (first == null || typeof first !== 'object' || Array.isArray(first)) {
|
if (!sortKey) return items
|
||||||
return items
|
return [...items].sort((a, b) => {
|
||||||
}
|
const left = readSortValue(a as Record<string, unknown>, sortKey)
|
||||||
const record = first as Record<string, unknown>
|
const right = readSortValue(b as Record<string, unknown>, sortKey)
|
||||||
if ('id' in record) {
|
if (typeof left === 'number' && typeof right === 'number') return left - right
|
||||||
return [...items].sort(
|
return String(left).localeCompare(String(right))
|
||||||
(a, b) => Number((a as Record<string, unknown>)?.id ?? 0) - Number((b as Record<string, unknown>)?.id ?? 0)
|
})
|
||||||
)
|
}
|
||||||
}
|
|
||||||
if ('serialNumber' in record) {
|
function isEmptyContainer(value: unknown): boolean {
|
||||||
return [...items].sort(
|
if (Array.isArray(value)) return value.length === 0
|
||||||
(a, b) =>
|
if (isPlainObject(value)) return Object.keys(value).length === 0
|
||||||
Number((a as Record<string, unknown>)?.serialNumber ?? 0)
|
return false
|
||||||
- Number((b as Record<string, unknown>)?.serialNumber ?? 0)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 递归规范化快照对象,便于语义对比 */
|
/** 递归规范化快照对象,便于语义对比 */
|
||||||
export function normalizeSnapshotForDiff(input: unknown): unknown {
|
export function normalizeSnapshotForDiff(input: unknown): unknown {
|
||||||
|
const normalized = normalizeSnapshotNode(input)
|
||||||
|
if (normalized === undefined || isEmptyContainer(normalized)) return undefined
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSnapshotNode(input: unknown): unknown {
|
||||||
if (input === null || input === undefined) return undefined
|
if (input === null || input === undefined) return undefined
|
||||||
|
|
||||||
if (Array.isArray(input)) {
|
if (Array.isArray(input)) {
|
||||||
const normalized = input
|
const normalized = input
|
||||||
.map((item) => normalizeSnapshotForDiff(item))
|
.map((item) => normalizeSnapshotNode(item))
|
||||||
.filter((item) => item !== undefined)
|
.filter((item) => item !== undefined)
|
||||||
return sortArrayItems(normalized)
|
return sortArrayItems(normalized)
|
||||||
}
|
}
|
||||||
if (typeof input === 'object') {
|
|
||||||
const record = input as Record<string, unknown>
|
if (isPlainObject(input)) {
|
||||||
|
const record = input
|
||||||
const keys = Object.keys(record).sort((a, b) =>
|
const keys = Object.keys(record).sort((a, b) =>
|
||||||
canonicalKey(a).localeCompare(canonicalKey(b))
|
canonicalKey(a).localeCompare(canonicalKey(b))
|
||||||
)
|
)
|
||||||
const out: Record<string, unknown> = {}
|
const out: Record<string, unknown> = {}
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const ck = canonicalKey(key)
|
const ck = canonicalKey(key)
|
||||||
if (AUDIT_KEYS.has(ck)) continue
|
if (NOISE_KEYS.has(ck)) continue
|
||||||
const val = normalizeSnapshotForDiff(record[key])
|
const val = normalizeSnapshotNode(record[key])
|
||||||
if (val === undefined) continue
|
if (val === undefined || isEmptyContainer(val)) continue
|
||||||
out[ck] = val
|
out[ck] = val
|
||||||
}
|
}
|
||||||
return out
|
return Object.keys(out).length > 0 ? out : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizeScalar(input)
|
return normalizeScalar(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSnapshotJson(raw: unknown): unknown {
|
/** 解析快照 JSON,兼容双重编码与 publish record 包装 */
|
||||||
|
export function parseSnapshotJson(raw: unknown, depth = 0): unknown {
|
||||||
|
if (depth > 4) return raw ?? {}
|
||||||
if (raw == null || raw === '') return {}
|
if (raw == null || raw === '') return {}
|
||||||
|
|
||||||
if (typeof raw === 'string') {
|
if (typeof raw === 'string') {
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
if (!trimmed) return {}
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw)
|
return parseSnapshotJson(JSON.parse(trimmed), depth + 1)
|
||||||
} catch {
|
} catch {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(raw)) {
|
||||||
|
const content = raw.content ?? raw.Content
|
||||||
|
const hasWrapper =
|
||||||
|
content != null &&
|
||||||
|
('serviceChannelId' in raw ||
|
||||||
|
'ServiceChannelId' in raw ||
|
||||||
|
'salesProductId' in raw ||
|
||||||
|
'SalesProductId' in raw ||
|
||||||
|
'operatorType' in raw ||
|
||||||
|
'OperatorType' in raw ||
|
||||||
|
'version' in raw ||
|
||||||
|
'Version' in raw)
|
||||||
|
if (hasWrapper) {
|
||||||
|
return parseSnapshotJson(content, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSnapshotJson(raw: unknown): string {
|
export function formatSnapshotJson(raw: unknown): string {
|
||||||
try {
|
try {
|
||||||
const parsed = parseSnapshotJson(raw)
|
const parsed = parseSnapshotJson(raw)
|
||||||
return JSON.stringify(parsed, null, 2)
|
const normalized = normalizeSnapshotForDiff(parsed)
|
||||||
|
return JSON.stringify(normalized ?? parsed ?? {}, null, 2)
|
||||||
} catch {
|
} catch {
|
||||||
return typeof raw === 'string' ? raw : '{}'
|
return typeof raw === 'string' ? raw : '{}'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function snapshotsSemanticallyEqual(originalRaw: unknown, modifiedRaw: unknown): boolean {
|
||||||
|
try {
|
||||||
|
const original = normalizeSnapshotForDiff(parseSnapshotJson(originalRaw))
|
||||||
|
const modified = normalizeSnapshotForDiff(parseSnapshotJson(modifiedRaw))
|
||||||
|
return JSON.stringify(original) === JSON.stringify(modified)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type SnapshotDiffRow =
|
export type SnapshotDiffRow =
|
||||||
| { type: 'add' | 'remove' | 'same'; text: string }
|
| { type: 'add' | 'remove' | 'same'; text: string }
|
||||||
| { type: 'change'; oldText: string; newText: string }
|
| { type: 'change'; oldText: string; newText: string }
|
||||||
@ -133,21 +231,22 @@ function flattenDiffParts(diff: ReturnType<typeof diffJson>) {
|
|||||||
export function computeSnapshotDiff(
|
export function computeSnapshotDiff(
|
||||||
originalRaw: unknown,
|
originalRaw: unknown,
|
||||||
modifiedRaw: unknown
|
modifiedRaw: unknown
|
||||||
): { length: number; rows: SnapshotDiffRow[] } {
|
): { length: number; rows: SnapshotDiffRow[]; equal: boolean } {
|
||||||
try {
|
try {
|
||||||
const original = normalizeSnapshotForDiff(parseSnapshotJson(originalRaw))
|
const original = normalizeSnapshotForDiff(parseSnapshotJson(originalRaw))
|
||||||
const modified = normalizeSnapshotForDiff(parseSnapshotJson(modifiedRaw))
|
const modified = normalizeSnapshotForDiff(parseSnapshotJson(modifiedRaw))
|
||||||
const originalText = JSON.stringify(original)
|
const originalText = JSON.stringify(original)
|
||||||
const modifiedText = JSON.stringify(modified)
|
const modifiedText = JSON.stringify(modified)
|
||||||
if (originalText === modifiedText) {
|
if (originalText === modifiedText) {
|
||||||
return { length: 0, rows: [] }
|
return { length: 0, rows: [], equal: true }
|
||||||
}
|
}
|
||||||
const diff = diffJson(original, modified)
|
const diff = diffJson(original, modified)
|
||||||
const rows = flattenDiffParts(diff)
|
const rows = flattenDiffParts(diff)
|
||||||
const length = diff.filter((item) => item.added || item.removed).length
|
const length = rows.filter((row) => row.type !== 'same').length
|
||||||
return { length, rows }
|
return { length, rows, equal: false }
|
||||||
} catch {
|
} catch (error) {
|
||||||
return { length: 0, rows: [] }
|
console.warn('[publishSnapshotDiff] compare failed', error)
|
||||||
|
return { length: -1, rows: [], equal: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1025,7 +1025,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { length } = computeSnapshotDiff(online, preview);
|
const { length } = computeSnapshotDiff(online, preview);
|
||||||
pricePublishPending.value = length > 0;
|
pricePublishPending.value = length < 0 ? null : length > 0;
|
||||||
} catch {
|
} catch {
|
||||||
pricePublishPending.value = null;
|
pricePublishPending.value = null;
|
||||||
}
|
}
|
||||||
@ -1331,6 +1331,7 @@
|
|||||||
const publishDiffStatus = computed(() => {
|
const publishDiffStatus = computed(() => {
|
||||||
if (!publishHasOnlineSnapshot.value) return 'first';
|
if (!publishHasOnlineSnapshot.value) return 'first';
|
||||||
if (publishDiffLength.value === 0) return 'synced';
|
if (publishDiffLength.value === 0) return 'synced';
|
||||||
|
if (publishDiffLength.value < 0) return 'changed';
|
||||||
return 'changed';
|
return 'changed';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -3109,6 +3109,7 @@ const pricePublishPending = ref<boolean | null>(null)
|
|||||||
const publishDiffStatus = computed(() => {
|
const publishDiffStatus = computed(() => {
|
||||||
if (!publishHasOnlineSnapshot.value) return 'first'
|
if (!publishHasOnlineSnapshot.value) return 'first'
|
||||||
if (publishDiffLength.value === 0) return 'synced'
|
if (publishDiffLength.value === 0) return 'synced'
|
||||||
|
if (publishDiffLength.value < 0) return 'changed'
|
||||||
return 'changed'
|
return 'changed'
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -3135,7 +3136,7 @@ async function refreshPricePublishSyncStatus(serviceChannelId?: number | string)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { length } = computeSnapshotDiff(online, preview)
|
const { length } = computeSnapshotDiff(online, preview)
|
||||||
pricePublishPending.value = length > 0
|
pricePublishPending.value = length < 0 ? null : length > 0
|
||||||
} catch {
|
} catch {
|
||||||
pricePublishPending.value = null
|
pricePublishPending.value = null
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user