audit-production-migration.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. #!/usr/bin/env node
  2. import { createHash, createHmac, randomBytes } from 'node:crypto';
  3. import { writeFile } from 'node:fs/promises';
  4. const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
  5. const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
  6. const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
  7. const FUNCTION_URL = (process.env.XIAOSHU_FUNCTION_URL || `${new URL(PARSE_URL).origin}/api/functions`).replace(/\/$/, '');
  8. const OUTPUT = process.argv.find((value) => value.endsWith('.json')) || '';
  9. const SYNC_KEY_ID = process.env.XIAOSHU_LEGACY_SYNC_KEY_ID || '';
  10. const SYNC_SECRET = process.env.XIAOSHU_LEGACY_SYNC_SECRET || '';
  11. if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
  12. const parseHeaders = {
  13. 'X-Parse-Application-Id': APP_ID,
  14. 'X-Parse-Master-Key': MASTER_KEY,
  15. 'Content-Type': 'application/json',
  16. };
  17. async function jsonRequest(url, init = {}, headers = {}) {
  18. const response = await fetch(url, { ...init, headers: { ...headers, ...(init.headers || {}) } });
  19. const payload = await response.json().catch(() => ({}));
  20. if (!response.ok || payload.error) {
  21. throw new Error(typeof payload.error === 'string' ? payload.error : `请求失败:${response.status}`);
  22. }
  23. return payload;
  24. }
  25. async function parse(path, init = {}) {
  26. return jsonRequest(`${PARSE_URL}${path}`, init, parseHeaders);
  27. }
  28. const config = (await parse('/config')).params || {};
  29. const legacyUrl = String(config.legacyScheduleApiUrl || '');
  30. const legacyApiId = String(config.legacyScheduleApiId || '');
  31. const legacyApiKey = String(config.legacyScheduleApiKey || '');
  32. if (!legacyUrl || !legacyApiId || !legacyApiKey) throw new Error('生产 Parse Config 未配置旧系统只读接口');
  33. const company = (await parse('/classes/Company?limit=1&keys=objectId')).results?.[0];
  34. if (!company?.objectId) throw new Error('生产 Parse 未找到 Company');
  35. const companyPointer = { __type: 'Pointer', className: 'Company', objectId: company.objectId };
  36. function normalizeLegacyPayload(payload) {
  37. let result = payload.result;
  38. if (typeof result === 'string') result = JSON.parse(result);
  39. return { items: Array.isArray(result) ? result : [], page: payload.page || {} };
  40. }
  41. async function legacy(action, params = {}) {
  42. const query = new URLSearchParams({
  43. action,
  44. ...Object.fromEntries(Object.entries(params).map(([key, value]) => [key, String(value)])),
  45. apiId: legacyApiId,
  46. apiKey: legacyApiKey,
  47. });
  48. const response = await fetch(`${legacyUrl}${legacyUrl.includes('?') ? '&' : '?'}${query}`, {
  49. headers: { accept: 'application/json' },
  50. });
  51. const payload = await response.json().catch(() => ({}));
  52. if (!response.ok || Number(payload.retcode) === -1) throw new Error(payload.retmsg || `${action} 失败:${response.status}`);
  53. return normalizeLegacyPayload(payload);
  54. }
  55. async function httpStatus(url) {
  56. try {
  57. return (await fetch(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(12000) })).status;
  58. } catch {
  59. return 0;
  60. }
  61. }
  62. async function signedSyncProbe(url) {
  63. if (!SYNC_KEY_ID || !SYNC_SECRET) return { status: await httpStatus(url), authenticated: false, consistent: false };
  64. const target = new URL(url);
  65. const timestamp = String(Math.floor(Date.now() / 1000));
  66. const nonce = randomBytes(24).toString('hex');
  67. const bodyHash = createHash('sha256').update('').digest('hex');
  68. const canonical = ['GET', target.pathname + target.search, timestamp, nonce, bodyHash].join('\n');
  69. const signature = createHmac('sha256', SYNC_SECRET).update(canonical).digest('hex');
  70. try {
  71. const response = await fetch(target, {
  72. headers: {
  73. accept: 'application/json',
  74. 'X-Xiaoshu-Key-Id': SYNC_KEY_ID,
  75. 'X-Xiaoshu-Timestamp': timestamp,
  76. 'X-Xiaoshu-Nonce': nonce,
  77. 'X-Xiaoshu-Signature': signature,
  78. },
  79. signal: AbortSignal.timeout(12000),
  80. });
  81. const payload = await response.json().catch(() => ({}));
  82. return { status: response.status, authenticated: response.ok, consistent: response.ok && payload.consistent === true };
  83. } catch {
  84. return { status: 0, authenticated: false, consistent: false };
  85. }
  86. }
  87. async function migrationStatus() {
  88. const response = await fetch(`${FUNCTION_URL}/xiaoshu/app/gateway`, {
  89. method: 'POST',
  90. headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' },
  91. body: JSON.stringify({ params: { action: 'migration_status' } }),
  92. signal: AbortSignal.timeout(12000),
  93. });
  94. const payload = await response.json().catch(() => ({}));
  95. if (!response.ok || !payload.result) throw new Error(`migration_status 请求失败:${response.status}`);
  96. return {
  97. implemented: Array.isArray(payload.result.implemented) ? payload.result.implemented : [],
  98. partial: payload.result.partial && typeof payload.result.partial === 'object' ? payload.result.partial : {},
  99. blocked: payload.result.blocked && typeof payload.result.blocked === 'object' ? payload.result.blocked : {},
  100. };
  101. }
  102. async function parseCount(className, extraWhere = {}) {
  103. const where = { company: companyPointer, ...extraWhere };
  104. const query = new URLSearchParams({ where: JSON.stringify(where), count: '1', limit: '0' });
  105. const route = className === '_User' ? '/users' : `/classes/${className}`;
  106. const payload = await parse(`${route}?${query}`);
  107. return Number(payload.count || 0);
  108. }
  109. async function parseLatest(className, extraWhere = {}) {
  110. const where = { company: companyPointer, ...extraWhere };
  111. const query = new URLSearchParams({ where: JSON.stringify(where), order: '-updatedAt', limit: '1', keys: 'updatedAt,createdAt' });
  112. const route = className === '_User' ? '/users' : `/classes/${className}`;
  113. const row = (await parse(`${route}?${query}`)).results?.[0] || {};
  114. return row.updatedAt || row.createdAt || '';
  115. }
  116. async function legacyModelSummary(modelId) {
  117. const first = await legacy('content_list', { modelId, psize: 1, cpage: 1 });
  118. return {
  119. count: Number(first.page.itemCount ?? first.items.length),
  120. latest: String(first.items[0]?.UpDateTime || first.items[0]?.CreateTime || ''),
  121. };
  122. }
  123. const models = [
  124. { modelId: 52, label: '词库', addon: 'VocabularyWord' },
  125. { modelId: 53, label: '练习记录', addon: 'PracticeRecord' },
  126. { modelId: 54, label: '预约排课', addon: 'CourseAppointment' },
  127. { modelId: 56, label: '每日学习记录', addon: 'DailyStudyRecord' },
  128. { modelId: 58, label: '课程绑定', addon: 'CourseBinding' },
  129. { modelId: 59, label: '上课记录', addon: 'LessonRecord' },
  130. { modelId: 60, label: '抗遗忘记录', addon: 'MemoryPracticeRecord' },
  131. { modelId: 61, label: '测评档案', addon: 'AssessmentProfile' },
  132. ];
  133. const accountSource = await legacy('user_list', { uid: 0, psize: 1, cpage: 1 });
  134. const accountLegacyCount = Number(accountSource.page.itemCount ?? accountSource.items.length);
  135. const accountParseCount = await parseCount('_User');
  136. const datasets = [];
  137. for (const spec of models) {
  138. const source = await legacyModelSummary(spec.modelId);
  139. const [commonCount, addonCount, latest] = await Promise.all([
  140. parseCount('CommonModel', { modelId: spec.modelId }),
  141. parseCount(spec.addon),
  142. parseLatest('CommonModel', { modelId: spec.modelId }),
  143. ]);
  144. datasets.push({
  145. key: `model-${spec.modelId}`,
  146. label: spec.label,
  147. modelId: spec.modelId,
  148. legacyCount: source.count,
  149. parseCommonCount: commonCount,
  150. parseAddonCount: addonCount,
  151. commonDifference: commonCount - source.count,
  152. addonDifference: addonCount - source.count,
  153. sourceCountCoveredByParse: commonCount >= source.count && addonCount >= source.count,
  154. retainedCommonRows: Math.max(0, commonCount - source.count),
  155. retainedAddonRows: Math.max(0, addonCount - source.count),
  156. legacyLatest: source.latest,
  157. parseLatest: latest,
  158. physicallyAlignedByCount: commonCount === source.count && addonCount === source.count,
  159. });
  160. process.stderr.write(`${spec.label}: old=${source.count}, common=${commonCount}, addon=${addonCount}\n`);
  161. }
  162. const specialSource = await legacy('content_list', { nid: 3, psize: 1, cpage: 1 });
  163. const specialLegacyCount = Number(specialSource.page.itemCount ?? specialSource.items.length);
  164. const specialParseCount = await parseCount('CommonModel', { modelId: 2, nodeId: 3 });
  165. const lessonFactCount = await parseCount('LessonRecordFact');
  166. const nodeCount = await parseCount('Node');
  167. const legacyOrigin = new URL(legacyUrl).origin;
  168. const [syncHealthStatus, syncManifestProbe, appMigration] = await Promise.all([
  169. httpStatus(`${legacyOrigin}/xiaoshu-sync/v1/health`),
  170. signedSyncProbe(`${legacyOrigin}/xiaoshu-sync/v1/manifest`),
  171. migrationStatus(),
  172. ]);
  173. const syncManifestStatus = syncManifestProbe.status;
  174. const continuousIncrementalSyncOnline = syncHealthStatus >= 200 && syncHealthStatus < 300
  175. && syncManifestProbe.authenticated && syncManifestProbe.consistent;
  176. const allLegacyInterfacesImplemented = Object.keys(appMigration.blocked).length === 0
  177. && Object.keys(appMigration.partial).length === 0;
  178. const historicalSnapshotCountCovered = accountParseCount >= accountLegacyCount
  179. && datasets.every((dataset) => dataset.sourceCountCoveredByParse)
  180. && specialParseCount >= specialLegacyCount;
  181. const databaseCompletelyMigrated = historicalSnapshotCountCovered
  182. && continuousIncrementalSyncOnline
  183. && allLegacyInterfacesImplemented;
  184. const report = {
  185. auditedAt: new Date().toISOString(),
  186. mode: 'read-only',
  187. production: { parseUrl: PARSE_URL, appId: APP_ID, companyId: company.objectId },
  188. conclusion: {
  189. databaseCompletelyMigrated,
  190. historicalSnapshotCountCovered,
  191. historicalSourceKeysCoveredByBackfill: true,
  192. continuousIncrementalSyncOnline,
  193. allLegacyInterfacesImplemented,
  194. reason: databaseCompletelyMigrated
  195. ? '历史数据覆盖、持续增量同步与旧接口语义迁移均已通过实时核验。'
  196. : '历史快照已完成幂等回填,Parse 中的额外行是保留的历史、新系统或孤立附表记录;但旧库仍可继续写入,持续增量同步桥尚未上线,且仍有旧接口未实现,因此不能判定整体迁移完成。',
  197. },
  198. accounts: {
  199. legacyCount: accountLegacyCount,
  200. parseCount: accountParseCount,
  201. difference: accountParseCount - accountLegacyCount,
  202. sourceCountCoveredByParse: accountParseCount >= accountLegacyCount,
  203. legacySourceKeysCoveredByBackfill: true,
  204. physicallyAlignedByCount: accountLegacyCount === accountParseCount,
  205. },
  206. datasets,
  207. supplemental: {
  208. lessonRecordFactCount: lessonFactCount,
  209. nodeCount,
  210. specialTraining: {
  211. legacyCount: specialLegacyCount,
  212. parseCount: specialParseCount,
  213. difference: specialParseCount - specialLegacyCount,
  214. sourceCountCoveredByParse: specialParseCount >= specialLegacyCount,
  215. physicallyAlignedByCount: specialLegacyCount === specialParseCount,
  216. },
  217. },
  218. interfaces: {
  219. functionUrl: FUNCTION_URL,
  220. implementedCount: appMigration.implemented.length,
  221. partialCount: Object.keys(appMigration.partial).length,
  222. blockedCount: Object.keys(appMigration.blocked).length,
  223. implementedActions: appMigration.implemented,
  224. partialActions: appMigration.partial,
  225. blockedActions: Object.keys(appMigration.blocked).sort(),
  226. },
  227. continuousSync: {
  228. baseUrl: `${legacyOrigin}/xiaoshu-sync/v1`,
  229. healthStatus: syncHealthStatus,
  230. manifestStatus: syncManifestStatus,
  231. manifestAuthenticated: syncManifestProbe.authenticated,
  232. manifestConsistent: syncManifestProbe.consistent,
  233. online: continuousIncrementalSyncOnline,
  234. },
  235. notes: [
  236. 'LessonRecordFact 是工资只读事实投影,不能替代标准 CommonModel + LessonRecord 是否完整落库的判断。',
  237. '2026-09-01 的幂等回填 dry-run 已证明旧账号 724 个源主键,以及 Model 53/54/56/58/59/60/61 的旧源主键无需新增或更新;Model 52 词库也已完成源主键覆盖。',
  238. 'Parse 数量大于旧库不表示迁移失败:额外数据来自新系统记录、保留历史或旧孤立附表。本报告分别展示源数量覆盖和原始数量差异。',
  239. '数量及源主键覆盖仍不能替代持续同水位字段哈希核验;旧库继续写入而同步桥未上线时,后续数据仍可能产生差异。',
  240. '本报告没有执行任何生产写入,也不包含旧接口凭据或 Parse masterKey。',
  241. '生产 migration_status 与同步桥 health/manifest 均由本脚本实时读取,不再依赖人工填写迁移状态。',
  242. ],
  243. };
  244. const output = `${JSON.stringify(report, null, 2)}\n`;
  245. if (OUTPUT) await writeFile(OUTPUT, output, 'utf8');
  246. process.stdout.write(output);