Browse Source

部署山西下载报告服务

liujiejie 3 tháng trước cách đây
mục cha
commit
63a8f8db2b

+ 4 - 0
downLoadServer/.env

@@ -13,11 +13,15 @@ MINIO_ACCESS_KEY=6VkF2ul6X7udr7RLsG2W
 MINIO_SECRET_KEY=jtBuqZ80biRWQf6sbwzDQJwHtEBicPjkZBtvjTrA
 # MINIO_ACCESS_KEY=haH1vePq7unSp4TG1One
 # MINIO_SECRET_KEY=idxO5SAjboUYERpDICgHgBoHX7bcYv355lMQANt6
+# 仅用 Puppeteer/npm 自带的 Chromium:注释 CHROME_PATH,并设置 CHART_CHROME_SOURCE=bundled,然后 npm run puppeteer:install
+# CHART_CHROME_SOURCE=bundled
 CHROME_PATH=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome
 CHART_RENDER_TIMEOUT_MS=180000
 CHART_RENDER_CONCURRENCY=2
 CHART_MAX_PAGES=2
 CHART_MAX_COLORBAR_CATEGORIES=50
+CHART_CHROME_OZONE_HEADLESS=1
+# 视为「真」的值:1、true、yes(不区分大小写)。
 #   nginx 配置 minio
 #   env MINIO_ENDPOINT=192.168.50.233;
 #   env MINIO_PORT=6900;

+ 53 - 0
downLoadServer/ecosystem.config.cjs

@@ -0,0 +1,53 @@
+/**
+ * PM2 生产配置
+ *
+ * Chromium 二选一(不要同时生效两套矛盾配置):
+ *
+ * 1) 麒麟 ARM64 推荐:用本机/离线包里的 ARM64 Chromium(避免 Puppeteer 拉到 x86 的 chrome-linux64)
+ *    CHART_CHROME_SOURCE=system + CHROME_PATH 指向真实 chrome 文件
+ *
+ * 2) 仅用 npm 缓存的 Chromium:需本机 node 为 arm64 且 npm run puppeteer:install 下的是 arm64 包
+ *    CHART_CHROME_SOURCE=bundled,不要设置 CHROME_PATH
+ *
+ * 密钥建议改为服务器环境变量注入,勿长期明文写在仓库里。
+ */
+module.exports = {
+  apps: [
+    {
+      name: "download-app",
+      cwd: "/data/nodejs/download-app-prod",
+      script: "/data/nodejs/download-app-prod/index.mjs",
+      interpreter: "node",
+      instances: 1,
+      exec_mode: "fork",
+      watch: false,
+      max_memory_restart: "800M",
+      env: {
+        NODE_ENV: "production",
+        API_BASE_URL: "http://192.168.50.234:3001",
+        MINIO_ENDPOINT: "192.168.50.234",
+        MINIO_PORT: "6900",
+        MINIO_ACCESS_KEY: "C5xa2oaFDz0v101ikIGh",
+        MINIO_SECRET_KEY: "dyeBulGGXjMNLqmw6tgiBSRrncBkasuTqGEj4IkB",
+
+        // —— 麒麟 ARM64:推荐系统/离线包里的 arm64 chrome(按你服务器实际路径改)——
+        CHART_CHROME_SOURCE: "system",
+        CHROME_PATH:
+          "/data/nodejs/chromium_138.0.7204.162_1-arm64_linux/chrome",
+
+        // —— 若坚持用 npm 自带 Chromium,改为下面两行并注释掉上面 CHART_CHROME_SOURCE + CHROME_PATH ——
+        // CHART_CHROME_SOURCE: "bundled",
+        // (bundled 时不要设置 CHROME_PATH)
+
+        // 可选:统一缓存目录(多实例/离线拷贝缓存时用)
+        // PUPPETEER_CACHE_DIR: "/data/nodejs/puppeteer-cache",
+
+        PUPPETEER_BROWSER_LAUNCH_TIMEOUT_MS: "120000",
+        CHART_RENDER_TIMEOUT_MS: "180000",
+        CHART_RENDER_CONCURRENCY: "2",
+        CHART_MAX_PAGES: "2",
+        CHART_CHROME_OZONE_HEADLESS: "1",
+      },
+    },
+  ],
+};

+ 2 - 5
downLoadServer/index.mjs

@@ -10,10 +10,7 @@
 import "dotenv/config";
 import { startServer } from "./src/server/server.js";
 
-try {
-  startServer();
-  console.log("服务器启动成功!");
-} catch (error) {
+startServer().catch((error) => {
   console.error("服务器启动失败:", error);
   process.exit(1);
-}
+});

+ 4 - 2
downLoadServer/package.json

@@ -6,7 +6,9 @@
   "type": "module",
   "scripts": {
     "start": "node index.mjs",
-    "test": "node src/test.mjs"
+    "test": "node src/test.mjs",
+    "puppeteer:install": "npx puppeteer browsers install chrome",
+    "puppeteer:list": "npx puppeteer browsers list"
   },
   "keywords": [],
   "author": "",
@@ -32,6 +34,6 @@
     "plotly.js-dist": "^2.34.0",
     "plotly.js-dist-min": "^2.34.0",
     "plotly.js-with-locales": "^1.31.2",
-    "puppeteer": "^19.11.1"
+    "puppeteer": "^23.11.1"
   }
 }

+ 1 - 0
downLoadServer/src/server/server.js

@@ -67,6 +67,7 @@ export const startServer = async () => {
       console.log(
         `🚀 Server running at http://${serverConfig.host}:${serverConfig.port}`,
       );
+      console.log("服务器启动成功!");
     });
   } catch (err) {
     console.error("❌ Server start failed:", err);

+ 152 - 0
downLoadServer/src/server/utils/chartService/browserLauncher.js

@@ -0,0 +1,152 @@
+import fs from "fs";
+import puppeteer from "puppeteer";
+
+/**
+ * 图表截图仅使用 Chromium/Chrome(Puppeteer)。
+ * - CHART_CHROME_SOURCE=bundled  仅使用 npm / Puppeteer 下载的 Chromium
+ * - CHART_CHROME_SOURCE=system   仅用系统或 CHROME_PATH
+ * - CHART_CHROME_SOURCE=auto      先 CHROME_PATH / 候选路径,找不到再用 bundled(默认)
+ *
+ * 部署后执行:npm run puppeteer:install
+ */
+
+const LINUX_CHROME_CANDIDATES = [
+  "/data/nodejs/chromium_138.0.7204.162_1-arm64_linux/chrome",
+  "/usr/bin/chromium-browser",
+  "/usr/bin/chromium",
+  "/usr/bin/google-chrome-stable",
+  "/usr/bin/google-chrome",
+];
+
+export function optionalEnvPath(key) {
+  const v = process.env[key];
+  if (v == null) return undefined;
+  const s = String(v).trim();
+  return s ? s : undefined;
+}
+
+function isExecutable(p) {
+  if (!p) return false;
+  try {
+    fs.accessSync(p, fs.constants.X_OK);
+    return fs.statSync(p).isFile();
+  } catch {
+    return false;
+  }
+}
+
+export function resolveChromeSourceMode() {
+  const v = String(process.env.CHART_CHROME_SOURCE || "auto")
+    .trim()
+    .toLowerCase();
+  if (v === "bundled" || v === "system" || v === "auto") return v;
+  return "auto";
+}
+
+/**
+ * @returns {string|undefined} undefined = Puppeteer 自带 Chromium
+ */
+export function resolveChromeExecutablePath() {
+  const mode = resolveChromeSourceMode();
+
+  if (mode === "bundled") {
+    console.log(
+      "📦 使用 Puppeteer/npm 自带的 Chromium(需已执行 npm run puppeteer:install)",
+    );
+    return undefined;
+  }
+
+  const fromEnv = optionalEnvPath("CHROME_PATH");
+  if (fromEnv && isExecutable(fromEnv)) return fromEnv;
+  if (fromEnv) {
+    console.warn(`⚠️ CHROME_PATH 无效 "${fromEnv}",尝试系统候选路径`);
+  }
+  if (process.platform === "linux") {
+    for (const c of LINUX_CHROME_CANDIDATES) {
+      if (isExecutable(c)) {
+        console.warn(`↪️ 使用系统 Chromium: ${c}`);
+        return c;
+      }
+    }
+  }
+
+  if (mode === "system") {
+    throw new Error(
+      "CHART_CHROME_SOURCE=system 但未找到浏览器。请设置 CHROME_PATH 或改用 CHART_CHROME_SOURCE=bundled 并运行 npm run puppeteer:install",
+    );
+  }
+
+  console.warn(
+    "📦 未找到系统 Chrome,改用 Puppeteer 自带的 Chromium(npm run puppeteer:install)",
+  );
+  return undefined;
+}
+
+export function getProtocolTimeoutMs(fallbackMs) {
+  const parsed = Number.parseInt(process.env.CHART_RENDER_TIMEOUT_MS || "", 10);
+  return Number.isFinite(parsed) ? parsed : fallbackMs;
+}
+
+export function getBrowserLaunchWaitMs() {
+  const parsed = Number.parseInt(
+    process.env.PUPPETEER_BROWSER_LAUNCH_TIMEOUT_MS || "",
+    10,
+  );
+  return Number.isFinite(parsed) ? parsed : 120000;
+}
+
+function buildChromeOptions(protocolTimeoutMs) {
+  const executablePath = resolveChromeExecutablePath();
+  const args = [
+    "--no-sandbox",
+    "--disable-setuid-sandbox",
+    "--disable-dev-shm-usage",
+    "--disable-gpu",
+    "--disable-extensions",
+  ];
+  const oz = String(process.env.CHART_CHROME_OZONE_HEADLESS || "")
+    .trim()
+    .toLowerCase();
+  if (oz === "1" || oz === "true" || oz === "yes") {
+    args.push("--use-gl=angle", "--ozone-platform=headless");
+  }
+  const extra = optionalEnvPath("CHROME_EXTRA_ARGS");
+  if (extra) {
+    for (const p of extra.split(/\s+/)) {
+      const t = p.trim();
+      if (t) args.push(t);
+    }
+  }
+  const opts = {
+    headless: true,
+    args,
+    protocolTimeout: protocolTimeoutMs,
+    timeout: getBrowserLaunchWaitMs(),
+    dumpio: false,
+  };
+  if (executablePath) opts.executablePath = executablePath;
+  return opts;
+}
+
+export async function launchChromeBrowser(protocolTimeoutMs) {
+  return puppeteer.launch(buildChromeOptions(protocolTimeoutMs));
+}
+
+export async function launchBrowserForChartService(protocolTimeoutMs) {
+  const browser = await launchChromeBrowser(protocolTimeoutMs);
+  return { browser, product: "chrome" };
+}
+
+/**
+ * 每次新开 Chromium(雷达图 / 3D 图等)
+ * @param {(browser: import('puppeteer').Browser) => Promise<unknown>} workFn
+ */
+export async function renderWithStandaloneChrome(workFn) {
+  const t = getProtocolTimeoutMs(500000);
+  const browser = await launchChromeBrowser(t);
+  try {
+    return await workFn(browser);
+  } finally {
+    await browser.close().catch(() => {});
+  }
+}

+ 20 - 57
downLoadServer/src/server/utils/chartService/browserPool.js

@@ -1,6 +1,7 @@
-import puppeteer from "puppeteer";
+import { launchBrowserForChartService } from "./browserLauncher.js";
 
 let browser;
+
 const pagePool = [];
 const MAX_PAGES = Number.parseInt(process.env.CHART_MAX_PAGES || "1", 10);
 const DEFAULT_TIMEOUT_MS = Number.parseInt(
@@ -22,66 +23,40 @@ const clearPagePool = async () => {
   );
 };
 
-const createPage = async () => {
-  if (!isBrowserAlive()) {
-    await initBrowserPool();
-  }
-
-  const page = await browser.newPage();
-  page.setDefaultTimeout(EFFECTIVE_TIMEOUT_MS);
-  page.setDefaultNavigationTimeout(EFFECTIVE_TIMEOUT_MS);
-  return page;
-};
-
 const isUsablePage = (page) =>
   page && !page.isClosed() && page.browser()?.isConnected();
 
-/**
- * 初始化浏览器 + 预创建 page
- */
+async function openPoolBrowser() {
+  const { browser: b } = await launchBrowserForChartService(EFFECTIVE_TIMEOUT_MS);
+  browser = b;
+  console.log("✅ Browser pool: Chromium");
+}
+
 export const initBrowserPool = async () => {
   if (!isBrowserAlive()) {
     await clearPagePool();
     browser = null;
 
-    browser = await puppeteer.launch({
-      headless: "new",
-      executablePath: process.env.CHROME_PATH || undefined,
-      args: [
-        "--no-sandbox",
-        "--disable-setuid-sandbox",
-        "--disable-dev-shm-usage",
-        "--disable-background-timer-throttling",
-        "--disable-backgrounding-occluded-windows",
-        "--disable-renderer-backgrounding",
-      ],
-      // 避免渲染压力下 CDP 调用超时(对应报错:Runtime.callFunctionOn timed out)
-      protocolTimeout: EFFECTIVE_TIMEOUT_MS,
-    });
+    await openPoolBrowser();
 
     browser.on("disconnected", () => {
       browser = null;
       pagePool.splice(0, pagePool.length);
     });
 
-    console.log("✅ Browser initialized");
-
-    // 预创建 page
     for (let i = 0; i < MAX_PAGES; i++) {
-      pagePool.push(await createPage());
+      const page = await browser.newPage();
+      page.setDefaultTimeout(EFFECTIVE_TIMEOUT_MS);
+      page.setDefaultNavigationTimeout(EFFECTIVE_TIMEOUT_MS);
+      pagePool.push(page);
     }
 
     console.log(`✅ Page pool initialized: ${MAX_PAGES}`);
   }
 };
 
-/**
- * 获取 page(核心)
- */
 export const getPage = async () => {
-  if (!isBrowserAlive()) {
-    await initBrowserPool();
-  }
+  if (!isBrowserAlive()) await initBrowserPool();
 
   if (pagePool.length > 0) {
     while (pagePool.length > 0) {
@@ -91,27 +66,21 @@ export const getPage = async () => {
     }
   }
 
-  // pool 空了就新建(兜底)
-  return createPage();
+  const page = await browser.newPage();
+  page.setDefaultTimeout(EFFECTIVE_TIMEOUT_MS);
+  page.setDefaultNavigationTimeout(EFFECTIVE_TIMEOUT_MS);
+  return page;
 };
 
-/**
- * 释放 page
- */
 export const releasePage = async (page) => {
   if (!page) return;
-
   try {
     if (!isUsablePage(page)) {
       await discardPage(page);
       return;
     }
-
-    if (pagePool.length < MAX_PAGES) {
-      pagePool.push(page);
-    } else {
-      await page.close();
-    }
+    if (pagePool.length < MAX_PAGES) pagePool.push(page);
+    else await page.close();
   } catch (err) {
     console.error("❌ releasePage error:", err);
   }
@@ -119,7 +88,6 @@ export const releasePage = async (page) => {
 
 export const discardPage = async (page) => {
   if (!page) return;
-
   try {
     if (!page.isClosed()) await page.close();
   } catch (err) {
@@ -127,18 +95,13 @@ export const discardPage = async (page) => {
   }
 };
 
-/**
- * 关闭
- */
 export const closeBrowserPool = async () => {
   try {
     await clearPagePool();
-
     if (browser) {
       await browser.close();
       browser = null;
     }
-
     console.log("🛑 Browser pool closed");
   } catch (err) {
     console.error("❌ closeBrowserPool error:", err);

+ 8 - 3
downLoadServer/src/server/utils/chartService/index.js

@@ -1,8 +1,13 @@
 // src/server/utils/chartService/index.js
-import { discardPage, getPage, releasePage } from "./browserPool.js";
+import {
+  discardPage,
+  getPage,
+  releasePage,
+  initBrowserPool,
+  closeBrowserPool,
+} from "./browserPool.js";
 import { buildHTML } from "./chartRenderer.js";
 import { chartLimit } from "./limiter.js";
-import { initBrowserPool, closeBrowserPool } from "./browserPool.js";
 import { initPlotly } from "./plotlyLoader.js";
 import { uploadBufferToMinIO } from "../minioService.js";
 
@@ -74,7 +79,7 @@ export async function renderChart({
           page.waitForFunction(() => window.chartReady === true, {
             timeout: timeoutMs,
           }),
-          page.waitForTimeout(timeoutMs),
+          new Promise((r) => setTimeout(r, timeoutMs)),
         ]);
 
         // 检查错误

+ 3 - 19
downLoadServer/src/server/utils/chartsCom/3DDrawingChart.js

@@ -1,4 +1,4 @@
-import puppeteer from "puppeteer";
+import { renderWithStandaloneChrome } from "../chartService/browserLauncher.js";
 import fs from "fs-extra";
 import path from "path";
 import FormData from "form-data";
@@ -29,17 +29,7 @@ export const generate3DDrawingChart = async (data, bucketName, objectName) => {
     );
     const plotlyContent = await fs.readFile(plotlyPath, "utf-8");
 
-    // 创建浏览器实例
-    const browser = await puppeteer.launch({
-      headless: "new",
-      // 根据系统改路径
-      executablePath: `${process.env.CHROME_PATH}`, // 根据系统改路径
-
-      args: ["--no-sandbox", "--disable-setuid-sandbox"],
-      protocolTimeout: timeoutMs,
-    });
-
-    try {
+    return renderWithStandaloneChrome(async (browser) => {
       const page = await browser.newPage();
       page.setDefaultTimeout(timeoutMs);
       page.setDefaultNavigationTimeout(timeoutMs);
@@ -221,14 +211,8 @@ export const generate3DDrawingChart = async (data, bucketName, objectName) => {
         { filePath: tempFilePath, bucketName, objectName },
       );
       return response?.data?.url;
-    } catch (error) {
-      // console.error("生成3D图失败:", error);
-      throw error;
-    } finally {
-      await browser.close();
-    }
+    });
   } catch (error) {
-    // console.error("生成3D图失败:", error);
     throw error;
   }
 };

+ 57 - 59
downLoadServer/src/server/utils/chartsCom/Radar.js

@@ -1,5 +1,5 @@
 // puppeteer-radar-chart.js
-import puppeteer from "puppeteer";
+import { renderWithStandaloneChrome } from "../chartService/browserLauncher.js";
 import fs from "fs-extra";
 import path from "path";
 import axios from "axios";
@@ -142,72 +142,70 @@ export const getRadarCharts = async (
   await fs.ensureDir(tempDir);
   const tempFilePath = path.join(
     tempDir,
-    `temp_radar_chart_${Date.now()}_${Math.random().toString(36).slice(2)}.jpeg`,
+    `temp_radar_chart_${Date.now()}_${Math.random()
+      .toString(36)
+      .slice(2)}.jpeg`,
   );
 
-  const browser = await puppeteer.launch({
-    headless: "new",
-    executablePath: process.env.CHROME_PATH,
-    args: ["--no-sandbox", "--disable-setuid-sandbox"],
-  });
-
-  try {
+  return renderWithStandaloneChrome(async (browser) => {
     const page = await browser.newPage();
-    const stats = calcStats(itemCsvData);
+    try {
+      const stats = calcStats(itemCsvData);
 
-    await page.setViewport({
-      width: 600,
-      height: 600,
-      deviceScaleFactor: 2,
-    });
+      await page.setViewport({
+        width: 600,
+        height: 600,
+        deviceScaleFactor: 2,
+      });
 
-    await page.setContent(getHtmlContent(), {
-      waitUntil: "domcontentloaded",
-      timeout: 0,
-    });
+      await page.setContent(getHtmlContent(), {
+        waitUntil: "domcontentloaded",
+        timeout: 0,
+      });
 
-    await page.waitForFunction(() => typeof window.renderChart === "function", {
-      timeout: 10000,
-    });
+      await page.waitForFunction(() => typeof window.renderChart === "function", {
+        timeout: 10000,
+      });
 
-    await page.evaluate(
-      (chartData, stats, engineTypeCode) => {
-        window.renderChart(chartData, stats, engineTypeCode);
-      },
-      chartData,
-      stats,
-      engineTypeCode,
-    );
-
-    await page.waitForFunction(() => window.chartFinished === true, {
-      timeout: 15000,
-    });
+      await page.evaluate(
+        (chartData, stats, engineTypeCode) => {
+          window.renderChart(chartData, stats, engineTypeCode);
+        },
+        chartData,
+        stats,
+        engineTypeCode,
+      );
+
+      await page.waitForFunction(() => window.chartFinished === true, {
+        timeout: 15000,
+      });
 
-    const chartElement = await page.waitForSelector("#chart");
+      const chartElement = await page.waitForSelector("#chart");
 
-    await chartElement.screenshot({
-      path: tempFilePath,
-      type: "jpeg",
-      quality: 100,
-    });
+      await chartElement.screenshot({
+        path: tempFilePath,
+        type: "jpeg",
+        quality: 100,
+      });
+
+      const newUrl = objectName.substring(0, objectName.lastIndexOf("/"));
 
-    const newUrl = objectName.substring(0, objectName.lastIndexOf("/"));
-
-    const response = await axios.post(
-      `${process.env.API_BASE_URL}/examples/upload`,
-      {
-        filePath: tempFilePath,
-        bucketName,
-        objectName: `${newUrl}/${chartData.wind_turbine_name}.jpg`,
-      },
-    );
-
-    return response?.data?.url;
-  } catch (error) {
-    console.error("getRadarCharts failed:", error);
-    throw error;
-  } finally {
-    await browser.close();
-    await fs.remove(tempFilePath).catch(() => {});
-  }
+      const response = await axios.post(
+        `${process.env.API_BASE_URL}/examples/upload`,
+        {
+          filePath: tempFilePath,
+          bucketName,
+          objectName: `${newUrl}/${chartData.wind_turbine_name}.jpg`,
+        },
+      );
+
+      return response?.data?.url;
+    } catch (error) {
+      console.error("getRadarCharts failed:", error);
+      throw error;
+    } finally {
+      await page.close().catch(() => {});
+      await fs.remove(tempFilePath).catch(() => {});
+    }
+  });
 };

+ 3 - 21
downLoadServer/src/server/utils/chartsCom/Time3DChart.js

@@ -1,4 +1,4 @@
-import puppeteer from "puppeteer";
+import { renderWithStandaloneChrome } from "../chartService/browserLauncher.js";
 import fs from "fs-extra";
 import path from "path";
 import FormData from "form-data";
@@ -35,21 +35,8 @@ export const generateTime3DChart = async (data, bucketName, objectName) => {
       "plotly-3.0.1.min.js",
     );
     const plotlyContent = await fs.readFile(plotlyPath, "utf-8");
-    // 创建浏览器实例
-    const browser = await puppeteer.launch({
-      headless: "new",
-      // 根据系统改路径
-      executablePath: `${process.env.CHROME_PATH}`, // 根据系统改路径
 
-      args: [
-        "--no-sandbox",
-        "--disable-setuid-sandbox",
-        "--window-size=1920,1080",
-      ],
-      protocolTimeout: timeoutMs,
-    });
-
-    try {
+    return renderWithStandaloneChrome(async (browser) => {
       const page = await browser.newPage();
       page.setDefaultTimeout(timeoutMs);
       page.setDefaultNavigationTimeout(timeoutMs);
@@ -235,12 +222,7 @@ export const generateTime3DChart = async (data, bucketName, objectName) => {
         { filePath: tempFilePath, bucketName, objectName },
       );
       return response?.data?.url;
-    } catch (error) {
-      console.error("生成3D图失败:", error);
-      throw error;
-    } finally {
-      await browser.close();
-    }
+    });
   } catch (error) {
     console.error("生成3D图失败:", error);
     throw error;

+ 24 - 1
downLoadServer/src/server/utils/minioService.js

@@ -79,6 +79,27 @@ export const minioClient = new Client({
   secretKey: MINIO_SECRET_KEY,
 });
 /**
+ * MinIO putObject 仅接受 string | Buffer | stream.Readable;
+ * Puppeteer screenshot 等可能返回 Uint8Array / ArrayBuffer,需先归一化。
+ */
+function toMinioPutBody(body) {
+  if (typeof body === "string" || Buffer.isBuffer(body)) {
+    return body;
+  }
+  if (body instanceof ArrayBuffer) {
+    return Buffer.from(body);
+  }
+  if (ArrayBuffer.isView(body)) {
+    return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
+  }
+  throw new TypeError(
+    `uploadBufferToMinIO: 需要 string/Buffer/ArrayBuffer/Uint8Array,实际为 ${Object.prototype.toString.call(
+      body,
+    )}`,
+  );
+}
+
+/**
  * 上传 Buffer 到 MinIO
  * @param {string} bucketName
  * @param {string} objectName
@@ -88,6 +109,8 @@ export const minioClient = new Client({
 
 export async function uploadBufferToMinIO(bucketName, objectName, buffer) {
   try {
+    const payload = toMinioPutBody(buffer);
+
     // 检查桶是否存在
     const exists = await minioClient.bucketExists(bucketName);
     if (!exists) {
@@ -95,7 +118,7 @@ export async function uploadBufferToMinIO(bucketName, objectName, buffer) {
     }
 
     // 上传
-    await minioClient.putObject(bucketName, objectName, buffer);
+    await minioClient.putObject(bucketName, objectName, payload);
 
     // 返回可访问 URL
     return `http://${MINIO_ENDPOINT}:${MINIO_PORT}/${bucketName}/${objectName}`;