// 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) * * 职责:把本地素材文件真正传到对象存储,拿到**公网可读 URL**,供 materialAssets[].url 使用。 * * 为什么存在:案例检索侧只看 materialAssets[].url,本地路径不算数。此前只有 * `--asset-base-url` 前缀派生(base + 文件名)——没给前缀就留空,给了也只是拼一个 * 并不存在的假链接,比空字符串更坏。本桥接把「先上传、再拿真 URL」落成代码。 * * 走 fmode-storage 的**第 0 级通道**(云函数免密钥预签名直传): * - 容器只需 FMODE_SESSION_TOKEN,真实 AK/SK 永远留在云函数侧,**技能里绝不内联任何凭据**; * - 云函数在服务端强制把对象挂到调用者自己的前缀下:user/<调用者id>/; * 因此这里的 key 一律是**相对 key**(不带 user/ 前缀),公网 URL 由上传回执给出。 * * object key 规范:case-library//-. * * 幂等:同一 (localPath, key) 在**本进程内**只传一次(Map 缓存),重复调用直接复用回执。 * 跨进程不做要求。 * * 安全:绝不打印 token / AK / SK / sessionToken(含错误分支);上传回执里也没有密钥。 */ import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { parseArgs } from 'node:util'; import { resolveSiblingScript, shortHash } from './lib.mjs'; const STORAGE_REL = path.join('fmode-storage', 'scripts', 'uploader.mjs'); // 云函数自动补 user// 前缀,这里只写相对 key const KEY_NAMESPACE = 'case-library'; /** 兄弟技能 fmode-storage 不可用(未安装 / 导入失败)。 */ export class StorageUnavailableError extends Error { constructor(message, detail = {}) { super(message); this.name = 'StorageUnavailableError'; this.code = 'STORAGE_UNAVAILABLE'; this.detail = detail; } } /** * 定位兄弟技能 fmode-storage 的 uploader.mjs。 * 可用环境变量 CASE_STORAGE_SCRIPT 覆盖(与 vision-bridge 的 CASE_VISION_SCRIPT 同构)。 * @returns {string|null} 找不到返回 null(调用方给可执行的降级提示,不抛栈) */ export function resolveStorageScript() { return resolveSiblingScript('CASE_STORAGE_SCRIPT', STORAGE_REL); } // 本进程内已上传缓存:`${localPath}::${key}` → { url, key, bytes } const uploadCache = new Map(); /** 清空本进程内上传缓存(测试用)。 */ export function resetUploadCache() { uploadCache.clear(); } /** 文件名 slug 化:中文/空格/特殊字符全部替换成 '-',避免坏链接。 */ export function slugify(value, max = 40) { const slug = String(value ?? '') .trim() .replace(/[^\w.-]+/g, '-') .replace(/-{2,}/g, '-') .replace(/^-+|-+$/g, ''); return slug.slice(0, max); } /** 归一化扩展名:去掉点、小写;空则回落到 bin。 */ export function normalizeExt(ext) { const clean = String(ext ?? '').trim().replace(/^\.+/, '').toLowerCase().replace(/[^a-z0-9]/g, ''); return clean || 'bin'; } /** * 文件内容指纹:避免不同文件落成同一个 object key 互相覆盖。 * 取「字节数 + sha1 前 12 位」;读不到文件时退化为长度 0 的兜底串 '0'。 */ function contentFingerprint(file) { try { const buf = fs.readFileSync(file); const digest = crypto.createHash('sha1').update(buf).digest('hex').slice(0, 12); return `${buf.length}-${digest}`; } catch { return '0'; } } /** 紧凑日期戳 YYYYMMDD(本地时区,和案例包其它时间字段口径一致)。 */ export function dateStamp(date = new Date()) { const pad = (n) => String(n).padStart(2, '0'); return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`; } /** * 组装 object key(**相对 key**,云函数会自动补 user// 前缀,别自己加)。 * 规范:case-library//-. * * 传入的 caseSlug 须已保证区分度(uploadMaterial 会在 slugify 结果为空时用内容指纹兜底), * 否则两个不同案例会在同一 <日期> 目录里撞 key 互相覆盖。 * @returns {string} */ export function buildObjectKey({ caseSlug, order, ext, date } = {}) { const slug = slugify(caseSlug) || 'case'; const seq = Number.isFinite(Number(order)) && Number(order) > 0 ? Math.trunc(Number(order)) : 1; return `${KEY_NAMESPACE}/${dateStamp(date)}/${slug}-${seq}.${normalizeExt(ext)}`; } /** 动态加载兄弟技能导出(路径用 file:// URL,避免 Windows 盘符/空格问题)。 */ async function loadUploader() { const script = resolveStorageScript(); if (!script) { throw new StorageUnavailableError( `未找到 fmode-storage(期望 ${STORAGE_REL})。请先安装:npx fmode-storage@latest install;` + '或用环境变量 CASE_STORAGE_SCRIPT 指向 uploader.mjs。若本机确实无法上传,请用 --no-upload 关闭上传。', { expected: STORAGE_REL }, ); } let mod; try { mod = await importQuietly(new URL(`file://${script.split(path.sep).join('/')}`).href); } catch (error) { throw new StorageUnavailableError(`加载 fmode-storage 失败:${error.message}`, { script }); } const missing = ['putViaCloudFunction', 'publicUrl', 'guessMime', 'normalizeKey'] .filter((name) => typeof mod[name] !== 'function'); if (missing.length) { throw new StorageUnavailableError( `fmode-storage 版本不兼容:uploader.mjs 缺少导出 ${missing.join(', ')}。请升级该技能。`, { script, missing }, ); } return { mod, script }; } /** * 静默导入:uploader.mjs 在**模块顶层**跑了一次 main(),而它的命令列表不含我们传的 argv, * 于是会往 **stdout** 打一行「用法: init | test | put | setacl | config」。 * 那一行会污染 `case-intake.mjs ingest` 的 JSON stdout(调用方按 JSON 解析), * 所以导入窗口内的 console.log 一律改投 stderr —— 不丢信息,也不污染 stdout。 */ async function importQuietly(url) { const originalLog = console.log; const captured = []; console.log = (...args) => { captured.push(args.map((arg) => String(arg)).join(' ')); }; try { return await import(url); } finally { console.log = originalLog; for (const line of captured) { if (line.trim()) process.stderr.write(`[fmode-storage] ${line}\n`); } } } /** 从上传回执取公网 URL:回执自带 publicUrl 优先,其次按 fmode-storage 的 publicUrl() 规则(CDN 前缀)拼。 */ function urlFromResult(mod, result) { if (result.url) return String(result.url); try { // 第 0 级通道的公网入口固定是 CDN(https://s3.fmode.cn/),不需要 bucket/endpoint return mod.publicUrl({ cdnDomain: 's3.fmode.cn' }, result.key); } catch { return ''; } } /** * 上传单个素材。 * * @param {string} localPath 本地文件绝对路径 * @param {{tenantId?:string, caseSlug?:string, order?:number, ext?:string}} options * ext 省略时取文件自身扩展名。 * @returns {Promise<{url:string, key:string, bytes:number}>} * @throws {StorageUnavailableError} 兄弟技能缺失 / 不可加载 * @throws {Error} 上传失败(网络 / 云函数未配置 / 文件读取失败) */ export async function uploadMaterial(localPath, options = {}) { const file = path.resolve(String(localPath || '')); if (!localPath || !fs.existsSync(file)) { throw new Error(`素材文件不存在:${localPath || '(空)'}`); } const ext = options.ext || path.extname(file).replace(/^\./, ''); // slug 兜底:调用方给的 slug 经 slugify 后可能为空(**中文标题**只含非 ASCII 时会被清空), // 若直接回落 'case',多个不同案例会在同一日期目录里撞 key 互相覆盖。 // 因此这里在 slug 失效时统一用「文件内容指纹」保证区分度。 const requestedSlug = slugify(options.caseSlug); const effectiveSlug = requestedSlug || `${contentFingerprint(file)}`; const key = options.key ? String(options.key) : buildObjectKey({ caseSlug: effectiveSlug, order: options.order, ext }); // 幂等:同 (localPath, key) 本进程内只传一次 const cacheKey = `${file}::${key}`; const cached = uploadCache.get(cacheKey); if (cached) return { ...cached }; const { mod } = await loadUploader(); // 归一化 key(fmode-storage 会剥离误写的 user// 前缀并打 warn 到 stderr—— // warn 文案里只有 key,不含任何密钥,可以安全透传) const normalized = mod.normalizeKey(key); const mime = mod.guessMime(file); const namespace = KEY_NAMESPACE; let result; try { result = await mod.putViaCloudFunction( file, normalized, namespace, path.basename(normalized), mime, ); } catch (error) { throw new Error(`上传通道报错:${error.message}`); } if (!result || result.ok !== true || !result.key) { // putViaCloudFunction 返回 null 表示第 0 级通道不可用(无 sessionToken / 云函数未配置 / 网络失败)。 // **不回落本地 AK/SK 通道**:技能红线是不在内联任何对象存储凭据,宁可在 ingest 里降级留空 URL。 throw new Error( '云函数免密钥上传通道不可用(无 FMODE_SESSION_TOKEN、云函数未配置或网络失败);' + '本技能不内联对象存储凭据,故不回落本地 AK/SK 通道', ); } const payload = { url: urlFromResult(mod, result), key: String(result.key), bytes: Number(result.bytes) || fs.statSync(file).size, }; if (!payload.url) throw new Error(`上传回执未给出公网 URL(key=${payload.key})`); uploadCache.set(cacheKey, payload); return { ...payload }; } // --------------------------------------------------------------------------- // CLI(可选,供人工单传 / 排障;ingest 走的是上面的函数) // --------------------------------------------------------------------------- const HELP_TEXT = `storage-bridge —— 素材上传(复用兄弟技能 fmode-storage,第 0 级免密钥通道) 用法: node storage-bridge.mjs --file ./img.png [--case-slug imperial-aero50006] [--order 1] [--out url.json] 选项: --file 要上传的本地素材(必填) --case-slug object key 里的案例短名(默认取文件名主干) --order object key 里的序号(默认 1) --ext 覆盖扩展名(默认取文件自身扩展名) --key 直接指定相对 key(给了就忽略 case-slug/order/ext) --out 把结果 JSON 写文件(不给则打印到 stdout) object key 规范:case-library//-.(云函数会自动补 user// 前缀) `; async function main() { const { values } = parseArgs({ options: { file: { type: 'string' }, 'case-slug': { type: 'string' }, order: { type: 'string' }, ext: { type: 'string' }, key: { type: 'string' }, out: { type: 'string' }, help: { type: 'boolean', default: false, short: 'h' }, }, allowPositionals: true, }); if (values.help) { process.stdout.write(HELP_TEXT); return 0; } if (!values.file) { process.stderr.write('需要 --file <本地素材>\n'); return 2; } const report = await uploadMaterial(values.file, { caseSlug: values['case-slug'] || path.basename(values.file, path.extname(values.file)), order: values.order ? Number(values.order) : 1, ext: values.ext, key: values.key, }); const text = `${JSON.stringify({ ok: true, ...report }, null, 2)}\n`; if (values.out) { fs.mkdirSync(path.dirname(path.resolve(values.out)), { recursive: true }); fs.writeFileSync(path.resolve(values.out), text, 'utf8'); } else { process.stdout.write(text); } return 0; } const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]).endsWith(path.join('scripts', 'storage-bridge.mjs')); if (invokedDirectly) { main().then((code) => process.exit(code)).catch((error) => { process.stderr.write(`storage-bridge 失败:${error.message}\n`); process.exit(error instanceof StorageUnavailableError ? 3 : 1); }); } export { STORAGE_REL, KEY_NAMESPACE };