cloud-client.mjs 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  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. // Trademark Notice:
  8. // The MPL-2.0 license grants copyright permissions for source code only.
  9. // It does NOT grant any rights to use trademarks including "未来飞马",
  10. // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
  11. // Any use of these trademarks requires separate written permission.
  12. /**
  13. * 云函数客户端:把案例包提交到 caseSubmit,落入待审清单。
  14. *
  15. * 契约(SHARED-CONTRACT.md 第 3 节):
  16. * 入参 {sourceType, idempotencyKey, title, summary, materialAssets[], groupInfo?,
  17. * schoolCanonical?, tags[], highlightTypes[], scenarioTags[], objectionTags[],
  18. * riskFlags[], authorizationStatus, submittedBy?}
  19. * 返回 {ok:true, result:{objectId, reviewStatus, readyForUse}}
  20. * 落库 reviewStatus='pending'、readyForUse=false —— 新案例先进待审清单,不进公共素材库。
  21. *
  22. * 安全:
  23. * - 云函数基址来自项目 CLAUDE.md:https://server.lumistedu.com/api/functions
  24. * - session token 从环境变量 / 本地配置读取,**仅内存持有**,不写文件、不进日志、不进报告。
  25. * - tenantId 由服务端从会话上下文解析,客户端不传(传了也会被服务端拒绝不匹配)。
  26. */
  27. const FUNCTIONS_URL = process.env.CASE_FUNCTIONS_URL
  28. || 'https://server.lumistedu.com/api/functions';
  29. const TOKEN_SOURCES = [
  30. { env: 'CASE_PARSE_SESSION_TOKEN', label: 'env:CASE_PARSE_SESSION_TOKEN' },
  31. { env: 'PARSE_SESSION_TOKEN', label: 'env:PARSE_SESSION_TOKEN' },
  32. ];
  33. function resolveToken(explicit) {
  34. if (explicit) return { token: explicit, source: 'explicit' };
  35. for (const item of TOKEN_SOURCES) {
  36. const value = process.env[item.env];
  37. if (value && value.trim()) return { token: value.trim(), source: item.label };
  38. }
  39. return { token: '', source: 'missing' };
  40. }
  41. export class CloudError extends Error {
  42. constructor(message, code, requestId) {
  43. super(message);
  44. this.name = 'CloudError';
  45. this.code = code || 'INTERNAL_ERROR';
  46. this.requestId = requestId || '';
  47. }
  48. }
  49. /**
  50. * 调用云函数。
  51. * @returns {Promise<{ok:true, result:object}|{ok:false, error:{code,message,requestId}}>}
  52. * 网络不可达时抛 CloudError(调用方转成 dry-run 提示,不伪造成功)。
  53. */
  54. export async function callFunction(name, params, options = {}) {
  55. const { token, source } = resolveToken(options.token);
  56. const headers = { 'Content-Type': 'application/json' };
  57. if (token) headers['X-Parse-Session-Token'] = token;
  58. const controller = new AbortController();
  59. const timer = setTimeout(() => controller.abort(), options.timeoutMs || 20000);
  60. let response;
  61. try {
  62. response = await fetch(FUNCTIONS_URL, {
  63. method: 'POST',
  64. headers,
  65. body: JSON.stringify({ path: `/${name}`, params: params || {}, token }),
  66. signal: controller.signal,
  67. });
  68. } catch (error) {
  69. clearTimeout(timer);
  70. const reason = error.name === 'AbortError' ? '请求超时' : error.message;
  71. throw new CloudError(`云函数 ${name} 不可达:${reason}(token 来源:${source})`, 'NETWORK_UNAVAILABLE');
  72. }
  73. clearTimeout(timer);
  74. let payload;
  75. try {
  76. payload = await response.json();
  77. } catch {
  78. throw new CloudError(`云函数 ${name} 返回不是 JSON(HTTP ${response.status})`, 'INTERNAL_ERROR');
  79. }
  80. if (!response.ok || payload.error) {
  81. const error = payload.error;
  82. // 本契约的错误是对象 {code,message,requestId};若 error 是字符串/HTML,
  83. // 说明这个端点根本不提供本项目的函数服务(如定位器端点、无函数运行时)→ 供上层回落。
  84. const isContractError = error && typeof error === 'object';
  85. return {
  86. ok: false,
  87. endpointUnavailable: !isContractError,
  88. error: {
  89. code: (isContractError && error.code) || 'INTERNAL_ERROR',
  90. message: (isContractError && error.message) || `云函数 ${name} 调用失败(HTTP ${response.status})`,
  91. requestId: (isContractError && error.requestId) || '',
  92. },
  93. };
  94. }
  95. return { ok: true, result: payload.result || payload.data || {} };
  96. }
  97. /**
  98. * 提交案例到待审清单。
  99. * @param {object} casePackage 组装好的案例包(见 case-intake.mjs buildPayload)
  100. */
  101. export async function submitCase(casePackage, options = {}) {
  102. const payload = {
  103. sourceType: casePackage.sourceType,
  104. idempotencyKey: casePackage.idempotencyKey,
  105. title: casePackage.title,
  106. summary: casePackage.summary,
  107. materialAssets: casePackage.materialAssets,
  108. groupInfo: casePackage.groupInfo,
  109. schoolCanonical: casePackage.schoolCanonical,
  110. tags: casePackage.tags,
  111. highlightTypes: casePackage.highlightTypes,
  112. scenarioTags: casePackage.scenarioTags,
  113. objectionTags: casePackage.objectionTags,
  114. riskFlags: casePackage.riskFlags,
  115. authorizationStatus: casePackage.authorizationStatus,
  116. };
  117. if (options.submittedBy) payload.submittedBy = options.submittedBy;
  118. return callFunction('caseSubmit', payload, options);
  119. }
  120. /** 待审清单(只读,便于采集完立刻确认落库)。 */
  121. export async function pendingList(params = {}, options = {}) {
  122. return callFunction('casePendingList', params, options);
  123. }