/** * parse-client.mjs — Parse REST 客户端 + 凭据四级解析(零硬编码) * * 上报后端:https://server.fmode.cn/parse/classes/ * 鉴权: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= PARSE_MASTER_KEY='); 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/
(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/
创建;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/
/ 更新;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/
(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/
建表;已存在的表(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/ 增量加缺失列(已存在的列 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 }; }