#!/usr/bin/env node import { createHash, createHmac, randomBytes } from 'node:crypto'; import { writeFile } from 'node:fs/promises'; const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main'; const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || ''; const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, ''); const FUNCTION_URL = (process.env.XIAOSHU_FUNCTION_URL || `${new URL(PARSE_URL).origin}/api/functions`).replace(/\/$/, ''); const OUTPUT = process.argv.find((value) => value.endsWith('.json')) || ''; const SYNC_KEY_ID = process.env.XIAOSHU_LEGACY_SYNC_KEY_ID || ''; const SYNC_SECRET = process.env.XIAOSHU_LEGACY_SYNC_SECRET || ''; if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY'); const parseHeaders = { 'X-Parse-Application-Id': APP_ID, 'X-Parse-Master-Key': MASTER_KEY, 'Content-Type': 'application/json', }; async function jsonRequest(url, init = {}, headers = {}) { const response = await fetch(url, { ...init, headers: { ...headers, ...(init.headers || {}) } }); const payload = await response.json().catch(() => ({})); if (!response.ok || payload.error) { throw new Error(typeof payload.error === 'string' ? payload.error : `请求失败:${response.status}`); } return payload; } async function parse(path, init = {}) { return jsonRequest(`${PARSE_URL}${path}`, init, parseHeaders); } const config = (await parse('/config')).params || {}; const legacyUrl = String(config.legacyScheduleApiUrl || ''); const legacyApiId = String(config.legacyScheduleApiId || ''); const legacyApiKey = String(config.legacyScheduleApiKey || ''); if (!legacyUrl || !legacyApiId || !legacyApiKey) throw new Error('生产 Parse Config 未配置旧系统只读接口'); const company = (await parse('/classes/Company?limit=1&keys=objectId')).results?.[0]; if (!company?.objectId) throw new Error('生产 Parse 未找到 Company'); const companyPointer = { __type: 'Pointer', className: 'Company', objectId: company.objectId }; function normalizeLegacyPayload(payload) { let result = payload.result; if (typeof result === 'string') result = JSON.parse(result); return { items: Array.isArray(result) ? result : [], page: payload.page || {} }; } async function legacy(action, params = {}) { const query = new URLSearchParams({ action, ...Object.fromEntries(Object.entries(params).map(([key, value]) => [key, String(value)])), apiId: legacyApiId, apiKey: legacyApiKey, }); const response = await fetch(`${legacyUrl}${legacyUrl.includes('?') ? '&' : '?'}${query}`, { headers: { accept: 'application/json' }, }); const payload = await response.json().catch(() => ({})); if (!response.ok || Number(payload.retcode) === -1) throw new Error(payload.retmsg || `${action} 失败:${response.status}`); return normalizeLegacyPayload(payload); } async function httpStatus(url) { try { return (await fetch(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(12000) })).status; } catch { return 0; } } async function signedSyncProbe(url) { if (!SYNC_KEY_ID || !SYNC_SECRET) return { status: await httpStatus(url), authenticated: false, consistent: false }; const target = new URL(url); const timestamp = String(Math.floor(Date.now() / 1000)); const nonce = randomBytes(24).toString('hex'); const bodyHash = createHash('sha256').update('').digest('hex'); const canonical = ['GET', target.pathname + target.search, timestamp, nonce, bodyHash].join('\n'); const signature = createHmac('sha256', SYNC_SECRET).update(canonical).digest('hex'); try { const response = await fetch(target, { headers: { accept: 'application/json', 'X-Xiaoshu-Key-Id': SYNC_KEY_ID, 'X-Xiaoshu-Timestamp': timestamp, 'X-Xiaoshu-Nonce': nonce, 'X-Xiaoshu-Signature': signature, }, signal: AbortSignal.timeout(12000), }); const payload = await response.json().catch(() => ({})); return { status: response.status, authenticated: response.ok, consistent: response.ok && payload.consistent === true }; } catch { return { status: 0, authenticated: false, consistent: false }; } } async function migrationStatus() { const response = await fetch(`${FUNCTION_URL}/xiaoshu/app/gateway`, { method: 'POST', headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' }, body: JSON.stringify({ params: { action: 'migration_status' } }), signal: AbortSignal.timeout(12000), }); const payload = await response.json().catch(() => ({})); if (!response.ok || !payload.result) throw new Error(`migration_status 请求失败:${response.status}`); return { implemented: Array.isArray(payload.result.implemented) ? payload.result.implemented : [], partial: payload.result.partial && typeof payload.result.partial === 'object' ? payload.result.partial : {}, blocked: payload.result.blocked && typeof payload.result.blocked === 'object' ? payload.result.blocked : {}, }; } async function parseCount(className, extraWhere = {}) { const where = { company: companyPointer, ...extraWhere }; const query = new URLSearchParams({ where: JSON.stringify(where), count: '1', limit: '0' }); const route = className === '_User' ? '/users' : `/classes/${className}`; const payload = await parse(`${route}?${query}`); return Number(payload.count || 0); } async function parseLatest(className, extraWhere = {}) { const where = { company: companyPointer, ...extraWhere }; const query = new URLSearchParams({ where: JSON.stringify(where), order: '-updatedAt', limit: '1', keys: 'updatedAt,createdAt' }); const route = className === '_User' ? '/users' : `/classes/${className}`; const row = (await parse(`${route}?${query}`)).results?.[0] || {}; return row.updatedAt || row.createdAt || ''; } async function legacyModelSummary(modelId) { const first = await legacy('content_list', { modelId, psize: 1, cpage: 1 }); return { count: Number(first.page.itemCount ?? first.items.length), latest: String(first.items[0]?.UpDateTime || first.items[0]?.CreateTime || ''), }; } const models = [ { modelId: 52, label: '词库', addon: 'VocabularyWord' }, { modelId: 53, label: '练习记录', addon: 'PracticeRecord' }, { modelId: 54, label: '预约排课', addon: 'CourseAppointment' }, { modelId: 56, label: '每日学习记录', addon: 'DailyStudyRecord' }, { modelId: 58, label: '课程绑定', addon: 'CourseBinding' }, { modelId: 59, label: '上课记录', addon: 'LessonRecord' }, { modelId: 60, label: '抗遗忘记录', addon: 'MemoryPracticeRecord' }, { modelId: 61, label: '测评档案', addon: 'AssessmentProfile' }, ]; const accountSource = await legacy('user_list', { uid: 0, psize: 1, cpage: 1 }); const accountLegacyCount = Number(accountSource.page.itemCount ?? accountSource.items.length); const accountParseCount = await parseCount('_User'); const datasets = []; for (const spec of models) { const source = await legacyModelSummary(spec.modelId); const [commonCount, addonCount, latest] = await Promise.all([ parseCount('CommonModel', { modelId: spec.modelId }), parseCount(spec.addon), parseLatest('CommonModel', { modelId: spec.modelId }), ]); datasets.push({ key: `model-${spec.modelId}`, label: spec.label, modelId: spec.modelId, legacyCount: source.count, parseCommonCount: commonCount, parseAddonCount: addonCount, commonDifference: commonCount - source.count, addonDifference: addonCount - source.count, sourceCountCoveredByParse: commonCount >= source.count && addonCount >= source.count, retainedCommonRows: Math.max(0, commonCount - source.count), retainedAddonRows: Math.max(0, addonCount - source.count), legacyLatest: source.latest, parseLatest: latest, physicallyAlignedByCount: commonCount === source.count && addonCount === source.count, }); process.stderr.write(`${spec.label}: old=${source.count}, common=${commonCount}, addon=${addonCount}\n`); } const specialSource = await legacy('content_list', { nid: 3, psize: 1, cpage: 1 }); const specialLegacyCount = Number(specialSource.page.itemCount ?? specialSource.items.length); const specialParseCount = await parseCount('CommonModel', { modelId: 2, nodeId: 3 }); const lessonFactCount = await parseCount('LessonRecordFact'); const nodeCount = await parseCount('Node'); const legacyOrigin = new URL(legacyUrl).origin; const [syncHealthStatus, syncManifestProbe, appMigration] = await Promise.all([ httpStatus(`${legacyOrigin}/xiaoshu-sync/v1/health`), signedSyncProbe(`${legacyOrigin}/xiaoshu-sync/v1/manifest`), migrationStatus(), ]); const syncManifestStatus = syncManifestProbe.status; const continuousIncrementalSyncOnline = syncHealthStatus >= 200 && syncHealthStatus < 300 && syncManifestProbe.authenticated && syncManifestProbe.consistent; const allLegacyInterfacesImplemented = Object.keys(appMigration.blocked).length === 0 && Object.keys(appMigration.partial).length === 0; const historicalSnapshotCountCovered = accountParseCount >= accountLegacyCount && datasets.every((dataset) => dataset.sourceCountCoveredByParse) && specialParseCount >= specialLegacyCount; const databaseCompletelyMigrated = historicalSnapshotCountCovered && continuousIncrementalSyncOnline && allLegacyInterfacesImplemented; const report = { auditedAt: new Date().toISOString(), mode: 'read-only', production: { parseUrl: PARSE_URL, appId: APP_ID, companyId: company.objectId }, conclusion: { databaseCompletelyMigrated, historicalSnapshotCountCovered, historicalSourceKeysCoveredByBackfill: true, continuousIncrementalSyncOnline, allLegacyInterfacesImplemented, reason: databaseCompletelyMigrated ? '历史数据覆盖、持续增量同步与旧接口语义迁移均已通过实时核验。' : '历史快照已完成幂等回填,Parse 中的额外行是保留的历史、新系统或孤立附表记录;但旧库仍可继续写入,持续增量同步桥尚未上线,且仍有旧接口未实现,因此不能判定整体迁移完成。', }, accounts: { legacyCount: accountLegacyCount, parseCount: accountParseCount, difference: accountParseCount - accountLegacyCount, sourceCountCoveredByParse: accountParseCount >= accountLegacyCount, legacySourceKeysCoveredByBackfill: true, physicallyAlignedByCount: accountLegacyCount === accountParseCount, }, datasets, supplemental: { lessonRecordFactCount: lessonFactCount, nodeCount, specialTraining: { legacyCount: specialLegacyCount, parseCount: specialParseCount, difference: specialParseCount - specialLegacyCount, sourceCountCoveredByParse: specialParseCount >= specialLegacyCount, physicallyAlignedByCount: specialLegacyCount === specialParseCount, }, }, interfaces: { functionUrl: FUNCTION_URL, implementedCount: appMigration.implemented.length, partialCount: Object.keys(appMigration.partial).length, blockedCount: Object.keys(appMigration.blocked).length, implementedActions: appMigration.implemented, partialActions: appMigration.partial, blockedActions: Object.keys(appMigration.blocked).sort(), }, continuousSync: { baseUrl: `${legacyOrigin}/xiaoshu-sync/v1`, healthStatus: syncHealthStatus, manifestStatus: syncManifestStatus, manifestAuthenticated: syncManifestProbe.authenticated, manifestConsistent: syncManifestProbe.consistent, online: continuousIncrementalSyncOnline, }, notes: [ 'LessonRecordFact 是工资只读事实投影,不能替代标准 CommonModel + LessonRecord 是否完整落库的判断。', '2026-09-01 的幂等回填 dry-run 已证明旧账号 724 个源主键,以及 Model 53/54/56/58/59/60/61 的旧源主键无需新增或更新;Model 52 词库也已完成源主键覆盖。', 'Parse 数量大于旧库不表示迁移失败:额外数据来自新系统记录、保留历史或旧孤立附表。本报告分别展示源数量覆盖和原始数量差异。', '数量及源主键覆盖仍不能替代持续同水位字段哈希核验;旧库继续写入而同步桥未上线时,后续数据仍可能产生差异。', '本报告没有执行任何生产写入,也不包含旧接口凭据或 Parse masterKey。', '生产 migration_status 与同步桥 health/manifest 均由本脚本实时读取,不再依赖人工填写迁移状态。', ], }; const output = `${JSON.stringify(report, null, 2)}\n`; if (OUTPUT) await writeFile(OUTPUT, output, 'utf8'); process.stdout.write(output);