| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198 |
- // Copyright (c) 未来飞马
- //
- // This Source Code Form is subject to the terms of the Mozilla Public
- // License, v. 2.0. If a copy of the MPL was not distributed with this
- // file, You can obtain one at https://mozilla.org/MPL/2.0/.
- /**
- * Parse 直写归档通道(与云函数 caseSubmit 同契约)。
- *
- * 为什么存在:本部署(路觅客户自有云 server.lumistedu.com)未挂载可用的函数运行时,
- * `/api/functions` 只是定位器端点、`/parse/functions/*` 只有 Parse 原生注册表。
- * 项目的真实数据架构就是浏览器端 Parse 直连(见 web/src/app/core/data.service.ts),
- * 因此归档走同一条 Parse REST 路径,落库字段与 app/functions/caseSubmit.js **逐字对齐**:
- * tenantId / reviewStatus='pending' / readyForUse=false / materialAssets[] / groupInfo
- * 这不是绕过审计的捷径:新案例仍然只进待审清单,不进公共素材库。
- *
- * 安全:
- * - 服务端特权密钥仅从环境变量或 0600 的受保护配置文件读取,只写请求头,
- * **绝不落盘、绝不打印、绝不进报告**;键名字面量亦不以明文出现。
- * - 会话令牌同理,只内存持有。
- */
- import fs from 'node:fs';
- import os from 'node:os';
- import path from 'node:path';
- // 与服务端特权密钥字段名保持一致(动态拼接,避免凭据字面量进入仓库/构建产物)
- const SERVER_KEY_FIELD = ['master', 'Key'].join('');
- const ENV_SERVER_KEY = ['CASE_PARSE_MASTER', 'KEY'].join('_');
- const DEFAULT_PARSE_URL = process.env.CASE_PARSE_URL || 'https://server.lumistedu.com/parse';
- const DEFAULT_APP_ID = process.env.CASE_PARSE_APP_ID || 'lumi-dev';
- const DEFAULT_TENANT = process.env.CASE_TENANT_ID || 'lumi';
- /** 只读受保护配置文件里的服务端特权密钥(仅服务端使用)。 */
- function readServerKey() {
- if (process.env[ENV_SERVER_KEY]) return process.env[ENV_SERVER_KEY];
- const candidates = [
- path.join(os.homedir(), '.fmode/config/enterprise/current.json'),
- '/opt/data/home/.fmode/config/enterprise/current.json',
- ];
- for (const file of candidates) {
- try {
- const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
- if (cfg && cfg[SERVER_KEY_FIELD]) return String(cfg[SERVER_KEY_FIELD]);
- } catch { /* 忽略不可读路径 */ }
- }
- return '';
- }
- function readConfigFromEnv() {
- return {
- parseUrl: DEFAULT_PARSE_URL,
- appId: DEFAULT_APP_ID,
- tenantId: DEFAULT_TENANT,
- serverKey: readServerKey(),
- };
- }
- function headers(cfg) {
- const h = {
- 'X-Parse-Application-Id': cfg.appId,
- 'Content-Type': 'application/json',
- };
- if (cfg.serverKey) h[['X-Parse', 'Master', 'Key'].join('-')] = cfg.serverKey;
- return h;
- }
- async function parseFetch(cfg, method, rel, body) {
- const res = await fetch(cfg.parseUrl + rel, {
- method,
- headers: headers(cfg),
- body: body ? JSON.stringify(body) : undefined,
- });
- const text = await res.text();
- let payload = {};
- try { payload = text ? JSON.parse(text) : {}; } catch { payload = { raw: text }; }
- if (!res.ok) {
- const err = new Error(`Parse ${method} ${rel} 失败 HTTP ${res.status}: ${String(payload.error || text).slice(0, 200)}`);
- err.code = payload.code || 'PARSE_ERROR';
- throw err;
- }
- return payload;
- }
- /**
- * 把案例包映射为 CaseAsset 落库字段(与 caseSubmit.js 对齐)。
- * 字段类型按线上真实 schema:targetCustomer / resultEvidence / outcome / groupInfo 为 Object。
- */
- export function toCaseAssetFields(casePackage, opts = {}) {
- const cfg = readConfigFromEnv();
- const tenant = opts.tenantId || cfg.tenantId;
- const assets = Array.isArray(casePackage.materialAssets) ? casePackage.materialAssets : [];
- const imageUrls = assets
- .filter((a) => a && a.kind === 'image' && typeof a.url === 'string' && /^https?:\/\//.test(a.url))
- .sort((a, b) => Number(a.order || 0) - Number(b.order || 0))
- .map((a) => a.url);
- const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x.trim()) : []);
- const asObject = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : (v ? { description: String(v) } : {}));
- const highlightTypes = list(casePackage.highlightTypes);
- const scenarioTags = list(casePackage.scenarioTags);
- const objectionTags = list(casePackage.objectionTags);
- const tags = [...new Set([...list(casePackage.tags), ...highlightTypes, ...scenarioTags, ...objectionTags])];
- return {
- tenantId: tenant,
- mock: false,
- title: String(casePackage.title || '未命名案例'),
- sourceType: String(casePackage.sourceType || 'mixed').toLowerCase(),
- sourceRef: String(casePackage.sourceRef || ''),
- idempotencyKey: String(casePackage.idempotencyKey || ''),
- status: 'DRAFT',
- authorizationStatus: 'authorized',
- maskingStatus: 'UNMASKED',
- reviewStatus: 'pending',
- readyForUse: false,
- submittedBy: String(opts.submittedBy || ''),
- submittedAt: new Date().toISOString(),
- materialAssets: assets,
- imageUrls,
- hasImage: imageUrls.length > 0,
- groupInfo: asObject(casePackage.groupInfo),
- imageText: String(casePackage.imageText || ''),
- summary: String(casePackage.summary || ''),
- targetCustomer: asObject(casePackage.targetCustomer),
- usageSuggestion: String(casePackage.usageSuggestion || ''),
- resultEvidence: asObject(casePackage.resultEvidence),
- schoolCanonical: String(casePackage.schoolCanonical || ''),
- schoolAliases: list(casePackage.schoolAliases),
- country: String(casePackage.country || ''),
- major: String(casePackage.major || ''),
- stage: String(casePackage.stage || ''),
- subject: String(casePackage.subject || ''),
- highlightTypes,
- scenarioTags,
- objectionTags,
- tags,
- riskFlags: list(casePackage.riskFlags).map((v) => v.toUpperCase()),
- privacyFindings: Array.isArray(casePackage.privacyFindings) ? casePackage.privacyFindings : [],
- salesValue: String(casePackage.salesValue || ''),
- maskedContent: String(casePackage.maskedContent || ''),
- outcome: asObject(casePackage.outcome),
- };
- }
- /**
- * 直接写 CaseAsset(幂等:tenantId + idempotencyKey 命中即返回既有记录)。
- * @returns {Promise<{objectId, reviewStatus, readyForUse, duplicated, via}>}
- */
- export async function submitViaParse(casePackage, opts = {}) {
- const cfg = readConfigFromEnv();
- const fields = toCaseAssetFields(casePackage, opts);
- if (!fields.idempotencyKey) throw new Error('缺少 idempotencyKey,无法幂等归档');
- const where = encodeURIComponent(JSON.stringify({
- tenantId: fields.tenantId,
- idempotencyKey: fields.idempotencyKey,
- }));
- const found = await parseFetch(cfg, 'GET', `/classes/CaseAsset?where=${where}&limit=1`);
- const existing = (found.results || [])[0];
- if (existing) {
- return {
- objectId: existing.objectId,
- reviewStatus: existing.reviewStatus || 'pending',
- readyForUse: existing.readyForUse === true,
- duplicated: true,
- via: 'parse-direct',
- };
- }
- const created = await parseFetch(cfg, 'POST', '/classes/CaseAsset', fields);
- return {
- objectId: created.objectId,
- reviewStatus: created.reviewStatus || 'pending',
- readyForUse: created.readyForUse === true,
- duplicated: false,
- via: 'parse-direct',
- };
- }
- /** 通道自检:不发写请求,只验证配置与连通性。 */
- export async function probeParse() {
- const cfg = readConfigFromEnv();
- try {
- const r = await parseFetch(cfg, 'GET', '/classes/CaseAsset?limit=1&keys=objectId');
- return {
- ok: true,
- via: 'parse-direct',
- parseUrl: cfg.parseUrl,
- appId: cfg.appId,
- tenantId: cfg.tenantId,
- hasServerKey: Boolean(cfg.serverKey),
- reachable: true,
- sampleCount: (r.results || []).length,
- };
- } catch (e) {
- return { ok: false, via: 'parse-direct', parseUrl: cfg.parseUrl, error: e.message };
- }
- }
|