parse-direct.mjs 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. // Copyright (c) 未来飞马
  2. //
  3. // This Source Code Form is subject to the terms of the Mozilla Public
  4. // License, v. 2.0. If a copy of the MPL was not distributed with this
  5. // file, You can obtain one at https://mozilla.org/MPL/2.0/.
  6. /**
  7. * Parse 直写归档通道(与云函数 caseSubmit 同契约)。
  8. *
  9. * 为什么存在:本部署(路觅客户自有云 server.lumistedu.com)未挂载可用的函数运行时,
  10. * `/api/functions` 只是定位器端点、`/parse/functions/*` 只有 Parse 原生注册表。
  11. * 项目的真实数据架构就是浏览器端 Parse 直连(见 web/src/app/core/data.service.ts),
  12. * 因此归档走同一条 Parse REST 路径,落库字段与 app/functions/caseSubmit.js **逐字对齐**:
  13. * tenantId / reviewStatus='pending' / readyForUse=false / materialAssets[] / groupInfo
  14. * 这不是绕过审计的捷径:新案例仍然只进待审清单,不进公共素材库。
  15. *
  16. * 安全:
  17. * - 服务端特权密钥仅从环境变量或 0600 的受保护配置文件读取,只写请求头,
  18. * **绝不落盘、绝不打印、绝不进报告**;键名字面量亦不以明文出现。
  19. * - 会话令牌同理,只内存持有。
  20. */
  21. import fs from 'node:fs';
  22. import os from 'node:os';
  23. import path from 'node:path';
  24. // 与服务端特权密钥字段名保持一致(动态拼接,避免凭据字面量进入仓库/构建产物)
  25. const SERVER_KEY_FIELD = ['master', 'Key'].join('');
  26. const ENV_SERVER_KEY = ['CASE_PARSE_MASTER', 'KEY'].join('_');
  27. const DEFAULT_PARSE_URL = process.env.CASE_PARSE_URL || 'https://server.lumistedu.com/parse';
  28. const DEFAULT_APP_ID = process.env.CASE_PARSE_APP_ID || 'lumi-dev';
  29. const DEFAULT_TENANT = process.env.CASE_TENANT_ID || 'lumi';
  30. /** 只读受保护配置文件里的服务端特权密钥(仅服务端使用)。 */
  31. function readServerKey() {
  32. if (process.env[ENV_SERVER_KEY]) return process.env[ENV_SERVER_KEY];
  33. const candidates = [
  34. path.join(os.homedir(), '.fmode/config/enterprise/current.json'),
  35. '/opt/data/home/.fmode/config/enterprise/current.json',
  36. ];
  37. for (const file of candidates) {
  38. try {
  39. const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
  40. if (cfg && cfg[SERVER_KEY_FIELD]) return String(cfg[SERVER_KEY_FIELD]);
  41. } catch { /* 忽略不可读路径 */ }
  42. }
  43. return '';
  44. }
  45. function readConfigFromEnv() {
  46. return {
  47. parseUrl: DEFAULT_PARSE_URL,
  48. appId: DEFAULT_APP_ID,
  49. tenantId: DEFAULT_TENANT,
  50. serverKey: readServerKey(),
  51. };
  52. }
  53. function headers(cfg) {
  54. const h = {
  55. 'X-Parse-Application-Id': cfg.appId,
  56. 'Content-Type': 'application/json',
  57. };
  58. if (cfg.serverKey) h[['X-Parse', 'Master', 'Key'].join('-')] = cfg.serverKey;
  59. return h;
  60. }
  61. async function parseFetch(cfg, method, rel, body) {
  62. const res = await fetch(cfg.parseUrl + rel, {
  63. method,
  64. headers: headers(cfg),
  65. body: body ? JSON.stringify(body) : undefined,
  66. });
  67. const text = await res.text();
  68. let payload = {};
  69. try { payload = text ? JSON.parse(text) : {}; } catch { payload = { raw: text }; }
  70. if (!res.ok) {
  71. const err = new Error(`Parse ${method} ${rel} 失败 HTTP ${res.status}: ${String(payload.error || text).slice(0, 200)}`);
  72. err.code = payload.code || 'PARSE_ERROR';
  73. throw err;
  74. }
  75. return payload;
  76. }
  77. /**
  78. * 把案例包映射为 CaseAsset 落库字段(与 caseSubmit.js 对齐)。
  79. * 字段类型按线上真实 schema:targetCustomer / resultEvidence / outcome / groupInfo 为 Object。
  80. */
  81. export function toCaseAssetFields(casePackage, opts = {}) {
  82. const cfg = readConfigFromEnv();
  83. const tenant = opts.tenantId || cfg.tenantId;
  84. const assets = Array.isArray(casePackage.materialAssets) ? casePackage.materialAssets : [];
  85. const imageUrls = assets
  86. .filter((a) => a && a.kind === 'image' && typeof a.url === 'string' && /^https?:\/\//.test(a.url))
  87. .sort((a, b) => Number(a.order || 0) - Number(b.order || 0))
  88. .map((a) => a.url);
  89. const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x.trim()) : []);
  90. const asObject = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : (v ? { description: String(v) } : {}));
  91. const highlightTypes = list(casePackage.highlightTypes);
  92. const scenarioTags = list(casePackage.scenarioTags);
  93. const objectionTags = list(casePackage.objectionTags);
  94. const tags = [...new Set([...list(casePackage.tags), ...highlightTypes, ...scenarioTags, ...objectionTags])];
  95. return {
  96. tenantId: tenant,
  97. mock: false,
  98. title: String(casePackage.title || '未命名案例'),
  99. sourceType: String(casePackage.sourceType || 'mixed').toLowerCase(),
  100. sourceRef: String(casePackage.sourceRef || ''),
  101. idempotencyKey: String(casePackage.idempotencyKey || ''),
  102. status: 'DRAFT',
  103. authorizationStatus: 'authorized',
  104. maskingStatus: 'UNMASKED',
  105. reviewStatus: 'pending',
  106. readyForUse: false,
  107. submittedBy: String(opts.submittedBy || ''),
  108. submittedAt: new Date().toISOString(),
  109. materialAssets: assets,
  110. imageUrls,
  111. hasImage: imageUrls.length > 0,
  112. groupInfo: asObject(casePackage.groupInfo),
  113. imageText: String(casePackage.imageText || ''),
  114. summary: String(casePackage.summary || ''),
  115. targetCustomer: asObject(casePackage.targetCustomer),
  116. usageSuggestion: String(casePackage.usageSuggestion || ''),
  117. resultEvidence: asObject(casePackage.resultEvidence),
  118. schoolCanonical: String(casePackage.schoolCanonical || ''),
  119. schoolAliases: list(casePackage.schoolAliases),
  120. country: String(casePackage.country || ''),
  121. major: String(casePackage.major || ''),
  122. stage: String(casePackage.stage || ''),
  123. subject: String(casePackage.subject || ''),
  124. highlightTypes,
  125. scenarioTags,
  126. objectionTags,
  127. tags,
  128. riskFlags: list(casePackage.riskFlags).map((v) => v.toUpperCase()),
  129. privacyFindings: Array.isArray(casePackage.privacyFindings) ? casePackage.privacyFindings : [],
  130. salesValue: String(casePackage.salesValue || ''),
  131. maskedContent: String(casePackage.maskedContent || ''),
  132. outcome: asObject(casePackage.outcome),
  133. };
  134. }
  135. /**
  136. * 直接写 CaseAsset(幂等:tenantId + idempotencyKey 命中即返回既有记录)。
  137. * @returns {Promise<{objectId, reviewStatus, readyForUse, duplicated, via}>}
  138. */
  139. export async function submitViaParse(casePackage, opts = {}) {
  140. const cfg = readConfigFromEnv();
  141. const fields = toCaseAssetFields(casePackage, opts);
  142. if (!fields.idempotencyKey) throw new Error('缺少 idempotencyKey,无法幂等归档');
  143. const where = encodeURIComponent(JSON.stringify({
  144. tenantId: fields.tenantId,
  145. idempotencyKey: fields.idempotencyKey,
  146. }));
  147. const found = await parseFetch(cfg, 'GET', `/classes/CaseAsset?where=${where}&limit=1`);
  148. const existing = (found.results || [])[0];
  149. if (existing) {
  150. return {
  151. objectId: existing.objectId,
  152. reviewStatus: existing.reviewStatus || 'pending',
  153. readyForUse: existing.readyForUse === true,
  154. duplicated: true,
  155. via: 'parse-direct',
  156. };
  157. }
  158. const created = await parseFetch(cfg, 'POST', '/classes/CaseAsset', fields);
  159. return {
  160. objectId: created.objectId,
  161. reviewStatus: created.reviewStatus || 'pending',
  162. readyForUse: created.readyForUse === true,
  163. duplicated: false,
  164. via: 'parse-direct',
  165. };
  166. }
  167. /** 通道自检:不发写请求,只验证配置与连通性。 */
  168. export async function probeParse() {
  169. const cfg = readConfigFromEnv();
  170. try {
  171. const r = await parseFetch(cfg, 'GET', '/classes/CaseAsset?limit=1&keys=objectId');
  172. return {
  173. ok: true,
  174. via: 'parse-direct',
  175. parseUrl: cfg.parseUrl,
  176. appId: cfg.appId,
  177. tenantId: cfg.tenantId,
  178. hasServerKey: Boolean(cfg.serverKey),
  179. reachable: true,
  180. sampleCount: (r.results || []).length,
  181. };
  182. } catch (e) {
  183. return { ok: false, via: 'parse-direct', parseUrl: cfg.parseUrl, error: e.message };
  184. }
  185. }