Przeglądaj źródła

fix: slug 兜底下沉到 storage-bridge(中文标题 slugify 为空 → 内容指纹)

FmodeAgent 5 godzin temu
rodzic
commit
c41345d83f
2 zmienionych plików z 26 dodań i 21 usunięć
  1. 3 18
      scripts/case-intake.mjs
  2. 23 3
      scripts/storage-bridge.mjs

+ 3 - 18
scripts/case-intake.mjs

@@ -42,7 +42,7 @@ import { parseArgs } from 'node:util';
 import {
   TENANT_DEFAULT, SKILL_ROOT, buildIdempotencyKey, deriveAssetUrl, ensureDir, firstText, imageUrlsFrom,
   learnAliases, learnTags, loadDictionary, newRunId, normalizeMaterialAsset, outputsDir,
-  readJson, reorderAssets, resolvePython, saveDictionary, sha1, sortAssets, truncate, uniq, writeJson,
+  readJson, reorderAssets, resolvePython, saveDictionary, sortAssets, truncate, uniq, writeJson,
 } from './lib.mjs';
 import { readImageInfo, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, DOC_EXTENSIONS, DECK_EXTENSIONS, TEXT_EXTENSIONS } from './image-size.mjs';
 import { applyGroupOverrides, groupMaterials, DEFAULT_ORDERING_RULE } from './grouping.mjs';
@@ -330,33 +330,18 @@ export async function runPlan(options = {}) {
  * @returns {Promise<{url:string, upload:object|null, fallback:boolean, error:Error|null, reason:string}>}
  */
 
-// 文件内容指纹:避免两个不同文件落成同一个 object key 互相覆盖(读不到则退化为 0)。
-function contentFingerprint(filePath) {
-  try {
-    const buf = fs.readFileSync(filePath);
-    return `${buf.length}-${sha1(buf.toString('base64')).slice(0, 12)}`;
-  } catch {
-    return '0';
-  }
-}
-
 export async function resolveAssetUrl(item, options = {}) {
   const remote = item && item.url ? String(item.url) : '';
   if (remote) return { url: remote, upload: null, fallback: false, error: null, reason: 'remote-url' };
 
   const localPath = (item && item.localPath) || '';
   let uploadError = null;
-  // key 区分度:优先 显式 slug / 来源引用 / 标题;都没有时用「文件内容指纹」兜底。
-  // 否则两个不同案例都会落成 case-1.png → 同日撞 key 互相覆盖(真实事故:两张不同图同 key)。
-  let slugSeed = options.caseSlug;
-  if (!slugSeed) {
-    slugSeed = localPath ? `c${contentFingerprint(localPath)}` : 'case';
-  }
   if (localPath && options.upload) {
     try {
       const upload = await uploadMaterial(localPath, {
         tenantId: options.tenantId,
-        caseSlug: slugSeed,
+        // slug 区分度由 storage-bridge 统一兜底(slugify 为空时用内容指纹),这里原样传
+        caseSlug: options.caseSlug,
         order: options.order,
       });
       if (upload && upload.url) {

+ 23 - 3
scripts/storage-bridge.mjs

@@ -33,6 +33,7 @@
 
 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';
 
@@ -84,6 +85,20 @@ export function normalizeExt(ext) {
   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');
@@ -94,8 +109,8 @@ export function dateStamp(date = new Date()) {
  * 组装 object key(**相对 key**,云函数会自动补 user/<id>/ 前缀,别自己加)。
  * 规范:case-library/<YYYYMMDD>/<caseSlug>-<order>.<ext>
  *
- * caseSlug 缺省一律用调用方传入值;调用方须保证其区分度(无标题时用内容指纹兜底,
- * 见 case-intake.resolveAssetUrl),否则两个不同案例会在同一 <日期> 目录里撞 key 互相覆盖。
+ * 传入的 caseSlug 须已保证区分度(uploadMaterial 会在 slugify 结果为空时用内容指纹兜底),
+ * 否则两个不同案例会在同一 <日期> 目录里撞 key 互相覆盖。
  * @returns {string}
  */
 export function buildObjectKey({ caseSlug, order, ext, date } = {}) {
@@ -179,9 +194,14 @@ export async function uploadMaterial(localPath, options = {}) {
   }
 
   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: options.caseSlug, order: options.order, ext });
+    : buildObjectKey({ caseSlug: effectiveSlug, order: options.order, ext });
 
   // 幂等:同 (localPath, key) 本进程内只传一次
   const cacheKey = `${file}::${key}`;