deploy-schedule-reconciliation.mjs 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  1. #!/usr/bin/env node
  2. import { execFileSync } from 'node:child_process';
  3. import { createHash } from 'node:crypto';
  4. import { mkdir, readFile, writeFile } from 'node:fs/promises';
  5. import path from 'node:path';
  6. import { fileURLToPath } from 'node:url';
  7. import vm from 'node:vm';
  8. const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
  9. const sourcePath = 'scripts/deploy-admin-functions.mjs';
  10. const base = process.argv.find((arg) => arg.startsWith('--base='))?.slice(7);
  11. const commit = process.argv.includes('--commit');
  12. const masterKey = process.env.XIAOSHU_MASTER_KEY || '';
  13. const appId = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
  14. const parseUrl = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
  15. const sha = (value) => createHash('sha256').update(value).digest('hex');
  16. if (!base) throw new Error('请指定部署前的源码版本:--base=<commit>');
  17. if (!masterKey) throw new Error('缺少 XIAOSHU_MASTER_KEY');
  18. const previousSource = execFileSync('git', ['show', `${base}:${sourcePath}`], { cwd: repo, encoding: 'utf8', maxBuffer: 10_000_000 });
  19. const nextSource = await readFile(path.join(repo, sourcePath), 'utf8');
  20. const markers = [
  21. ['function appointmentProjection(', 'async function allAppointmentRows('],
  22. ['async function liveAppointmentRows(', 'async function cachedLiveAppointmentRows('],
  23. ['async function cachedLiveAppointmentRows(', 'function payrollDateIso('],
  24. ['async function professionalScheduleDataLocal(', 'function addBusinessDays('],
  25. ["if(operation==='ops/schedule/calendar')", "if(operation==='ops/coach-availability/list')"],
  26. ];
  27. function section(source, start, end) {
  28. const from = source.indexOf(start);
  29. const to = source.indexOf(end, from + start.length);
  30. if (from < 0 || to < 0) throw new Error(`找不到排课代码段:${start}`);
  31. return source.slice(from, to);
  32. }
  33. function replaceOnce(source, before, after) {
  34. const first = source.indexOf(before);
  35. if (first < 0 || source.indexOf(before, first + before.length) >= 0) throw new Error('线上排课代码与预期不一致,已拒绝覆盖');
  36. return source.slice(0, first) + after + source.slice(first + before.length);
  37. }
  38. const headers = {
  39. 'X-Parse-Application-Id': appId,
  40. 'X-Parse-Master-Key': masterKey,
  41. 'Content-Type': 'application/json',
  42. };
  43. const functionName = 'xiaoshu.ops.gateway.v3';
  44. const where = encodeURIComponent(JSON.stringify({ name: functionName }));
  45. const queryPath = `/classes/Function?where=${where}&keys=objectId,name,code,updatedAt`;
  46. async function parse(pathname, init = {}) {
  47. const response = await fetch(parseUrl + pathname, { ...init, headers });
  48. const body = await response.json();
  49. if (!response.ok || body.error) throw new Error(`云函数接口失败:HTTP ${response.status} ${String(body.error || '')}`);
  50. return body;
  51. }
  52. const initial = (await parse(queryPath)).results || [];
  53. if (initial.length !== 1 || typeof initial[0].code !== 'string') throw new Error('未找到唯一的线上运营网关');
  54. const original = initial[0];
  55. let patched = original.code;
  56. for (const [start, end] of markers) {
  57. const before = section(previousSource, start, end);
  58. const after = section(nextSource, start, end);
  59. if (before === after) throw new Error(`本地排课代码段没有变化:${start}`);
  60. patched = replaceOnce(patched, before, after);
  61. }
  62. const helper = section(nextSource, 'function reconcileScheduleAppointments(', 'async function professionalScheduleDataLocal(');
  63. if (patched.includes('function reconcileScheduleAppointments(')) throw new Error('线上已有排课对账函数,不能重复部署');
  64. patched = replaceOnce(patched, 'async function professionalScheduleDataLocal(', helper + 'async function professionalScheduleDataLocal(');
  65. new vm.Script(patched, { filename: functionName });
  66. const result = { functionName, objectId: original.objectId, before: sha(original.code), after: sha(patched), sourceUpdatedAt: original.updatedAt, changedSections: markers.length + 1 };
  67. if (!commit) {
  68. console.log(JSON.stringify({ ...result, dryRun: true }, null, 2));
  69. process.exit(0);
  70. }
  71. const backupDir = path.join(repo, 'dist', 'schedule-deploy-backup');
  72. await mkdir(backupDir, { recursive: true });
  73. const backupPath = path.join(backupDir, `${new Date().toISOString().replace(/[:.]/g, '-')}-${original.objectId}.json`);
  74. await writeFile(backupPath, JSON.stringify({ ...result, code: original.code }, null, 2));
  75. const beforeWrite = (await parse(queryPath)).results?.[0];
  76. if (!beforeWrite || sha(beforeWrite.code || '') !== result.before || beforeWrite.updatedAt !== original.updatedAt) throw new Error('线上网关在部署期间发生变化,已停止写入');
  77. try {
  78. await parse(`/classes/Function/${original.objectId}`, { method: 'PUT', body: JSON.stringify({ code: patched }) });
  79. const afterWrite = (await parse(queryPath)).results?.[0];
  80. if (!afterWrite || sha(afterWrite.code || '') !== result.after) throw new Error('写入后代码校验失败');
  81. console.log(JSON.stringify({ ...result, deployed: true, backupPath }, null, 2));
  82. } catch (error) {
  83. const current = (await parse(queryPath).catch(() => ({ results: [] }))).results?.[0];
  84. if (current && sha(current.code || '') === result.after) {
  85. await parse(`/classes/Function/${original.objectId}`, { method: 'PUT', body: JSON.stringify({ code: original.code }) });
  86. }
  87. throw error;
  88. }