Bladeren bron

修复上传

liuxiaole 18 uur geleden
bovenliggende
commit
3809cdd3e6
1 gewijzigde bestanden met toevoegingen van 350 en 97 verwijderingen
  1. 350 97
      src/api/upload.js

+ 350 - 97
src/api/upload.js

@@ -1,14 +1,9 @@
 import { useUserStore } from "@/stores/modules/user";
 import { ElMessage } from "element-plus";
-import { isAiImageModerationEnabled, uploadFileViaAi, isLikelyVideoFileForAiUpload } from "@/api/modules/aiImageUpload";
+import { AI_UPLOAD_FILES_PUBLIC_BASE, BASE_AI_URL } from "@/utils/config";
 
-/**
- * 门店/文件相关接口基址(与 src/api/modules/upload.ts 中 httpStoreAlienStore 一致)
- * 若签名接口部署在其它域名,可改为 VITE_API_URL_PLATFORM 等
- */
-function getStoreApiBase() {
-  return String(import.meta.env.VITE_API_URL_STORE || "").replace(/\/$/, "");
-}
+/** 非 TUS 简单上传接口路径(与 Apifox「上传文件-非TUS」一致;开发环境经 VITE_PROXY /ai-upload 转发) */
+const SIMPLE_UPLOAD_PATH = "/upload/simple";
 
 /** 从路径或 fileType 解析文件后缀(如 jpg、mp4),用于生成带格式的文件名 */
 export function getFileExtension(filePath, fileType) {
@@ -25,34 +20,6 @@ export function getFileExtension(filePath, fileType) {
 }
 
 /**
- * GET 获取 OSS 直传签名(与 uni 版 1.js 一致)
- * @returns {Promise<Record<string, any>>}
- */
-async function fetchOssSignature() {
-  const base = getStoreApiBase();
-  if (!base) {
-    throw new Error("未配置 VITE_API_URL_STORE");
-  }
-  const userStore = useUserStore();
-  const res = await fetch(`${base}/oss/direct/new/signature`, {
-    method: "GET",
-    headers: {
-      Authorization: userStore.token || ""
-    },
-    credentials: "include"
-  });
-  if (!res.ok) {
-    throw new Error("获取签名失败");
-  }
-  const body = await res.json();
-  const data = body.data ?? body;
-  if (!data || !data.host) {
-    throw new Error(body.msg || "获取签名失败");
-  }
-  return data;
-}
-
-/**
  * 将入参统一为 File[]
  * @param {File | File[] | FileList} input
  * @returns {File[]}
@@ -70,58 +37,374 @@ function normalizeFiles(input) {
 }
 
 /**
- * 单个 File 上传到 OSS(POST multipart,字段与 1.js / OSS4 直传一致)
- * @param {Record<string, any>} signRes 签名接口返回
+ * 常见「可直接用于 img/video src」的字段(含蛇形命名)
+ * 注意:勿把 upload_id / filename 放前面——/upload/simple 会返回 upload_id 与完整 URL 并存,
+ * 若先按「裸 id」拼到 /files/ 会得到错误地址,图片会裂图。
+ */
+const URL_LIKE_KEYS = [
+  "download_url",
+  "downloadUrl",
+  "preview_url",
+  "previewUrl",
+  "url",
+  "fileUrl",
+  "file_url",
+  "accessUrl",
+  "access_url",
+  "ossUrl",
+  "cdnUrl",
+  "path",
+  "filePath",
+  "file_path",
+  "objectKey",
+  "object_key",
+  "key",
+  "location",
+  "href"
+];
+
+/**
+ * 将接口返回的路径或 id 规范为可访问 URL
+ * @param {string} raw
+ * @returns {string}
+ */
+function normalizeToFileUrl(raw) {
+  const t = String(raw ?? "").trim();
+  if (!t) return "";
+  if (/^https?:\/\//i.test(t)) return t;
+  if (t.startsWith("//")) return `https:${t}`;
+
+  const filesBase = String(AI_UPLOAD_FILES_PUBLIC_BASE || "").replace(/\/$/, "");
+  if (filesBase.startsWith("http")) {
+    try {
+      if (t.startsWith("/")) {
+        return new URL(t, new URL(`${filesBase}/`).origin).href;
+      }
+      if (/^files\//i.test(t)) {
+        return new URL(`/${t.replace(/^\/+/, "")}`, new URL(`${filesBase}/`).origin).href;
+      }
+      // 相对路径 dir/file.ext(无协议)
+      if (t.includes("/") && !t.startsWith("/") && !/^[a-z]+:/i.test(t)) {
+        return `${filesBase}/${t.replace(/^\/+/, "")}`;
+      }
+      // 仅返回 uuid/文件名 等,按「对外文件基址 + 片段」拼接
+      if (!t.includes("/") && !t.includes("\\") && t.length <= 512) {
+        return `${filesBase}/${encodeURIComponent(t)}`;
+      }
+    } catch {
+      /* ignore */
+    }
+  }
+  return "";
+}
+
+/**
+ * 从对象上按已知字段取「可能是地址」的字符串
+ * @param {unknown} o
+ * @returns {string}
+ */
+function pickUrlLikeFromObject(o) {
+  if (!o || typeof o !== "object" || Array.isArray(o)) return "";
+  const r = /** @type {Record<string, unknown>} */ (o);
+  for (const k of URL_LIKE_KEYS) {
+    const v = r[k];
+    if (typeof v === "string" && v.trim()) {
+      const abs = normalizeToFileUrl(v);
+      if (abs) return abs;
+    }
+  }
+  return "";
+}
+
+/**
+ * 深度查找第一个 http(s) 字符串
+ * @param {unknown} val
+ * @param {number} depth
+ * @param {number} maxDepth
+ * @returns {string}
+ */
+function deepFindHttpUrl(val, depth = 0, maxDepth = 8) {
+  if (depth > maxDepth || val == null) return "";
+  if (typeof val === "string") {
+    const t = val.trim();
+    return /^https?:\/\//i.test(t) ? t : "";
+  }
+  if (Array.isArray(val)) {
+    for (const item of val) {
+      const u = deepFindHttpUrl(item, depth + 1, maxDepth);
+      if (u) return u;
+    }
+    return "";
+  }
+  if (typeof val === "object") {
+    for (const k of Object.keys(val)) {
+      const u = deepFindHttpUrl(/** @type {Record<string, unknown>} */ (val)[k], depth + 1, maxDepth);
+      if (u) return u;
+    }
+  }
+  return "";
+}
+
+/**
+ * 从 JSON 体中解析文件访问地址(兼容多种后端约定)
+ * @param {unknown} body
+ * @returns {string}
+ */
+function pickUrlFromJsonBody(body) {
+  if (body == null) return "";
+
+  if (typeof body === "string") {
+    return normalizeToFileUrl(body) || (body.trim().startsWith("http") ? body.trim() : "");
+  }
+
+  if (typeof body !== "object" || Array.isArray(body)) {
+    return deepFindHttpUrl(body);
+  }
+
+  const b = /** @type {Record<string, unknown>} */ (body);
+  const unwrap = v => {
+    if (v == null) return null;
+    if (typeof v === "object" && !Array.isArray(v) && /** @type {Record<string, unknown>} */ (v).data !== undefined) {
+      return /** @type {Record<string, unknown>} */ (v).data;
+    }
+    return v;
+  };
+
+  const candidates = [unwrap(b.data), b.data, b.result, unwrap(b.result), b.payload, b.body, b];
+
+  for (const d of candidates) {
+    if (d == null) continue;
+    if (typeof d === "string") {
+      const u = normalizeToFileUrl(d) || (d.trim().startsWith("http") ? d.trim() : "");
+      if (u) return u;
+    } else if (typeof d === "object") {
+      let u = pickUrlLikeFromObject(d);
+      if (u) return u;
+      u = deepFindHttpUrl(d);
+      if (u) return u;
+    }
+  }
+
+  return deepFindHttpUrl(body);
+}
+
+/** 审核服务返回的违规大类 → 面向用户的短说明(与业务文案风格一致) */
+const VIOLATION_CATEGORY_USER_HINT = {
+  GAMBLING: "图片涉及赌博等违规内容",
+  PORNOGRAPHY: "图片涉及色情等违规内容",
+  PORN: "图片涉及色情等违规内容",
+  ADULT: "图片涉及色情等违规内容",
+  SEXUAL: "图片涉及色情等违规内容",
+  DRUGS: "图片涉及毒品等违规内容",
+  DRUG: "图片涉及毒品等违规内容",
+  VIOLENCE: "图片涉及暴力、血腥等违规内容",
+  GORE: "图片涉及暴力、血腥等违规内容",
+  POLITICAL: "图片涉及不当政治或敏感信息",
+  POLITICS: "图片涉及不当政治或敏感信息",
+  SPAM: "图片涉及违规推广或垃圾信息",
+  ABUSE: "图片涉及辱骂、人身攻击等不文明内容",
+  ILLEGAL: "图片含违法违规内容"
+};
+
+/**
+ * 根据 reason / 关键词生成友好提示(与 aiImageUpload 中审核话术对齐)
+ * @param {string} raw
+ * @returns {string}
+ */
+/** 仅返回「原因」短句,后缀由 formatSimpleUploadModerationMessage 统一拼接 */
+function mapSimpleModerationReasonToTip(raw) {
+  const s = raw == null ? "" : String(raw).trim();
+  if (!s) return "";
+  if (/赌|赌博|casino|gambl|GAMBLING/i.test(s)) return "图片涉及赌博等违规内容";
+  if (/色情|淫秽|porn|sex|PORNOGRAPHY|PORN/i.test(s)) return "图片涉及色情等违规内容";
+  if (/毒品|涉毒|drug|DRUG/i.test(s)) return "图片涉及毒品等违规内容";
+  if (/暴力|血腥|violence|gore|VIOLENCE/i.test(s)) return "图片涉及暴力、血腥等违规内容";
+  if (/辱骂|谩骂|人身攻击|abuse|insult/i.test(s)) return "图片涉及不文明内容";
+  if (/政治|谣言/i.test(s)) return "图片涉及违规信息";
+  if (/广告|spam|营销/i.test(s)) return "图片涉及违规推广内容";
+  if (/违法|违禁|illegal/i.test(s)) return "图片含违法违规内容";
+  if (/黄赌毒/i.test(s)) return "请勿上传涉黄、涉赌、涉毒等违规内容";
+  if (/审核|moderat|violation|flagged|VLM/i.test(s)) return "图片未通过内容审核";
+  return "";
+}
+
+/**
+ * 解析 /upload/simple 审核未通过等响应(saved: false + moderation)
+ * @param {unknown} parsed
+ * @returns {string} 非空则可直接作为 ElMessage / Error 文案
+ */
+function formatSimpleUploadModerationMessage(parsed) {
+  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "";
+  const p = /** @type {Record<string, unknown>} */ (parsed);
+  const notSaved = p.saved === false;
+  const mod = p.moderation;
+  const hasModeration = mod != null && typeof mod === "object";
+  let flagged = false;
+  if (hasModeration) {
+    const results = /** @type {Record<string, unknown>} */ (mod).results;
+    if (Array.isArray(results) && results[0] && typeof results[0] === "object") {
+      flagged = /** @type {Record<string, unknown>} */ (results[0]).flagged === true;
+    }
+  }
+  if (!notSaved && !flagged) return "";
+
+  const apiMsg = String(p.message ?? p.msg ?? "").trim();
+
+  let firstResult = null;
+  if (hasModeration) {
+    const results = /** @type {Record<string, unknown>} */ (mod).results;
+    if (Array.isArray(results) && results[0] && typeof results[0] === "object") {
+      firstResult = /** @type {Record<string, unknown>} */ (results[0]);
+    }
+  }
+
+  let reason = "";
+  let firstCategory = "";
+  if (firstResult) {
+    reason = String(firstResult.reason ?? "").trim();
+    const cats = firstResult.violation_categories;
+    if (Array.isArray(cats) && cats[0] && typeof cats[0] === "object") {
+      firstCategory = String(/** @type {Record<string, unknown>} */ (cats[0]).category ?? "")
+        .trim()
+        .toUpperCase();
+    }
+  }
+
+  const suffix = "未通过审核,文件未保存。请更换后重试";
+
+  if (firstCategory && VIOLATION_CATEGORY_USER_HINT[firstCategory]) {
+    return `${VIOLATION_CATEGORY_USER_HINT[firstCategory]},${suffix}`;
+  }
+
+  const fromReason = mapSimpleModerationReasonToTip(reason);
+  if (fromReason) {
+    return `${fromReason.replace(/。$/, "")},${suffix}`;
+  }
+
+  if (apiMsg) {
+    if (/请更换|更换后|重新选择|换一张/i.test(apiMsg)) return apiMsg;
+    if (/未保存|未通过/.test(apiMsg)) return `${apiMsg.replace(/。$/, "")}。请更换后重试`;
+    return `${apiMsg.replace(/。$/, "")},${suffix}`;
+  }
+
+  return "图片未通过内容审核,文件未保存。请更换后重试";
+}
+
+/**
+ * 业务层 code / success(HTTP 200 但业务失败时常有)
+ * @param {unknown} parsed
+ */
+function assertSimpleUploadBusinessOk(parsed) {
+  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
+  const p = /** @type {Record<string, unknown>} */ (parsed);
+  if (p.success === false) {
+    throw new Error(String(p.msg ?? p.message ?? p.error ?? "上传失败"));
+  }
+  const c = p.code;
+  if (c === undefined || c === null) return;
+  const ok = c === 200 || c === 0 || c === "200" || c === "0";
+  if (!ok) {
+    throw new Error(String(p.msg ?? p.message ?? p.error ?? "上传失败"));
+  }
+}
+
+/**
+ * POST multipart:字段 file;可选 filename(此处不传则服务端用原名)
  * @param {File} file
- * @param {string} key 对象 key(含 dir 前缀)
  * @returns {Promise<string>} 文件访问 URL
  */
-async function postFileToOss(signRes, file, key) {
+async function postFileToSimpleUpload(file) {
+  const base = String(BASE_AI_URL || "").replace(/\/$/, "");
+  if (!base) {
+    throw new Error("未配置上传服务地址(VITE_AI_UPLOAD_BASE 或默认 /ai-upload)");
+  }
+
   const formData = new FormData();
-  formData.append("success_action_status", "200");
-  formData.append("policy", signRes.policy);
-  formData.append("x-oss-signature", signRes.signature);
-  formData.append("x-oss-signature-version", "OSS4-HMAC-SHA256");
-  formData.append("x-oss-credential", signRes.x_oss_credential || signRes["x-oss-credential"] || "");
-  formData.append("x-oss-date", signRes.x_oss_date || signRes["x-oss-date"] || "");
-  formData.append("key", key);
-  if (signRes.security_token != null) {
-    formData.append("x-oss-security-token", signRes.security_token);
-  }
-  formData.append("file", file, file.name);
-
-  const res = await fetch(signRes.host, {
+  formData.append("file", file, file.name || "file");
+
+  const userStore = useUserStore();
+  const res = await fetch(`${base}${SIMPLE_UPLOAD_PATH}`, {
     method: "POST",
+    headers: {
+      Authorization: userStore.token || ""
+    },
+    credentials: "include",
     body: formData
   });
 
-  if (res.status !== 200) {
-    let msg = "上传失败";
+  const rawText = await res.text();
+  let parsed = null;
+  const trimmed = rawText.trim();
+  if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
     try {
-      const t = await res.text();
-      if (t) msg = t.slice(0, 200);
+      parsed = JSON.parse(rawText);
     } catch (_) {
       /* ignore */
     }
+  }
+
+  if (!res.ok) {
+    const modHint = formatSimpleUploadModerationMessage(parsed);
+    let msg = modHint;
+    if (!msg && parsed && typeof parsed === "object") {
+      const p = /** @type {Record<string, unknown>} */ (parsed);
+      const m = p.msg ?? p.message ?? p.error;
+      if (m != null && String(m)) msg = String(m);
+    }
+    if (!msg && trimmed) msg = trimmed.slice(0, 200);
+    if (!msg) msg = "上传失败";
     throw new Error(msg);
   }
 
-  const text = await res.text();
-  if (text && typeof text === "string" && text.trim().startsWith("http")) {
-    return text.trim();
+  try {
+    assertSimpleUploadBusinessOk(parsed);
+  } catch (bizErr) {
+    console.error("[upload/simple] 业务失败", bizErr, trimmed.slice(0, 500));
+    throw bizErr;
+  }
+
+  const moderationUserTip = formatSimpleUploadModerationMessage(parsed);
+  if (moderationUserTip) {
+    throw new Error(moderationUserTip);
   }
-  return signRes.host.replace(/\/$/, "") + "/" + key;
+
+  let url = pickUrlFromJsonBody(parsed);
+  if (!url && trimmed.startsWith("http")) {
+    url = trimmed;
+  }
+  if (!url) {
+    const fromHeader =
+      res.headers.get("x-file-url") ||
+      res.headers.get("x-url") ||
+      res.headers.get("x-oss-url") ||
+      res.headers.get("file-url") ||
+      "";
+    if (fromHeader?.trim()) {
+      const h = fromHeader.trim();
+      url = normalizeToFileUrl(h) || (h.startsWith("http") ? h : "");
+    }
+  }
+  if (!url) {
+    const loc = res.headers.get("location") || res.headers.get("Location") || "";
+    if (loc) {
+      url = normalizeToFileUrl(loc.trim()) || (loc.trim().startsWith("http") ? loc.trim() : "");
+    }
+  }
+  if (!url) {
+    console.error("[upload/simple] 无法解析文件地址,响应片段:", trimmed.slice(0, 800));
+    throw new Error("上传失败,未返回文件地址");
+  }
+  return url;
 }
 
 /**
- * 上传文件:视频一律走 Tus + moderateVideo / 轮询(uploadFileViaAi),不走 OSS;
- * 开启 AI 时图片走 Tus+审核,否则图片与其它类型走 OSS 直传。
+ * 上传文件:图片与视频均走同一接口 POST /upload/simple,formData 键 file。
  * @param {File | File[] | FileList} files 浏览器文件对象;支持单个 File、数组或 FileList
- * @param {string} [fileType] 文件类型(如 'image' | 'video'),用于在文件名无后缀时推断格式
+ * @param {string} [_fileType] 保留参数,兼容旧调用(当前不参与分支)
  * @param {{ showLoading?: boolean }} [options] showLoading 为 true 时用 ElMessage 提示上传中(非阻塞)
  * @returns {Promise<string[]>} 上传成功后的文件 URL 列表
  */
-export async function uploadFilesToOss(files, fileType, options = {}) {
+export async function uploadFilesToOss(files, _fileType, options = {}) {
   const { showLoading = false } = options;
   const fileArr = normalizeFiles(files);
   if (fileArr.length === 0) {
@@ -135,41 +418,11 @@ export async function uploadFilesToOss(files, fileType, options = {}) {
   }
 
   try {
-    let signRes = null;
     const uploadedUrls = [];
-    const useAiImage = isAiImageModerationEnabled();
-
-    for (let i = 0; i < fileArr.length; i++) {
-      const file = fileArr[i];
-      const isImageBranch = fileType === "image" || (!fileType && file.type && String(file.type).startsWith("image/"));
-      const isVideoBranch = fileType === "video" || isLikelyVideoFileForAiUpload(file);
-
-      if (isVideoBranch) {
-        const url = await uploadFileViaAi(file, { skipModerate: !useAiImage });
-        uploadedUrls.push(url);
-        continue;
-      }
-
-      if (useAiImage && isImageBranch) {
-        const url = await uploadFileViaAi(file);
-        uploadedUrls.push(url);
-        continue;
-      }
-
-      if (!signRes) {
-        signRes = await fetchOssSignature();
-      }
-      const filePath = file.name || "";
-      const ext = getFileExtension(filePath, fileType);
-      const rawBase = filePath.split("/").pop() || "";
-      const baseName = rawBase.replace(/\.[^.]+$/, "") || `file_${Date.now()}_${i}`;
-      const fileName = baseName.includes(".") ? baseName : `${baseName}.${ext}`;
-      const key = (signRes.dir ? signRes.dir.replace(/\/$/, "") + "/" : "") + fileName;
-
-      const url = await postFileToOss(signRes, file, key);
+    for (const file of fileArr) {
+      const url = await postFileToSimpleUpload(file);
       uploadedUrls.push(url);
     }
-
     closeLoading();
     return uploadedUrls;
   } catch (e) {