storage-bridge.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. // Copyright (c) 未来飞马
  2. //
  3. // This Source Code Form is subject to the terms of the Mozilla Public
  4. // License, v. 2.0. If a copy of the MPL was not distributed with this
  5. // file, You can obtain one at https://mozilla.org/MPL/2.0/.
  6. //
  7. // Trademark Notice:
  8. // The MPL-2.0 license grants copyright permissions for source code only.
  9. // It does NOT grant any rights to use trademarks including "未来飞马",
  10. // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
  11. // Any use of these trademarks requires separate written permission.
  12. /**
  13. * 素材上传桥(复用兄弟技能 fmode-storage)
  14. *
  15. * 职责:把本地素材文件真正传到对象存储,拿到**公网可读 URL**,供 materialAssets[].url 使用。
  16. *
  17. * 为什么存在:案例检索侧只看 materialAssets[].url,本地路径不算数。此前只有
  18. * `--asset-base-url` 前缀派生(base + 文件名)——没给前缀就留空,给了也只是拼一个
  19. * 并不存在的假链接,比空字符串更坏。本桥接把「先上传、再拿真 URL」落成代码。
  20. *
  21. * 走 fmode-storage 的**第 0 级通道**(云函数免密钥预签名直传):
  22. * - 容器只需 FMODE_SESSION_TOKEN,真实 AK/SK 永远留在云函数侧,**技能里绝不内联任何凭据**;
  23. * - 云函数在服务端强制把对象挂到调用者自己的前缀下:user/<调用者id>/<key>;
  24. * 因此这里的 key 一律是**相对 key**(不带 user/ 前缀),公网 URL 由上传回执给出。
  25. *
  26. * object key 规范:case-library/<YYYYMMDD>/<caseSlug>-<order>.<ext>
  27. *
  28. * 幂等:同一 (localPath, key) 在**本进程内**只传一次(Map 缓存),重复调用直接复用回执。
  29. * 跨进程不做要求。
  30. *
  31. * 安全:绝不打印 token / AK / SK / sessionToken(含错误分支);上传回执里也没有密钥。
  32. */
  33. import fs from 'node:fs';
  34. import path from 'node:path';
  35. import { parseArgs } from 'node:util';
  36. import { resolveSiblingScript, shortHash } from './lib.mjs';
  37. const STORAGE_REL = path.join('fmode-storage', 'scripts', 'uploader.mjs');
  38. // 云函数自动补 user/<id>/ 前缀,这里只写相对 key
  39. const KEY_NAMESPACE = 'case-library';
  40. /** 兄弟技能 fmode-storage 不可用(未安装 / 导入失败)。 */
  41. export class StorageUnavailableError extends Error {
  42. constructor(message, detail = {}) {
  43. super(message);
  44. this.name = 'StorageUnavailableError';
  45. this.code = 'STORAGE_UNAVAILABLE';
  46. this.detail = detail;
  47. }
  48. }
  49. /**
  50. * 定位兄弟技能 fmode-storage 的 uploader.mjs。
  51. * 可用环境变量 CASE_STORAGE_SCRIPT 覆盖(与 vision-bridge 的 CASE_VISION_SCRIPT 同构)。
  52. * @returns {string|null} 找不到返回 null(调用方给可执行的降级提示,不抛栈)
  53. */
  54. export function resolveStorageScript() {
  55. return resolveSiblingScript('CASE_STORAGE_SCRIPT', STORAGE_REL);
  56. }
  57. // 本进程内已上传缓存:`${localPath}::${key}` → { url, key, bytes }
  58. const uploadCache = new Map();
  59. /** 清空本进程内上传缓存(测试用)。 */
  60. export function resetUploadCache() {
  61. uploadCache.clear();
  62. }
  63. /** 文件名 slug 化:中文/空格/特殊字符全部替换成 '-',避免坏链接。 */
  64. export function slugify(value, max = 40) {
  65. const slug = String(value ?? '')
  66. .trim()
  67. .replace(/[^\w.-]+/g, '-')
  68. .replace(/-{2,}/g, '-')
  69. .replace(/^-+|-+$/g, '');
  70. return slug.slice(0, max);
  71. }
  72. /** 归一化扩展名:去掉点、小写;空则回落到 bin。 */
  73. export function normalizeExt(ext) {
  74. const clean = String(ext ?? '').trim().replace(/^\.+/, '').toLowerCase().replace(/[^a-z0-9]/g, '');
  75. return clean || 'bin';
  76. }
  77. /** 紧凑日期戳 YYYYMMDD(本地时区,和案例包其它时间字段口径一致)。 */
  78. export function dateStamp(date = new Date()) {
  79. const pad = (n) => String(n).padStart(2, '0');
  80. return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`;
  81. }
  82. /**
  83. * 组装 object key(**相对 key**,云函数会自动补 user/<id>/ 前缀,别自己加)。
  84. * 规范:case-library/<YYYYMMDD>/<caseSlug>-<order>.<ext>
  85. *
  86. * caseSlug 缺省一律用调用方传入值;调用方须保证其区分度(无标题时用内容指纹兜底,
  87. * 见 case-intake.resolveAssetUrl),否则两个不同案例会在同一 <日期> 目录里撞 key 互相覆盖。
  88. * @returns {string}
  89. */
  90. export function buildObjectKey({ caseSlug, order, ext, date } = {}) {
  91. const slug = slugify(caseSlug) || 'case';
  92. const seq = Number.isFinite(Number(order)) && Number(order) > 0 ? Math.trunc(Number(order)) : 1;
  93. return `${KEY_NAMESPACE}/${dateStamp(date)}/${slug}-${seq}.${normalizeExt(ext)}`;
  94. }
  95. /** 动态加载兄弟技能导出(路径用 file:// URL,避免 Windows 盘符/空格问题)。 */
  96. async function loadUploader() {
  97. const script = resolveStorageScript();
  98. if (!script) {
  99. throw new StorageUnavailableError(
  100. `未找到 fmode-storage(期望 ${STORAGE_REL})。请先安装:npx fmode-storage@latest install;`
  101. + '或用环境变量 CASE_STORAGE_SCRIPT 指向 uploader.mjs。若本机确实无法上传,请用 --no-upload 关闭上传。',
  102. { expected: STORAGE_REL },
  103. );
  104. }
  105. let mod;
  106. try {
  107. mod = await importQuietly(new URL(`file://${script.split(path.sep).join('/')}`).href);
  108. } catch (error) {
  109. throw new StorageUnavailableError(`加载 fmode-storage 失败:${error.message}`, { script });
  110. }
  111. const missing = ['putViaCloudFunction', 'publicUrl', 'guessMime', 'normalizeKey']
  112. .filter((name) => typeof mod[name] !== 'function');
  113. if (missing.length) {
  114. throw new StorageUnavailableError(
  115. `fmode-storage 版本不兼容:uploader.mjs 缺少导出 ${missing.join(', ')}。请升级该技能。`,
  116. { script, missing },
  117. );
  118. }
  119. return { mod, script };
  120. }
  121. /**
  122. * 静默导入:uploader.mjs 在**模块顶层**跑了一次 main(),而它的命令列表不含我们传的 argv,
  123. * 于是会往 **stdout** 打一行「用法: init | test | put | setacl | config」。
  124. * 那一行会污染 `case-intake.mjs ingest` 的 JSON stdout(调用方按 JSON 解析),
  125. * 所以导入窗口内的 console.log 一律改投 stderr —— 不丢信息,也不污染 stdout。
  126. */
  127. async function importQuietly(url) {
  128. const originalLog = console.log;
  129. const captured = [];
  130. console.log = (...args) => { captured.push(args.map((arg) => String(arg)).join(' ')); };
  131. try {
  132. return await import(url);
  133. } finally {
  134. console.log = originalLog;
  135. for (const line of captured) {
  136. if (line.trim()) process.stderr.write(`[fmode-storage] ${line}\n`);
  137. }
  138. }
  139. }
  140. /** 从上传回执取公网 URL:回执自带 publicUrl 优先,其次按 fmode-storage 的 publicUrl() 规则(CDN 前缀)拼。 */
  141. function urlFromResult(mod, result) {
  142. if (result.url) return String(result.url);
  143. try {
  144. // 第 0 级通道的公网入口固定是 CDN(https://s3.fmode.cn/<key>),不需要 bucket/endpoint
  145. return mod.publicUrl({ cdnDomain: 's3.fmode.cn' }, result.key);
  146. } catch {
  147. return '';
  148. }
  149. }
  150. /**
  151. * 上传单个素材。
  152. *
  153. * @param {string} localPath 本地文件绝对路径
  154. * @param {{tenantId?:string, caseSlug?:string, order?:number, ext?:string}} options
  155. * ext 省略时取文件自身扩展名。
  156. * @returns {Promise<{url:string, key:string, bytes:number}>}
  157. * @throws {StorageUnavailableError} 兄弟技能缺失 / 不可加载
  158. * @throws {Error} 上传失败(网络 / 云函数未配置 / 文件读取失败)
  159. */
  160. export async function uploadMaterial(localPath, options = {}) {
  161. const file = path.resolve(String(localPath || ''));
  162. if (!localPath || !fs.existsSync(file)) {
  163. throw new Error(`素材文件不存在:${localPath || '(空)'}`);
  164. }
  165. const ext = options.ext || path.extname(file).replace(/^\./, '');
  166. const key = options.key
  167. ? String(options.key)
  168. : buildObjectKey({ caseSlug: options.caseSlug, order: options.order, ext });
  169. // 幂等:同 (localPath, key) 本进程内只传一次
  170. const cacheKey = `${file}::${key}`;
  171. const cached = uploadCache.get(cacheKey);
  172. if (cached) return { ...cached };
  173. const { mod } = await loadUploader();
  174. // 归一化 key(fmode-storage 会剥离误写的 user/<id>/ 前缀并打 warn 到 stderr——
  175. // warn 文案里只有 key,不含任何密钥,可以安全透传)
  176. const normalized = mod.normalizeKey(key);
  177. const mime = mod.guessMime(file);
  178. const namespace = KEY_NAMESPACE;
  179. let result;
  180. try {
  181. result = await mod.putViaCloudFunction(
  182. file,
  183. normalized,
  184. namespace,
  185. path.basename(normalized),
  186. mime,
  187. );
  188. } catch (error) {
  189. throw new Error(`上传通道报错:${error.message}`);
  190. }
  191. if (!result || result.ok !== true || !result.key) {
  192. // putViaCloudFunction 返回 null 表示第 0 级通道不可用(无 sessionToken / 云函数未配置 / 网络失败)。
  193. // **不回落本地 AK/SK 通道**:技能红线是不在内联任何对象存储凭据,宁可在 ingest 里降级留空 URL。
  194. throw new Error(
  195. '云函数免密钥上传通道不可用(无 FMODE_SESSION_TOKEN、云函数未配置或网络失败);'
  196. + '本技能不内联对象存储凭据,故不回落本地 AK/SK 通道',
  197. );
  198. }
  199. const payload = {
  200. url: urlFromResult(mod, result),
  201. key: String(result.key),
  202. bytes: Number(result.bytes) || fs.statSync(file).size,
  203. };
  204. if (!payload.url) throw new Error(`上传回执未给出公网 URL(key=${payload.key})`);
  205. uploadCache.set(cacheKey, payload);
  206. return { ...payload };
  207. }
  208. // ---------------------------------------------------------------------------
  209. // CLI(可选,供人工单传 / 排障;ingest 走的是上面的函数)
  210. // ---------------------------------------------------------------------------
  211. const HELP_TEXT = `storage-bridge —— 素材上传(复用兄弟技能 fmode-storage,第 0 级免密钥通道)
  212. 用法:
  213. node storage-bridge.mjs --file ./img.png [--case-slug imperial-aero50006] [--order 1] [--out url.json]
  214. 选项:
  215. --file <path> 要上传的本地素材(必填)
  216. --case-slug <slug> object key 里的案例短名(默认取文件名主干)
  217. --order <n> object key 里的序号(默认 1)
  218. --ext <ext> 覆盖扩展名(默认取文件自身扩展名)
  219. --key <objectKey> 直接指定相对 key(给了就忽略 case-slug/order/ext)
  220. --out <file> 把结果 JSON 写文件(不给则打印到 stdout)
  221. object key 规范:case-library/<YYYYMMDD>/<caseSlug>-<order>.<ext>(云函数会自动补 user/<id>/ 前缀)
  222. `;
  223. async function main() {
  224. const { values } = parseArgs({
  225. options: {
  226. file: { type: 'string' },
  227. 'case-slug': { type: 'string' },
  228. order: { type: 'string' },
  229. ext: { type: 'string' },
  230. key: { type: 'string' },
  231. out: { type: 'string' },
  232. help: { type: 'boolean', default: false, short: 'h' },
  233. },
  234. allowPositionals: true,
  235. });
  236. if (values.help) {
  237. process.stdout.write(HELP_TEXT);
  238. return 0;
  239. }
  240. if (!values.file) {
  241. process.stderr.write('需要 --file <本地素材>\n');
  242. return 2;
  243. }
  244. const report = await uploadMaterial(values.file, {
  245. caseSlug: values['case-slug'] || path.basename(values.file, path.extname(values.file)),
  246. order: values.order ? Number(values.order) : 1,
  247. ext: values.ext,
  248. key: values.key,
  249. });
  250. const text = `${JSON.stringify({ ok: true, ...report }, null, 2)}\n`;
  251. if (values.out) {
  252. fs.mkdirSync(path.dirname(path.resolve(values.out)), { recursive: true });
  253. fs.writeFileSync(path.resolve(values.out), text, 'utf8');
  254. } else {
  255. process.stdout.write(text);
  256. }
  257. return 0;
  258. }
  259. const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]).endsWith(path.join('scripts', 'storage-bridge.mjs'));
  260. if (invokedDirectly) {
  261. main().then((code) => process.exit(code)).catch((error) => {
  262. process.stderr.write(`storage-bridge 失败:${error.message}\n`);
  263. process.exit(error instanceof StorageUnavailableError ? 3 : 1);
  264. });
  265. }
  266. export { STORAGE_REL, KEY_NAMESPACE };