Browse Source

v1.2: 大文件网关能力(bigfile-gateway) + 品牌化接收页(未来飞马蓝紫) + 技能介绍页 + 官网落地页

liuyuyang 9 hours ago
parent
commit
36a5788cdc

+ 105 - 2
skills/fmode-storage/SKILL.md

@@ -12,7 +12,11 @@ keywords:
   - upload
   - upload
   - cdn
   - cdn
   - share-link
   - share-link
-version: 1.1.0
+  - bigfile
+  - gateway
+  - presigned
+  - sigv4
+version: 1.2.0
 author: Yuyang001 (FmodeAgent)
 author: Yuyang001 (FmodeAgent)
 license: MPL-2.0
 license: MPL-2.0
 copyright: "Copyright (c) 2026 未来飞马 Fmode"
 copyright: "Copyright (c) 2026 未来飞马 Fmode"
@@ -49,7 +53,30 @@ node <skill_dir>/scripts/uploader.mjs test
 > 省略 `--key` 则自动生成 `user/<你的id>/report/<YYYYMM>/<uuid>-<文件名>`。
 > 省略 `--key` 则自动生成 `user/<你的id>/report/<YYYYMM>/<uuid>-<文件名>`。
 > 上传即 `public-read`,**不需要**再调 `setacl`。
 > 上传即 `public-read`,**不需要**再调 `setacl`。
 
 
-## 凭据链(5 级,命中即用)
+## 项目级 Storage 正确姿势(2026-10-12 实测修复)
+
+**不要用 uploader put 走个人级当"项目级"!** uploader put 的首选通道是 level0 cloudfunc(个人空间 user/<id>/)。
+
+项目级 = nova-cloud 桶 `dev/<projectId>/` 前缀,公网 https://app.fmode.cn/dev/<projectId>/<file>。正确流程:
+
+1. 项目未注册先注册:`POST server.fmode.cn/api/functions {token, path:'/fmode/project-register', params:{name:'<slug>'}}` → 返回 projectId(objectId,如 GvsWkfZb36)
+2. 签发项目级 STS:`POST server.fmode.cn/api/apig/deploy/huaweicloud {token, projectId}` → 200 返回 accessKey/secretKey/securityToken + obsPath `obs://nova-cloud/dev/<PID>/`
+3. obsutil ini(无[default]段头,endpoint=obs.cn-south-1配合nova-cloud 桶):
+   ```
+   endpoint=obs.cn-south-1.myhuaweicloud.com
+   ak=...
+   sk=...
+   token=...
+   ```
+4. 上传:`obsutil cp <file> obs://nova-cloud/dev/<PID>/<file> -f -acl=public-read -config=<ini>` (**必须 -config= 指定 ini,不要 obsutil config 全局污染**;不要用 obsutil ls 测试权限——直接 cp 才准,ls 可能因 STS policy 范围报 403)
+5. CDN 刷新 app.fmode.cn 域名组
+
+脚本:`/opt/data/scripts/upload-project.sh <sessionToken> <projectId> <file...>`
+
+认证区身份:sessionToken 用 ~/.fmode/config/user.json(真实用户,userId=liuyuyang),不是 ~/.fmode/config.json。
+
+坑:①赖着 uploader put(level0 个人空间)当"项目级"= 错误;②obsutil 用 `obsutil config -c` 写临时 ini 会污染且报 not well-formed,用 `-config=` 参数指向自立 ini;③deploy STS endpoint 是 obs.cn-south-1(nova-cloud 桶在 south-1,north-4 会 NoSuchBucket/签名不匹配 403);④**`resolveStorageProjectId()` 常命中一个不属于当前用户的陈旧 projectId**(本机 `~/.fmode/config.json` = `GvsWkfZb36`),此时 deploy STS 返回 `{code:403,"没有权限"}`——用 `gateway` 子命令会自动注册/复用自己的项目(`bigfile-gateway`),或显式 `--project-id` 传一个本人有权限的 objectId。
+
 
 
 ```
 ```
 ┌─ 第0级【首选·免密钥】sessionToken → 云函数 fmodeagent-upload-url 签发预签名 PUT
 ┌─ 第0级【首选·免密钥】sessionToken → 云函数 fmodeagent-upload-url 签发预签名 PUT
@@ -82,11 +109,87 @@ node <skill_dir>/scripts/uploader.mjs put ./data.bin --key x/data.bin --mime app
 > ⚠️ **覆盖同名 key 后可能"改了没生效"**:CDN 缓存 TTL 为 30 天,且缓存的是**整个响应(含 Content-Type 头)**。
 > ⚠️ **覆盖同名 key 后可能"改了没生效"**:CDN 缓存 TTL 为 30 天,且缓存的是**整个响应(含 Content-Type 头)**。
 > 建议换新 key 路径发布(如 `about-me-v2/index.html`)。
 > 建议换新 key 路径发布(如 `about-me-v2/index.html`)。
 
 
+## 大文件网关(bigfile-gateway)— 通道超限时生成接收页
+
+**何时用**:主通道被**不可协商的大小上限**挡住时(典型:企微入站 20MB,用户发大文件报「超过 100M 无法发送」,
+文件从未到达智能体)。此时**不要再走聊天通道**——生成一个接收页,用户浏览器**直连对象存储**上传,
+再由 collect 取回。**平时不生成**,只在超限时触发。
+
+> 为什么必须对象存储中转:容器跑在 NAT 内网,**没有公网入口**,不能自建接收服务(踩坑 #4)。
+> 两段式「直传 + 取回」是唯一可行解。
+
+### 命令
+
+```bash
+# 生成 + 部署接收页(默认 20 槽位、1 小时有效),输出页面 URL
+node <skill_dir>/scripts/uploader.mjs gateway <outDir>
+# 等价:node <skill_dir>/scripts/gateway.mjs mint <outDir>
+
+# 常见参数
+node scripts/gateway.mjs mint ./gw --slots 100 --hours 1 \
+     --title "大文件接收网关 · 刘雨飏" \
+     --channel "企微入站" --limit 20MB \
+     --note "企微入站在 20MB 处直接拒收,文件在到达前就已不存在"
+# --project-slug <slug>   复用/注册的项目名(默认 bigfile-gateway,幂等)
+# --project-id <id>       直接指定已有项目 objectId
+# --slot-prefix <p>       槽位键前缀(默认 gateway/<时间戳>)
+# --no-deploy             只本地生成,不部署
+# --json                  机器可读输出
+
+# 取回(扫描到位文件 → 下载 → 台账)
+node scripts/gateway.mjs collect --manifest <outDir>/mint.json            # 只列
+node scripts/gateway.mjs collect --manifest <outDir>/mint.json --out ~/inbox  # 下载
+# 生成的 <outDir>/collect.sh 已封装以上命令:bash collect.sh [--list|--out DIR]
+```
+
+### 流程(5 步)
+
+1. `resolveSessionToken()` 取登录态(复用 uploader 的会话语义)
+2. 云函数 `/fmode/project-register` 注册/复用项目 → `projectId`(幂等)
+3. `POST /api/apig/deploy/huaweicloud {token, projectId}` → **项目级 STS(真身 1h)**
+4. **自己实现 SigV4** 为 N 个槽位各签一条预签名 PUT(host-only 签名,`UNSIGNED-PAYLOAD`),
+   渲染 `templates/gateway.html` → `<outDir>/index.html`,再用同一 STS 把页面传回 `dev/<pid>/bigfile/index.html`
+5. 输出**页面 URL**(`https://app.fmode.cn/dev/<pid>/bigfile/index.html`)+ `mint.json` + `collect.sh`
+
+用户传完 → `collect` 逐槽位 HEAD 探测 → 下载到本地 + 写 `ledger.jsonl`(只记 key/大小/路径)。
+
+### 页面特性
+
+单文件自包含、中文、移动端可用:拖拽/点选多文件、逐文件进度条、完成显示「上传成功 ✓」+ 查看链接;
+**不含任何 AK/SK/token**(只内联当次预签名 URL);文件只写各自槽位(`gateway/<ts>/0000.bin`…),互不影响。
+
+### ⚠️ 踩坑库(实测 2026-10-13,必守)
+
+| # | 坑 / 现象 | 根因 | 本实现的做法 |
+|---|---|---|---|
+| 1 | 不存在对象的公网 URL 被 CDN 回落成 **200 + text/html**(占位页),全被误判「有文件」 | 只看状态码判「到位」 | `looksLikeObject()`:**必须排除 `content-type: text/html`**;实测该桶对缺失 key 返回 404,但仍严格按此判 |
+| 2 | 签名标称 48h,用户**第二天传全失败** | 签名带 `X-Amz-Security-Token`,**STS 真身只 1h**;可用窗口 = min(签名, 1h) | 页面 `--hours` 默认 1、封顶 1h;用户过时须 **重新 mint + 重新部署**,不可沿用旧页 |
+| 3 | 读 `.obsutilconfig` 直接失败 | 里面是**占位符凭据**(`http://your-endpoint` + 假 AK/SK) | 不读它;凭据走项目 STS,签名自实现 |
+| 4 | 想「起 http 服务让用户传」外部连不上 | 容器在 NAT 内网、无公网入口 | 放弃自建接收端,用对象存储中转 |
+| 5 | 用云函数签的 URL 做上传页,用户点开已失效 | 云函数签名 `expires/ttl/expiresIn` 全被忽略,**恒定 282s** | **不用**云函数签名;只用 `/api/functions` 注册项目拿到项目 STS(1h),签名自实现 |
+| 6 | 页面发出后用户传不上去,来回沟通成本极高 | 生成→部署间有多道环节(签名/CORS/key 前缀/CDN) | **生成后必须先真传一个小文件实测**再发用户(本工具不改这条铁律) |
+| 7 | 页面放进站点项目后 key 与预期不符、取回扫不到 | 站点 `dist-deploy` 重写规则给绝对路径加项目前缀 | 页面**不塞独立站点项目**,直接用同一 STS 写入 `dev/<pid>/bigfile/`,key 与预期一致 |
+| 8 | bucket `NoSuchBucket` | **nova-cloud 桶在 cn-south-1**(north-4 无此桶) | endpoint 固定 `obs.cn-south-1.myhuaweicloud.com`,region `cn-south-1` |
+| 9 | 逐槽位扫描想用 `ListObjects` | **STS 策略不允许 List**(实测 403,只允许 prefix 内 GetObject/PutObject) | 取回靠**逐槽位 HEAD 探测**(`app.fmode.cn/<key>`),不依赖列举 |
+
+### 实测(2026-10-13,本机)
+
+- 页面:`https://app.fmode.cn/dev/OtEAl5m4JP/bigfile/index.html`(项目 `bigfile-gateway`→`OtEAl5m4JP`)
+- 真传:浏览器同一路径的预签名 PUT → slot `0000`/`0002` 上传 **200** → 公网 GET 回读 **200** 内容一致(含中文)
+- 取回:`collect --list` 命中 2/2;`collect --out` 下载 2 文件 + `ledger.jsonl` 全部成功
+- SigV4 预签名 PUT/GET、CORS 预检(回显 `content-type,x-obs-acl`)均为 200
+
+### 安全边界
+
+预签名 URL **只对单个 key 有 PUT 权限**(不能读/列/删其他对象)——链接即使公开,别人也只能往那一个空槽位写。
+页面 URL 本身公开:敏感资料用 `--hours 1` 短效并告知用户「用完即焚」。
+
 ## 何时用
 ## 何时用
 
 
 - 生成了 HTML 报告/课件要"发给别人看" → `put` 后把 URL 发出
 - 生成了 HTML 报告/课件要"发给别人看" → `put` 后把 URL 发出
 - 图片/音频/视频素材需要稳定外链 → `put`
 - 图片/音频/视频素材需要稳定外链 → `put`
 - 本地磁盘被大文件占满 → `put` 完成后可删本地原件
 - 本地磁盘被大文件占满 → `put` 完成后可删本地原件
+- **用户发来的文件被通道上限挡住(如企微 20MB)→ `gateway` 生成接收页 + `collect` 取回**
 
 
 ## 命令
 ## 命令
 
 

+ 160 - 0
skills/fmode-storage/docs/skill-storage-intro.html

@@ -0,0 +1,160 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>skill-storage · 对象存储与文件传输技能 — 未来飞马 FmodeAgent</title>
+<style>
+  :root{
+    --ink:#1B2559; --sub:#64748B; --line:#E2E8F0; --card:#fff; --bg:#F5F8FF;
+    --accent:#5B5BD6; --accent-soft:#EEF0FF; --accent-line:#D5D9F5;
+    --accent2:#7C6FF0; --grad:linear-gradient(135deg,#5B5BD6,#7C6FF0 60%,#9BB8F5);
+    --ok:#1E7A46; --ok-soft:#EAF6EF; --radius:16px;
+    --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
+  }
+  *{box-sizing:border-box}
+  html,body{margin:0;padding:0}
+  body{background:var(--bg);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;line-height:1.6;-webkit-font-smoothing:antialiased}
+  .hero{background:var(--grad);color:#fff;padding:48px 20px 64px;text-align:center;position:relative;overflow:hidden}
+  .hero::after{content:"";position:absolute;inset:0;background:radial-gradient(600px 200px at 80% 0%,rgba(255,255,255,.18),transparent 60%),radial-gradient(500px 180px at 10% 100%,rgba(255,255,255,.14),transparent 60%)}
+  .hero>*{position:relative;z-index:1}
+  .brand{display:flex;align-items:center;justify-content:center;gap:8px;margin-bottom:14px;font-weight:800;font-size:14px}
+  .logo{width:30px;height:30px;border-radius:9px;background:rgba(255,255,255,.22);display:inline-flex;align-items:center;justify-content:center;font-size:16px}
+  .hero h1{font-size:34px;margin:0 0 10px;letter-spacing:.5px}
+  .hero .tag{display:inline-block;background:rgba(255,255,255,.18);border:1px solid rgba(255,255,255,.3);border-radius:99px;padding:4px 16px;font-size:13px;font-weight:600}
+  .hero p.sub{max-width:640px;margin:14px auto 0;font-size:15px;opacity:.92}
+  .wrap{max-width:1060px;margin:-34px auto 60px;padding:0 20px;position:relative;z-index:2}
+  .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:18px;margin-top:18px}
+  .card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:22px;box-shadow:0 4px 16px rgba(27,37,89,.06)}
+  .card .ic{width:42px;height:42px;border-radius:12px;background:var(--accent-soft);display:flex;align-items:center;justify-content:center;font-size:20px;margin-bottom:12px;border:1px solid var(--accent-line)}
+  .card h3{margin:0 0 8px;font-size:17px}
+  .card p{margin:0;font-size:13.5px;color:var(--sub)}
+  .card .meta{font-family:var(--mono);font-size:12px;color:var(--accent);margin-top:10px;display:block}
+  .sec{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:24px;margin-top:18px;box-shadow:0 4px 16px rgba(27,37,89,.06)}
+  .sec h2{margin:0 0 6px;font-size:20px}
+  .sec .lead{color:var(--sub);font-size:14px;margin:0 0 16px}
+  .steps{display:flex;flex-direction:column;gap:12px}
+  .step{display:flex;gap:14px;align-items:flex-start}
+  .step .no{flex:0 0 30px;height:30px;border-radius:9px;background:var(--grad);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:14px}
+  .step b{display:block}
+  .step span{color:var(--sub);font-size:13px}
+  code,pre{font-family:var(--mono)}
+  pre{background:#101A3A;color:#E8EDFF;border-radius:12px;padding:16px 18px;font-size:12.8px;overflow-x:auto;line-height:1.7;margin:10px 0}
+  pre b{color:#A5B4FC}
+  .grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:18px;margin-top:18px}
+  .tool-table{width:100%;border-collapse:collapse;margin-top:12px;font-size:13px}
+  .tool-table th,.tool-table td{border-bottom:1px solid var(--line);padding:10px 12px;text-align:left}
+  .tool-table th{background:var(--accent-soft);color:var(--accent);font-weight:700;font-size:12px}
+  .tool-table td code{background:var(--accent-soft);border-radius:5px;padding:1px 6px;font-size:12px;color:var(--accent)}
+  .badge{display:inline-block;background:var(--ok-soft);color:var(--ok);border-radius:99px;padding:2px 12px;font-size:12px;font-weight:700}
+  .note{background:var(--accent-soft);border:1px solid var(--accent-line);border-radius:12px;padding:14px 16px;margin-top:16px;font-size:13px;color:#3B3F76}
+  footer{text-align:center;color:#94A3B8;font-size:12px;padding:8px 20px 44px}
+  footer b{color:var(--accent)}
+  @media(max-width:680px){.hero h1{font-size:26px}.wrap{margin-top:-24px}}
+</style>
+</head>
+<body>
+
+<div class="hero">
+  <div class="brand"><span class="logo">F</span> 未来飞马 FmodeAgent · skill-storage</div>
+  <div class="tag">AI Agent 的对象存储技能</div>
+  <h1>对象存储 · 公开分享 · 大文件传输</h1>
+  <p class="sub">个人级云存储 · 企业级云存储 · 大文件网关 —— 一个技能,三套能力。零配置、开箱即用、密钥永不落盘。</p>
+</div>
+
+<div class="wrap">
+
+  <div class="cards">
+    <div class="card">
+      <div class="ic">☁️</div>
+      <h3>个人级云存储</h3>
+      <p>任何二进制文件(图片/音视频/HTML 报告)一键上传对象存储,拿到公网 URL 即分享即嵌入。本地零长期占用。</p>
+      <span class="meta">user/&lt;你的ID&gt;/… → https://s3.fmode.cn/…</span>
+    </div>
+    <div class="card">
+      <div class="ic">🏢</div>
+      <h3>企业级云存储</h3>
+      <p>项目级 Storage:注册项目后签发项目专属 STS,发布到 app.fmode.cn/dev/&lt;项目&gt;/,项目间隔离、权限收敛。</p>
+      <span class="meta">dev/&lt;ProjectID&gt;/… → https://app.fmode.cn/…</span>
+    </div>
+    <div class="card">
+      <div class="ic">📦</div>
+      <h3>大文件传输网关</h3>
+      <p>当通道限制挡住大文件(如企微 20MB),生成接收页让用户拖拽直传对象存储,再用 collect 取回。</p>
+      <span class="meta">拖拽直传 · 进度条 · 槽位取回</span>
+    </div>
+  </div>
+
+  <div class="sec">
+    <h2>🚀 三行命令跑通</h2>
+    <p class="lead">安装 skill-storage 后,无需任何 AK/SK 配置(依赖登录态,云函数签发临时上传地址):</p>
+    <pre><b># 1. 自检通道</b>
+node uploader.mjs test
+<b># 2. 上传任意文件 → 拿公开 URL</b>
+node uploader.mjs put ./report.html --key report/202610/report.html
+<b># → https://s3.fmode.cn/user/&lt;你的ID&gt;/report/202610/report.html</b>
+<b># 3. 大文件被通道挡住?生成接收网关页发给对方</b>
+node uploader.mjs gateway ./out   # 自动部署 → 输出页面 URL</pre>
+    <div class="note">💡 上传即 public-read;可用 <code>--mime</code> 指定类型(HTML 直接浏览器渲染);覆盖同名 key 后 CDN 缓存 30 天,建议换新路径发布。</div>
+  </div>
+
+  <div class="sec">
+    <h2>📦 一键全局安装(npm)</h2>
+    <p class="lead">技能已发布到 npm,装一次即可在任何工具里调用;也支持从 Gogs 仓库直接 clone。</p>
+    <pre><b># npm 全局安装</b>
+npm i -g @fmode/skill-storage
+<b># 或从仓库获取(fmode/skill-storage · Gogs)</b>
+git clone https://git.fmode.cn/fmode/skill-storage</pre>
+  </div>
+
+  <div class="sec">
+    <h2>🔧 切换不同工具,让 AI 自主安装</h2>
+    <p class="lead">把下面任意一段提示词丢给对应工具,它会自己为当前工具全局安装并验证 skill-storage:</p>
+    <table class="tool-table">
+      <tr><th>工具</th><th>提示词</th></tr>
+      <tr><td><b>FmodeCode / Claude Code</b></td><td>请安装 skill-storage 技能到全局(npm i -g @fmode/skill-storage),并把它的 SKILL.md 与 scripts/ 放入你读取技能的全局位置,然后运行自检 node …/uploader.mjs test 确认通道可用,再告诉我安装结果。</td></tr>
+      <tr><td><b>Codex CLI</b></td><td>请把 @fmode/skill-storage 的全局技能安装到我的 Codex 技能目录(npm 包解包后放入 ~/.codex/skills 或对应 AGENTS.md 引用),并执行 test 自检,汇报总 URL 能力。</td></tr>
+      <tr><td><b>WorkBuddy / Trae / 千问</b></td><td>请在我的工作台安装 skill-storage 技能:从 https://git.fmode.cn/fmode/skill-storage 获取,把它注册为全局技能,并跑通「上传一个测试文件拿到公开 URL」的完整实测。</td></tr>
+      <tr><td><b>FmodeAgent / Hermes</b></td><td>请把 /opt/data/skills/fmode-storage 或 npm 包 @fmode/skill-storage 加入我的全局技能库(skills/ 目录),并执行 node …/uploader.mjs test 自检,汇报 ok/via。</td></tr>
+      <tr><td><b>Gemini CLI / 其他</b></td><td>请安装 skill-storage(npm i -g @fmode/skill-storage),将该技能的 SKILL.md 按你的技能格式注册,并用 uploader.mjs 上传一个小文件验证公开链接可访问。</td></tr>
+    </table>
+    <div class="note">🎯 技巧:让 AI 自安装时,告诉它「完成后自己实测一次(上传小文件拿 URL)」——它会自己验证而不是假装成功,避免装了个空壳。</div>
+  </div>
+
+  <div class="sec">
+    <h2>🛡️ 安全边界</h2>
+    <div class="grid2">
+      <div><b>密钥零落盘</b><p style="color:var(--sub);font-size:13px">AK/SK/sessionToken/STS 永不进仓库、日志、对话。第 0 级通道由云函数保管密钥,容器只拿 5 分钟预签名地址。</p></div>
+      <div><b>权限收敛</b><p style="color:var(--sub);font-size:13px">个人级强制 user/&lt;你的ID&gt;/ 前缀;项目级 STS 作用域仅 dev/&lt;ProjectID&gt;/,禁止 List/Delete。</p></div>
+      <div><b>大文件网关安全</b><p style="color:var(--sub);font-size:13px">接收页不含任何密钥;每个文件独立槽位、独立预签名 URL;STS 1h 过期后页面自动失效,不沿用旧页。</p></div>
+      <div><b>诚实失败</b><p style="color:var(--sub);font-size:13px">通道全失败时打印初始化向导并 exit 2,绝不伪装成功;生成页面后必须实测一次再发用户。</p></div>
+    </div>
+  </div>
+
+  <div class="sec">
+    <h2>📖 大文件网关:什么时候用、怎么用</h2>
+    <p class="lead">平时完全不需要;只有「通过当前通道发文件被上限挡住」时才触发。</p>
+    <div class="steps">
+      <div class="step"><div class="no">1</div><div><b>识别触发</b><span>主通道报超限(如企微入站 20MB / 出站文件 20MB)→ 启用网关。</span></div></div>
+      <div class="step"><div class="no">2</div><div><b>生成接收页</b><span><code>node uploader.mjs gateway ./out</code> → 自动部署,输出页面 URL(含当次 1h 授权)。</span></div></div>
+      <div class="step"><div class="no">3</div><div><b>发给对方</b><span>把 URL 发给对方,对方浏览器拖拽/选择文件直传 OBS(不占聊天通道)。</span></div></div>
+      <div class="step"><div class="no">4</div><div><b>取回</b><span><code>bash collect.sh --list</code> 看槽位;<code>--out ~/inbox</code> 下载并登记台账。</span></div></div>
+    </div>
+  </div>
+
+  <div class="sec">
+    <h2>🔗 相关链接</h2>
+    <table class="tool-table">
+      <tr><th>资源</th><th>地址</th></tr>
+      <tr><td>技能仓库(Gogs)</td><td><code>https://git.fmode.cn/fmode/skill-storage</code></td></tr>
+      <tr><td>npm 包</td><td><code>@fmode/skill-storage</code></td></tr>
+      <tr><td>大文件网关经验页</td><td><code>https://app.fmode.cn/dev/GErSoNNP6j/bigfile-gateway.html</code></td></tr>
+      <tr><td>本技能官方页</td><td><code>https://fmode.cn/skills/skill-storage/</code></td></tr>
+    </table>
+  </div>
+
+</div>
+
+<footer>由 <b>未来飞马 FmodeAgent</b> 提供技术支持 · <b>fmode.cn</b> · skill-storage v1.2.0</footer>
+</body>
+</html>

+ 523 - 0
skills/fmode-storage/scripts/gateway.mjs

@@ -0,0 +1,523 @@
+// 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.
+/**
+ * fmode-storage 大文件接收网关(bigfile-gateway)— 零依赖(Node ≥18)。
+ *
+ * 场景:主通道(如企微入站 20MB)挡住大文件时,生成一个自包含接收页,用户浏览器
+ * **直连对象存储**(预签名 PUT)把大文件传上来,再由 collect 取回。绕开聊天通道。
+ *
+ * 链路(全部复用 uploader.mjs 的会话语义,密钥永不落盘、永不进页面):
+ *   1. resolveSessionToken()                     ← uploader.mjs
+ *   2. 项目注册/复用 → projectId                 ← 云函数 /fmode/project-register
+ *   3. 项目级 STS(1h)                          ← /api/apig/deploy/huaweicloud
+ *   4. 自己实现 SigV4 预签名(不使用云函数签名——它恒定 282s,不可用于上传页)
+ *   5. 渲染 templates/gateway.html → <outDir>/index.html
+ *   6. 用同一 STS 把 index.html 传回项目前缀 → 公网 URL https://app.fmode.cn/dev/<pid>/bigfile/
+ *   7. 生成 collect.sh / mint.json(不含密钥)供取回
+ *
+ * ⚠️ 关键事实(实测 2026-10-13,见 SKILL.md 踩坑库):
+ *   - STS 真身有效期 1h(不是签名标称的 48h)→ 用户第二天传必失败,须重新 mint+部署。
+ *   - bucket 是 nova-cloud,region=cn-south-1(north-4 会 NoSuchBucket)。
+ *   - STS 策略只允许 prefix=dev/<pid>/ 的 GetObject/PutObject,**不允许 List** → 取回靠逐槽位探测,不靠列举。
+ *   - 不存在对象的公网 URL 在 CDN 上可能回落成 200 + text/html(占位页)→ 判「到位」必须排除 text/html。
+ */
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import crypto from 'crypto';
+import { fileURLToPath } from 'url';
+
+import {
+  resolveSessionToken,
+  fetchStsViaDeploy,
+} from './uploader.mjs';
+
+const FMODE_API_BASE = (process.env.FMODE_API_BASE || 'https://server.fmode.cn').replace(/\/$/, '');
+const FUNCTIONS_URL = `${FMODE_API_BASE}/api/functions`;
+const DEPLOY_STS_URL = `${FMODE_API_BASE}/api/apig/deploy/huaweicloud`;
+const PROJECT_REGISTER_PATH = '/fmode/project-register';
+const CDN_BASE = (process.env.FMODE_CDN_BASE || 'https://s3.fmode.cn').replace(/\/$/, '');
+const SITE_BASE = (process.env.FMODE_SITE_BASE || 'https://app.fmode.cn').replace(/\/$/, '');
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const TEMPLATE_PATH = path.join(__dirname, '..', 'templates', 'gateway.html');
+
+const DEFAULT_SLOTS = 20;
+const DEFAULT_TTL_HOURS = 1;   // STS 真身上限即 1h,标本一致(踩坑 #2)
+const DEFAULT_PROJECT_SLUG = 'bigfile-gateway';
+const DEFAULT_ENDPOINT = 'obs.cn-south-1.myhuaweicloud.com';
+
+// ============================================================================
+// SigV4 预签名(自实现;query 签名,payload=UNSIGNED-PAYLOAD)
+// ============================================================================
+
+function _sha256hex(s) { return crypto.createHash('sha256').update(s).digest('hex'); }
+function _hmac(key, data) { return crypto.createHmac('sha256', key).update(data).digest(); }
+/** RFC3986 转义(SigV4 要求:仅 A-Za-z0-9-_.~ 不转义) */
+function _rfc3986(str) {
+  return encodeURIComponent(String(str)).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
+}
+function _encodeKey(key) { return String(key).split('/').map(_rfc3986).join('/'); }
+
+/**
+ * 华为云 OBS SigV4 预签名 URL。
+ *
+ * 只签 host(不签 content-type)——这样浏览器上传时可自由设置文件类型,
+ * 且 x-obs-acl: public-read 走自定义头不会破坏签名(bucket 预检会回显请求头)。
+ *
+ * @param {object} o
+ * @param {string} o.endpoint  如 obs.cn-south-1.myhuaweicloud.com
+ * @param {string} o.region    如 cn-south-1(从 endpoint 推得)
+ * @param {string} o.bucket
+ * @param {string} o.key       对象键(不含前导 /)
+ * @param {string} o.ak o.sk o.securityToken
+ * @param {'PUT'|'GET'|'HEAD'} [o.method]
+ * @param {number} [o.expires] 秒
+ * @param {object} [o.query]   额外 query(如 list-type)
+ * @returns {string} 预签名 URL
+ */
+export function presignV4(o) {
+  const now = o.now || new Date();
+  const method = (o.method || 'PUT').toUpperCase();
+  const host = `${o.bucket}.${o.endpoint}`;
+  const amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
+  const dateStamp = amzDate.slice(0, 8);
+  const scope = `${dateStamp}/${o.region}/s3/aws4_request`;
+
+  const lower = { host };
+  for (const [k, v] of Object.entries(o.headers || {})) lower[k.toLowerCase()] = String(v);
+  const names = Object.keys(lower).sort();
+  const canonicalHeaders = names.map((n) => `${n}:${lower[n].trim()}`).join('\n') + '\n';
+  const signedHeaders = names.join(';');
+
+  const q = {
+    ...(o.query || {}),
+    'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
+    'X-Amz-Credential': `${o.ak}/${scope}`,
+    'X-Amz-Date': amzDate,
+    'X-Amz-Expires': String(o.expires || DEFAULT_TTL_HOURS * 3600),
+    'X-Amz-SignedHeaders': signedHeaders,
+  };
+  if (o.securityToken) q['X-Amz-Security-Token'] = o.securityToken;
+  const canonicalQuery = Object.keys(q).sort()
+    .map((k) => `${_rfc3986(k)}=${_rfc3986(q[k])}`).join('&');
+
+  const canonicalUri = '/' + _encodeKey(o.key || '');
+  const payloadHash = 'UNSIGNED-PAYLOAD';
+  const canonicalRequest = [method, canonicalUri, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join('\n');
+  const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, _sha256hex(canonicalRequest)].join('\n');
+  let k = _hmac(`AWS4${o.sk}`, dateStamp);
+  k = _hmac(k, o.region); k = _hmac(k, 's3'); k = _hmac(k, 'aws4_request');
+  const signature = crypto.createHmac('sha256', k).update(stringToSign).digest('hex');
+  return `https://${host}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`;
+}
+
+/** 从 endpoint 推断 region:obs.cn-south-1.myhuaweicloud.com → cn-south-1 */
+export function regionOf(endpoint) {
+  const m = String(endpoint || '').match(/obs\.([a-z0-9-]+)\./);
+  return m ? m[1] : 'cn-south-1';
+}
+
+// ============================================================================
+// 项目注册 / STS
+// ============================================================================
+
+/** 注册或复用一个项目(云函数 /fmode/project-register,幂等:同名返回 reused:true)。 */
+export async function registerOrReuseProject(sessionToken, name) {
+  if (!sessionToken || !name) return null;
+  try {
+    const res = await fetch(FUNCTIONS_URL, {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ token: sessionToken, path: PROJECT_REGISTER_PATH, params: { name } }),
+      signal: AbortSignal.timeout(20000),
+    });
+    if (!res.ok) return null;
+    const j = await res.json().catch(() => null);
+    if (!j || !j.success || !j.projectId) return null;
+    return { projectId: String(j.projectId), projectName: String(j.projectName || name), reused: !!j.reused };
+  } catch { return null; }
+}
+
+/**
+ * 解析本次网关使用的 projectId:
+ *   --project-id > 环境变量 > 注册/复用 <projectSlug>。
+ * 为什么用项目而非个人空间:项目级 STS 是唯一能拿到 **1h 有效期签名** 的通道
+ * (云函数签名恒定 282s),而个人空间无对应 STS 签发端点。
+ */
+export async function resolveGatewayProject(sessionToken, { projectId, projectSlug } = {}) {
+  if (projectId) return { projectId, projectName: projectSlug || null, reused: null, via: 'explicit' };
+  const slug = projectSlug || process.env.FMODE_GATEWAY_PROJECT || DEFAULT_PROJECT_SLUG;
+  const reg = await registerOrReuseProject(sessionToken, slug);
+  if (reg) return { ...reg, via: 'cloudfunc:project-register' };
+  return null;
+}
+
+/** 用 sessionToken + projectId 取项目级 STS(1h)。仅内存持有。 */
+export async function mintSts(sessionToken, projectId) {
+  const sts = await fetchStsViaDeploy(sessionToken, projectId);
+  if (!sts) return null;
+  // 兜底:deploy 端点返回的 obsPath 已解析出 bucket/prefix;endpoint 固定 south-1。
+  return {
+    ak: sts.ak, sk: sts.sk, securityToken: sts.securityToken,
+    bucket: sts.bucket || 'nova-cloud',
+    endpoint: sts.endpoint || DEFAULT_ENDPOINT,
+    prefix: sts.prefix || `dev/${projectId}/`,
+    projectId,
+    expiresAt: sts.expiresAt || null,
+  };
+}
+
+// ============================================================================
+// 槽位(slot)
+// ============================================================================
+
+/**
+ * 生成 N 个槽位。每个槽位 = 一个独立对象键 + 一条独立预签名 PUT URL + 一条公网读 URL。
+ *
+ * 为什么槽位化:浏览器拿不到本地文件名,槽位号是唯一的服务端信号 —— 既是写入位置,
+ * 也是「哪个位子有文件」的判据。原作者注释说「原始文件名靠取回阶段命名规则补回来」,
+ * 本实现做了增强:文件名在**上传时**由页面显式写进槽位的 Content-Disposition 无关;
+ * 页面把 file.name 一起记入本地清单,取回时按 manifest 还原 —— 更贴近「按文件名取回」。
+ */
+export function buildSlots({ sts, count, slotPrefix = '', expires }) {
+  const slots = [];
+  const base = `${sts.prefix}${slotPrefix ? slotPrefix.replace(/^\/+|\/+$/g, '') + '/' : ''}`;
+  for (let i = 0; i < count; i++) {
+    const n = String(i).padStart(4, '0');
+    const key = `${base}${n}.bin`;
+    const url = presignV4({
+      ak: sts.ak, sk: sts.sk, securityToken: sts.securityToken,
+      endpoint: sts.endpoint, region: regionOf(sts.endpoint), bucket: sts.bucket,
+      key, method: 'PUT', expires,
+    });
+    slots.push({
+      n,
+      key,
+      contentType: 'application/octet-stream',
+      url,
+      publicUrl: `${SITE_BASE}/${key}`,
+    });
+  }
+  return slots;
+}
+
+// ============================================================================
+// 页面渲染 + 部署
+// ============================================================================
+
+/** 渲染接收页(模板 + 内联 CONFIG,自包含单文件)。 */
+export function renderPage({ title, channelName, limitText, limitNote, publicBase, slots, ttlHours }) {
+  const tpl = fs.readFileSync(TEMPLATE_PATH, 'utf8');
+  const cfg = { publicBase, ttlHours, slots: slots.map((s) => ({ n: s.n, name: `${s.n}.bin`, url: s.url, publicUrl: s.publicUrl, contentType: s.contentType })) };
+  const esc = (s) => String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
+  // 配置 JSON 内联进 <script type="application/json">:需转义 </ 以免提前闭合脚本标签
+  const cfgJson = JSON.stringify(cfg).replace(/<\//g, '<\\/');
+  return tpl
+    .replace(/__TITLE__/g, esc(title))
+    .replace(/__CHANNEL_NAME__/g, esc(channelName))
+    .replace(/__LIMIT_TEXT__/g, esc(limitText))
+    .replace(/__LIMIT_NOTE__/g, esc(limitNote))
+    .replace(/__TTL_HOURS__/g, String(ttlHours))
+    .replace(/__CONFIG_JSON__/g, cfgJson);
+}
+
+/**
+ * 部署页面:用同一 STS 把 index.html 直传项目前缀(而不是塞进个人空间或独立站点项目)。
+ * 好处:仍在 STS 允许的 dev/<pid>/ 前缀内,key 与预期一致(规避站点 dist-deploy 重写坑)。
+ * 落手即 public-read,公网 https://app.fmode.cn/dev/<pid>/bigfile/index.html
+ */
+export async function deployPage(sts, html) {
+  const key = `${sts.prefix}bigfile/index.html`;
+  const url = presignV4({
+    ak: sts.ak, sk: sts.sk, securityToken: sts.securityToken,
+    endpoint: sts.endpoint, region: regionOf(sts.endpoint), bucket: sts.bucket,
+    key, method: 'PUT', expires: 3600,
+  });
+  const res = await fetch(url, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'text/html; charset=utf-8', 'x-obs-acl': 'public-read' },
+    body: html,
+    signal: AbortSignal.timeout(30000),
+  });
+  if (!res.ok) return { ok: false, status: res.status, body: (await res.text()).slice(0, 300) };
+  return { ok: true, key, url: `${SITE_BASE}/${key}` };
+}
+
+// ============================================================================
+// collect(取回)
+// ============================================================================
+
+/**
+ * 判「远端对象到位」:CDN 对不存在的路径可能回落 200 + text/html(占位页),
+ * 因此仅凭 200 不足 —— 必须排除 text/html(踩坑 #1)。
+ */
+export function looksLikeObject(status, contentType) {
+  if (status !== 200) return false;
+  const ct = String(contentType || '').toLowerCase();
+  if (ct.includes('text/html')) return false;
+  return true;
+}
+
+/**
+ * 扫描槽位:对每个槽位的公网 URL 发 HEAD,判定是否已上传。
+ * 不依赖 ListObjects(STS 策略不允许 List —— 实测 403)。
+ */
+export async function scanSlots(manifest) {
+  const found = [];
+  for (const s of manifest.slots) {
+    const url = `${SITE_BASE}/${s.key}`;   // key 已含 dev/<pid>/ 前缀,勿再叠加 publicBase
+    try {
+      const res = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(15000) });
+      const ct = res.headers.get('content-type');
+      const len = Number(res.headers.get('content-length') || 0);
+      if (looksLikeObject(res.status, ct)) {
+        found.push({ n: s.n, key: s.key, bytes: len, contentType: ct, publicUrl: url });
+      }
+    } catch { /* 单槽位失败不阻断整体扫描 */ }
+  }
+  return found;
+}
+
+// ============================================================================
+// 主流程
+// ============================================================================
+
+/** 读取当前用户显示名(用于页面标题;缺失则回落到项目名/id)。非机密。 */
+export function resolveDisplayName() {
+  const candidates = [
+    path.join(os.homedir(), '.fmode', 'config', 'user.json'),
+    path.join(os.homedir(), '.fmode', 'config.json'),
+    '/opt/data/fmode-identity.json',
+  ];
+  for (const p of candidates) {
+    try {
+      if (!fs.existsSync(p)) continue;
+      const j = JSON.parse(fs.readFileSync(p, 'utf8').replace(/^/, ''));
+      const n = j.displayName || j.username || j.user?.displayName || j.user?.username || j.userid || j.userId;
+      if (n && String(n).trim()) return String(n).trim();
+    } catch { /* next */ }
+  }
+  return null;
+}
+
+/**
+ * mint:领 STS → 建槽位 → 渲染 → 部署 → 写 mint.json / collect.sh。
+ * @returns {Promise<{ok:boolean, ...}>}
+ */
+export async function gatewayMint(opts = {}) {
+  const outDir = path.resolve(opts.outDir || process.cwd());
+  const slotsCount = Number(opts.slots || DEFAULT_SLOTS);
+  const ttlHours = Number(opts.hours || DEFAULT_TTL_HOURS);
+  const channelName = opts.channel || '企微入站(用户 → 我)';
+  const limitText = opts.limit || '20MB';
+  const limitNote = opts.note || `${channelName} 是可协商不掉的上游硬限,文件在到达我这一侧之前就被拒收;改本地上限无效。`;
+
+  const sessionToken = resolveSessionToken();
+  if (!sessionToken) {
+    return { ok: false, error: 'no_session_token', message: '未找到登录态 sessionToken(云函数/STS 通道均依赖它)' };
+  }
+
+  const proj = await resolveGatewayProject(sessionToken, { projectId: opts.projectId, projectSlug: opts.projectSlug });
+  if (!proj) return { ok: false, error: 'project_unresolved', message: '项目注册/解析失败(云函数 /fmode/project-register 未成功)' };
+
+  const sts = await mintSts(sessionToken, proj.projectId);
+  if (!sts) {
+    return { ok: false, error: 'sts_failed', message: `项目级 STS 签发失败(项目 ${proj.projectId} 无权限或 token 失效)` };
+  }
+
+  const slotPrefix = opts.slotPrefix != null ? opts.slotPrefix : `gateway/${Date.now()}`;
+  const slots = buildSlots({ sts, count: slotsCount, slotPrefix, expires: Math.min(ttlHours * 3600, 3600) });
+
+  const title = opts.title || `大文件接收网关 · ${resolveDisplayName() || proj.projectName || proj.projectId}`;
+  const html = renderPage({
+    title, channelName, limitText, limitNote,
+    publicBase: `${SITE_BASE}/${sts.prefix.replace(/\/$/, '')}`,
+    slots, ttlHours,
+  });
+
+  fs.mkdirSync(outDir, { recursive: true });
+  const indexPath = path.join(outDir, 'index.html');
+  fs.writeFileSync(indexPath, html, 'utf8');
+
+  const manifest = {
+    v: 1,
+    kind: 'fmode-bigfile-gateway',
+    createdAt: new Date().toISOString(),
+    ttlHours,
+    projectId: proj.projectId,
+    projectName: proj.projectName || null,
+    bucket: sts.bucket,
+    endpoint: sts.endpoint,
+    region: regionOf(sts.endpoint),
+    prefix: sts.prefix,
+    publicBase: `${SITE_BASE}/${sts.prefix.replace(/\/$/, '')}`,
+    slotPrefix,
+    slots: slots.map((s) => ({ n: s.n, key: s.key, name: `${s.n}.bin` })),
+    indexPath,
+  };
+  // mint.json 只含非机密信息(projectId / key 列表)——签名 URL 有 1h 时效且随 STS 失效,不入盘。
+  const manifestPath = path.join(outDir, 'mint.json');
+  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 });
+
+  // 部署页面(可 --no-deploy 跳过,仅本地生成)
+  let deployed = null;
+  if (!opts.noDeploy) {
+    deployed = await deployPage(sts, html);
+  }
+
+  // collect 包装脚本(取回用;内部再 mint 新鲜 STS,不落盘任何密钥)
+  const collectSh = path.join(outDir, 'collect.sh');
+  fs.writeFileSync(collectSh, renderCollectSh(__dirname), { mode: 0o755 });
+
+  const report = {
+    ok: true,
+    pageUrl: deployed && deployed.ok ? deployed.url : null,
+    pageKey: deployed && deployed.ok ? deployed.key : null,
+    deployError: deployed && !deployed.ok ? `${deployed.status} ${deployed.body}` : null,
+    slots: slotsCount,
+    ttlHours,
+    projectId: proj.projectId,
+    projectName: proj.projectName || null,
+    projectVia: proj.via,
+    prefix: sts.prefix,
+    publicBase: manifest.publicBase,
+    stsExpiresAt: sts.expiresAt,
+    indexPath,
+    manifestPath,
+    collectScript: collectSh,
+    collectHint: `bash ${collectSh} --list        # 只看哪些槽位已有文件\nbash ${collectSh} --out ~/inbox  # 下载到 ~/inbox 并登记台账`,
+    stsExpiresNote: `STS 真身有效期约 ${ttlHours}h;用户过时未传须重新 mint+部署(不可沿用旧页)`,
+  };
+  return report;
+}
+
+function renderCollectSh(scriptsDir) {
+  return `#!/usr/bin/env bash
+# 大文件网关取回器 —— 扫描到位文件 → 下载 → 台账。
+# 不落盘任何密钥:每次运行重新 mint 项目级 STS(1h),只输出预签名 GET URL。
+# 用法:
+#   bash collect.sh --list                 # 只列出已到位的槽位
+#   bash collect.sh --out ~/inbox          # 下载到 ~/inbox 并写 ledger.jsonl
+#   bash collect.sh --manifest mint.json --out ./received
+set -euo pipefail
+DIR="$(cd "$(dirname "$0")" && pwd)"
+NODE_BIN="\${NODE_BIN:-node}"
+exec "$NODE_BIN" "${scriptsDir}/gateway.mjs" collect --manifest "$DIR/mint.json" "$@"
+`;
+}
+
+// ============================================================================
+// CLI
+// ============================================================================
+
+function parseArgs(rest) {
+  const a = { _: [] };
+  for (let i = 0; i < rest.length; i++) {
+    const t = rest[i];
+    if (t.startsWith('--')) { const k = t.slice(2); const v = rest[i + 1]; if (v === undefined || v.startsWith('--')) { a[k] = true; } else { a[k] = v; i++; } }
+    else a._.push(t);
+  }
+  return a;
+}
+
+async function cmdMint(rest) {
+  const a = parseArgs(rest);
+  const outDir = a.out || a._[0] || path.join(process.cwd(), 'gateway-out');
+  const report = await gatewayMint({
+    outDir,
+    slots: a.slots,
+    hours: a.hours,
+    title: a.title,
+    channel: a.channel,
+    limit: a.limit,
+    note: a.note,
+    slotPrefix: a['slot-prefix'],
+    projectId: a['project-id'],
+    projectSlug: a['project-slug'],
+    noDeploy: !!a['no-deploy'],
+  });
+  if (!report.ok) { console.error(JSON.stringify(report, null, 2)); process.exit(2); }
+  if (a.json) { console.log(JSON.stringify(report, null, 2)); return 0; }
+  console.log('大文件接收网关已生成');
+  console.log('  页面 URL :', report.pageUrl || `(未部署:${report.deployError})`);
+  console.log('  本地文件 :', report.indexPath);
+  console.log('  槽位数   :', report.slots, '  有效期约:', report.ttlHours + 'h');
+  console.log('  项目     :', report.projectId, `(${report.projectVia})`);
+  console.log('  前缀     :', report.prefix);
+  console.log('  取回     :', report.collectHint.replace(/\n/g, '\n             '));
+  console.log('  ⚠️ 发用户前先真传一个小文件实测(本工具不改这条铁律)。');
+  return 0;
+}
+
+async function cmdCollect(rest) {
+  const a = parseArgs(rest);
+  const manifestPath = a.manifest || path.join(process.cwd(), 'mint.json');
+  if (!fs.existsSync(manifestPath)) { console.error('找不到 manifest:', manifestPath); process.exit(2); }
+  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
+  const outDir = a.out ? path.resolve(a.out) : null;
+
+  console.log(`扫描槽位(${manifest.slots.length} 个)…`);
+  const found = await scanSlots(manifest);
+  if (found.length === 0) {
+    console.log('没有检测到任何已到位的文件。');
+    console.log('(若用户尚未上传,属正常;STS 过期需重新 mint+部署)');
+    return 0;
+  }
+  console.log(`检测到 ${found.length} 个已到位文件:`);
+  for (const f of found) console.log(`  [${f.n}] ${f.key}  ${f.bytes} B  ${f.contentType}`);
+
+  if (!outDir) { return 0; }   // --list 行为
+
+  const sessionToken = resolveSessionToken();
+  const sts = await mintSts(sessionToken, manifest.projectId);
+  if (!sts) { console.error('取回失败:无法 mint 新鲜 STS(sessionToken 失效或项目无权限)'); process.exit(2); }
+  fs.mkdirSync(outDir, { recursive: true });
+  const ledgerPath = path.join(outDir, 'ledger.jsonl');
+  let ok = 0;
+  for (const f of found) {
+    const url = presignV4({
+      ak: sts.ak, sk: sts.sk, securityToken: sts.securityToken,
+      endpoint: sts.endpoint, region: regionOf(sts.endpoint), bucket: sts.bucket,
+      key: f.key, method: 'GET', expires: 3600,
+    });
+    try {
+      const res = await fetch(url, { signal: AbortSignal.timeout(120000) });
+      if (!looksLikeObject(res.status, res.headers.get('content-type'))) { console.error(`  跳过 [${f.n}](非对象响应)`); continue; }
+      const buf = Buffer.from(await res.arrayBuffer());
+      const local = path.join(outDir, `${f.n}.bin`);
+      fs.writeFileSync(local, buf);
+      fs.appendFileSync(ledgerPath, JSON.stringify({ n: f.n, key: f.key, bytes: buf.length, contentType: f.contentType, localPath: local, at: new Date().toISOString() }) + '\n');
+      console.log(`  下载 [${f.n}] → ${local}  (${buf.length} B)`);
+      ok++;
+    } catch (e) { console.error(`  下载 [${f.n}] 失败: ${e.message}`); }
+  }
+  console.log(`完成:${ok}/${found.length} 已下载到 ${outDir};台账 ${ledgerPath}`);
+  return 0;
+}
+
+export async function main(argv = process.argv.slice(2)) {
+  const [cmd, ...rest] = argv;
+  if (cmd === 'mint' || cmd === 'generate' || cmd === 'gen') return cmdMint(rest);
+  if (cmd === 'collect' || cmd === 'fetch') return cmdCollect(rest);
+  console.log(`用法(大文件接收网关):
+  node gateway.mjs mint  [--slots 20] [--hours 1] [--out DIR] [--title T]
+                         [--channel "企微入站"] [--limit 20MB] [--note "..."]
+                         [--project-id ID | --project-slug slug] [--slot-prefix P]
+                         [--no-deploy] [--json]
+  node gateway.mjs collect --manifest mint.json [--out DIR]   # 无 --out 即 --list`);
+  return cmd === 'help' ? 0 : 2;
+}
+
+// 作为脚本直接运行时才执行(被 import 时不自动跑,避免干扰 uploader.mjs)
+if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
+  main().then((c) => process.exit(c)).catch((e) => { console.error('未预期错误:', e.message); process.exit(1); });
+}

+ 16 - 2
skills/fmode-storage/scripts/uploader.mjs

@@ -647,6 +647,16 @@ async function main() {
   if (cmd === 'init') { process.exit(await cmdInit(rest) ? 0 : 1); }
   if (cmd === 'init') { process.exit(await cmdInit(rest) ? 0 : 1); }
   if (cmd === 'test') { process.exit(await cmdTest() ? 0 : 1); }
   if (cmd === 'test') { process.exit(await cmdTest() ? 0 : 1); }
 
 
+  // ── gateway:大文件接收网关(委托 scripts/gateway.mjs)──────────────────────
+  // 用法: node uploader.mjs gateway <outDir>      → mint 到 <outDir>
+  //        node uploader.mjs gateway mint ...      → 透传子命令
+  //        node uploader.mjs gateway collect --manifest ...
+  if (cmd === 'gateway') {
+    const { main: gwMain } = await import('./gateway.mjs');
+    const sub = ['mint', 'gen', 'generate', 'collect', 'fetch', 'help'].includes(rest[0]) ? rest : ['mint', ...rest];
+    process.exit(await gwMain(sub));
+  }
+
   // ── 第0级(首选,免密钥):put 直接走云函数预签名直传 ──────────────────
   // ── 第0级(首选,免密钥):put 直接走云函数预签名直传 ──────────────────
   // 必须在凭据链解析之前:否则"4 级全未命中 → 打印向导 exit 2"会把这条路挡住,
   // 必须在凭据链解析之前:否则"4 级全未命中 → 打印向导 exit 2"会把这条路挡住,
   // 而这条路恰恰是唯一不需要任何本机凭据的通道。
   // 而这条路恰恰是唯一不需要任何本机凭据的通道。
@@ -732,7 +742,11 @@ async function main() {
     }, null, 2));
     }, null, 2));
     return;
     return;
   }
   }
-  console.log('用法: init | test | put | setacl | config');
+  console.log('用法: init | test | put | setacl | config | gateway <outDir>');
 }
 }
 
 
-main().catch((e) => { console.error('未预期错误:', e.message); process.exit(1); });
+// 仅当作为脚本直接运行时执行 main();被 import(如 gateway.mjs)时不自动跑,
+// 避免 print 用法噪音或误触命令。
+if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
+  main().catch((e) => { console.error('未预期错误:', e.message); process.exit(1); });
+}

+ 158 - 0
skills/fmode-storage/templates/gateway.html

@@ -0,0 +1,158 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<meta name="color-scheme" content="light">
+<title>__TITLE__</title>
+<style>
+  :root{
+    --ink:#1B2559; --sub:#64748B; --line:#E2E8F0; --card:#FFFFFF;
+    --bg:#F5F8FF; --accent:#5B5BD6; --accent-soft:#EEF0FF; --accent-line:#D5D9F5;
+    --accent2:#7C6FF0; --grad:linear-gradient(135deg,#5B5BD6 0%,#7C6FF0 60%,#9BB8F5 100%);
+    --ok:#1E7A46; --ok-soft:#EAF6EF; --warn:#8A6A1F; --warn-soft:#FAF4E6;
+    --radius:14px;
+  }
+  *{box-sizing:border-box}
+  html,body{margin:0;padding:0}
+  body{
+    background:var(--bg); color:var(--ink); min-height:100vh;
+    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
+    line-height:1.55; -webkit-font-smoothing:antialiased;
+    padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
+  }
+  .wrap{max-width:720px;margin:0 auto;padding:20px 16px 56px}
+  header{padding:8px 0 4px}
+  .eyebrow{color:var(--accent);font-size:12px;font-weight:700;letter-spacing:.14em;text-transform:uppercase}
+  h1{font-size:22px;margin:6px 0 2px;line-height:1.3;word-break:break-word}
+  .sub{color:var(--sub);font-size:13.5px;margin:0 0 4px}
+  .card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px;margin-top:14px;box-shadow:0 1px 2px rgba(26,22,20,.03)}
+  .limit{background:var(--warn-soft);border:1px solid #EFE1BE;border-radius:var(--radius);padding:14px 16px;margin-top:14px}
+  .limit .big{font-weight:800;font-size:15px}
+  .limit .channel{color:var(--accent);font-weight:700}
+  .limit p{margin:6px 0 0;font-size:13px;color:var(--sub)}
+  .notice{display:flex;gap:10px;align-items:flex-start;background:var(--accent-soft);border:1px solid var(--accent-line);border-radius:12px;padding:12px 14px;margin-top:12px;font-size:13px;color:#7A1220}
+  .notice .ico{flex:0 0 auto;font-weight:800}
+  #drop{
+    margin-top:14px;border:2px dashed var(--accent-line);border-radius:var(--radius);
+    background:linear-gradient(180deg,#fff, #FFF9F9); padding:30px 18px; text-align:center;
+    cursor:pointer; transition:border-color .15s, background .15s;
+  }
+  #drop.hot{border-color:var(--accent);background:var(--accent-soft)}
+  #drop .big{font-size:16px;font-weight:700;margin-bottom:6px}
+  #drop .small{font-size:12.5px;color:var(--sub)}
+  #drop .pick{display:inline-block;margin-top:12px;background:var(--accent);color:#fff;font-weight:700;
+    border:0;border-radius:10px;padding:10px 20px;font-size:14.5px;cursor:pointer}
+  #drop .pick:active{transform:translateY(1px)}
+  .files{margin-top:16px;display:flex;flex-direction:column;gap:10px}
+  .f{border:1px solid var(--line);border-radius:12px;padding:12px 14px;background:#fff}
+  .f .row{display:flex;justify-content:space-between;gap:10px;align-items:baseline}
+  .f .name{font-weight:600;font-size:13.5px;word-break:break-all}
+  .f .meta{color:var(--sub);font-size:12px;white-space:nowrap}
+  .f .slot{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--sub);margin-top:2px;word-break:break-all}
+  .bar{height:7px;border-radius:99px;background:#EFEAE3;overflow:hidden;margin-top:9px}
+  .bar > i{display:block;height:100%;width:0;background:var(--accent);transition:width .2s}
+  .f.done .bar > i{background:var(--ok)}
+  .f.done{border-color:#CDE7D7;background:var(--ok-soft)}
+  .f.err .bar > i{background:#B8182A}
+  .status{font-size:12px;margin-top:6px;font-weight:600}
+  .status.ok{color:var(--ok)} .status.err{color:var(--accent)} .status.up{color:var(--sub)}
+  footer{color:var(--sub);font-size:11.5px;margin-top:22px;text-align:center;line-height:1.7}
+  code{background:#F2EDE6;border-radius:5px;padding:1px 5px;font-size:12px}
+  .hide{display:none}
+</style>
+</head>
+<body>
+<div class="wrap">
+  <header>
+    <div class="brand" style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
+      <span style="width:26px;height:26px;border-radius:8px;background:var(--grad);display:inline-flex;align-items:center;justify-content:center;color:#fff;font-weight:800;font-size:14px">F</span>
+      <span style="font-weight:800;font-size:13px;color:var(--accent)">未来飞马 FmodeAgent</span>
+      <span style="font-size:11px;color:var(--sub)">大文件接收网关</span>
+    </div>
+    <h1>__TITLE__</h1>
+    <p class="sub">拖入文件即直传对象存储,不占聊天通道 —— 多大都行。</p>
+  </header>
+
+  <div class="limit">
+    <div class="big">当前通道限制:<span class="channel">__CHANNEL_NAME__</span></div>
+    <div class="big" style="margin-top:2px">上限 __LIMIT_TEXT__</div>
+    <p>__LIMIT_NOTE__</p>
+  </div>
+
+  <div class="notice"><span class="ico">!</span><span>这是通道限制,不是你操作的问题。用本页直传存储,原文件多大都可以。</span></div>
+
+  <div id="drop">
+    <div class="big">拖拽文件到此处,或点击选择</div>
+    <div class="small">支持多文件同时上传 · 手机、电脑均可</div>
+    <button class="pick" id="pick" type="button">选择文件</button>
+    <input id="input" type="file" multiple class="hide">
+  </div>
+
+  <div class="files" id="files"></div>
+
+  <footer>
+    <div>上传地址由临时授权(STS)签发,约 __TTL_HOURS__ 小时内有效。</div>
+    <div>本页不含任何密钥;每个文件写入独立槽位,互不影响。</div>
+    <div id="slotsleft"></div>
+    <div style="margin-top:12px;padding-top:10px;border-top:1px solid var(--line);color:#94A3B8;font-size:11px">由 <b style="color:var(--accent)">未来飞马 FmodeAgent</b> 提供技术支持 · fmode.cn</div>
+  </footer>
+</div>
+
+<script id="gateway-config" type="application/json">__CONFIG_JSON__</script>
+<script>
+(function(){
+  var CFG = JSON.parse(document.getElementById('gateway-config').textContent);
+  var PUB_BASE = CFG.publicBase, SLOTS = CFG.slots, TTL = CFG.ttlHours;
+  var drop = document.getElementById('drop'), input = document.getElementById('input'),
+      pick = document.getElementById('pick'), filesEl = document.getElementById('files');
+  var free = SLOTS.slice();
+  function fmt(n){ if(n<1024) return n+' B'; var u=['KB','MB','GB'], i=-1; do{ n/=1024; i++; }while(n>=1024&&i<2); return n.toFixed(n<10?1:0)+' '+u[i]; }
+  function esc(s){ return String(s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
+  pick.addEventListener('click', function(e){ e.stopPropagation(); input.click(); });
+  drop.addEventListener('click', function(){ input.click(); });
+  input.addEventListener('change', function(){ enqueue(input.files); input.value=''; });
+  ['dragenter','dragover'].forEach(function(ev){ drop.addEventListener(ev, function(e){ e.preventDefault(); drop.classList.add('hot'); }); });
+  ['dragleave','drop'].forEach(function(ev){ drop.addEventListener(ev, function(e){ e.preventDefault(); drop.classList.remove('hot'); }); });
+  drop.addEventListener('drop', function(e){ if(e.dataTransfer && e.dataTransfer.files) enqueue(e.dataTransfer.files); });
+
+  function updateLeft(){ var el=document.getElementById('slotsleft'); el.textContent = '剩余可用槽位 '+free.length+' / '+SLOTS.length; }
+  updateLeft();
+
+  function enqueue(fl){
+    for(var i=0;i<fl.length;i++){ (function(f){ uploadOne(f); })(fl[i]); }
+  }
+
+  function uploadOne(file){
+    var slot = free.shift();
+    var row = document.createElement('div');
+    row.className='f';
+    row.innerHTML = '<div class="row"><span class="name">'+esc(file.name)+'</span><span class="meta">'+fmt(file.size)+'</span></div>'
+      + '<div class="slot">'+(slot? '槽位 '+slot.n+' · '+esc(slot.name):'槽位已用尽')+'</div>'
+      + '<div class="bar"><i></i></div><div class="status up">'+ (slot? '等待上传…' : '无可用槽位,请重新生成页面') +'</div>';
+    filesEl.appendChild(row);
+    updateLeft();
+    if(!slot){ row.classList.add('err'); row.querySelector('.status').className='status err'; return; }
+    var bar = row.querySelector('.bar > i'), st = row.querySelector('.status');
+    var xhr = new XMLHttpRequest();
+    xhr.open('PUT', slot.url, true);
+    xhr.setRequestHeader('Content-Type', slot.contentType || 'application/octet-stream');
+    xhr.setRequestHeader('x-obs-acl', 'public-read');
+    st.textContent='上传中…'; st.className='status up';
+    xhr.upload.onprogress = function(e){ if(e.lengthComputable){ var p=Math.round(e.loaded/e.total*100); bar.style.width=p+'%'; st.textContent='上传中… '+p+'%'; } };
+    xhr.onload = function(){
+      if(xhr.status>=200 && xhr.status<300){
+        bar.style.width='100%'; row.classList.add('done'); st.className='status ok';
+        st.innerHTML = '上传成功 ✓ <a href="'+slot.publicUrl+'" target="_blank" rel="noopener">查看/下载</a>';
+      } else {
+        row.classList.add('err'); st.className='status err';
+        st.textContent='上传失败(HTTP '+xhr.status+')。若本页已打开超过 '+TTL+' 小时,请让智能体重新生成。';
+      }
+    };
+    xhr.onerror = function(){ row.classList.add('err'); st.className='status err'; st.textContent='网络中断,请重试或让智能体重新生成页面。'; };
+    xhr.send(file);
+  }
+})();
+</script>
+</body>
+</html>