// 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/. // // Trademark Notice: // The MPL-2.0 license grants copyright permissions for source code only. // It does NOT grant any rights to use trademarks including "未来飞马", // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步". // Any use of these trademarks requires separate written permission. /** * 云函数客户端:把案例包提交到 caseSubmit,落入待审清单。 * * 契约(SHARED-CONTRACT.md 第 3 节): * 入参 {sourceType, idempotencyKey, title, summary, materialAssets[], groupInfo?, * schoolCanonical?, tags[], highlightTypes[], scenarioTags[], objectionTags[], * riskFlags[], authorizationStatus, submittedBy?} * 返回 {ok:true, result:{objectId, reviewStatus, readyForUse}} * 落库 reviewStatus='pending'、readyForUse=false —— 新案例先进待审清单,不进公共素材库。 * * 安全: * - 云函数基址来自项目 CLAUDE.md:https://server.lumistedu.com/api/functions * - session token 从环境变量 / 本地配置读取,**仅内存持有**,不写文件、不进日志、不进报告。 * - tenantId 由服务端从会话上下文解析,客户端不传(传了也会被服务端拒绝不匹配)。 */ const FUNCTIONS_URL = process.env.CASE_FUNCTIONS_URL || 'https://server.lumistedu.com/api/functions'; const TOKEN_SOURCES = [ { env: 'CASE_PARSE_SESSION_TOKEN', label: 'env:CASE_PARSE_SESSION_TOKEN' }, { env: 'PARSE_SESSION_TOKEN', label: 'env:PARSE_SESSION_TOKEN' }, ]; function resolveToken(explicit) { if (explicit) return { token: explicit, source: 'explicit' }; for (const item of TOKEN_SOURCES) { const value = process.env[item.env]; if (value && value.trim()) return { token: value.trim(), source: item.label }; } return { token: '', source: 'missing' }; } export class CloudError extends Error { constructor(message, code, requestId) { super(message); this.name = 'CloudError'; this.code = code || 'INTERNAL_ERROR'; this.requestId = requestId || ''; } } /** * 调用云函数。 * @returns {Promise<{ok:true, result:object}|{ok:false, error:{code,message,requestId}}>} * 网络不可达时抛 CloudError(调用方转成 dry-run 提示,不伪造成功)。 */ export async function callFunction(name, params, options = {}) { const { token, source } = resolveToken(options.token); const headers = { 'Content-Type': 'application/json' }; if (token) headers['X-Parse-Session-Token'] = token; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), options.timeoutMs || 20000); let response; try { response = await fetch(FUNCTIONS_URL, { method: 'POST', headers, body: JSON.stringify({ path: `/${name}`, params: params || {}, token }), signal: controller.signal, }); } catch (error) { clearTimeout(timer); const reason = error.name === 'AbortError' ? '请求超时' : error.message; throw new CloudError(`云函数 ${name} 不可达:${reason}(token 来源:${source})`, 'NETWORK_UNAVAILABLE'); } clearTimeout(timer); let payload; try { payload = await response.json(); } catch { throw new CloudError(`云函数 ${name} 返回不是 JSON(HTTP ${response.status})`, 'INTERNAL_ERROR'); } if (!response.ok || payload.error) { const error = payload.error; // 本契约的错误是对象 {code,message,requestId};若 error 是字符串/HTML, // 说明这个端点根本不提供本项目的函数服务(如定位器端点、无函数运行时)→ 供上层回落。 const isContractError = error && typeof error === 'object'; return { ok: false, endpointUnavailable: !isContractError, error: { code: (isContractError && error.code) || 'INTERNAL_ERROR', message: (isContractError && error.message) || `云函数 ${name} 调用失败(HTTP ${response.status})`, requestId: (isContractError && error.requestId) || '', }, }; } return { ok: true, result: payload.result || payload.data || {} }; } /** * 提交案例到待审清单。 * @param {object} casePackage 组装好的案例包(见 case-intake.mjs buildPayload) */ export async function submitCase(casePackage, options = {}) { const payload = { sourceType: casePackage.sourceType, idempotencyKey: casePackage.idempotencyKey, title: casePackage.title, summary: casePackage.summary, materialAssets: casePackage.materialAssets, groupInfo: casePackage.groupInfo, schoolCanonical: casePackage.schoolCanonical, tags: casePackage.tags, highlightTypes: casePackage.highlightTypes, scenarioTags: casePackage.scenarioTags, objectionTags: casePackage.objectionTags, riskFlags: casePackage.riskFlags, authorizationStatus: casePackage.authorizationStatus, }; if (options.submittedBy) payload.submittedBy = options.submittedBy; return callFunction('caseSubmit', payload, options); } /** 待审清单(只读,便于采集完立刻确认落库)。 */ export async function pendingList(params = {}, options = {}) { return callFunction('casePendingList', params, options); }