|
@@ -0,0 +1,185 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * parse-client.mjs — Parse REST 客户端 + 凭据四级解析(零硬编码)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 上报后端:https://server.fmode.cn/parse/classes/<Table>
|
|
|
|
|
+ * 鉴权:X-Parse-Application-Id + X-Parse-Master-Key(或用户 sessionToken)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 凭据解析链(优先级从高到低,全部运行时读取,零密钥入库):
|
|
|
|
|
+ * 1. 环境变量 PARSE_APP_ID / PARSE_MASTER_KEY(或 sessionToken 走 X-Parse-Session-Token)
|
|
|
|
|
+ * 2. ~/.fmode/config.json → parse 段({ appId, masterKey } 或顶层同名键)
|
|
|
|
|
+ * 3. future-server/config/config.nova.json → parse 段(平台开发机标准位置)
|
|
|
|
|
+ * 4. 都找不到 → 打印指引并退出(绝不猜、绝不硬编码)
|
|
|
|
|
+ *
|
|
|
|
|
+ * Date 字段铁律(schema 111 坑):
|
|
|
|
|
+ * 裸字符串 "2026-09-12T08:00:00.000Z" 直接写 Parse 报 schema 111;
|
|
|
|
|
+ * 必须 wrapDate() 包装为 {"__type":"Date","iso":"..."} —— 本模块统一处理。
|
|
|
|
|
+ */
|
|
|
|
|
+
|
|
|
|
|
+import fs from 'fs';
|
|
|
|
|
+import path from 'path';
|
|
|
|
|
+import os from 'os';
|
|
|
|
|
+
|
|
|
|
|
+const DEFAULT_SERVER_URL = process.env.PARSE_SERVER_URL || 'https://server.fmode.cn/parse';
|
|
|
|
|
+
|
|
|
|
|
+function readJsonMaybe(filePath) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (!filePath || !fs.existsSync(filePath)) return null;
|
|
|
|
|
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8').replace(/^/, ''));
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 在任意层级找 parse 配置段:{parse:{appId,masterKey}} 或顶层 {appId,masterKey} */
|
|
|
|
|
+function extractParseSection(obj) {
|
|
|
|
|
+ if (!obj || typeof obj !== 'object') return null;
|
|
|
|
|
+ const sec = obj.parse && typeof obj.parse === 'object' ? obj.parse : obj;
|
|
|
|
|
+ if (sec.appId && sec.masterKey) {
|
|
|
|
|
+ return { appId: String(sec.appId), masterKey: String(sec.masterKey), serverURL: sec.serverURLOnline || sec.publicServerURL || sec.serverURL || null };
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 凭据四级解析。返回 {appId, masterKey, serverURL, source} 或 null。 */
|
|
|
|
|
+export function resolveCredentials() {
|
|
|
|
|
+ // 第 1 级:环境变量
|
|
|
|
|
+ if (process.env.PARSE_APP_ID && process.env.PARSE_MASTER_KEY) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ appId: process.env.PARSE_APP_ID.trim(),
|
|
|
|
|
+ masterKey: process.env.PARSE_MASTER_KEY.trim(),
|
|
|
|
|
+ serverURL: process.env.PARSE_SERVER_URL || DEFAULT_SERVER_URL,
|
|
|
|
|
+ source: 'env',
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ // 第 2 级:~/.fmode/config.json
|
|
|
|
|
+ const homeCfg = readJsonMaybe(path.join(os.homedir(), '.fmode', 'config.json'));
|
|
|
|
|
+ const fromHome = extractParseSection(homeCfg);
|
|
|
|
|
+ if (fromHome) {
|
|
|
|
|
+ return { ...fromHome, serverURL: fromHome.serverURL || DEFAULT_SERVER_URL, source: '~/.fmode/config.json' };
|
|
|
|
|
+ }
|
|
|
|
|
+ // 第 3 级:future-server config.nova.json(平台开发机标准位置)
|
|
|
|
|
+ const candidates = [
|
|
|
|
|
+ path.join(os.homedir(), 'git-repos', 'future-server', 'config', 'config.nova.json'),
|
|
|
|
|
+ '/opt/data/git-repos/future-server/config/config.nova.json',
|
|
|
|
|
+ process.env.FUTURE_SERVER_DIR ? path.join(process.env.FUTURE_SERVER_DIR, 'config', 'config.nova.json') : null,
|
|
|
|
|
+ ].filter(Boolean);
|
|
|
|
|
+ for (const p of candidates) {
|
|
|
|
|
+ const cfg = readJsonMaybe(p);
|
|
|
|
|
+ const sec = extractParseSection(cfg);
|
|
|
|
|
+ if (sec) {
|
|
|
|
|
+ return { ...sec, serverURL: sec.serverURL || DEFAULT_SERVER_URL, source: p };
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 未配置凭据时的统一报错指引(退出码 2) */
|
|
|
|
|
+export function failNoCredentials() {
|
|
|
|
|
+ console.error('[task-progress] 未找到 Parse 凭据。按以下任一方式配置(零硬编码,凭据不入仓):');
|
|
|
|
|
+ console.error(' 1. 环境变量: export PARSE_APP_ID=<appId> PARSE_MASTER_KEY=<masterKey>');
|
|
|
|
|
+ console.error(' 2. ~/.fmode/config.json 写入 {"parse":{"appId":"...","masterKey":"..."}}');
|
|
|
|
|
+ console.error(' 3. 平台开发机: future-server/config/config.nova.json 的 parse 段自动读取');
|
|
|
|
|
+ process.exit(2);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Date 字段包装:裸字符串 → {"__type":"Date","iso":...}(防 schema 111) */
|
|
|
|
|
+export function wrapDate(value) {
|
|
|
|
|
+ if (!value) return value;
|
|
|
|
|
+ if (value && typeof value === 'object' && value.__type === 'Date' && value.iso) return value;
|
|
|
|
|
+ const d = new Date(value);
|
|
|
|
|
+ if (isNaN(d.getTime())) throw new Error(`无效日期: ${value}`);
|
|
|
|
|
+ return { __type: 'Date', iso: d.toISOString() };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Pointer 包装 */
|
|
|
|
|
+export function wrapPointer(className, objectId) {
|
|
|
|
|
+ return { __type: 'Pointer', className, objectId };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function headers(cred, extra) {
|
|
|
|
|
+ const h = {
|
|
|
|
|
+ 'X-Parse-Application-Id': cred.appId,
|
|
|
|
|
+ 'X-Parse-Master-Key': cred.masterKey,
|
|
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
|
|
+ ...extra,
|
|
|
|
|
+ };
|
|
|
|
|
+ return h;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function parseFetch(url, options) {
|
|
|
|
|
+ const res = await fetch(url, { ...options, signal: AbortSignal.timeout(30000) });
|
|
|
|
|
+ const body = await res.json().catch(() => null);
|
|
|
|
|
+ if (!res.ok) {
|
|
|
|
|
+ const code = body && body.code ? body.code : res.status;
|
|
|
|
|
+ const err = new Error(`Parse ${res.status} code=${code}: ${JSON.stringify(body && body.error ? body.error : body)}`);
|
|
|
|
|
+ err.parseCode = code;
|
|
|
|
|
+ throw err;
|
|
|
|
|
+ }
|
|
|
|
|
+ return body;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** GET /classes/<Table>(filters: {where, limit, order, keys}) */
|
|
|
|
|
+export async function queryObjects(table, cred, filters = {}) {
|
|
|
|
|
+ const qs = new URLSearchParams();
|
|
|
|
|
+ if (filters.where) qs.set('where', JSON.stringify(filters.where));
|
|
|
|
|
+ if (filters.limit) qs.set('limit', String(filters.limit));
|
|
|
|
|
+ if (filters.order) qs.set('order', filters.order);
|
|
|
|
|
+ if (filters.keys) qs.set('keys', filters.keys);
|
|
|
|
|
+ const url = `${cred.serverURL}/classes/${table}${qs.toString() ? `?${qs}` : ''}`;
|
|
|
|
|
+ const body = await parseFetch(url, { method: 'GET', headers: headers(cred) });
|
|
|
|
|
+ return body.results || [];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** POST /classes/<Table> 创建;data 中 Date 字段自动包装 */
|
|
|
|
|
+export async function createObject(table, cred, data) {
|
|
|
|
|
+ const url = `${cred.serverURL}/classes/${table}`;
|
|
|
|
|
+ return parseFetch(url, { method: 'POST', headers: headers(cred), body: JSON.stringify(data) });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** PUT /classes/<Table>/<objectId> 更新;data 中 Date 字段自动包装 */
|
|
|
|
|
+export async function updateObject(table, cred, objectId, data) {
|
|
|
|
|
+ const url = `${cred.serverURL}/classes/${table}/${objectId}`;
|
|
|
|
|
+ return parseFetch(url, { method: 'PUT', headers: headers(cred), body: JSON.stringify(data) });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** GET /schemas/<Table>(schema 校验用) */
|
|
|
|
|
+export async function getSchema(table, cred) {
|
|
|
|
|
+ const url = `${cred.serverURL}/schemas/${table}`;
|
|
|
|
|
+ try {
|
|
|
|
|
+ return await parseFetch(url, { method: 'GET', headers: headers(cred) });
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ if (e.parseCode === 103 || (e.message && e.message.includes('class'))) return null; // 表不存在
|
|
|
|
|
+ throw e;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** POST /schemas/<Table> 建表;已存在的表(code 105)回落 PUT 增量补列(幂等) */
|
|
|
|
|
+export async function createSchema(table, cred, fields) {
|
|
|
|
|
+ const url = `${cred.serverURL}/schemas/${table}`;
|
|
|
|
|
+ try {
|
|
|
|
|
+ return await parseFetch(url, { method: 'POST', headers: headers(cred), body: JSON.stringify({ className: table, fields }) });
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ if (e.parseCode === 105 || (e.message && e.message.includes('already exist'))) {
|
|
|
|
|
+ // 表已存在:PUT /schemas/<Class> 增量加缺失列(已存在的列 Parse 自动忽略)
|
|
|
|
|
+ const body = await parseFetch(url, { method: 'PUT', headers: headers(cred), body: JSON.stringify({ fields }) });
|
|
|
|
|
+ return { idempotent: true, ...body };
|
|
|
|
|
+ }
|
|
|
|
|
+ // 106 = 字段已存在(幂等场景视为成功)
|
|
|
|
|
+ if (e.parseCode === 106 || (e.message && e.message.includes('already exist'))) {
|
|
|
|
|
+ return { idempotent: true, parseCode: e.parseCode };
|
|
|
|
|
+ }
|
|
|
|
|
+ throw e;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 幂等 upsert:按 where 查第一条,命中则覆盖更新,未命中则创建。返回 {objectId, created} */
|
|
|
|
|
+export async function upsertObject(table, cred, where, data) {
|
|
|
|
|
+ const existing = await queryObjects(table, cred, { where, limit: 1 });
|
|
|
|
|
+ if (existing.length > 0) {
|
|
|
|
|
+ const objectId = existing[0].objectId;
|
|
|
|
|
+ await updateObject(table, cred, objectId, data);
|
|
|
|
|
+ return { objectId, created: false };
|
|
|
|
|
+ }
|
|
|
|
|
+ const created = await createObject(table, cred, data);
|
|
|
|
|
+ return { objectId: created.objectId, created: true };
|
|
|
|
|
+}
|