#!/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='); 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; }