// 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 }; } }