| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268 |
- #!/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);
|