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)
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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';
|
||||
});
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user