| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319 |
- // 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 一律是**相对 key**(不带 user/ 前缀),公网 URL 由上传回执给出。
- *
- * object key 规范:case-library/<YYYYMMDD>/<caseSlug>-<order>.<ext>
- *
- * 幂等:同一 (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/<id>/ 前缀,这里只写相对 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/<id>/ 前缀,别自己加)。
- * 规范:case-library/<YYYYMMDD>/<caseSlug>-<order>.<ext>
- *
- * 传入的 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/<key>),不需要 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/<id>/ 前缀并打 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 <path> 要上传的本地素材(必填)
- --case-slug <slug> object key 里的案例短名(默认取文件名主干)
- --order <n> object key 里的序号(默认 1)
- --ext <ext> 覆盖扩展名(默认取文件自身扩展名)
- --key <objectKey> 直接指定相对 key(给了就忽略 case-slug/order/ext)
- --out <file> 把结果 JSON 写文件(不给则打印到 stdout)
- object key 规范:case-library/<YYYYMMDD>/<caseSlug>-<order>.<ext>(云函数会自动补 user/<id>/ 前缀)
- `;
- 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 };
|