deploy-cloud-fn.mjs 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. #!/usr/bin/env node
  2. /**
  3. * 云函数热更脚本(本地使用,不部署、不上传)
  4. *
  5. * ## 用途
  6. *
  7. * 把 `cloud/functions/*.js` 的最新源码推到线上 Parse 的 `Function` 表。
  8. * 热更免重启(`PUT /parse/classes/Function/<objectId>`)。
  9. *
  10. * ## 为什么需要 Master-Key
  11. *
  12. * 写 `Function` 表属于平台管理操作,只有 Master-Key 有权限。
  13. * 🔴 **Master-Key 绝不能写进代码或提交进 git**,只能通过环境变量传入:
  14. *
  15. * ```bash
  16. * PARSE_MASTER_KEY=xxx node scripts/deploy-cloud-fn.mjs
  17. * PARSE_MASTER_KEY=xxx node scripts/deploy-cloud-fn.mjs --dry-run
  18. * ```
  19. *
  20. * ## 安全
  21. *
  22. * · 默认 **dry-run**:只打印将要更新什么,不发请求
  23. * · `--apply` 才真正 PUT
  24. * · 不发请求前先 GET 一次,对比线上版本,避免误覆盖别人改过的代码
  25. *
  26. * ## 频率限制
  27. *
  28. * 实测:`PUT` 连续请求会返回 403,需退避重试(见操作追踪 :3125)。
  29. */
  30. import { readFileSync } from 'node:fs';
  31. import { dirname, resolve } from 'node:path';
  32. import { fileURLToPath } from 'node:url';
  33. const here = dirname(fileURLToPath(import.meta.url));
  34. const root = resolve(here, '..');
  35. const APP_ID = 'ncloudmaster';
  36. const BASE = 'https://server.fmode.cn/parse';
  37. const MK = process.env.PARSE_MASTER_KEY;
  38. const APPLY = process.argv.includes('--apply');
  39. /**
  40. * 只部署指定函数(2026-11-12 新增)。
  41. *
  42. * 背景:本地保存的是各函数的源码副本,但不保证与线上随时同步 ——
  43. * 别人可能在控制台改过线上版本。批量 `--apply` 会把本地较旧的副本推上去,
  44. * 覆盖掉那些改动。
  45. *
  46. * 用法:`--only=fmodeagent-matrix-provision`(按 name 或 id 精确匹配)
  47. */
  48. const ONLY = (process.argv.find((a) => a.startsWith('--only=')) ?? '').slice('--only='.length);
  49. /** 要部署的函数清单
  50. *
  51. * `create: true` 表示「线上还没这条就新建」——新建后会在控制台打印 objectId,
  52. * 需要把它写回 `src/app/core/api/parse-api.ts` 的 `FN` 常量。
  53. */
  54. const TARGETS = [
  55. {
  56. file: 'cloud/functions/fmodeagent-chatthreads.js',
  57. id: 'oR5O67i1Cm',
  58. name: 'fmodeagent-chatthreads',
  59. version: '0.1.9',
  60. },
  61. {
  62. file: 'cloud/functions/fmodeagent-matrix-provision.js',
  63. id: 'RP5Ht55NeX',
  64. name: 'fmodeagent-matrix-provision',
  65. version: '0.1.6',
  66. },
  67. {
  68. file: 'cloud/functions/fmode-file-hub.js',
  69. id: null, // 新建前未知,部署时按 name 查
  70. name: 'fmode-file-hub',
  71. /* v0.3.0(2026-11-13):归属主键切 roomId + 新增 listByUrl */
  72. version: '0.3.0',
  73. create: true,
  74. },
  75. ];
  76. function authHeaders() {
  77. return {
  78. 'X-Parse-Application-Id': APP_ID,
  79. 'X-Parse-Master-Key': MK,
  80. 'Content-Type': 'application/json',
  81. };
  82. }
  83. async function getRemote(id) {
  84. const res = await fetch(`${BASE}/classes/Function/${id}`, { headers: authHeaders() });
  85. if (!res.ok) throw new Error(`读取失败 HTTP ${res.status}`);
  86. return res.json();
  87. }
  88. /** 按函数名查 objectId(新建前用于判断「线上是否已存在」) */
  89. async function findByName(name) {
  90. const where = encodeURIComponent(JSON.stringify({ name }));
  91. const res = await fetch(`${BASE}/classes/Function?where=${where}&limit=1`, {
  92. headers: authHeaders(),
  93. });
  94. if (!res.ok) throw new Error(`按名查询失败 HTTP ${res.status}`);
  95. const j = await res.json();
  96. return (j.results ?? [])[0] ?? null;
  97. }
  98. /** 新建云函数(POST /classes/Function),返回新 objectId */
  99. async function createRemote(name, code, version) {
  100. const res = await fetch(`${BASE}/classes/Function`, {
  101. method: 'POST',
  102. headers: authHeaders(),
  103. body: JSON.stringify({ name, code, version, enabled: true }),
  104. });
  105. const text = await res.text();
  106. if (!res.ok) throw new Error(`新建失败 HTTP ${res.status}: ${text.slice(0, 200)}`);
  107. const j = JSON.parse(text);
  108. return j.objectId;
  109. }
  110. /** PUT 有频率限制,403 时退避重试 */
  111. async function putRemote(id, body, attempt = 1) {
  112. const res = await fetch(`${BASE}/classes/Function/${id}`, {
  113. method: 'PUT',
  114. headers: authHeaders(),
  115. body: JSON.stringify(body),
  116. });
  117. if (res.status === 403 && attempt <= 4) {
  118. const wait = 1000 * attempt;
  119. console.log(` ⚠️ 403(频率限制),${wait}ms 后重试 ${attempt}/4`);
  120. await new Promise((r) => setTimeout(r, wait));
  121. return putRemote(id, body, attempt + 1);
  122. }
  123. const text = await res.text();
  124. if (!res.ok) throw new Error(`PUT 失败 HTTP ${res.status}: ${text.slice(0, 200)}`);
  125. try {
  126. return JSON.parse(text);
  127. } catch {
  128. return { raw: text.slice(0, 200) };
  129. }
  130. }
  131. async function main() {
  132. if (!MK) {
  133. console.error('❌ 缺少 PARSE_MASTER_KEY 环境变量');
  134. console.error(' 用法:PARSE_MASTER_KEY=xxx node scripts/deploy-cloud-fn.mjs [--apply]');
  135. process.exit(2);
  136. }
  137. console.log(APPLY ? '🔴 APPLY:将热更线上云函数' : '🟡 DRY-RUN:只对比,不写入');
  138. if (ONLY) console.log(`🎯 只处理:${ONLY}`);
  139. for (const t of TARGETS) {
  140. /* `--only=` 指定时,跳过不匹配的(按 name 或 id) */
  141. if (ONLY && t.name !== ONLY && t.id !== ONLY) continue;
  142. const code = readFileSync(resolve(root, t.file), 'utf8');
  143. console.log(`\n=== ${t.name} (${t.id ?? '待新建'}) ===`);
  144. console.log(` 本地源码:${t.file},${code.length} 字符,v${t.version}`);
  145. /* 新建分支:线上还没有这条 Function 记录 */
  146. if (t.create) {
  147. let existing = null;
  148. try {
  149. existing = await findByName(t.name);
  150. } catch (e) {
  151. console.log(` ⚠️ 按名查询失败:${e.message}`);
  152. }
  153. if (!existing) {
  154. console.log(' → 线上不存在,将新建');
  155. if (!APPLY) {
  156. console.log(' → 加 --apply 才会真正新建');
  157. continue;
  158. }
  159. const id = await createRemote(t.name, code, t.version);
  160. console.log(` ✅ 已新建,objectId = ${id}`);
  161. console.log(` 🔴 请把它写进 src/app/core/api/parse-api.ts 的 FN 常量`);
  162. continue;
  163. }
  164. t.id = existing.objectId;
  165. console.log(` → 线上已存在,objectId = ${t.id},转为更新`);
  166. }
  167. let remote;
  168. try {
  169. remote = await getRemote(t.id);
  170. } catch (e) {
  171. console.log(` ⚠️ 读取线上失败:${e.message}(跳过对比)`);
  172. remote = null;
  173. }
  174. if (remote) {
  175. const rc = remote.code || '';
  176. console.log(` 线上源码:${rc.length} 字符,v${remote.version ?? '?'}`);
  177. if (rc === code) {
  178. console.log(' ✅ 与线上完全一致,无需部署');
  179. continue;
  180. }
  181. if (rc.length && code.length < rc.length * 0.7) {
  182. console.log(' 🔴 警告:本地源码明显小于线上,可能回退功能,已跳过');
  183. continue;
  184. }
  185. }
  186. if (!APPLY) {
  187. console.log(' → 加 --apply 才会真正热更');
  188. continue;
  189. }
  190. const r = await putRemote(t.id, { code, version: t.version, enabled: true });
  191. console.log(` ✅ 已热更:${JSON.stringify(r).slice(0, 120)}(免重启,立即生效)`);
  192. }
  193. }
  194. main().catch((e) => {
  195. console.error('❌ 执行失败:', e.message);
  196. process.exit(1);
  197. });