fix: harden publish snapshot diff normalization and error handling

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-23 12:03:47 +08:00
parent 6ec72d8635
commit 81dd2f05d1
3 changed files with 144 additions and 43 deletions

View File

@ -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 }
} }
} }

View File

@ -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';
}); });

View File

@ -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
} }