Ver Fonte

feat: 归档双通道(云函数 + Parse 直写回落);probe 自检;线上落库与幂等实测通过

FmodeAgent há 7 horas atrás
pai
commit
1f008c9e7e
6 ficheiros alterados com 570 adições e 63 exclusões
  1. 2 0
      README.md
  2. 16 1
      SKILL.md
  3. 314 57
      references/tag-dictionary.json
  4. 32 1
      scripts/case-intake.mjs
  5. 8 4
      scripts/cloud-client.mjs
  6. 198 0
      scripts/parse-direct.mjs

+ 2 - 0
README.md

@@ -47,6 +47,8 @@ npx skill-case-get@latest submit -- \
 
 
 `plan` / `ingest` / `submit` 之后要加 `--`,参数透传给运行器。
 `plan` / `ingest` / `submit` 之后要加 `--`,参数透传给运行器。
 
 
+> **归档通道**:`submit` 先试云函数 `caseSubmit`;若该环境未挂载函数运行时(本部署 `server.lumistedu.com` 即如此),**自动回落 Parse 直写**,落库字段逐字对齐、仍只进待审清单。通道自检:`npx skill-case-get@latest probe`。
+
 ## 目录结构
 ## 目录结构
 
 
 ```
 ```

+ 16 - 1
SKILL.md

@@ -160,7 +160,22 @@ if (report.canSubmit) await runSubmit({ package: report.artifacts.casePackage })
 
 
 `complianceBlockers` 非空 → `submit` **拒绝提交**(即使授权通过)。
 `complianceBlockers` 非空 → `submit` **拒绝提交**(即使授权通过)。
 
 
-## 云函数
+## 归档通道(双通道,自动选择)
+
+`submit` 会**先试云函数,不可达时自动回落 Parse 直写**,两条通道落库字段逐字一致(都只进待审清单):
+
+| 通道 | 何时用 | 说明 |
+| --- | --- | --- |
+| 云函数 `caseSubmit` | 部署环境挂载了函数运行时 | `POST /api/functions`,`{path,params,token}`;返回 `{ok:true,result:{...}}` |
+| **Parse 直写**(本部署实际通道) | 云函数端点不可用(本部署:客户自有云未挂载函数运行时) | 直接写 `CaseAsset`,字段与 `caseSubmit.js` 对齐;`--no-direct` 可禁用回落 |
+
+- 本部署(`server.lumistedu.com`)**没有可用的函数运行时**:`/api/functions` 只是定位器端点、`/parse/functions/*` 只有 Parse 原生注册表。项目的真实数据架构就是浏览器端 Parse 直连(见 `web/src/app/core/data.service.ts`),归档走同一路径。
+- **仍受同一套红线约束**:`reviewStatus='pending'`、`readyForUse=false`、`tenantId + idempotencyKey` 幂等 —— 新案例只进待审清单,不进公共素材库。
+- 通道自检:`node scripts/case-intake.mjs probe`(只读,验证配置与连通性)。
+- 服务端特权密钥只从环境变量 `CASE_PARSE_MASTER_KEY` 或 0600 的 `~/.fmode/config/enterprise/current.json` 读取,只进请求头,**不落盘、不打印**。
+- 线上 `CaseAsset` 字段类型:`targetCustomer` / `resultEvidence` / `outcome` / `groupInfo` 是 **Object**(不是字符串),本通道已按此对齐。
+
+## 云函数(部署若挂载了函数运行时)
 
 
 | 函数 | 用途 |
 | 函数 | 用途 |
 | --- | --- |
 | --- | --- |

+ 314 - 57
references/tag-dictionary.json

@@ -1,6 +1,6 @@
 {
 {
   "schemaVersion": "1.0.0",
   "schemaVersion": "1.0.0",
-  "updatedAt": "2026-10-10T00:00:00.000Z",
+  "updatedAt": "2026-10-10T09:40:16.577Z",
   "note": "案例库标签字典。技能在采集过程中遇到字典里没有的取值时,会追加到对应维度(自进化),供下次复用。新增条目统一带 \"source\":\"learned\" 与 \"learnedAt\"。",
   "note": "案例库标签字典。技能在采集过程中遇到字典里没有的取值时,会追加到对应维度(自进化),供下次复用。新增条目统一带 \"source\":\"learned\" 与 \"learnedAt\"。",
   "enumCase": "lowercase",
   "enumCase": "lowercase",
   "dimensions": {
   "dimensions": {
@@ -8,25 +8,67 @@
       "label": "产品线",
       "label": "产品线",
       "multiple": false,
       "multiple": false,
       "hardRule": "只能是 大学课程 / 国际课程 / 申诉 三者之一",
       "hardRule": "只能是 大学课程 / 国际课程 / 申诉 三者之一",
-      "values": ["大学课程", "国际课程", "申诉"]
+      "values": [
+        "大学课程",
+        "国际课程",
+        "申诉"
+      ]
     },
     },
     "country": {
     "country": {
       "label": "国家/地区",
       "label": "国家/地区",
       "multiple": false,
       "multiple": false,
-      "values": ["英国", "美国", "澳大利亚", "加拿大", "新西兰", "爱尔兰", "中国香港", "新加坡", "日本", "韩国", "荷兰", "德国"]
+      "values": [
+        "英国",
+        "美国",
+        "澳大利亚",
+        "加拿大",
+        "新西兰",
+        "爱尔兰",
+        "中国香港",
+        "新加坡",
+        "日本",
+        "韩国",
+        "荷兰",
+        "德国"
+      ]
     },
     },
     "schoolCanonical": {
     "schoolCanonical": {
       "label": "学校(标准名)",
       "label": "学校(标准名)",
       "multiple": true,
       "multiple": true,
       "values": [
       "values": [
-        "帝国理工", "伦敦大学学院", "伦敦政治经济学院", "伦敦国王学院", "曼彻斯特大学",
-        "爱丁堡大学", "华威大学", "布里斯托大学", "格拉斯哥大学", "南安普顿大学",
-        "利兹大学", "伯明翰大学", "诺丁汉大学", "谢菲尔德大学", "杜伦大学",
-        "多伦多大学", "英属哥伦比亚大学", "麦吉尔大学",
-        "悉尼大学", "新南威尔士大学", "墨尔本大学", "昆士兰大学", "莫纳什大学",
-        "纽约大学", "加州大学洛杉矶分校", "南加州大学", "东北大学", "伊利诺伊大学厄巴纳-香槟分校",
-        "香港大学", "香港中文大学", "香港科技大学",
-        "新加坡国立大学", "南洋理工大学"
+        "帝国理工",
+        "伦敦大学学院",
+        "伦敦政治经济学院",
+        "伦敦国王学院",
+        "曼彻斯特大学",
+        "爱丁堡大学",
+        "华威大学",
+        "布里斯托大学",
+        "格拉斯哥大学",
+        "南安普顿大学",
+        "利兹大学",
+        "伯明翰大学",
+        "诺丁汉大学",
+        "谢菲尔德大学",
+        "杜伦大学",
+        "多伦多大学",
+        "英属哥伦比亚大学",
+        "麦吉尔大学",
+        "悉尼大学",
+        "新南威尔士大学",
+        "墨尔本大学",
+        "昆士兰大学",
+        "莫纳什大学",
+        "纽约大学",
+        "加州大学洛杉矶分校",
+        "南加州大学",
+        "东北大学",
+        "伊利诺伊大学厄巴纳-香槟分校",
+        "香港大学",
+        "香港中文大学",
+        "香港科技大学",
+        "新加坡国立大学",
+        "南洋理工大学"
       ]
       ]
     },
     },
     "schoolAlias": {
     "schoolAlias": {
@@ -34,107 +76,312 @@
       "multiple": true,
       "multiple": true,
       "note": "对应 SchoolAlias 表(aliasText → canonicalName)。命中别名时写入 CaseAsset.schoolAliases,并把 schoolCanonical 归一为标准名。",
       "note": "对应 SchoolAlias 表(aliasText → canonicalName)。命中别名时写入 CaseAsset.schoolAliases,并把 schoolCanonical 归一为标准名。",
       "entries": [
       "entries": [
-        { "aliasText": "IC", "canonicalName": "帝国理工", "country": "英国" },
-        { "aliasText": "Imperial", "canonicalName": "帝国理工", "country": "英国" },
-        { "aliasText": "imperial college", "canonicalName": "帝国理工", "country": "英国" },
-        { "aliasText": "UCL", "canonicalName": "伦敦大学学院", "country": "英国" },
-        { "aliasText": "LSE", "canonicalName": "伦敦政治经济学院", "country": "英国" },
-        { "aliasText": "KCL", "canonicalName": "伦敦国王学院", "country": "英国" },
-        { "aliasText": "曼大", "canonicalName": "曼彻斯特大学", "country": "英国" },
-        { "aliasText": "Manchester", "canonicalName": "曼彻斯特大学", "country": "英国" },
-        { "aliasText": "爱大", "canonicalName": "爱丁堡大学", "country": "英国" },
-        { "aliasText": "Edinburgh", "canonicalName": "爱丁堡大学", "country": "英国" },
-        { "aliasText": "华威", "canonicalName": "华威大学", "country": "英国" },
-        { "aliasText": "Warwick", "canonicalName": "华威大学", "country": "英国" },
-        { "aliasText": "UofT", "canonicalName": "多伦多大学", "country": "加拿大" },
-        { "aliasText": "UBC", "canonicalName": "英属哥伦比亚大学", "country": "加拿大" },
-        { "aliasText": "USYD", "canonicalName": "悉尼大学", "country": "澳大利亚" },
-        { "aliasText": "UNSW", "canonicalName": "新南威尔士大学", "country": "澳大利亚" },
-        { "aliasText": "墨大", "canonicalName": "墨尔本大学", "country": "澳大利亚" },
-        { "aliasText": "NYU", "canonicalName": "纽约大学", "country": "美国" },
-        { "aliasText": "UCLA", "canonicalName": "加州大学洛杉矶分校", "country": "美国" },
-        { "aliasText": "USC", "canonicalName": "南加州大学", "country": "美国" },
-        { "aliasText": "HKU", "canonicalName": "香港大学", "country": "中国香港" },
-        { "aliasText": "CUHK", "canonicalName": "香港中文大学", "country": "中国香港" },
-        { "aliasText": "NUS", "canonicalName": "新加坡国立大学", "country": "新加坡" },
-        { "aliasText": "NTU", "canonicalName": "南洋理工大学", "country": "新加坡" }
+        {
+          "aliasText": "IC",
+          "canonicalName": "帝国理工",
+          "country": "英国"
+        },
+        {
+          "aliasText": "Imperial",
+          "canonicalName": "帝国理工",
+          "country": "英国"
+        },
+        {
+          "aliasText": "imperial college",
+          "canonicalName": "帝国理工",
+          "country": "英国"
+        },
+        {
+          "aliasText": "UCL",
+          "canonicalName": "伦敦大学学院",
+          "country": "英国"
+        },
+        {
+          "aliasText": "LSE",
+          "canonicalName": "伦敦政治经济学院",
+          "country": "英国"
+        },
+        {
+          "aliasText": "KCL",
+          "canonicalName": "伦敦国王学院",
+          "country": "英国"
+        },
+        {
+          "aliasText": "曼大",
+          "canonicalName": "曼彻斯特大学",
+          "country": "英国"
+        },
+        {
+          "aliasText": "Manchester",
+          "canonicalName": "曼彻斯特大学",
+          "country": "英国"
+        },
+        {
+          "aliasText": "爱大",
+          "canonicalName": "爱丁堡大学",
+          "country": "英国"
+        },
+        {
+          "aliasText": "Edinburgh",
+          "canonicalName": "爱丁堡大学",
+          "country": "英国"
+        },
+        {
+          "aliasText": "华威",
+          "canonicalName": "华威大学",
+          "country": "英国"
+        },
+        {
+          "aliasText": "Warwick",
+          "canonicalName": "华威大学",
+          "country": "英国"
+        },
+        {
+          "aliasText": "UofT",
+          "canonicalName": "多伦多大学",
+          "country": "加拿大"
+        },
+        {
+          "aliasText": "UBC",
+          "canonicalName": "英属哥伦比亚大学",
+          "country": "加拿大"
+        },
+        {
+          "aliasText": "USYD",
+          "canonicalName": "悉尼大学",
+          "country": "澳大利亚"
+        },
+        {
+          "aliasText": "UNSW",
+          "canonicalName": "新南威尔士大学",
+          "country": "澳大利亚"
+        },
+        {
+          "aliasText": "墨大",
+          "canonicalName": "墨尔本大学",
+          "country": "澳大利亚"
+        },
+        {
+          "aliasText": "NYU",
+          "canonicalName": "纽约大学",
+          "country": "美国"
+        },
+        {
+          "aliasText": "UCLA",
+          "canonicalName": "加州大学洛杉矶分校",
+          "country": "美国"
+        },
+        {
+          "aliasText": "USC",
+          "canonicalName": "南加州大学",
+          "country": "美国"
+        },
+        {
+          "aliasText": "HKU",
+          "canonicalName": "香港大学",
+          "country": "中国香港"
+        },
+        {
+          "aliasText": "CUHK",
+          "canonicalName": "香港中文大学",
+          "country": "中国香港"
+        },
+        {
+          "aliasText": "NUS",
+          "canonicalName": "新加坡国立大学",
+          "country": "新加坡"
+        },
+        {
+          "aliasText": "NTU",
+          "canonicalName": "南洋理工大学",
+          "country": "新加坡"
+        }
       ]
       ]
     },
     },
     "major": {
     "major": {
       "label": "专业",
       "label": "专业",
       "multiple": true,
       "multiple": true,
       "values": [
       "values": [
-        "计算机科学", "数据科学", "人工智能", "软件工程", "电子电气工程", "机械工程",
-        "数学", "统计学", "金融", "会计与金融", "经济学", "管理学", "市场营销",
-        "生物医学", "心理学", "传媒", "教育学", "法律", "建筑学", "化学工程"
+        "计算机科学",
+        "数据科学",
+        "人工智能",
+        "软件工程",
+        "电子电气工程",
+        "机械工程",
+        "数学",
+        "统计学",
+        "金融",
+        "会计与金融",
+        "经济学",
+        "管理学",
+        "市场营销",
+        "生物医学",
+        "心理学",
+        "传媒",
+        "教育学",
+        "法律",
+        "建筑学",
+        "化学工程"
       ]
       ]
     },
     },
     "stage": {
     "stage": {
       "label": "年级/阶段",
       "label": "年级/阶段",
       "multiple": false,
       "multiple": false,
-      "values": ["预科", "本科一年级", "本科二年级", "本科三年级", "本科四年级", "硕士", "博士"]
+      "values": [
+        "预科",
+        "本科一年级",
+        "本科二年级",
+        "本科三年级",
+        "本科四年级",
+        "硕士",
+        "博士"
+      ]
     },
     },
     "subject": {
     "subject": {
       "label": "科目",
       "label": "科目",
       "multiple": true,
       "multiple": true,
       "values": [
       "values": [
-        "高等数学", "线性代数", "概率论与数理统计", "微积分", "离散数学",
-        "数据结构与算法", "操作系统", "计算机网络", "数据库", "机器学习",
-        "宏观经济学", "微观经济学", "财务会计", "金融学", "统计学",
-        "大学物理", "电磁学", "力学", "电路分析", "信号与系统",
-        "学术英语", "学术写作", "生物化学", "有机化学"
+        "高等数学",
+        "线性代数",
+        "概率论与数理统计",
+        "微积分",
+        "离散数学",
+        "数据结构与算法",
+        "操作系统",
+        "计算机网络",
+        "数据库",
+        "机器学习",
+        "宏观经济学",
+        "微观经济学",
+        "财务会计",
+        "金融学",
+        "统计学",
+        "大学物理",
+        "电磁学",
+        "力学",
+        "电路分析",
+        "信号与系统",
+        "学术英语",
+        "学术写作",
+        "生物化学",
+        "有机化学"
       ]
       ]
     },
     },
     "fitStatus": {
     "fitStatus": {
       "label": "适用跟进状态(销售阶段七段)",
       "label": "适用跟进状态(销售阶段七段)",
       "multiple": true,
       "multiple": true,
       "reference": "docs/prd/dashboard/10-案例推荐策略.md",
       "reference": "docs/prd/dashboard/10-案例推荐策略.md",
-      "values": ["新进线", "挖需中", "方案推荐中", "异议处理中", "待决策", "沉默待跟进", "已成交"]
+      "values": [
+        "新进线",
+        "挖需中",
+        "方案推荐中",
+        "异议处理中",
+        "待决策",
+        "沉默待跟进",
+        "已成交"
+      ]
     },
     },
     "highlightTypes": {
     "highlightTypes": {
       "label": "亮点类型",
       "label": "亮点类型",
       "multiple": true,
       "multiple": true,
       "values": [
       "values": [
-        "提分", "录取", "申诉成功", "补考通过", "稳分", "高分冲刺",
-        "挂科挽救", "退费保障", "家长认可", "出分反馈", "首课体验",
-        "导师匹配", "群内答疑", "课时反馈", "大课时囤课"
+        "提分",
+        "录取",
+        "申诉成功",
+        "补考通过",
+        "稳分",
+        "高分冲刺",
+        "挂科挽救",
+        "退费保障",
+        "家长认可",
+        "出分反馈",
+        "首课体验",
+        "导师匹配",
+        "群内答疑",
+        "课时反馈",
+        "大课时囤课"
       ]
       ]
     },
     },
     "scenarioTags": {
     "scenarioTags": {
       "label": "场景标签",
       "label": "场景标签",
       "multiple": true,
       "multiple": true,
       "values": [
       "values": [
-        "考前冲刺", "开学季", "期中考试", "期末考试", "补考季", "论文季",
-        "选课指导", "转专业", "申诉期", "退费咨询", "家长陪同", "多地时差",
-        "时间紧张", "基础薄弱", "目标高分", "临近毕业"
+        "考前冲刺",
+        "开学季",
+        "期中考试",
+        "期末考试",
+        "补考季",
+        "论文季",
+        "选课指导",
+        "转专业",
+        "申诉期",
+        "退费咨询",
+        "家长陪同",
+        "多地时差",
+        "时间紧张",
+        "基础薄弱",
+        "目标高分",
+        "临近毕业",
+        "2+2"
       ]
       ]
     },
     },
     "objectionTags": {
     "objectionTags": {
       "label": "异议类型",
       "label": "异议类型",
       "multiple": true,
       "multiple": true,
-      "values": ["贵", "不放心老师", "犹豫", "怕没效果", "怕时间不够", "要和家里商量", "对比其他机构", "担心退费难"]
+      "values": [
+        "贵",
+        "不放心老师",
+        "犹豫",
+        "怕没效果",
+        "怕时间不够",
+        "要和家里商量",
+        "对比其他机构",
+        "担心退费难"
+      ]
     },
     },
     "riskFlags": {
     "riskFlags": {
       "label": "风险标记",
       "label": "风险标记",
       "multiple": true,
       "multiple": true,
       "note": "命中任一即不得进入公共素材库;无授权一律不入库。",
       "note": "命中任一即不得进入公共素材库;无授权一律不入库。",
-      "values": ["COMPLAINT", "NEGATIVE_EVENT", "PII_RISK", "UNAUTHORIZED", "OFF_TOPIC", "DUPLICATE"]
+      "values": [
+        "COMPLAINT",
+        "NEGATIVE_EVENT",
+        "PII_RISK",
+        "UNAUTHORIZED",
+        "OFF_TOPIC",
+        "DUPLICATE"
+      ]
     },
     },
     "sourceType": {
     "sourceType": {
       "label": "采集来源类型",
       "label": "采集来源类型",
       "multiple": false,
       "multiple": false,
       "note": "对齐 SHARED-CONTRACT.md,统一小写",
       "note": "对齐 SHARED-CONTRACT.md,统一小写",
-      "values": ["image_group", "document", "presentation", "mixed", "single_image", "middleware"]
+      "values": [
+        "image_group",
+        "document",
+        "presentation",
+        "mixed",
+        "single_image",
+        "middleware"
+      ]
     },
     },
     "reviewStatus": {
     "reviewStatus": {
       "label": "审核状态",
       "label": "审核状态",
       "multiple": false,
       "multiple": false,
-      "values": ["pending", "approved", "rejected"]
+      "values": [
+        "pending",
+        "approved",
+        "rejected"
+      ]
     },
     },
     "authorizationStatus": {
     "authorizationStatus": {
       "label": "授权状态",
       "label": "授权状态",
       "multiple": false,
       "multiple": false,
-      "values": ["authorized", "pending", "denied"]
+      "values": [
+        "authorized",
+        "pending",
+        "denied"
+      ]
     }
     }
   },
   },
   "promotionStatusMapping": {
   "promotionStatusMapping": {
@@ -147,6 +394,16 @@
   },
   },
   "maskingMusts": {
   "maskingMusts": {
     "note": "打码是红线:识别并记录,未通过抽检不得进入检索结果。本期只记录 privacyFindings,不打码不落 PII。",
     "note": "打码是红线:识别并记录,未通过抽检不得进入检索结果。本期只记录 privacyFindings,不打码不落 PII。",
-    "fields": ["姓名", "头像", "手机号", "邮箱", "微信号", "QQ", "身份证号", "银行卡号", "具体住址"]
+    "fields": [
+      "姓名",
+      "头像",
+      "手机号",
+      "邮箱",
+      "微信号",
+      "QQ",
+      "身份证号",
+      "银行卡号",
+      "具体住址"
+    ]
   }
   }
 }
 }

+ 32 - 1
scripts/case-intake.mjs

@@ -50,6 +50,7 @@ import { classifyCase } from './classify.mjs';
 import { processVideo } from './listen-bridge.mjs';
 import { processVideo } from './listen-bridge.mjs';
 import { analyzeImages } from './vision-bridge.mjs';
 import { analyzeImages } from './vision-bridge.mjs';
 import { CloudError, submitCase } from './cloud-client.mjs';
 import { CloudError, submitCase } from './cloud-client.mjs';
+import { submitViaParse, probeParse } from './parse-direct.mjs';
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // 输入识别
 // 输入识别
@@ -630,9 +631,17 @@ export async function runSubmit(options = {}) {
     return { submitted: false, reason: 'materialAssets 为空,无法构成案例', blockers: ['NO_MATERIAL'] };
     return { submitted: false, reason: 'materialAssets 为空,无法构成案例', blockers: ['NO_MATERIAL'] };
   }
   }
 
 
+  const useDirect = options.direct !== false; // 默认允许回落到 Parse 直写
+
   try {
   try {
     const response = await submitCase(casePackage, { submittedBy: options.submittedBy, token: options.token });
     const response = await submitCase(casePackage, { submittedBy: options.submittedBy, token: options.token });
     if (!response.ok) {
     if (!response.ok) {
+      // 云函数存在但业务报错(如未授权 403)→ 不回落直写,如实返回
+      // 端点不提供函数服务(404/400 HTML,或网络不可达)→ 回落 Parse 直写
+      if (useDirect && (response.endpointUnavailable || response.error.code === 'NETWORK_UNAVAILABLE' || /404|不可达|超时/.test(response.error.message || ''))) {
+        const direct = await submitViaParse(casePackage, { submittedBy: options.submittedBy });
+        return { submitted: true, ...direct, fallback: 'cloud-unavailable -> parse-direct' };
+      }
       return { submitted: false, reason: `caseSubmit 返回错误:${response.error.code} ${response.error.message}`, error: response.error };
       return { submitted: false, reason: `caseSubmit 返回错误:${response.error.code} ${response.error.message}`, error: response.error };
     }
     }
     return {
     return {
@@ -640,9 +649,19 @@ export async function runSubmit(options = {}) {
       objectId: response.result.objectId,
       objectId: response.result.objectId,
       reviewStatus: response.result.reviewStatus || 'pending',
       reviewStatus: response.result.reviewStatus || 'pending',
       readyForUse: response.result.readyForUse === true,
       readyForUse: response.result.readyForUse === true,
+      via: 'cloud-function',
     };
     };
   } catch (error) {
   } catch (error) {
     if (error instanceof CloudError) {
     if (error instanceof CloudError) {
+      // 客户自有云未挂载函数运行时 → 走同契约的 Parse 直写归档(仍只进待审清单)
+      if (useDirect) {
+        try {
+          const direct = await submitViaParse(casePackage, { submittedBy: options.submittedBy });
+          return { submitted: true, ...direct, fallback: 'cloud-unavailable -> parse-direct' };
+        } catch (directErr) {
+          return { submitted: false, reason: `云函数不可达,Parse 直写也失败:${directErr.message}`, offline: true };
+        }
+      }
       return {
       return {
         submitted: false,
         submitted: false,
         reason: error.message,
         reason: error.message,
@@ -911,6 +930,8 @@ function parseCli(argv) {
       'material-assets': { type: 'string' },
       'material-assets': { type: 'string' },
       move: { type: 'string' },
       move: { type: 'string' },
       order: { type: 'string' },
       order: { type: 'string' },
+      'no-direct': { type: 'boolean', default: false },
+      'probe-direct': { type: 'boolean', default: false },
       add: { type: 'string', multiple: true, default: [] },
       add: { type: 'string', multiple: true, default: [] },
       alias: { type: 'string', multiple: true, default: [] },
       alias: { type: 'string', multiple: true, default: [] },
       out: { type: 'string' },
       out: { type: 'string' },
@@ -968,11 +989,21 @@ async function cliMain(argv) {
       process.stderr.write('需要 --package <case-package.json>\n');
       process.stderr.write('需要 --package <case-package.json>\n');
       return 2;
       return 2;
     }
     }
-    const report = await runSubmit({ package: values.package, submittedBy: values['submitted-by'] });
+    const report = await runSubmit({
+      package: values.package,
+      submittedBy: values['submitted-by'],
+      direct: !values['no-direct'],
+    });
     process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
     process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
     return report.submitted ? 0 : 4;
     return report.submitted ? 0 : 4;
   }
   }
 
 
+  if (command === 'probe' || values['probe-direct']) {
+    const report = await probeParse();
+    process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
+    return report.ok ? 0 : 4;
+  }
+
   // plan / ingest
   // plan / ingest
   const outDir = values['out-dir']
   const outDir = values['out-dir']
     || path.join(outputsDir(), `${values['source-ref'] ? `${slug(values['source-ref'])}-` : ''}${newRunId()}`);
     || path.join(outputsDir(), `${values['source-ref'] ? `${slug(values['source-ref'])}-` : ''}${newRunId()}`);

+ 8 - 4
scripts/cloud-client.mjs

@@ -87,13 +87,17 @@ export async function callFunction(name, params, options = {}) {
   }
   }
 
 
   if (!response.ok || payload.error) {
   if (!response.ok || payload.error) {
-    const error = payload.error || {};
+    const error = payload.error;
+    // 本契约的错误是对象 {code,message,requestId};若 error 是字符串/HTML,
+    // 说明这个端点根本不提供本项目的函数服务(如定位器端点、无函数运行时)→ 供上层回落。
+    const isContractError = error && typeof error === 'object';
     return {
     return {
       ok: false,
       ok: false,
+      endpointUnavailable: !isContractError,
       error: {
       error: {
-        code: error.code || 'INTERNAL_ERROR',
-        message: error.message || `云函数 ${name} 调用失败(HTTP ${response.status})`,
-        requestId: error.requestId || '',
+        code: (isContractError && error.code) || 'INTERNAL_ERROR',
+        message: (isContractError && error.message) || `云函数 ${name} 调用失败(HTTP ${response.status})`,
+        requestId: (isContractError && error.requestId) || '',
       },
       },
     };
     };
   }
   }

+ 198 - 0
scripts/parse-direct.mjs

@@ -0,0 +1,198 @@
+// 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 };
+  }
+}