storage-bridge.mjs 13 KB

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