parse-client.mjs 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. /**
  2. * parse-client.mjs — Parse REST 客户端 + 凭据四级解析(零硬编码)
  3. *
  4. * 上报后端:https://server.fmode.cn/parse/classes/<Table>
  5. * 鉴权:X-Parse-Application-Id + X-Parse-Master-Key(或用户 sessionToken)
  6. *
  7. * 凭据解析链(优先级从高到低,全部运行时读取,零密钥入库):
  8. * 1. 环境变量 PARSE_APP_ID / PARSE_MASTER_KEY(或 sessionToken 走 X-Parse-Session-Token)
  9. * 2. ~/.fmode/config.json → parse 段({ appId, masterKey } 或顶层同名键)
  10. * 3. future-server/config/config.nova.json → parse 段(平台开发机标准位置)
  11. * 4. 都找不到 → 打印指引并退出(绝不猜、绝不硬编码)
  12. *
  13. * Date 字段铁律(schema 111 坑):
  14. * 裸字符串 "2026-09-12T08:00:00.000Z" 直接写 Parse 报 schema 111;
  15. * 必须 wrapDate() 包装为 {"__type":"Date","iso":"..."} —— 本模块统一处理。
  16. */
  17. import fs from 'fs';
  18. import path from 'path';
  19. import os from 'os';
  20. const DEFAULT_SERVER_URL = process.env.PARSE_SERVER_URL || 'https://server.fmode.cn/parse';
  21. function readJsonMaybe(filePath) {
  22. try {
  23. if (!filePath || !fs.existsSync(filePath)) return null;
  24. return JSON.parse(fs.readFileSync(filePath, 'utf-8').replace(/^/, ''));
  25. } catch {
  26. return null;
  27. }
  28. }
  29. /** 在任意层级找 parse 配置段:{parse:{appId,masterKey}} 或顶层 {appId,masterKey} */
  30. function extractParseSection(obj) {
  31. if (!obj || typeof obj !== 'object') return null;
  32. const sec = obj.parse && typeof obj.parse === 'object' ? obj.parse : obj;
  33. if (sec.appId && sec.masterKey) {
  34. return { appId: String(sec.appId), masterKey: String(sec.masterKey), serverURL: sec.serverURLOnline || sec.publicServerURL || sec.serverURL || null };
  35. }
  36. return null;
  37. }
  38. /** 凭据四级解析。返回 {appId, masterKey, serverURL, source} 或 null。 */
  39. export function resolveCredentials() {
  40. // 第 1 级:环境变量
  41. if (process.env.PARSE_APP_ID && process.env.PARSE_MASTER_KEY) {
  42. return {
  43. appId: process.env.PARSE_APP_ID.trim(),
  44. masterKey: process.env.PARSE_MASTER_KEY.trim(),
  45. serverURL: process.env.PARSE_SERVER_URL || DEFAULT_SERVER_URL,
  46. source: 'env',
  47. };
  48. }
  49. // 第 2 级:~/.fmode/config.json
  50. const homeCfg = readJsonMaybe(path.join(os.homedir(), '.fmode', 'config.json'));
  51. const fromHome = extractParseSection(homeCfg);
  52. if (fromHome) {
  53. return { ...fromHome, serverURL: fromHome.serverURL || DEFAULT_SERVER_URL, source: '~/.fmode/config.json' };
  54. }
  55. // 第 3 级:future-server config.nova.json(平台开发机标准位置)
  56. const candidates = [
  57. path.join(os.homedir(), 'git-repos', 'future-server', 'config', 'config.nova.json'),
  58. '/opt/data/git-repos/future-server/config/config.nova.json',
  59. process.env.FUTURE_SERVER_DIR ? path.join(process.env.FUTURE_SERVER_DIR, 'config', 'config.nova.json') : null,
  60. ].filter(Boolean);
  61. for (const p of candidates) {
  62. const cfg = readJsonMaybe(p);
  63. const sec = extractParseSection(cfg);
  64. if (sec) {
  65. return { ...sec, serverURL: sec.serverURL || DEFAULT_SERVER_URL, source: p };
  66. }
  67. }
  68. return null;
  69. }
  70. /** 未配置凭据时的统一报错指引(退出码 2) */
  71. export function failNoCredentials() {
  72. console.error('[task-progress] 未找到 Parse 凭据。按以下任一方式配置(零硬编码,凭据不入仓):');
  73. console.error(' 1. 环境变量: export PARSE_APP_ID=<appId> PARSE_MASTER_KEY=<masterKey>');
  74. console.error(' 2. ~/.fmode/config.json 写入 {"parse":{"appId":"...","masterKey":"..."}}');
  75. console.error(' 3. 平台开发机: future-server/config/config.nova.json 的 parse 段自动读取');
  76. process.exit(2);
  77. }
  78. /** Date 字段包装:裸字符串 → {"__type":"Date","iso":...}(防 schema 111) */
  79. export function wrapDate(value) {
  80. if (!value) return value;
  81. if (value && typeof value === 'object' && value.__type === 'Date' && value.iso) return value;
  82. const d = new Date(value);
  83. if (isNaN(d.getTime())) throw new Error(`无效日期: ${value}`);
  84. return { __type: 'Date', iso: d.toISOString() };
  85. }
  86. /** Pointer 包装 */
  87. export function wrapPointer(className, objectId) {
  88. return { __type: 'Pointer', className, objectId };
  89. }
  90. function headers(cred, extra) {
  91. const h = {
  92. 'X-Parse-Application-Id': cred.appId,
  93. 'X-Parse-Master-Key': cred.masterKey,
  94. 'Content-Type': 'application/json',
  95. ...extra,
  96. };
  97. return h;
  98. }
  99. async function parseFetch(url, options) {
  100. const res = await fetch(url, { ...options, signal: AbortSignal.timeout(30000) });
  101. const body = await res.json().catch(() => null);
  102. if (!res.ok) {
  103. const code = body && body.code ? body.code : res.status;
  104. const err = new Error(`Parse ${res.status} code=${code}: ${JSON.stringify(body && body.error ? body.error : body)}`);
  105. err.parseCode = code;
  106. throw err;
  107. }
  108. return body;
  109. }
  110. /** GET /classes/<Table>(filters: {where, limit, order, keys}) */
  111. export async function queryObjects(table, cred, filters = {}) {
  112. const qs = new URLSearchParams();
  113. if (filters.where) qs.set('where', JSON.stringify(filters.where));
  114. if (filters.limit) qs.set('limit', String(filters.limit));
  115. if (filters.order) qs.set('order', filters.order);
  116. if (filters.keys) qs.set('keys', filters.keys);
  117. const url = `${cred.serverURL}/classes/${table}${qs.toString() ? `?${qs}` : ''}`;
  118. const body = await parseFetch(url, { method: 'GET', headers: headers(cred) });
  119. return body.results || [];
  120. }
  121. /** POST /classes/<Table> 创建;data 中 Date 字段自动包装 */
  122. export async function createObject(table, cred, data) {
  123. const url = `${cred.serverURL}/classes/${table}`;
  124. return parseFetch(url, { method: 'POST', headers: headers(cred), body: JSON.stringify(data) });
  125. }
  126. /** PUT /classes/<Table>/<objectId> 更新;data 中 Date 字段自动包装 */
  127. export async function updateObject(table, cred, objectId, data) {
  128. const url = `${cred.serverURL}/classes/${table}/${objectId}`;
  129. return parseFetch(url, { method: 'PUT', headers: headers(cred), body: JSON.stringify(data) });
  130. }
  131. /** GET /schemas/<Table>(schema 校验用) */
  132. export async function getSchema(table, cred) {
  133. const url = `${cred.serverURL}/schemas/${table}`;
  134. try {
  135. return await parseFetch(url, { method: 'GET', headers: headers(cred) });
  136. } catch (e) {
  137. if (e.parseCode === 103 || (e.message && e.message.includes('class'))) return null; // 表不存在
  138. throw e;
  139. }
  140. }
  141. /** POST /schemas/<Table> 建表;已存在的表(code 105)回落 PUT 增量补列(幂等) */
  142. export async function createSchema(table, cred, fields) {
  143. const url = `${cred.serverURL}/schemas/${table}`;
  144. try {
  145. return await parseFetch(url, { method: 'POST', headers: headers(cred), body: JSON.stringify({ className: table, fields }) });
  146. } catch (e) {
  147. if (e.parseCode === 105 || (e.message && e.message.includes('already exist'))) {
  148. // 表已存在:PUT /schemas/<Class> 增量加缺失列(已存在的列 Parse 自动忽略)
  149. const body = await parseFetch(url, { method: 'PUT', headers: headers(cred), body: JSON.stringify({ fields }) });
  150. return { idempotent: true, ...body };
  151. }
  152. // 106 = 字段已存在(幂等场景视为成功)
  153. if (e.parseCode === 106 || (e.message && e.message.includes('already exist'))) {
  154. return { idempotent: true, parseCode: e.parseCode };
  155. }
  156. throw e;
  157. }
  158. }
  159. /** 幂等 upsert:按 where 查第一条,命中则覆盖更新,未命中则创建。返回 {objectId, created} */
  160. export async function upsertObject(table, cred, where, data) {
  161. const existing = await queryObjects(table, cred, { where, limit: 1 });
  162. if (existing.length > 0) {
  163. const objectId = existing[0].objectId;
  164. await updateObject(table, cred, objectId, data);
  165. return { objectId, created: false };
  166. }
  167. const created = await createObject(table, cred, data);
  168. return { objectId: created.objectId, created: true };
  169. }