|
|
@@ -0,0 +1,99 @@
|
|
|
+#!/usr/bin/env node
|
|
|
+
|
|
|
+import { execFileSync } from 'node:child_process';
|
|
|
+import { createHash } from 'node:crypto';
|
|
|
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
|
+import path from 'node:path';
|
|
|
+import { fileURLToPath } from 'node:url';
|
|
|
+import vm from 'node:vm';
|
|
|
+
|
|
|
+const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
+const sourcePath = 'scripts/deploy-admin-functions.mjs';
|
|
|
+const base = process.argv.find((arg) => arg.startsWith('--base='))?.slice(7);
|
|
|
+const commit = process.argv.includes('--commit');
|
|
|
+const masterKey = process.env.XIAOSHU_MASTER_KEY || '';
|
|
|
+const appId = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
|
|
|
+const parseUrl = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
|
|
|
+const sha = (value) => createHash('sha256').update(value).digest('hex');
|
|
|
+
|
|
|
+if (!base) throw new Error('请指定部署前的源码版本:--base=<commit>');
|
|
|
+if (!masterKey) throw new Error('缺少 XIAOSHU_MASTER_KEY');
|
|
|
+
|
|
|
+const previousSource = execFileSync('git', ['show', `${base}:${sourcePath}`], { cwd: repo, encoding: 'utf8', maxBuffer: 10_000_000 });
|
|
|
+const nextSource = await readFile(path.join(repo, sourcePath), 'utf8');
|
|
|
+const markers = [
|
|
|
+ ['function appointmentProjection(', 'async function allAppointmentRows('],
|
|
|
+ ['async function liveAppointmentRows(', 'async function cachedLiveAppointmentRows('],
|
|
|
+ ['async function cachedLiveAppointmentRows(', 'function payrollDateIso('],
|
|
|
+ ['async function professionalScheduleDataLocal(', 'function addBusinessDays('],
|
|
|
+ ["if(operation==='ops/schedule/calendar')", "if(operation==='ops/coach-availability/list')"],
|
|
|
+];
|
|
|
+
|
|
|
+function section(source, start, end) {
|
|
|
+ const from = source.indexOf(start);
|
|
|
+ const to = source.indexOf(end, from + start.length);
|
|
|
+ if (from < 0 || to < 0) throw new Error(`找不到排课代码段:${start}`);
|
|
|
+ return source.slice(from, to);
|
|
|
+}
|
|
|
+
|
|
|
+function replaceOnce(source, before, after) {
|
|
|
+ const first = source.indexOf(before);
|
|
|
+ if (first < 0 || source.indexOf(before, first + before.length) >= 0) throw new Error('线上排课代码与预期不一致,已拒绝覆盖');
|
|
|
+ return source.slice(0, first) + after + source.slice(first + before.length);
|
|
|
+}
|
|
|
+
|
|
|
+const headers = {
|
|
|
+ 'X-Parse-Application-Id': appId,
|
|
|
+ 'X-Parse-Master-Key': masterKey,
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+};
|
|
|
+const functionName = 'xiaoshu.ops.gateway.v3';
|
|
|
+const where = encodeURIComponent(JSON.stringify({ name: functionName }));
|
|
|
+const queryPath = `/classes/Function?where=${where}&keys=objectId,name,code,updatedAt`;
|
|
|
+async function parse(pathname, init = {}) {
|
|
|
+ const response = await fetch(parseUrl + pathname, { ...init, headers });
|
|
|
+ const body = await response.json();
|
|
|
+ if (!response.ok || body.error) throw new Error(`云函数接口失败:HTTP ${response.status} ${String(body.error || '')}`);
|
|
|
+ return body;
|
|
|
+}
|
|
|
+
|
|
|
+const initial = (await parse(queryPath)).results || [];
|
|
|
+if (initial.length !== 1 || typeof initial[0].code !== 'string') throw new Error('未找到唯一的线上运营网关');
|
|
|
+const original = initial[0];
|
|
|
+let patched = original.code;
|
|
|
+for (const [start, end] of markers) {
|
|
|
+ const before = section(previousSource, start, end);
|
|
|
+ const after = section(nextSource, start, end);
|
|
|
+ if (before === after) throw new Error(`本地排课代码段没有变化:${start}`);
|
|
|
+ patched = replaceOnce(patched, before, after);
|
|
|
+}
|
|
|
+const helper = section(nextSource, 'function reconcileScheduleAppointments(', 'async function professionalScheduleDataLocal(');
|
|
|
+if (patched.includes('function reconcileScheduleAppointments(')) throw new Error('线上已有排课对账函数,不能重复部署');
|
|
|
+patched = replaceOnce(patched, 'async function professionalScheduleDataLocal(', helper + 'async function professionalScheduleDataLocal(');
|
|
|
+new vm.Script(patched, { filename: functionName });
|
|
|
+
|
|
|
+const result = { functionName, objectId: original.objectId, before: sha(original.code), after: sha(patched), sourceUpdatedAt: original.updatedAt, changedSections: markers.length + 1 };
|
|
|
+if (!commit) {
|
|
|
+ console.log(JSON.stringify({ ...result, dryRun: true }, null, 2));
|
|
|
+ process.exit(0);
|
|
|
+}
|
|
|
+
|
|
|
+const backupDir = path.join(repo, 'dist', 'schedule-deploy-backup');
|
|
|
+await mkdir(backupDir, { recursive: true });
|
|
|
+const backupPath = path.join(backupDir, `${new Date().toISOString().replace(/[:.]/g, '-')}-${original.objectId}.json`);
|
|
|
+await writeFile(backupPath, JSON.stringify({ ...result, code: original.code }, null, 2));
|
|
|
+const beforeWrite = (await parse(queryPath)).results?.[0];
|
|
|
+if (!beforeWrite || sha(beforeWrite.code || '') !== result.before || beforeWrite.updatedAt !== original.updatedAt) throw new Error('线上网关在部署期间发生变化,已停止写入');
|
|
|
+
|
|
|
+try {
|
|
|
+ await parse(`/classes/Function/${original.objectId}`, { method: 'PUT', body: JSON.stringify({ code: patched }) });
|
|
|
+ const afterWrite = (await parse(queryPath)).results?.[0];
|
|
|
+ if (!afterWrite || sha(afterWrite.code || '') !== result.after) throw new Error('写入后代码校验失败');
|
|
|
+ console.log(JSON.stringify({ ...result, deployed: true, backupPath }, null, 2));
|
|
|
+} catch (error) {
|
|
|
+ const current = (await parse(queryPath).catch(() => ({ results: [] }))).results?.[0];
|
|
|
+ if (current && sha(current.code || '') === result.after) {
|
|
|
+ await parse(`/classes/Function/${original.objectId}`, { method: 'PUT', body: JSON.stringify({ code: original.code }) });
|
|
|
+ }
|
|
|
+ throw error;
|
|
|
+}
|