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)
}
const AUDIT_KEYS = new Set([
/** 对比时可忽略的审计/元数据字段 */
const NOISE_KEYS = new Set([
'createtime',
'updatetime',
'createby',
'updateby',
'createtimeutc',
'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 {
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 {
if (typeof value !== 'string') return value
if (isDefaultDateString(value)) return undefined
if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/.test(value)) {
return value.replace('T', ' ').slice(0, 19)
if (value === null || value === undefined) return undefined
if (typeof value === 'string') {
if (value.trim() === '') return undefined
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
}
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[] {
if (items.length === 0) return items
const first = items[0]
if (first == null || typeof first !== 'object' || Array.isArray(first)) {
return items
}
const record = first as Record<string, unknown>
if ('id' in record) {
return [...items].sort(
(a, b) => Number((a as Record<string, unknown>)?.id ?? 0) - Number((b as Record<string, unknown>)?.id ?? 0)
)
}
if ('serialNumber' in record) {
return [...items].sort(
(a, b) =>
Number((a as Record<string, unknown>)?.serialNumber ?? 0)
- Number((b as Record<string, unknown>)?.serialNumber ?? 0)
)
}
return items
if (items.length <= 1) return items
const sortKey = detectArraySortKey(items)
if (!sortKey) return items
return [...items].sort((a, b) => {
const left = readSortValue(a as Record<string, unknown>, sortKey)
const right = readSortValue(b as Record<string, unknown>, sortKey)
if (typeof left === 'number' && typeof right === 'number') return left - right
return String(left).localeCompare(String(right))
})
}
function isEmptyContainer(value: unknown): boolean {
if (Array.isArray(value)) return value.length === 0
if (isPlainObject(value)) return Object.keys(value).length === 0
return false
}
/** 递归规范化快照对象,便于语义对比 */
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 (Array.isArray(input)) {
const normalized = input
.map((item) => normalizeSnapshotForDiff(item))
.map((item) => normalizeSnapshotNode(item))
.filter((item) => item !== undefined)
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) =>
canonicalKey(a).localeCompare(canonicalKey(b))
)
const out: Record<string, unknown> = {}
for (const key of keys) {
const ck = canonicalKey(key)
if (AUDIT_KEYS.has(ck)) continue
const val = normalizeSnapshotForDiff(record[key])
if (val === undefined) continue
if (NOISE_KEYS.has(ck)) continue
const val = normalizeSnapshotNode(record[key])
if (val === undefined || isEmptyContainer(val)) continue
out[ck] = val
}
return out
return Object.keys(out).length > 0 ? out : undefined
}
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 (typeof raw === 'string') {
const trimmed = raw.trim()
if (!trimmed) return {}
try {
return JSON.parse(raw)
return parseSnapshotJson(JSON.parse(trimmed), depth + 1)
} catch {
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
}
export function formatSnapshotJson(raw: unknown): string {
try {
const parsed = parseSnapshotJson(raw)
return JSON.stringify(parsed, null, 2)
const normalized = normalizeSnapshotForDiff(parsed)
return JSON.stringify(normalized ?? parsed ?? {}, null, 2)
} catch {
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 =
| { type: 'add' | 'remove' | 'same'; text: string }
| { type: 'change'; oldText: string; newText: string }
@ -133,21 +231,22 @@ function flattenDiffParts(diff: ReturnType<typeof diffJson>) {
export function computeSnapshotDiff(
originalRaw: unknown,
modifiedRaw: unknown
): { length: number; rows: SnapshotDiffRow[] } {
): { length: number; rows: SnapshotDiffRow[]; equal: boolean } {
try {
const original = normalizeSnapshotForDiff(parseSnapshotJson(originalRaw))
const modified = normalizeSnapshotForDiff(parseSnapshotJson(modifiedRaw))
const originalText = JSON.stringify(original)
const modifiedText = JSON.stringify(modified)
if (originalText === modifiedText) {
return { length: 0, rows: [] }
return { length: 0, rows: [], equal: true }
}
const diff = diffJson(original, modified)
const rows = flattenDiffParts(diff)
const length = diff.filter((item) => item.added || item.removed).length
return { length, rows }
} catch {
return { length: 0, rows: [] }
const length = rows.filter((row) => row.type !== 'same').length
return { length, rows, equal: false }
} catch (error) {
console.warn('[publishSnapshotDiff] compare failed', error)
return { length: -1, rows: [], equal: false }
}
}

View File

@ -1025,7 +1025,7 @@
return;
}
const { length } = computeSnapshotDiff(online, preview);
pricePublishPending.value = length > 0;
pricePublishPending.value = length < 0 ? null : length > 0;
} catch {
pricePublishPending.value = null;
}
@ -1331,6 +1331,7 @@
const publishDiffStatus = computed(() => {
if (!publishHasOnlineSnapshot.value) return 'first';
if (publishDiffLength.value === 0) return 'synced';
if (publishDiffLength.value < 0) return 'changed';
return 'changed';
});

View File

@ -3109,6 +3109,7 @@ const pricePublishPending = ref<boolean | null>(null)
const publishDiffStatus = computed(() => {
if (!publishHasOnlineSnapshot.value) return 'first'
if (publishDiffLength.value === 0) return 'synced'
if (publishDiffLength.value < 0) return 'changed'
return 'changed'
})
@ -3135,7 +3136,7 @@ async function refreshPricePublishSyncStatus(serviceChannelId?: number | string)
return
}
const { length } = computeSnapshotDiff(online, preview)
pricePublishPending.value = length > 0
pricePublishPending.value = length < 0 ? null : length > 0
} catch {
pricePublishPending.value = null
}