liu
7 小时以前 6c0fd2cb801554f7999d14e45a94ef0f0d0dfb8d
feat(mes): 二维码大批量导出改为后台任务,取消数量上限

原先 8 处二维码导出接口共用 MAX_QR_EXPORT=2000 的硬上限,单批次达 5 万张时被直接拒绝,
且报错既不说明上限也不给出路。现按数量分流:

- 不超过分界线(默认 2000 张)走原同步流式下载,秒级返回,不排队、不校验 CPU,
保证导几百张永远秒出,不会被正在跑的大任务挡住
- 超过分界线则落后台任务,流式生成单个 ZIP 落盘,前端轮询真实进度并支持取消/隐藏

配套改动:

- 抽出 MesProQrLabelAssembler,消除袋码/托盘/批次三份重复的标签装配逻辑
- 后台任务受两道闸门约束:并发(同时仅 1 个)与 CPU(提交 80% 拒绝、生成中 85% 让路降速);
两者都只约束大批量,不影响小批量同步导出
- 临时 ZIP 下载后立即删除;下载中断或始终未下载的,由定时任务按保留期兜底清理
- 服务启动时回收因进程重启而停留在「生成中」的残留任务
- 移除已无引用的 PRO_QR_EXPORT_TOO_MANY 错误码

新增 mes_pro_qr_export_task 表,见 sql/mysql/mes_pro_qr_export_task_init.sql。
实测:1000 张约 6 秒,生成速率约 149 张/秒,5 万张约 5.6 分钟。

Co-Authored-By: Claude <noreply@anthropic.com>
已添加21个文件
已修改9个文件
2565 ■■■■■ 文件已修改
scripts/qr-export-big-batch.cjs 286 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mes_pro_qr_export_task_init.sql 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/MesProBagCodeController.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/batch/MesProBatchController.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/pallet/MesProPalletController.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/qrexport/MesProQrExportTaskController.java 178 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/qrexport/vo/MesProQrExportCountRespVO.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/qrexport/vo/MesProQrExportTaskRespVO.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/qrexport/vo/MesProQrExportTaskSubmitReqVO.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/qrexport/MesProQrExportTaskDO.java 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/bagcode/MesProBagCodeMapper.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/qrexport/MesProQrExportTaskMapper.java 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/pro/MesProQrExportBizTypeEnum.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/pro/MesProQrExportTaskStatusEnum.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrcode/QrCodeImageUtils.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrexport/MesProQrExportExecutorConfig.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrexport/MesProQrExportProperties.java 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrexport/MesProQrExportTaskRegistry.java 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrexport/SystemCpuLoadMonitor.java 148 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/job/pro/MesProQrExportCleanupTask.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeService.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeServiceImpl.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrExportRunner.java 223 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrExportTaskService.java 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrExportTaskServiceImpl.java 297 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrLabelAssembler.java 130 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrLabelSource.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrLabelSourceFactory.java 180 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application.yaml 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/qr-export-big-batch.cjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,286 @@
/**
 * äºŒç»´ç å¤§æ‰¹é‡å¯¼å‡ºâ€”—端到端实测
 *
 * è¦†ç›–:分流判断 â†’ åŽå°ä»»åŠ¡ç”Ÿæˆï¼ˆçœŸå®žè¿›åº¦ï¼‰â†’ éšè—ä¸ºæµ®æ ‡ â†’ å®ŒæˆåŽä¸‹è½½ ZIP
 *
 * ç”¨æ³•:node qr-export-big-batch.cjs [--headed] [--uuid=<生成组>] [--cancel-after=<秒>]
 *   --cancel-after è®¾å®šåŽï¼Œç”Ÿæˆåˆ°è¯¥ç§’数时点“取消”,只验证取消链路(不等完成)
 *
 * è¯´æ˜Žï¼šçœŸå®žä½¿ç”¨æ—¶å‰ç«¯ä¼šå…ˆå¼¹ã€Œä¿å­˜ä½ç½®ã€å¯¹è¯æ¡†ï¼ˆFile System Access API),边下载边写盘;
 *      è‡ªåŠ¨åŒ–ä¸‹è¯¥å¯¹è¯æ¡†ä¼šé˜»å¡žæµç¨‹ï¼Œæ•…æœ¬è„šæœ¬æŠŠå®ƒé™çº§ï¼Œèµ°æµè§ˆå™¨åŽŸç”Ÿä¸‹è½½ä»¥ä¾¿æ–­è¨€äº§ç‰©ã€‚
 */
const { chromium } = require('playwright');
const path = require('node:path');
const fs = require('node:fs');
const BASE = process.env.QR_TEST_BASE || 'http://127.0.0.1:5666';
const API = process.env.QR_TEST_API || 'http://127.0.0.1:48080';
const USERNAME = process.env.QR_TEST_USER || 'admin';
const PASSWORD = process.env.QR_TEST_PWD || 'admin123';
const DEFAULT_UUID = 'ae1229655a3e48d7b4bc842e3e74e15c';
const args = process.argv.slice(2);
const HEADED = args.includes('--headed');
/** è·‘完后保留浏览器窗口不关,供人工查看二维码 */
const KEEP_OPEN = args.includes('--keep-open');
const uuidArg = args.find((a) => a.startsWith('--uuid='));
const UUID = uuidArg ? uuidArg.slice('--uuid='.length) : DEFAULT_UUID;
const cancelArg = args.find((a) => a.startsWith('--cancel-after='));
const CANCEL_AFTER_SEC = cancelArg ? Number(cancelArg.split('=')[1]) : 0;
const OUT_DIR = path.resolve(__dirname, 'qr-export-test-out');
/**
 * è¿›åº¦æµ®å±‚与浮标的选择器
 * ç”¨ç»“构特征而非 .last() ä¹‹ç±»çš„æ¨¡ç³Šå®šä½ï¼šé¡µé¢é‡Œå…¶å®ƒå…ƒç´ ï¼ˆå¦‚ Ant çš„ message æç¤ºï¼‰
 * åŒæ ·å«â€œäºŒç»´ç å¯¼å‡ºâ€æ–‡æ¡ˆï¼Œæ¨¡ç³Šå®šä½ä¼šç‚¹åˆ°æç¤ºæ¡ä¸Šï¼Œå¯¼è‡´è¯¯åˆ¤ã€‚
 */
const PANEL = 'div.fixed.bottom-6.w-80';
const PILL = 'div.fixed.bottom-6.cursor-pointer';
const DOWNLOAD_DIR = path.join(OUT_DIR, 'downloads');
const LOG_FILE = path.join(OUT_DIR, 'run.log');
fs.mkdirSync(DOWNLOAD_DIR, { recursive: true });
fs.writeFileSync(LOG_FILE, '');
const started = Date.now();
function log(message) {
  const line = `[${((Date.now() - started) / 1000).toFixed(1)}s] ${message}`;
  console.log(line);
  fs.appendFileSync(LOG_FILE, line + '\n');
}
function step(m) {
  log(`\n=== ${m} ===`);
}
function fail(m) {
  log(`FAIL: ${m}`);
  throw new Error(m);
}
/**
 * æŒ‰æ–‡æœ¬ç‚¹æŒ‰é’®ï¼šå®¹å¿ Ant Design åœ¨ä¸¤ä¸ªæ±‰å­—之间自动插入的空格(“搜索”会被渲染成“搜 ç´¢â€ï¼‰
 */
async function clickButton(page, text, options = {}) {
  const re = new RegExp(text.split('').join('\\s*'));
  const locator = page.locator('button').filter({ hasText: re });
  const count = await locator.count();
  if (count === 0) fail(`找不到按钮:${text}`);
  await locator.first().click(options);
}
/** é¡µé¢è¿›åº¦æµ®å±‚上的按钮(我的组件用 2 å­—中文,同样会被插空格) */
async function clickPanelButton(page, text) {
  const re = new RegExp(text.split('').join('\\s*'));
  const locator = page.locator('button').filter({ hasText: re });
  if ((await locator.count()) === 0) fail(`进度浮层上找不到「${text}」按钮`);
  await locator.first().click();
}
async function apiPreflight() {
  step('阶段 0:接口预检(确认后端已含本次改动)');
  const loginRes = await fetch(`${API}/admin-api/system/auth/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username: USERNAME, password: PASSWORD }),
  }).then((r) => r.json());
  if (loginRes.code !== 0) fail(`登录失败:${JSON.stringify(loginRes)}`);
  log(`接口登录成功,userId=${loginRes.data.userId}`);
  const countRes = await fetch(
    `${API}/admin-api/mes/pro/qr-export/count?bizType=BAG_LABEL&uuid=${UUID}`,
    { headers: { Authorization: `Bearer ${loginRes.data.accessToken}` } },
  ).then((r) => r.json());
  if (countRes.code === 404) fail('新接口返回 404 â€”— åŽç«¯æœªå«æœ¬æ¬¡æ”¹åЍ');
  if (countRes.code !== 0) fail(`统计接口异常:${JSON.stringify(countRes)}`);
  const { totalCount, syncThreshold, mode } = countRes.data;
  log(`接口统计:${totalCount} å¼ ï¼Œåˆ†ç•Œçº¿ ${syncThreshold},方式 ${mode}`);
  return { totalCount, mode, syncThreshold };
}
/** æ ¡éªŒä¸‹è½½äº§ç‰©ï¼šä½“积、ZIP å¤´å°¾ç­¾å */
async function verifyDownload(download) {
  const saved = path.join(DOWNLOAD_DIR, download.suggestedFilename());
  await download.saveAs(saved);
  const size = fs.statSync(saved).size;
  log(`下载完成:${download.suggestedFilename()},${(size / 1024 / 1024).toFixed(2)} MB`);
  const fd = fs.openSync(saved, 'r');
  const head = Buffer.alloc(4);
  fs.readSync(fd, head, 0, 4, 0);
  const tail = Buffer.alloc(22);
  fs.readSync(fd, tail, 0, 22, size - 22);
  fs.closeSync(fd);
  const headOk = head.readUInt32LE(0) === 0x04034b50;
  const tailOk = tail.readUInt32LE(0) === 0x06054b50;
  log(`ZIP å¤´ç­¾å PK\\x03\\x04:${headOk}`);
  log(`ZIP å°¾ EOCD ç­¾å PK\\x05\\x06:${tailOk}`);
  if (!headOk || !tailOk) fail('下载产物不是结构完整的 ZIP');
  return { saved, size };
}
async function main() {
  const { totalCount, mode, syncThreshold } = await apiPreflight();
  step('阶段 1:启动浏览器并登录');
  const browser = await chromium.launch({
    headless: !HEADED,
    channel: HEADED ? 'msedge' : undefined,
  });
  const context = await browser.newContext({
    acceptDownloads: true,
    viewport: { width: 1600, height: 900 },
  });
  const page = await context.newPage();
  // é™çº§ File System Access API,使下载走浏览器原生通道,便于断言产物
  await page.addInitScript(() => {
    Object.defineProperty(window, 'showSaveFilePicker', {
      value: undefined,
      configurable: true,
    });
  });
  const pageErrors = [];
  page.on('pageerror', (e) => {
    pageErrors.push(e.message);
    log(`!! é¡µé¢ JS é”™è¯¯: ${e.message}`);
  });
  try {
    await page.goto(`${BASE}/auth/login`, { waitUntil: 'domcontentloaded' });
    await page.locator('input[name=username]').waitFor({ timeout: 60_000 });
    await page.locator('input[name=username]').fill(USERNAME);
    await page.locator('input[name=password]').fill(PASSWORD);
    await page.locator('button:has-text("登录")').last().click();
    await page.waitForURL((u) => !u.pathname.includes('/auth/login'), { timeout: 60_000 });
    log('登录成功:' + page.url());
    step('阶段 2:进入袋码列表页并按生成组筛选');
    await page.goto(`${BASE}/bagcode/bag-code`, { waitUntil: 'domcontentloaded' });
    await page.getByRole('button', { name: '导出二维码' }).first().waitFor({ timeout: 60_000 });
    await page.screenshot({ path: path.join(OUT_DIR, '01-列表页.png') });
    await page.getByPlaceholder('请输入生成组编号').fill(UUID);
    log(`已填写生成组:${UUID}`);
    await clickButton(page, '搜索');
    await page.waitForTimeout(4000);
    await page.screenshot({ path: path.join(OUT_DIR, '02-筛选后.png') });
    step(`阶段 3:点击「导出二维码」(${totalCount} å¼ ï¼Œåˆ†ç•Œçº¿ ${syncThreshold},预期走${mode === 'SYNC' ? '同步下载' : '后台任务'})`);
    // ä¸‹è½½äº‹ä»¶å¿…须先挂上,避免事件早于监听而漏掉
    const downloadPromise = page.waitForEvent('download', { timeout: 60 * 60 * 1000 });
    await page.getByRole('button', { name: '导出二维码' }).first().click();
    if (mode === 'SYNC') {
      // å°æ‰¹é‡ï¼šç§’级同步下载,不进后台任务、不排队、不受 CPU é—¸é—¨çº¦æŸ
      await page
        .locator(PANEL)
        .waitFor({ timeout: 30_000 })
        .catch(() => log('(同步路径未展示浮层,直接下载)'));
      const download = await downloadPromise;
      await verifyDownload(download);
      await page.screenshot({ path: path.join(OUT_DIR, 'sync-完成.png') });
      if (pageErrors.length > 0) log(`注意:测试期间出现 ${pageErrors.length} æ¡é¡µé¢ JS é”™è¯¯`);
      log(`\nPASS:${totalCount} å¼ æœªè¶…过分界线 ${syncThreshold},走同步下载,产物完整`);
      return;
    }
    await page.locator(PANEL).waitFor({ timeout: 90_000 });
    log('进度浮层已出现');
    await page.waitForTimeout(3000);
    // æäº¤å¯èƒ½è¢«å¹¶å‘闸门拒绝(已有任务在跑);明确报出来,避免后续步骤以讹传讹
    const afterClick = await page.locator('body').innerText();
    if (/导出失败/.test(afterClick)) {
      const busy = afterClick.match(/已有二维码导出任务正在进行[^\n]*/);
      fail(busy ? `被并发闸门拒绝:${busy[0]}` : '导出失败(见页面提示)');
    }
    await page.screenshot({ path: path.join(OUT_DIR, '03-进度浮层.png') });
    const cancelBtn = page.locator('button').filter({ hasText: /取\s*消/ });
    if ((await cancelBtn.count()) === 0) fail('进度浮层上找不到「取消」按钮');
    log('进度浮层已出现,「取消」按钮存在 âœ“');
    step('阶段 4:验证「隐藏」→ æ”¶æˆæµ®æ ‡ â†’ ç‚¹æµ®æ ‡å†å±•å¼€');
    await page.screenshot({ path: path.join(OUT_DIR, '04a-隐藏前.png') });
    await clickPanelButton(page, '隐藏');
    await page.waitForTimeout(1500);
    if ((await page.locator(PILL).count()) === 0) fail('点击「隐藏」后浮标未出现');
    log('已收起为浮标 âœ“');
    await page.screenshot({ path: path.join(OUT_DIR, '04b-隐藏后浮标.png') });
    await page.locator(PILL).first().click();
    await page.waitForTimeout(1200);
    if ((await page.locator(PANEL).count()) === 0) fail('点击浮标后进度面板未恢复');
    log('点击浮标后进度面板恢复 âœ“');
    await page.screenshot({ path: path.join(OUT_DIR, '05-恢复面板.png') });
    if (CANCEL_AFTER_SEC > 0) {
      step(`阶段 5:等待 ${CANCEL_AFTER_SEC}s åŽéªŒè¯ã€Œå–消」链路`);
      await page.waitForTimeout(CANCEL_AFTER_SEC * 1000);
      await clickPanelButton(page, '取消');
      await page.waitForTimeout(5000);
      await page.screenshot({ path: path.join(OUT_DIR, '06-取消后.png') });
      const canceled = await page
        .locator('text=/已取消/')
        .first()
        .isVisible()
        .catch(() => false);
      log(`取消后界面提示「已取消」:${canceled}`);
      if (!canceled) fail('点击取消后界面未进入已取消状态');
      log('取消链路 âœ“');
      return;
    }
    step(`阶段 5:等待后台生成完成(共 ${totalCount} å¼ ï¼‰`);
    let lastLogged = '';
    for (let i = 0; i < 3600; i++) {
      await page.waitForTimeout(2000);
      const body = await page.locator('body').innerText().catch(() => '');
      const m = body.match(/正在生成((\d+)\s*\/\s*(\d+))/);
      if (m) {
        const cur = `${m[1]}/${m[2]} (${((Number(m[1]) / Number(m[2])) * 100).toFixed(1)}%)`;
        if (cur !== lastLogged) {
          log(`生成进度 ${cur}`);
          lastLogged = cur;
        }
      }
      const phase = body.match(/正在下载|导出完成|导出失败/);
      if (phase) {
        log(`进入下一阶段:${phase[0]}`);
        break;
      }
    }
    await page.screenshot({ path: path.join(OUT_DIR, '06-生成完成.png') });
    step('阶段 6:等待下载落盘并校验产物');
    const download = await downloadPromise;
    await verifyDownload(download);
    await page.screenshot({ path: path.join(OUT_DIR, '07-下载完成.png') });
    if (pageErrors.length > 0) log(`注意:测试期间出现 ${pageErrors.length} æ¡é¡µé¢ JS é”™è¯¯`);
    log(`\nPASS:分流 â†’ åŽå°ç”Ÿæˆ ${totalCount} å¼  â†’ çœŸå®žè¿›åº¦ â†’ éšè—/恢复 â†’ ä¸‹è½½ ZIP å…¨é“¾è·¯é€šè¿‡`);
  } catch (error) {
    await page.screenshot({ path: path.join(OUT_DIR, 'error.png') }).catch(() => {});
    log(`失败截图:${path.join(OUT_DIR, 'error.png')}`);
    throw error;
  } finally {
    if (KEEP_OPEN) {
      // ä¿æŒæµè§ˆå™¨æ‰“开供人工查看二维码;等待页面被手动关闭后进程才退出
      log('(--keep-open:浏览器保持打开,请自行查看,关闭窗口后本进程退出)');
      await page
        .waitForEvent('close', { timeout: 0 })
        .catch(() => {});
      await browser.close().catch(() => {});
      return;
    }
    if (HEADED) {
      // ç•™å‡ºå‡ ç§’让人看清最终界面,再自动收尾,避免留下悬挂的浏览器与 node è¿›ç¨‹
      log('(headed æ¨¡å¼ï¼š10 ç§’后自动关闭浏览器)');
      await page.waitForTimeout(10_000).catch(() => {});
    }
    await browser.close().catch(() => {});
  }
}
main().catch((e) => {
  log(`\nFAILED: ${e.message}`);
  process.exit(1);
});
sql/mysql/mes_pro_qr_export_task_init.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
-- äºŒç»´ç å¤§æ‰¹é‡å¯¼å‡ºä»»åŠ¡ï¼šä¸´æ—¶ ZIP è½ç›˜ + è¿›åº¦/取消
-- è¯´æ˜Žï¼šå¤§æ‰¹é‡å¯¼å‡ºä¸åœ¨è¯·æ±‚线程内完成,改为落任务后由后台线程流式生成 ZIP,
--       å‰ç«¯è½®è¯¢çœŸå®žè¿›åº¦å¹¶æ”¯æŒå–消;ZIP ä¸‹è½½å®ŒæˆåŽç«‹å³åˆ é™¤ï¼Œå¼‚常残留由定时任务清理。
CREATE TABLE IF NOT EXISTS `mes_pro_qr_export_task` (
    `id`             BIGINT       NOT NULL AUTO_INCREMENT COMMENT '编号',
    `biz_type`       VARCHAR(32)  NOT NULL COMMENT '业务类型(BAG_LABEL/BAG_BUSINESS/PALLET_LABEL/PALLET_BUSINESS/BATCH_LABEL/BATCH_BUSINESS)',
    `uuid`           VARCHAR(64)  DEFAULT NULL COMMENT '生成组 UUID(与批次编号二选一)',
    `batch_id`       BIGINT       DEFAULT NULL COMMENT '生产批次编号(与 UUID äºŒé€‰ä¸€ï¼‰',
    `task_name`      VARCHAR(128) DEFAULT NULL COMMENT '任务名称(展示用)',
    `status`         TINYINT      NOT NULL COMMENT '状态(10 æŽ’队中 20 ç”Ÿæˆä¸­ 30 å¾…下载 40 å·²å–消 50 å·²å¤±è´¥ 60 å·²ä¸‹è½½ï¼‰',
    `total_count`    INT          NOT NULL DEFAULT 0 COMMENT '标签总张数',
    `finished_count` INT          NOT NULL DEFAULT 0 COMMENT '已生成张数',
    `file_path`      VARCHAR(512) DEFAULT NULL COMMENT '临时 ZIP ç»å¯¹è·¯å¾„(为空表示未落盘或已删除)',
    `file_size`      BIGINT       DEFAULT NULL COMMENT '临时 ZIP å¤§å°ï¼ˆå­—节)',
    `error_msg`      VARCHAR(512) DEFAULT NULL COMMENT '失败原因',
    `finish_time`    DATETIME     DEFAULT NULL COMMENT '完成时间',
    `creator`        VARCHAR(64)  DEFAULT '' COMMENT '创建者',
    `create_time`    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updater`        VARCHAR(64)  DEFAULT '' COMMENT '更新者',
    `update_time`    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `deleted`        BIT(1)       NOT NULL DEFAULT b'0' COMMENT '是否删除',
    PRIMARY KEY (`id`),
    KEY `idx_status` (`status`),
    KEY `idx_create_time` (`create_time`)
) ENGINE = InnoDB COMMENT = 'MES äºŒç»´ç å¯¼å‡ºä»»åŠ¡';
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/MesProBagCodeController.java
@@ -22,12 +22,12 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchDO;
import cn.iocoder.yudao.module.mes.framework.qrcode.MesProQrLabelBuilder;
import cn.iocoder.yudao.module.mes.framework.qrcode.QrCodeImageUtils;
import cn.iocoder.yudao.module.mes.framework.qrcode.QrLabelInfo;
import cn.iocoder.yudao.module.mes.framework.qrexport.MesProQrExportProperties;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.pro.bagcode.MesProBagCodeService;
import cn.iocoder.yudao.module.mes.service.pro.batch.MesProBatchService;
import cn.iocoder.yudao.module.mes.service.pro.qrexport.MesProQrLabelAssembler;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import io.swagger.v3.oas.annotations.Operation;
@@ -42,11 +42,9 @@
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@@ -56,7 +54,7 @@
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_BAG_CODE_NOT_EXISTS;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_PARAMS_INVALID;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_TOO_MANY;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_TOO_MANY_FOR_SYNC;
@Tag(name = "管理后台 - MES è¢‹ç èµ‹ç ï¼ˆä¸€è¢‹ä¸€ç ï¼‰")
@RestController
@@ -72,6 +70,10 @@
    private AdminUserApi adminUserApi;
    @Resource
    private MesMdmItemService mdmItemService;
    @Resource
    private MesProQrLabelAssembler labelAssembler;
    @Resource
    private MesProQrExportProperties qrExportProperties;
    @PostMapping("/generate")
    @Operation(summary = "生成袋码数据包(一袋一码),累计达到阈值时自动生成托盘码")
@@ -211,10 +213,8 @@
        if (CollUtil.isEmpty(list)) {
            throw exception(PRO_BAG_CODE_NOT_EXISTS);
        }
        if (list.size() > QrCodeImageUtils.MAX_QR_EXPORT) {
            throw exception(PRO_QR_EXPORT_TOO_MANY);
        }
        QrCodeImageUtils.writeZip(response, zipName, buildBagCodeQrLabels(list));
        assertSyncExportAllowed(list.size());
        QrCodeImageUtils.writeZip(response, zipName, labelAssembler.buildBagCodeLabels(list));
    }
    // ==================== æ‹¼æŽ¥ VO ====================
@@ -239,9 +239,9 @@
        else if (batchId != null) list = bagCodeService.getPrintableBagCodeListByBatchId(batchId);
        else throw exception(PRO_QR_EXPORT_PARAMS_INVALID);
        if (CollUtil.isEmpty(list)) throw exception(PRO_BAG_CODE_NOT_EXISTS);
        if (list.size() > QrCodeImageUtils.MAX_QR_EXPORT) throw exception(PRO_QR_EXPORT_TOO_MANY);
        List<QrLabelInfo> labels = list.stream().map(MesProQrLabelBuilder::buildBagBusinessCodeLabel).toList();
        QrCodeImageUtils.writeZip(response, "袋码业务二维码-" + (StrUtil.isNotBlank(uuid) ? uuid : batchId), labels);
        assertSyncExportAllowed(list.size());
        QrCodeImageUtils.writeZip(response, "袋码业务二维码-" + (StrUtil.isNotBlank(uuid) ? uuid : batchId),
                labelAssembler.buildBagBusinessLabels(list));
    }
    private List<MesProBagCodeRespVO> buildBagCodeRespVOList(List<MesProBagCodeDO> list) {
@@ -303,24 +303,15 @@
    }
    /**
     * æž„建袋码二维码标签列表(二维码编码纯码值,图片叠加关键信息文字)
     * åŒæ­¥å¯¼å‡ºåªæ‰¿æŽ¥å°æ‰¹é‡
     * <p>
     * æ•°é‡è¶…过分界线时必须走后台导出任务:同步响应会随张数线性变长(上万张可达数分钟、响应体上 GB),
     * å¿…然被网关或浏览器截断,用户看到的将是「下载失败」而不是可操作的提示。
     */
    private List<QrLabelInfo> buildBagCodeQrLabels(List<MesProBagCodeDO> list) {
        // 1. èŽ·å¾—å…³è”æ•°æ®ï¼ˆæ‰¹æ¬¡ã€å“ç§ã€è´£ä»»äººï¼‰ï¼›å“ç§é›†åˆå«è¢‹ç è‡ªèº«å“ç§ä¸ŽåŽ†å²æ‰¹æ¬¡å“ç§
        Map<Long, MesProBatchDO> batchMap = convertMap(
                batchService.getBatchList(convertSet(list, MesProBagCodeDO::getBatchId)), MesProBatchDO::getId);
        Set<Long> itemIds = convertSet(list, MesProBagCodeDO::getItemId);
        itemIds.addAll(convertSet(batchMap.values(), MesProBatchDO::getItemId));
        Map<Long, MesMdmItemDO> itemMap = mdmItemService.getItemMap(itemIds);
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
                convertSet(batchMap.values(), MesProBatchDO::getOwnerUserId));
        // 2. æž„建标签
        List<QrLabelInfo> labels = new ArrayList<>(list.size());
        for (MesProBagCodeDO bagCode : list) {
            labels.add(MesProQrLabelBuilder.buildBagCodeLabel(
                    bagCode, batchMap.get(bagCode.getBatchId()), itemMap, userMap));
    private void assertSyncExportAllowed(int count) {
        if (count > qrExportProperties.getSyncThreshold()) {
            throw exception(PRO_QR_EXPORT_TOO_MANY_FOR_SYNC, count, qrExportProperties.getSyncThreshold());
        }
        return labels;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/batch/MesProBatchController.java
@@ -23,13 +23,12 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchWorkerDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.pallet.MesProPalletDO;
import cn.iocoder.yudao.module.mes.framework.qrcode.MesProQrLabelBuilder;
import cn.iocoder.yudao.module.mes.framework.qrcode.QrCodeImageUtils;
import cn.iocoder.yudao.module.mes.framework.qrcode.QrLabelInfo;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.pro.bagcode.MesProBagCodeService;
import cn.iocoder.yudao.module.mes.service.pro.batch.MesProBatchService;
import cn.iocoder.yudao.module.mes.service.pro.pallet.MesProPalletService;
import cn.iocoder.yudao.module.mes.service.pro.qrexport.MesProQrLabelAssembler;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import io.swagger.v3.oas.annotations.Operation;
@@ -44,7 +43,6 @@
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
@@ -58,13 +56,20 @@
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_BATCH_NOT_EXISTS;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_TOO_MANY;
@Tag(name = "管理后台 - MES ç”Ÿäº§æ‰¹æ¬¡")
@RestController
@RequestMapping("/mes/pro/batch")
@Validated
public class MesProBatchController {
    /**
     * æ‰¹æ¬¡è¿½æº¯å“åº”内嵌的袋码列表上限
     * <p>
     * è¿™æ˜¯å“åº”体积的保护,与二维码导出无关:袋码总量由 bagTotal è¿”回,列表本身只作展示用途。
     * å¯¼å‡ºè·¯å¾„不再有数量上限。
     */
    private static final int MAX_TRACE_BAG_LIST_SIZE = 2000;
    @Resource
    private MesProBatchService batchService;
@@ -76,6 +81,8 @@
    private MesProPalletService palletService;
    @Resource
    private MesProBagCodeService bagCodeService;
    @Resource
    private MesProQrLabelAssembler labelAssembler;
    @PostMapping("/create")
    @Operation(summary = "创建生产批次(一次录入业务信息,自动生成批次号)")
@@ -200,11 +207,8 @@
            throw exception(PRO_BATCH_NOT_EXISTS);
        }
        List<MesProPalletDO> pallets = palletService.getPalletListByBatchId(batchId);
        if (pallets.size() + 1 > QrCodeImageUtils.MAX_QR_EXPORT) {
            throw exception(PRO_QR_EXPORT_TOO_MANY);
        }
        String qrCode = StrUtil.blankToDefault(batch.getCode(), batch.getTempCode());
        QrCodeImageUtils.writeZip(response, "批次二维码-" + qrCode, buildBatchQrLabels(batch, pallets));
        QrCodeImageUtils.writeZip(response, "批次二维码-" + qrCode, labelAssembler.buildBatchLabels(batch, pallets));
    }
    // ==================== æ‹¼æŽ¥ VO ====================
@@ -229,11 +233,8 @@
            throw new cn.iocoder.yudao.framework.common.exception.ServiceException(400, "已作废批次不可导出业务二维码");
        List<MesProPalletDO> pallets = palletService.getPalletListByBatchId(batchId).stream()
                .filter(record -> !cn.iocoder.yudao.module.mes.enums.pro.MesProPalletStatusEnum.CANCELED.getStatus().equals(record.getStatus())).toList();
        if (pallets.size() + 1 > QrCodeImageUtils.MAX_QR_EXPORT) throw exception(PRO_QR_EXPORT_TOO_MANY);
        List<QrLabelInfo> labels = new java.util.ArrayList<>();
        labels.add(MesProQrLabelBuilder.buildBatchBusinessCodeLabel(batch));
        pallets.forEach(pallet -> labels.add(MesProQrLabelBuilder.buildPalletBusinessCodeLabel(pallet)));
        QrCodeImageUtils.writeZip(response, "批次业务二维码-" + batch.getBusinessCode(), labels);
        QrCodeImageUtils.writeZip(response, "批次业务二维码-" + batch.getBusinessCode(),
                labelAssembler.buildBatchBusinessLabels(batch, pallets));
    }
    private List<MesProBatchRespVO> buildBatchRespVOList(List<MesProBatchDO> list) {
@@ -282,12 +283,12 @@
        respVO.setWorkers(buildWorkerRespVOList(batchService.getBatchWorkerListByBatchId(batch.getId())));
        // æ‰˜ç›˜åˆ—表
        respVO.setPallets(BeanUtils.toBean(palletService.getPalletListByBatchId(batch.getId()), MesProPalletRespVO.class));
        // è¢‹ç åˆ—表(封顶 2000,bagTotal è®°å½•总数)
        // è¢‹ç åˆ—表(封顶 MAX_TRACE_BAG_LIST_SIZE,bagTotal è®°å½•总数)
        List<MesProBagCodeDO> bags = bagCodeService.getBagCodeListByBatchId(batch.getId());
        respVO.setPendingBagQuantity((int) bags.stream().filter(b -> !Integer.valueOf(30).equals(b.getStatus()) && b.getInboundEventId() == null).count());
        respVO.setBagTotal(bags.size());
        if (bags.size() > QrCodeImageUtils.MAX_QR_EXPORT) {
            bags = bags.subList(0, QrCodeImageUtils.MAX_QR_EXPORT);
        if (bags.size() > MAX_TRACE_BAG_LIST_SIZE) {
            bags = bags.subList(0, MAX_TRACE_BAG_LIST_SIZE);
        }
        respVO.setBags(buildBagCodeRespVOList(bags));
        return respVO;
@@ -303,21 +304,6 @@
        return BeanUtils.toBean(list, MesProBagCodeRespVO.class, vo ->
                MapUtils.findAndThen(batchMap, vo.getBatchId(), batch ->
                        vo.setLineCode(batch.getLineCode()).setLineName(batch.getLineName())));
    }
    private List<QrLabelInfo> buildBatchQrLabels(MesProBatchDO batch, List<MesProPalletDO> pallets) {
        // èŽ·å¾—å…³è”æ•°æ®ï¼ˆå“ç§ã€è´£ä»»äººï¼‰â€”â€”æ‰¹æ¬¡ä¸Žæ‰˜ç›˜åŒå±žä¸€æ‰¹ï¼Œå…±ç”¨åŒä¸€ä»½
        Map<Long, MesMdmItemDO> itemMap = mdmItemService.getItemMap(
                convertSet(Collections.singletonList(batch), MesProBatchDO::getItemId));
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
                convertSet(Collections.singletonList(batch), MesProBatchDO::getOwnerUserId));
        // æž„建标签:批次图 + å…¨éƒ¨æ‰˜ç›˜å›¾
        List<QrLabelInfo> labels = new ArrayList<>(pallets.size() + 1);
        labels.add(MesProQrLabelBuilder.buildBatchLabel(batch, itemMap, userMap));
        for (MesProPalletDO pallet : pallets) {
            labels.add(MesProQrLabelBuilder.buildPalletLabel(pallet, batch, itemMap, userMap));
        }
        return labels;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/pallet/MesProPalletController.java
@@ -21,13 +21,12 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.pallet.MesProPalletDO;
import cn.iocoder.yudao.module.mes.framework.qrcode.MesProQrLabelBuilder;
import cn.iocoder.yudao.module.mes.framework.qrcode.QrCodeImageUtils;
import cn.iocoder.yudao.module.mes.framework.qrcode.QrLabelInfo;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.pro.bagcode.MesProBagCodeService;
import cn.iocoder.yudao.module.mes.service.pro.batch.MesProBatchService;
import cn.iocoder.yudao.module.mes.service.pro.pallet.MesProPalletService;
import cn.iocoder.yudao.module.mes.service.pro.qrexport.MesProQrLabelAssembler;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import io.swagger.v3.oas.annotations.Operation;
@@ -41,7 +40,6 @@
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -53,7 +51,6 @@
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_PALLET_NOT_EXISTS;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_TOO_MANY;
@Tag(name = "管理后台 - MES æ‰˜ç›˜ç ")
@RestController
@@ -71,6 +68,8 @@
    private MesMdmItemService mdmItemService;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private MesProQrLabelAssembler labelAssembler;
    @PostMapping("/batch-create")
    @Operation(summary = "批量生成托盘码(一次录入批次生成正式托盘码)")
@@ -106,10 +105,7 @@
        if (CollUtil.isEmpty(list)) {
            throw exception(PRO_PALLET_NOT_EXISTS);
        }
        if (list.size() > QrCodeImageUtils.MAX_QR_EXPORT) {
            throw exception(PRO_QR_EXPORT_TOO_MANY);
        }
        QrCodeImageUtils.writeZip(response, "托盘码二维码", buildPalletQrLabels(list));
        QrCodeImageUtils.writeZip(response, "托盘码二维码", labelAssembler.buildPalletLabels(list));
    }
    @DeleteMapping("/delete")
@@ -190,10 +186,7 @@
        if (CollUtil.isEmpty(list)) {
            throw exception(PRO_PALLET_NOT_EXISTS);
        }
        if (list.size() > QrCodeImageUtils.MAX_QR_EXPORT) {
            throw exception(PRO_QR_EXPORT_TOO_MANY);
        }
        QrCodeImageUtils.writeZip(response, "托盘码二维码-" + batchId, buildPalletQrLabels(list));
        QrCodeImageUtils.writeZip(response, "托盘码二维码-" + batchId, labelAssembler.buildPalletLabels(list));
    }
    // ==================== æ‹¼æŽ¥ VO ====================
@@ -215,9 +208,7 @@
        List<MesProPalletDO> list = palletService.getPalletListByIds(reqVO.getIds()).stream()
                .filter(record -> !cn.iocoder.yudao.module.mes.enums.pro.MesProPalletStatusEnum.CANCELED.getStatus().equals(record.getStatus())).toList();
        if (CollUtil.isEmpty(list)) throw exception(PRO_PALLET_NOT_EXISTS);
        if (list.size() > QrCodeImageUtils.MAX_QR_EXPORT) throw exception(PRO_QR_EXPORT_TOO_MANY);
        QrCodeImageUtils.writeZip(response, "托盘业务二维码", list.stream()
                .map(MesProQrLabelBuilder::buildPalletBusinessCodeLabel).toList());
        QrCodeImageUtils.writeZip(response, "托盘业务二维码", labelAssembler.buildPalletBusinessLabels(list));
    }
    private List<MesProPalletRespVO> buildPalletRespVOList(List<MesProPalletDO> list) {
@@ -275,23 +266,6 @@
        return BeanUtils.toBean(list, MesProBagCodeRespVO.class, vo ->
                MapUtils.findAndThen(batchMap, vo.getBatchId(), batch ->
                        vo.setLineCode(batch.getLineCode()).setLineName(batch.getLineName())));
    }
    private List<QrLabelInfo> buildPalletQrLabels(List<MesProPalletDO> list) {
        // èŽ·å¾—å…³è”æ•°æ®ï¼ˆæ‰¹æ¬¡ã€å“ç§ã€è´£ä»»äººï¼‰
        Map<Long, MesProBatchDO> batchMap = convertMap(
                batchService.getBatchList(convertSet(list, MesProPalletDO::getBatchId)), MesProBatchDO::getId);
        Map<Long, MesMdmItemDO> itemMap = mdmItemService.getItemMap(
                convertSet(batchMap.values(), MesProBatchDO::getItemId));
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
                convertSet(batchMap.values(), MesProBatchDO::getOwnerUserId));
        // æž„建标签
        List<QrLabelInfo> labels = new ArrayList<>(list.size());
        for (MesProPalletDO pallet : list) {
            labels.add(MesProQrLabelBuilder.buildPalletLabel(
                    pallet, batchMap.get(pallet.getBatchId()), itemMap, userMap));
        }
        return labels;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/qrexport/MesProQrExportTaskController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,178 @@
package cn.iocoder.yudao.module.mes.controller.admin.pro.qrexport;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService;
import cn.iocoder.yudao.module.mes.controller.admin.pro.qrexport.vo.MesProQrExportCountRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.qrexport.vo.MesProQrExportTaskRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.qrexport.vo.MesProQrExportTaskSubmitReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.qrexport.MesProQrExportTaskDO;
import cn.iocoder.yudao.module.mes.enums.pro.MesProQrExportBizTypeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProQrExportTaskStatusEnum;
import cn.iocoder.yudao.module.mes.framework.qrexport.MesProQrExportProperties;
import cn.iocoder.yudao.module.mes.service.pro.qrexport.MesProQrExportTaskService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_PARAMS_INVALID;
/**
 * äºŒç»´ç å¤§æ‰¹é‡å¯¼å‡ºä»»åŠ¡æŽ¥å£
 * <p>
 * æ•°é‡ä¸è¶…过分界线的导出不走这里(调用方直接调原下载接口,秒级返回、不排队);
 * è¶…过分界线才提交任务、轮询进度、完成后下载。
 * <p>
 * è¯´æ˜Žï¼šä¸‹è½½ä¸Žå–消未额外限制「只能操作自己创建的任务」。能进入本接口的用户已持有对应模块的导出权限,
 * ä¹Ÿå°±å·²ç»å¯ä»¥ç›´æŽ¥è°ƒç”¨åŒæ­¥å¯¼å‡ºæŽ¥å£æ‹¿åˆ°åŒæ ·çš„ ZIP å†…容,再加一层创建人校验并不能提供实际保护。
 */
@Tag(name = "管理后台 - MES äºŒç»´ç å¯¼å‡ºä»»åŠ¡")
@RestController
@RequestMapping("/mes/pro/qr-export")
@Validated
public class MesProQrExportTaskController {
    /**
     * ä»»ä¸€æ¨¡å—的导出权限即可访问本接口;具体业务类型再逐项校验
     */
    private static final String ANY_EXPORT_PERMISSION =
            "@ss.hasAnyPermissions('mes:pro-bag-code:export','mes:pro-pallet:export','mes:pro-batch:export')";
    @Resource
    private MesProQrExportTaskService taskService;
    @Resource
    private MesProQrExportProperties properties;
    @Resource
    private SecurityFrameworkService securityFrameworkService;
    @GetMapping("/count")
    @Operation(summary = "统计二维码导出张数并给出导出方式(SYNC ç›´æŽ¥ä¸‹è½½ / ASYNC åŽå°ä»»åŠ¡ï¼‰")
    @PreAuthorize(ANY_EXPORT_PERMISSION)
    public CommonResult<MesProQrExportCountRespVO> count(@RequestParam("bizType") String bizType,
                                                         @RequestParam(value = "uuid", required = false) String uuid,
                                                         @RequestParam(value = "batchId", required = false) Long batchId) {
        MesProQrExportBizTypeEnum bizTypeEnum = resolveBizType(bizType);
        assertBizPermission(bizTypeEnum);
        long total = taskService.countLabels(bizTypeEnum, uuid, batchId);
        MesProQrExportCountRespVO respVO = new MesProQrExportCountRespVO();
        respVO.setTotalCount(total);
        respVO.setSyncThreshold(properties.getSyncThreshold());
        respVO.setMode(total > properties.getSyncThreshold()
                ? MesProQrExportCountRespVO.MODE_ASYNC : MesProQrExportCountRespVO.MODE_SYNC);
        return success(respVO);
    }
    @PostMapping("/task")
    @Operation(summary = "提交二维码后台导出任务(大批量)")
    @PreAuthorize(ANY_EXPORT_PERMISSION)
    public CommonResult<MesProQrExportTaskRespVO> submit(@Valid @RequestBody MesProQrExportTaskSubmitReqVO reqVO) {
        MesProQrExportBizTypeEnum bizTypeEnum = resolveBizType(reqVO.getBizType());
        assertBizPermission(bizTypeEnum);
        MesProQrExportTaskDO task = taskService.submit(bizTypeEnum, reqVO.getUuid(), reqVO.getBatchId());
        return success(convert(task));
    }
    @GetMapping("/task/get")
    @Operation(summary = "获得二维码导出任务(用于轮询进度)")
    @Parameter(name = "id", description = "任务编号", required = true, example = "1024")
    @PreAuthorize(ANY_EXPORT_PERMISSION)
    public CommonResult<MesProQrExportTaskRespVO> get(@RequestParam("id") Long id) {
        return success(convert(taskService.getTask(id)));
    }
    @PostMapping("/task/cancel")
    @Operation(summary = "取消二维码导出任务")
    @Parameter(name = "id", description = "任务编号", required = true, example = "1024")
    @PreAuthorize(ANY_EXPORT_PERMISSION)
    public CommonResult<Boolean> cancel(@RequestParam("id") Long id) {
        taskService.cancel(id);
        return success(true);
    }
    @GetMapping("/task/download")
    @Operation(summary = "下载二维码导出产物(下载完成后系统立即删除临时文件)")
    @Parameter(name = "id", description = "任务编号", required = true, example = "1024")
    @PreAuthorize(ANY_EXPORT_PERMISSION)
    public void download(@RequestParam("id") Long id, HttpServletResponse response) throws IOException {
        taskService.download(id, response);
    }
    // ==================== ç§æœ‰æ–¹æ³• ====================
    private MesProQrExportBizTypeEnum resolveBizType(String bizType) {
        for (MesProQrExportBizTypeEnum value : MesProQrExportBizTypeEnum.values()) {
            if (value.getType().equals(bizType)) {
                return value;
            }
        }
        throw exception(PRO_QR_EXPORT_PARAMS_INVALID);
    }
    /**
     * æ ¡éªŒä¸šåŠ¡ç±»åž‹å¯¹åº”çš„æ¨¡å—å¯¼å‡ºæƒé™
     * <p>
     * å•靠「任一导出权限」会让只有托盘导出权限的用户也能导出袋码,故按类型再校验一次。
     */
    private void assertBizPermission(MesProQrExportBizTypeEnum bizType) {
        String permission = switch (bizType) {
            case BAG_LABEL, BAG_BUSINESS -> "mes:pro-bag-code:export";
            case PALLET_LABEL, PALLET_BUSINESS -> "mes:pro-pallet:export";
            case BATCH_LABEL, BATCH_BUSINESS -> "mes:pro-batch:export";
        };
        if (!securityFrameworkService.hasAnyPermissions(permission)) {
            throw exception(FORBIDDEN);
        }
    }
    private MesProQrExportTaskRespVO convert(MesProQrExportTaskDO task) {
        MesProQrExportTaskRespVO respVO = new MesProQrExportTaskRespVO();
        respVO.setId(task.getId());
        respVO.setBizType(task.getBizType());
        respVO.setTaskName(task.getTaskName());
        respVO.setStatus(task.getStatus());
        MesProQrExportTaskStatusEnum statusEnum = parseStatus(task.getStatus());
        respVO.setStatusName(statusEnum == null ? null : statusEnum.getName());
        respVO.setTotalCount(task.getTotalCount());
        respVO.setFinishedCount(task.getFinishedCount());
        respVO.setProgress(calcProgress(task));
        respVO.setFileSize(task.getFileSize());
        respVO.setErrorMsg(task.getErrorMsg());
        respVO.setDownloadable(MesProQrExportTaskStatusEnum.FINISHED.getStatus().equals(task.getStatus())
                && task.getFilePath() != null && !task.getFilePath().isBlank());
        respVO.setCreateTime(task.getCreateTime());
        respVO.setFinishTime(task.getFinishTime());
        return respVO;
    }
    private Integer calcProgress(MesProQrExportTaskDO task) {
        Integer total = task.getTotalCount();
        if (total == null || total <= 0) {
            return 0;
        }
        int finished = task.getFinishedCount() == null ? 0 : task.getFinishedCount();
        return (int) Math.min(100L, (long) finished * 100 / total);
    }
    private MesProQrExportTaskStatusEnum parseStatus(Integer status) {
        if (status == null) {
            return null;
        }
        for (MesProQrExportTaskStatusEnum value : MesProQrExportTaskStatusEnum.values()) {
            if (value.getStatus().equals(status)) {
                return value;
            }
        }
        return null;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/qrexport/vo/MesProQrExportCountRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.mes.controller.admin.pro.qrexport.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - MES äºŒç»´ç å¯¼å‡ºæ•°é‡ä¸Žæ–¹å¼ Response VO")
@Data
public class MesProQrExportCountRespVO {
    /**
     * åŒæ­¥ç›´æŽ¥ä¸‹è½½ï¼šæ•°é‡ä¸è¶…过分界线,调用方按原有的下载接口取 ZIP å³å¯ï¼ˆç§’级返回,不排队)
     */
    public static final String MODE_SYNC = "SYNC";
    /**
     * åŽå°ä»»åŠ¡ï¼šæ•°é‡è¶…è¿‡åˆ†ç•Œçº¿ï¼Œéœ€æäº¤ä»»åŠ¡åŽè½®è¯¢è¿›åº¦ã€å®ŒæˆåŽä¸‹è½½
     */
    public static final String MODE_ASYNC = "ASYNC";
    @Schema(description = "待导出标签张数", requiredMode = Schema.RequiredMode.REQUIRED, example = "52872")
    private Long totalCount;
    @Schema(description = "同步/后台的分界线(张)", example = "2000")
    private Integer syncThreshold;
    @Schema(description = "导出方式(SYNC åŒæ­¥ç›´æŽ¥ä¸‹è½½ / ASYNC åŽå°ä»»åŠ¡ï¼‰", requiredMode = Schema.RequiredMode.REQUIRED,
            example = "ASYNC")
    private String mode;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/qrexport/vo/MesProQrExportTaskRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.mes.controller.admin.pro.qrexport.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - MES äºŒç»´ç å¯¼å‡ºä»»åŠ¡ Response VO")
@Data
public class MesProQrExportTaskRespVO {
    @Schema(description = "任务编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    private Long id;
    @Schema(description = "业务类型", example = "BAG_LABEL")
    private String bizType;
    @Schema(description = "任务名称", example = "袋码二维码标签-20260818")
    private String taskName;
    @Schema(description = "状态(10 æŽ’队中 20 ç”Ÿæˆä¸­ 30 å¾…下载 40 å·²å–消 50 å·²å¤±è´¥ 60 å·²ä¸‹è½½ï¼‰", example = "20")
    private Integer status;
    @Schema(description = "状态名称", example = "生成中")
    private String statusName;
    @Schema(description = "标签总张数", example = "52872")
    private Integer totalCount;
    @Schema(description = "已生成张数", example = "12000")
    private Integer finishedCount;
    @Schema(description = "进度百分比(0~100)", example = "23")
    private Integer progress;
    @Schema(description = "ZIP æ–‡ä»¶å¤§å°ï¼ˆå­—节)", example = "1536000")
    private Long fileSize;
    @Schema(description = "失败原因")
    private String errorMsg;
    @Schema(description = "是否可下载(已生成完成且文件仍存在)", example = "true")
    private Boolean downloadable;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
    @Schema(description = "完成时间")
    private LocalDateTime finishTime;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/qrexport/vo/MesProQrExportTaskSubmitReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.mes.controller.admin.pro.qrexport.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
@Schema(description = "管理后台 - MES äºŒç»´ç å¯¼å‡ºä»»åŠ¡æäº¤ Request VO")
@Data
public class MesProQrExportTaskSubmitReqVO {
    @Schema(description = "业务类型(BAG_LABEL/BAG_BUSINESS/PALLET_LABEL/PALLET_BUSINESS/BATCH_LABEL/BATCH_BUSINESS)",
            requiredMode = Schema.RequiredMode.REQUIRED, example = "BAG_LABEL")
    @NotEmpty(message = "业务类型不能为空")
    private String bizType;
    @Schema(description = "生成组 UUID(与生产批次编号二选一)", example = "20260818A01")
    private String uuid;
    @Schema(description = "生产批次编号(与生成组 UUID äºŒé€‰ä¸€ï¼‰", example = "1024")
    private Long batchId;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/qrexport/MesProQrExportTaskDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,85 @@
package cn.iocoder.yudao.module.mes.dal.dataobject.pro.qrexport;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.time.LocalDateTime;
/**
 * MES äºŒç»´ç å¯¼å‡ºä»»åŠ¡ DO
 * <p>
 * å¤§æ‰¹é‡äºŒç»´ç å¯¼å‡ºä¸åœ¨è¯·æ±‚线程内完成,而是落成任务后由后台线程流式生成 ZIP,
 * å‰ç«¯æ® {@link #finishedCount}/{@link #totalCount} å±•示真实进度,并支持中途取消。
 */
@TableName("mes_pro_qr_export_task")
@KeySequence("mes_pro_qr_export_task_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MesProQrExportTaskDO extends BaseDO {
    @TableId
    private Long id;
    /**
     * ä¸šåŠ¡ç±»åž‹ï¼Œè§ {@link cn.iocoder.yudao.module.mes.enums.pro.MesProQrExportBizTypeEnum}
     */
    private String bizType;
    /**
     * ç”Ÿæˆç»„ UUID(与 batchId äºŒé€‰ä¸€ï¼‰
     */
    private String uuid;
    /**
     * ç”Ÿäº§æ‰¹æ¬¡ç¼–号(与 uuid äºŒé€‰ä¸€ï¼‰
     */
    private Long batchId;
    /**
     * ä»»åŠ¡åç§°ï¼ˆå±•ç¤ºç”¨ï¼Œå½¢å¦‚ã€Œè¢‹ç äºŒç»´ç æ ‡ç­¾-PC20260818L01001」)
     */
    private String taskName;
    /**
     * çŠ¶æ€ï¼Œè§ {@link cn.iocoder.yudao.module.mes.enums.pro.MesProQrExportTaskStatusEnum}
     */
    private Integer status;
    /**
     * æ ‡ç­¾æ€»å¼ æ•°
     */
    private Integer totalCount;
    /**
     * å·²ç”Ÿæˆå¼ æ•°
     */
    private Integer finishedCount;
    /**
     * ä¸´æ—¶ ZIP æ–‡ä»¶ç»å¯¹è·¯å¾„;为空表示尚未落盘或已删除
     */
    private String filePath;
    /**
     * ä¸´æ—¶ ZIP æ–‡ä»¶å¤§å°ï¼ˆå­—节)
     */
    private Long fileSize;
    /**
     * å¤±è´¥åŽŸå› 
     */
    private String errorMsg;
    /**
     * å®Œæˆï¼ˆå®Œæˆ/取消/失败)时间
     */
    private LocalDateTime finishTime;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/bagcode/MesProBagCodeMapper.java
@@ -108,6 +108,58 @@
                .orderByAsc(MesProBagCodeDO::getBagNo));
    }
    /**
     * ç»Ÿè®¡ç”Ÿæˆç»„下可印刷的袋码数量(供导出分流判断,避免为了拿数量而全量加载)
     */
    default Long selectPrintableCountByUuid(String uuid) {
        return selectCount(new LambdaQueryWrapperX<MesProBagCodeDO>()
                .eq(MesProBagCodeDO::getUuid, uuid)
                .ne(MesProBagCodeDO::getStatus, MesProBagCodeStatusEnum.CANCELED.getStatus()));
    }
    /**
     * ç»Ÿè®¡æ‰¹æ¬¡ä¸‹å¯å°åˆ·çš„袋码数量(供导出分流判断)
     */
    default Long selectPrintableCountByBatchId(Long batchId) {
        return selectCount(new LambdaQueryWrapperX<MesProBagCodeDO>()
                .eq(MesProBagCodeDO::getBatchId, batchId)
                .ne(MesProBagCodeDO::getStatus, MesProBagCodeStatusEnum.CANCELED.getStatus()));
    }
    /**
     * åˆ†é¡µæŸ¥è¯¢ç”Ÿæˆç»„下可印刷的袋码(后台导出任务用,逐页取以控制内存)
     * <p>
     * æŽ’序与全量查询保持一致(按袋序号升序),保证 ZIP å†…的标签顺序不变。
     */
    default List<MesProBagCodeDO> selectPrintablePageByUuid(String uuid, int pageNo, int pageSize) {
        return selectPageByWrapper(new LambdaQueryWrapperX<MesProBagCodeDO>()
                .eq(MesProBagCodeDO::getUuid, uuid)
                .ne(MesProBagCodeDO::getStatus, MesProBagCodeStatusEnum.CANCELED.getStatus())
                .orderByAsc(MesProBagCodeDO::getBagNo), pageNo, pageSize);
    }
    /**
     * åˆ†é¡µæŸ¥è¯¢æ‰¹æ¬¡ä¸‹å¯å°åˆ·çš„袋码(后台导出任务用)
     */
    default List<MesProBagCodeDO> selectPrintablePageByBatchId(Long batchId, int pageNo, int pageSize) {
        return selectPageByWrapper(new LambdaQueryWrapperX<MesProBagCodeDO>()
                .eq(MesProBagCodeDO::getBatchId, batchId)
                .ne(MesProBagCodeDO::getStatus, MesProBagCodeStatusEnum.CANCELED.getStatus())
                .orderByAsc(MesProBagCodeDO::getBagNo), pageNo, pageSize);
    }
    /**
     * æŒ‰æ¡ä»¶åˆ†é¡µæŸ¥è¯¢ï¼Œä¸æ‰§è¡Œ count æŸ¥è¯¢ï¼ˆè°ƒç”¨æ–¹é€šå¸¸å·²çŸ¥æ€»æ•°ï¼‰
     */
    private List<MesProBagCodeDO> selectPageByWrapper(
            com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<MesProBagCodeDO> wrapper,
            int pageNo, int pageSize) {
        com.baomidou.mybatisplus.extension.plugins.pagination.Page<MesProBagCodeDO> page =
                new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(pageNo, pageSize);
        page.setSearchCount(false);
        return selectPage(page, wrapper).getRecords();
    }
    default Long selectCountByBatchId(Long batchId) {
        return selectCount(MesProBagCodeDO::getBatchId, batchId);
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/qrexport/MesProQrExportTaskMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,88 @@
package cn.iocoder.yudao.module.mes.dal.mysql.pro.qrexport;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.qrexport.MesProQrExportTaskDO;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
/**
 * MES äºŒç»´ç å¯¼å‡ºä»»åŠ¡ Mapper
 */
@Mapper
public interface MesProQrExportTaskMapper extends BaseMapperX<MesProQrExportTaskDO> {
    default List<MesProQrExportTaskDO> selectListByStatus(Integer status) {
        return selectList(MesProQrExportTaskDO::getStatus, status);
    }
    default List<MesProQrExportTaskDO> selectListByStatusIn(Collection<Integer> statuses) {
        return selectList(new LambdaQueryWrapperX<MesProQrExportTaskDO>()
                .in(MesProQrExportTaskDO::getStatus, statuses)
                .orderByAsc(MesProQrExportTaskDO::getId));
    }
    default Long selectCountByStatusIn(Collection<Integer> statuses) {
        return selectCount(new LambdaQueryWrapperX<MesProQrExportTaskDO>()
                .in(MesProQrExportTaskDO::getStatus, statuses));
    }
    /**
     * æŸ¥è¯¢å·²ç»ˆæ€ä¸”完成时间早于 deadline çš„任务,用于清理临时文件与任务记录
     */
    default List<MesProQrExportTaskDO> selectExpiredFinalList(Collection<Integer> finalStatuses,
                                                              LocalDateTime deadline) {
        return selectList(new LambdaQueryWrapperX<MesProQrExportTaskDO>()
                .in(MesProQrExportTaskDO::getStatus, finalStatuses)
                .and(wrapper -> wrapper.isNull(MesProQrExportTaskDO::getFinishTime)
                        .or().lt(MesProQrExportTaskDO::getFinishTime, deadline)));
    }
    /**
     * æŸ¥è¯¢æ‰€æœ‰éžç»ˆæ€ä»»åŠ¡ï¼Œç”¨äºŽå›žæ”¶è¿›ç¨‹é‡å¯åŽæ®‹ç•™çš„è®°å½•
     * <p>
     * ä¸æŒ‰æ—¶é—´è¿‡æ»¤ï¼šæ˜¯å¦ä»åœ¨æ‰§è¡Œç”±è¿è¡Œæ€æ³¨å†Œè¡¨åˆ¤å®šï¼Œè¡¨é‡ŒæŸ¥å‡ºæ¥çš„非终态记录若不在注册表中,
     * å°±ä¸€å®šæ²¡æœ‰æ‰§è¡Œçº¿ç¨‹åœ¨æŽ¨è¿›å®ƒã€‚
     */
    default List<MesProQrExportTaskDO> selectUnfinishedList(Collection<Integer> statuses) {
        return selectListByStatusIn(statuses);
    }
    /**
     * ä»…更新进度,避免把整行字段都写回(生成过程中高频调用)
     */
    default int updateProgress(Long id, Integer finishedCount) {
        return update(null, new LambdaUpdateWrapper<MesProQrExportTaskDO>()
                .eq(MesProQrExportTaskDO::getId, id)
                .set(MesProQrExportTaskDO::getFinishedCount, finishedCount));
    }
    /**
     * æ›´æ–°ä»»åŠ¡ç»ˆæ€ï¼›null å­—段不覆盖
     */
    default int updateStatus(Long id, Integer status, String filePath, Long fileSize,
                             String errorMsg, LocalDateTime finishTime) {
        return update(null, new LambdaUpdateWrapper<MesProQrExportTaskDO>()
                .eq(MesProQrExportTaskDO::getId, id)
                .set(MesProQrExportTaskDO::getStatus, status)
                .set(filePath != null, MesProQrExportTaskDO::getFilePath, filePath)
                .set(fileSize != null, MesProQrExportTaskDO::getFileSize, fileSize)
                .set(errorMsg != null, MesProQrExportTaskDO::getErrorMsg, errorMsg)
                .set(finishTime != null, MesProQrExportTaskDO::getFinishTime, finishTime));
    }
    /**
     * æ¸…空临时文件路径(文件已删除,保留任务记录用于展示历史)
     */
    default int clearFilePath(Long id) {
        return update(null, new LambdaUpdateWrapper<MesProQrExportTaskDO>()
                .eq(MesProQrExportTaskDO::getId, id)
                .set(MesProQrExportTaskDO::getFilePath, null)
                .set(MesProQrExportTaskDO::getFileSize, null));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java
@@ -838,11 +838,16 @@
    ErrorCode PRO_BATCH_STATUS_INVALID = new ErrorCode(1_040_750_004, "生产批次状态流转不合法");
    ErrorCode PRO_BATCH_WORKER_EMPTY = new ErrorCode(1_040_750_005, "生产批次必须配置至少一名生产人员");
    ErrorCode PRO_BATCH_STATUS_NOT_ALLOW_DELETE = new ErrorCode(1_040_750_006, "只有草稿或已作废的生产批次才允许删除");
    ErrorCode PRO_QR_EXPORT_TOO_MANY = new ErrorCode(1_040_750_007, "二维码批量导出数量超过上限");
    ErrorCode PRO_QR_IMAGE_GENERATE_ERROR = new ErrorCode(1_040_750_008, "二维码图片生成失败");
    ErrorCode PRO_QR_EXPORT_PARAMS_INVALID = new ErrorCode(1_040_750_009, "二维码批量导出缺少查询条件");
    ErrorCode PRO_BATCH_ALREADY_FILLED = new ErrorCode(1_040_750_012, "生产批次已补录正式信息,不允许重复补录");
    ErrorCode PRO_BATCH_NOT_FILLED = new ErrorCode(1_040_750_013, "生产批次尚未补录正式信息");
    ErrorCode PRO_QR_EXPORT_TASK_NOT_EXISTS = new ErrorCode(1_040_750_010, "二维码导出任务不存在");
    ErrorCode PRO_QR_EXPORT_TASK_BUSY = new ErrorCode(1_040_750_011, "已有二维码导出任务正在进行({}%),请等待其完成或取消后再试");
    ErrorCode PRO_QR_EXPORT_SERVER_BUSY = new ErrorCode(1_040_750_014, "服务器 CPU è´Ÿè½½è¿‡é«˜ï¼ˆå½“前 {}%,阈值 {}%),已暂停大批量导出,请稍后再试");
    ErrorCode PRO_QR_EXPORT_TOO_MANY_FOR_SYNC = new ErrorCode(1_040_750_015, "本次需导出 {} å¼ ï¼Œè¶…过同步导出上限 {} å¼ ï¼Œè¯·æ”¹ç”¨åŽå°å¯¼å‡ºä»»åŠ¡");
    ErrorCode PRO_QR_EXPORT_TASK_FILE_MISSING = new ErrorCode(1_040_750_016, "导出文件不存在或已被删除,请重新导出");
    ErrorCode PRO_QR_EXPORT_TASK_STATUS_INVALID = new ErrorCode(1_040_750_017, "当前导出任务状态不允许该操作");
    // ========== MES ç”Ÿäº§ç®¡ç†-袋码赋码(1-040-752-000) ==========
    ErrorCode PRO_BAG_CODE_NOT_EXISTS = new ErrorCode(1_040_752_000, "袋码不存在");
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/pro/MesProQrExportBizTypeEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
package cn.iocoder.yudao.module.mes.enums.pro;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
 * MES äºŒç»´ç å¯¼å‡ºä¸šåŠ¡ç±»åž‹æžšä¸¾
 * <p>
 * å¯¼å‡ºå‚数统一为「生成组 UUID」或「生产批次编号」二选一,故不区分具体入口来源。
 */
@Getter
@AllArgsConstructor
public enum MesProQrExportBizTypeEnum implements ArrayValuable<String> {
    BAG_LABEL("BAG_LABEL", "袋码二维码标签"),
    BAG_BUSINESS("BAG_BUSINESS", "袋码业务二维码"),
    PALLET_LABEL("PALLET_LABEL", "托盘二维码标签"),
    PALLET_BUSINESS("PALLET_BUSINESS", "托盘业务二维码"),
    BATCH_LABEL("BATCH_LABEL", "批次二维码标签"),
    BATCH_BUSINESS("BATCH_BUSINESS", "批次业务二维码");
    public static final String[] ARRAYS = Arrays.stream(values())
            .map(MesProQrExportBizTypeEnum::getType).toArray(String[]::new);
    /**
     * ç±»åž‹æ ‡è¯†
     */
    private final String type;
    /**
     * ç±»åž‹åç§°
     */
    private final String name;
    @Override
    public String[] array() {
        return ARRAYS;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/pro/MesProQrExportTaskStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,65 @@
package cn.iocoder.yudao.module.mes.enums.pro;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
 * MES äºŒç»´ç å¯¼å‡ºä»»åŠ¡çŠ¶æ€æžšä¸¾
 */
@Getter
@AllArgsConstructor
public enum MesProQrExportTaskStatusEnum implements ArrayValuable<Integer> {
    /**
     * å·²åˆ›å»ºå¾…执行(受并发闸门限制时短暂停留该状态)
     */
    PENDING(10, "排队中"),
    /**
     * ç”Ÿæˆä¸­
     */
    RUNNING(20, "生成中"),
    /**
     * ç”Ÿæˆå®Œæˆï¼Œç­‰å¾…下载
     */
    FINISHED(30, "待下载"),
    /**
     * ç”¨æˆ·å–消
     */
    CANCELED(40, "已取消"),
    /**
     * ç”Ÿæˆå¤±è´¥
     */
    FAILED(50, "已失败"),
    /**
     * å·²ä¸‹è½½å¹¶åˆ é™¤ä¸´æ—¶æ–‡ä»¶ï¼ˆç»ˆæ€ï¼‰
     */
    DOWNLOADED(60, "已下载");
    public static final Integer[] ARRAYS = Arrays.stream(values())
            .map(MesProQrExportTaskStatusEnum::getStatus).toArray(Integer[]::new);
    /**
     * çŠ¶æ€
     */
    private final Integer status;
    /**
     * çŠ¶æ€åç§°
     */
    private final String name;
    /**
     * æ˜¯å¦ä¸ºç»ˆæ€ï¼ˆä¸å†å˜åŒ–,可被清理)
     */
    public boolean isFinal() {
        return this == FINISHED || this == CANCELED || this == FAILED || this == DOWNLOADED;
    }
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrcode/QrCodeImageUtils.java
@@ -33,9 +33,6 @@
    private QrCodeImageUtils() {
    }
    /** æ‰¹é‡å¯¼å‡ºäºŒç»´ç æ•°é‡ä¸Šé™ï¼ˆé˜²æ­¢ä¸€æ¬¡æ€§ç”Ÿæˆè¿‡å¤šå¯¼è‡´å†…å­˜/响应过大) */
    public static final int MAX_QR_EXPORT = 2000;
    /** äºŒç»´ç è¾¹é•¿ */
    private static final int QR_SIZE = 260;
    /** å›¾ç‰‡å››å‘¨ç•™ç™½ */
@@ -120,10 +117,7 @@
        ZipOutputStream zip = new ZipOutputStream(response.getOutputStream());
        try {
            for (QrLabelInfo label : labels) {
                BufferedImage image = createQrLabel(label.getCode(), label.getFileName());
                zip.putNextEntry(new ZipEntry(label.getFileName() + ".png"));
                ImageIO.write(image, "png", zip);
                zip.closeEntry();
                writeZipEntry(zip, label);
            }
            zip.finish();
            zip.flush();
@@ -132,6 +126,22 @@
        }
    }
    /**
     * ç”Ÿæˆå•张标签并写入 ZIP æ¡ç›®
     * <p>
     * ä¾›åŽå°å¯¼å‡ºä»»åŠ¡è‡ªå»ºå†™å…¥å¾ªçŽ¯æ—¶å¤ç”¨ï¼šè°ƒç”¨æ–¹éœ€è¦è‡ªè¡Œåœ¨æ¯å¼ ä¹‹é—´æ£€æŸ¥å–æ¶ˆã€é‡‡æ · CPU å¹¶å›žå†™è¿›åº¦ï¼Œ
     * å› æ­¤è¿™é‡Œåªè´Ÿè´£"一张"的处理,不接管循环。
     *
     * @param zip   ZIP è¾“出流
     * @param label æ ‡ç­¾ä¿¡æ¯
     */
    public static void writeZipEntry(ZipOutputStream zip, QrLabelInfo label) throws IOException {
        BufferedImage image = createQrLabel(label.getCode(), label.getFileName());
        zip.putNextEntry(new ZipEntry(label.getFileName() + ".png"));
        ImageIO.write(image, "png", zip);
        zip.closeEntry();
    }
    // ==================== ç§æœ‰æ–¹æ³• ====================
    /**
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrexport/MesProQrExportExecutorConfig.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
package cn.iocoder.yudao.module.mes.framework.qrexport;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.ThreadPoolExecutor;
/**
 * äºŒç»´ç åŽå°å¯¼å‡ºçº¿ç¨‹æ± 
 * <p>
 * å•独建池而不是复用公共异步池:二维码生成是长时间、CPU å¯†é›†çš„任务,混进公共池会把其他异步业务一起堵住。
 * æ± å¤§å°å–并发闸门配置,队列留少量冗余以吸收「校验通过但线程尚未就绪」的瞬时窗口;真正超出的提交
 * ç”± {@link MesProQrExportTaskRegistry} çš„并发许可拦在更前面,不会堆到这里。
 */
@Configuration(proxyBeanMethods = false)
public class MesProQrExportExecutorConfig {
    /**
     * çº¿ç¨‹åï¼Œä¾¿äºŽçº¿ä¸Šå®šä½å¯¼å‡ºçº¿ç¨‹
     */
    private static final String THREAD_NAME_PREFIX = "mes-qr-export-";
    @Resource
    private MesProQrExportProperties properties;
    @Bean(name = "mesQrExportExecutor")
    public ThreadPoolTaskExecutor mesQrExportExecutor() {
        int concurrency = Math.max(1, properties.getMaxConcurrentTasks());
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(concurrency);
        executor.setMaxPoolSize(concurrency);
        executor.setQueueCapacity(concurrency);
        executor.setThreadNamePrefix(THREAD_NAME_PREFIX);
        // é˜Ÿåˆ—满即拒绝,由调用方把任务置为失败,避免任务无声地堆在队列里
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
        executor.initialize();
        return executor;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrexport/MesProQrExportProperties.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,90 @@
package cn.iocoder.yudao.module.mes.framework.qrexport;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
 * MES äºŒç»´ç å¯¼å‡ºé…ç½®é¡¹
 * <p>
 * æ‰¹é‡å¯¼å‡ºäºŒç»´ç æ˜¯ CPU å¯†é›†åž‹æ“ä½œï¼ˆé€å¼ ç”ŸæˆäºŒç»´ç  + åˆæˆ PNG),且单批次可达成千上万张,
 * å› æ­¤å¯¼å‡ºè¢«æ‹†æˆä¸¤æ¡è·¯å¾„:
 * <ul>
 *     <li>小批量(不超过 {@link #syncThreshold})走同步流式下载,秒级返回,不受任何闸门限制;</li>
 *     <li>大批量转后台任务,受并发闸门与 CPU é—¸é—¨ç®¡æŽ§ï¼Œé¿å…æŠŠåŒæœºéƒ¨ç½²çš„业务页面拖垮。</li>
 * </ul>
 */
@Data
@Component
@ConfigurationProperties(prefix = "mes.qr-export")
public class MesProQrExportProperties {
    /**
     * åŒæ­¥/后台的分界线(张)
     * <p>
     * ä¸è¶…过该数量直接同步流式下载,超过则转后台导出任务。同步路径不排队、不校验 CPU,
     * ä¿è¯ã€Œå¯¼å‡ ç™¾å¼ ã€æ°¸è¿œæ˜¯ç§’出,不会被正在跑的大批量任务挡住。
     */
    private int syncThreshold = 2000;
    /**
     * æäº¤åŽå°å¯¼å‡ºä»»åŠ¡æ—¶çš„ç³»ç»Ÿ CPU ä½¿ç”¨çŽ‡æ‹’ç»é˜ˆå€¼ï¼ˆ0~1)
     * <p>
     * è¾¾åˆ°è¯¥å€¼å³æ‹’绝新建任务(提示服务器繁忙),避免在服务器已经很忙时再叠加导出负载。
     */
    private double cpuRejectThreshold = 0.80D;
    /**
     * ç”Ÿæˆè¿‡ç¨‹ä¸­çš„系统 CPU èŠ‚æµé˜ˆå€¼ï¼ˆ0~1)
     * <p>
     * ä»»åŠ¡ä¸€æ—¦å¼€å§‹å°±å¿…é¡»è·‘å®Œï¼Œä»…é æäº¤æ—¶æ‹’ç»æ— æ³•è¦†ç›–ã€Œæäº¤æ—¶ CPU ä½Žã€è·‘起来把业务拖卡」的情况,
     * å› æ­¤ç”Ÿæˆè¿‡ç¨‹ä¸­æŒç»­é‡‡æ ·ï¼Œè¶…过该水位即让出 CPU(降速但不中断)。
     */
    private double cpuThrottleThreshold = 0.85D;
    /**
     * CPU é«˜äºŽèŠ‚æµé˜ˆå€¼æ—¶ï¼Œæ¯æ¬¡è®©å‡º CPU çš„æ¯«ç§’æ•°
     */
    private long cpuThrottleSleepMillis = 300L;
    /**
     * ç”Ÿæˆè¿‡ç¨‹ä¸­æ£€æŸ¥å–消、采样 CPU çš„间隔(每多少张一次)
     */
    private int progressCheckInterval = 200;
    /**
     * åŒæ—¶å…è®¸è¿è¡Œçš„后台导出任务数
     * <p>
     * ä»…约束大批量任务,小批量同步导出不受此限制。
     */
    private int maxConcurrentTasks = 1;
    /**
     * å•个后台任务的最长运行时间(分钟),超时自动取消并清理,避免异常任务长期占用
     */
    private int maxRuntimeMinutes = 30;
    /**
     * ä¸´æ—¶ ZIP å­˜æ”¾ç›®å½•,为空时使用 {@code ${java.io.tmpdir}/mes-qr-export}
     */
    private String tempDir = "";
    /**
     * ä¸´æ—¶ ZIP çš„保留时间(分钟)
     * <p>
     * æ­£å¸¸ä¸‹è½½å®ŒæˆåŽç«‹å³åˆ é™¤ï¼›ä»…在「下载中断」或「生成完成后始终未下载」时保留一小段时间,
     * åˆ°æœŸç”±æ¸…理任务删除。否则每次中断都要重新生成上万张标签,或者上 GB çš„临时文件一直占着磁盘。
     */
    private int tempFileRetentionMinutes = 10;
    /**
     * è§£æžä¸´æ—¶ç›®å½•,未配置时回退到系统临时目录
     */
    public String resolveTempDir() {
        if (tempDir == null || tempDir.isBlank()) {
            return System.getProperty("java.io.tmpdir") + java.io.File.separator + "mes-qr-export";
        }
        return tempDir;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrexport/MesProQrExportTaskRegistry.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,113 @@
package cn.iocoder.yudao.module.mes.framework.qrexport;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicBoolean;
/**
 * äºŒç»´ç å¯¼å‡ºä»»åŠ¡è¿è¡Œæ€æ³¨å†Œè¡¨
 * <p>
 * ä¿å­˜ã€Œå“ªäº›ä»»åŠ¡æ­£åœ¨ç”Ÿæˆã€ä¸Žã€Œæ˜¯å¦å·²è¯·æ±‚å–æ¶ˆã€ã€‚å–æ¶ˆæ ‡è®°æ”¾å†…å­˜è€Œéžæ•°æ®åº“ï¼Œæ˜¯å› ä¸ºç”Ÿæˆçº¿ç¨‹éœ€è¦
 * æ¯æ•°ç™¾å¼ å°±æ£€æŸ¥ä¸€æ¬¡å–消,走数据库会产生大量无谓的查询;本项目为单机部署,两者同进程。
 * <p>
 * è¿›ç¨‹é‡å¯ä¼šè®©æ‰€æœ‰è¿è¡Œæ€ä¸¢å¤±ï¼Œæ­¤æ—¶ç”±æ¸…理任务把残留的「生成中」任务回收为失败,不会永久卡住。
 */
@Component
public class MesProQrExportTaskRegistry {
    @Resource
    private MesProQrExportProperties properties;
    /**
     * æ­£åœ¨è¿è¡Œçš„任务:任务编号 â†’ å–消标记
     */
    private final Map<Long, AtomicBoolean> runningTasks = new ConcurrentHashMap<>();
    /**
     * å¹¶å‘许可:约束的是大批量后台任务数,小批量同步导出不经过此处
     */
    private Semaphore permits;
    @PostConstruct
    public void init() {
        permits = new Semaphore(Math.max(1, properties.getMaxConcurrentTasks()));
    }
    /**
     * å°è¯•占用一个运行名额
     *
     * @return true è¡¨ç¤ºå ç”¨æˆåŠŸï¼Œå¯å¼€å§‹ç”Ÿæˆ
     */
    public boolean tryAcquire() {
        return permits.tryAcquire();
    }
    /**
     * é‡Šæ”¾è¿è¡Œåé¢ï¼ˆç”Ÿæˆç»“束、失败、取消均需调用)
     */
    public void release() {
        permits.release();
    }
    /**
     * å½“前由本进程持有的任务数(含刚建好、尚未被线程接管的)
     */
    public int getRunningCount() {
        return runningTasks.size();
    }
    /**
     * æ³¨å†Œä»»åŠ¡å¹¶è¿”å›žå…¶å–æ¶ˆæ ‡è®°
     * <p>
     * å¿…须在任务落库后、投递线程池前调用:否则从「落库」到「线程开始运行」之间存在一个窗口,
     * æ¸…理任务会把这条尚未注册的非终态记录误判为进程重启后的残留而回收掉。
     */
    public AtomicBoolean register(Long taskId) {
        AtomicBoolean flag = new AtomicBoolean(false);
        runningTasks.put(taskId, flag);
        return flag;
    }
    /**
     * èŽ·å–ä»»åŠ¡çš„å–æ¶ˆæ ‡è®°
     *
     * @return å–消标记;任务不属于本进程时返回 null
     */
    public AtomicBoolean get(Long taskId) {
        return runningTasks.get(taskId);
    }
    /**
     * ä»»åŠ¡æ˜¯å¦ç”±æœ¬è¿›ç¨‹æŒæœ‰ï¼ˆæ­£åœ¨ç”Ÿæˆæˆ–ç­‰å¾…çº¿ç¨‹æŽ¥ç®¡ï¼‰
     */
    public boolean isRunning(Long taskId) {
        return runningTasks.containsKey(taskId);
    }
    /**
     * æ³¨é”€ä»»åŠ¡
     */
    public void unregister(Long taskId) {
        runningTasks.remove(taskId);
    }
    /**
     * è¯·æ±‚取消:任务在跑则置位取消标记,返回 true
     *
     * @return true è¡¨ç¤ºä»»åŠ¡æ­£åœ¨ç”Ÿæˆã€å–æ¶ˆè¯·æ±‚å·²é€è¾¾ï¼›false è¡¨ç¤ºä»»åŠ¡ä¸åœ¨æœ¬è¿›ç¨‹è¿è¡Œ
     */
    public boolean requestCancel(Long taskId) {
        AtomicBoolean flag = runningTasks.get(taskId);
        if (flag == null) {
            return false;
        }
        flag.set(true);
        return true;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrexport/SystemCpuLoadMonitor.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,148 @@
package cn.iocoder.yudao.module.mes.framework.qrexport;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.lang.management.ManagementFactory;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
 * ç³»ç»Ÿ CPU ä½¿ç”¨çŽ‡ç›‘æŽ§
 * <p>
 * ç”¨ JDK è‡ªå¸¦çš„ {@code com.sun.management.OperatingSystemMXBean#getCpuLoad()} å–值,不引入额外依赖。
 * ä¹‹æ‰€ä»¥è¦å•独起采样线程,是因为该接口返回的是「距上次调用之间」的平均值:若只在提交导出时调用一次,
 * é‡‡æ ·åŒºé—´å¯èƒ½é•¿è¾¾æ•°å°æ—¶ï¼Œä¼šæŠŠå½“前的高负载尖峰平滑掉,从而放过本该拒绝的请求。固定周期采样后,
 * æäº¤é—¸é—¨ä¸Žè¿è¡ŒèŠ‚æµè¯»åˆ°çš„éƒ½æ˜¯æœ€è¿‘æ•°ç§’çš„çœŸå®žè´Ÿè½½ã€‚
 * <p>
 * ä¸‰ä¸ªå¿…须处理的边界:
 * <ol>
 *     <li>首次采样无效 â€”— è¯¥æŽ¥å£éœ€è¦å‰åŽä¸¤æ¬¡é‡‡æ ·æ‰èƒ½ç®—出结果,故取最近若干次的滑动平均,避免抖动误判;</li>
 *     <li>取不到值 â€”— è¿”回负数时视为「未知」,由调用方按放行处理,绝不能因为读不到指标就阻断业务;</li>
 *     <li>容器部署 â€”— è¯¥æŒ‡æ ‡åæ˜ çš„æ˜¯å®¿ä¸»æœºè€Œéžå®¹å™¨é…é¢ï¼Œè‹¥éƒ¨ç½²åœ¨å¸¦ CPU limit çš„容器中会失真,
 *         æ­¤æ—¶éœ€æ”¹ä¸º cgroup æ„ŸçŸ¥çš„口径。</li>
 * </ol>
 */
@Slf4j
@Component
public class SystemCpuLoadMonitor {
    /**
     * é‡‡æ ·å‘¨æœŸï¼ˆç§’):足够短以反映当前负载,足够长以避免采样本身带来开销
     */
    private static final long SAMPLE_PERIOD_SECONDS = 2L;
    /**
     * æ»‘动平均窗口大小:取最近 3 æ¬¡é‡‡æ ·çš„均值,抹掉单次抖动
     */
    private static final int WINDOW_SIZE = 3;
    /**
     * é‡‡æ ·çº¿ç¨‹åï¼Œä¾¿äºŽçº¿ä¸ŠæŽ’查
     */
    private static final String THREAD_NAME = "mes-cpu-load-monitor";
    /**
     * ç³»ç»Ÿ CPU æŽ¢é’ˆï¼›å½“前 JVM ä¸æ”¯æŒæ—¶ä¿æŒä¸º {@code null}
     */
    private final com.sun.management.OperatingSystemMXBean osBean = resolveOsBean();
    private final Deque<Double> samples = new ArrayDeque<>(WINDOW_SIZE);
    private ScheduledExecutorService executor;
    @PostConstruct
    public void start() {
        if (osBean == null) {
            log.warn("[start][当前 JVM ä¸æ”¯æŒç³»ç»Ÿ CPU é‡‡æ ·ï¼Œå¤§æ‰¹é‡äºŒç»´ç å¯¼å‡ºçš„ CPU é—¸é—¨å°†å¤±æ•ˆï¼ˆæŒ‰æ”¾è¡Œå¤„理)]");
            return;
        }
        executor = Executors.newSingleThreadScheduledExecutor(runnable -> {
            Thread thread = new Thread(runnable, THREAD_NAME);
            thread.setDaemon(true);
            return thread;
        });
        executor.scheduleWithFixedDelay(this::sampleQuietly, SAMPLE_PERIOD_SECONDS, SAMPLE_PERIOD_SECONDS,
                TimeUnit.SECONDS);
    }
    @PreDestroy
    public void stop() {
        if (executor != null) {
            executor.shutdownNow();
        }
    }
    /**
     * èŽ·å–ç³»ç»Ÿ CPU ä½¿ç”¨çŽ‡ï¼ˆæœ€è¿‘è‹¥å¹²æ¬¡é‡‡æ ·çš„æ»‘åŠ¨å¹³å‡ï¼‰
     *
     * @return 0~1 ä¹‹é—´è¡¨ç¤ºå ç”¨çŽ‡ï¼›è¿”å›žè´Ÿæ•°è¡¨ç¤ºå½“å‰çŽ¯å¢ƒæ— æ³•å–å€¼ï¼ˆè°ƒç”¨æ–¹åº”æŒ‰æ”¾è¡Œå¤„ç†ï¼‰
     */
    public double getCpuLoad() {
        synchronized (samples) {
            if (samples.isEmpty()) {
                // é‡‡æ ·çº¿ç¨‹å°šæœªäº§å‡ºç»“果(如服务刚启动):同步取一次,拿不到就交给调用方按放行处理
                double current = sample();
                return current;
            }
            double sum = 0D;
            for (Double value : samples) {
                sum += value;
            }
            return sum / samples.size();
        }
    }
    /**
     * æ˜¯å¦å¯é‡‡é›†ç³»ç»Ÿ CPU(不可采集时上层应跳过硬闸门)
     */
    public boolean isAvailable() {
        return osBean != null;
    }
    private void sampleQuietly() {
        try {
            double value = sample();
            if (value < 0) {
                return;
            }
            synchronized (samples) {
                if (samples.size() >= WINDOW_SIZE) {
                    samples.pollFirst();
                }
                samples.addLast(value);
            }
        } catch (Exception e) {
            // é‡‡æ ·å¤±è´¥ä¸åº”影响任何业务,仅记录一次
            log.warn("[sampleQuietly][系统 CPU é‡‡æ ·å¤±è´¥]", e);
        }
    }
    /**
     * é‡‡æ ·ä¸€æ¬¡ç³»ç»Ÿ CPU ä½¿ç”¨çއ
     *
     * @return 0~1 è¡¨ç¤ºå ç”¨çŽ‡ï¼›è´Ÿæ•°è¡¨ç¤ºä¸å¯ç”¨
     */
    private double sample() {
        if (osBean == null) {
            return -1D;
        }
        double value = osBean.getCpuLoad();
        // è¯¥æŽ¥å£åœ¨ä¸å¯ç”¨æˆ–首次采样时返回负数,统一按「未知」处理
        return value < 0 ? -1D : Math.min(value, 1D);
    }
    private static com.sun.management.OperatingSystemMXBean resolveOsBean() {
        java.lang.management.OperatingSystemMXBean bean = ManagementFactory.getOperatingSystemMXBean();
        if (bean instanceof com.sun.management.OperatingSystemMXBean operatingSystemMXBean) {
            return operatingSystemMXBean;
        }
        return null;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/job/pro/MesProQrExportCleanupTask.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,63 @@
package cn.iocoder.yudao.module.mes.job.pro;
import cn.iocoder.yudao.module.mes.service.pro.qrexport.MesProQrExportTaskService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
 * äºŒç»´ç å¯¼å‡ºä¸´æ—¶æ–‡ä»¶æ¸…理
 * <p>
 * å¤§æ‰¹é‡å¯¼å‡ºçš„临时 ZIP å¯è¾¾ä¸Š GB,正常下载完成后即删,本任务只兜底两类残留:
 * <ol>
 *     <li>下载中断或生成完成后始终未下载的文件,超过保留期后删除;</li>
 *     <li>因服务重启而失去执行线程、永远停在「生成中」的任务记录。</li>
 * </ol>
 * ç”¨ Spring è‡ªå¸¦çš„ {@code @Scheduled} è€Œéž Quartz JobHandler:后者需要在管理后台手工登记任务,
 * æ¼ç™»è®°å°±ä¼šé™é»˜ä¸æ‰§è¡Œï¼Œè€Œè¿™é‡Œçš„æ¸…理属于「不执行就会占满磁盘」的兜底逻辑,必须开箱即跑。
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class MesProQrExportCleanupTask {
    private final MesProQrExportTaskService taskService;
    /**
     * æ¯ 5 åˆ†é’Ÿæ¸…理一次:与默认 10 åˆ†é’Ÿçš„临时文件保留期匹配,避免残留文件长时间占用磁盘
     */
    @Scheduled(cron = "0 */5 * * * ?")
    public void cleanup() {
        try {
            int files = taskService.cleanupExpiredFiles();
            int tasks = taskService.recycleStaleTasks();
            if (files > 0 || tasks > 0) {
                log.info("[cleanup][二维码导出清理完成:删除临时文件 {} ä¸ªï¼Œå›žæ”¶æ®‹ç•™ä»»åŠ¡ {} ä¸ª]", files, tasks);
            }
        } catch (Exception e) {
            log.error("[cleanup][二维码导出清理失败]", e);
        }
    }
    /**
     * å¯åŠ¨æ—¶ç«‹åˆ»å›žæ”¶ä¸€æ¬¡æ®‹ç•™ä»»åŠ¡
     * <p>
     * è¿›ç¨‹é‡å¯åŽè¿è¡Œæ€æ³¨å†Œè¡¨ä¸ºç©ºï¼Œæ­¤æ—¶è¡¨ä¸­çš„非终态记录必定没有执行线程在推进,
     * ç«‹åˆ»å›žæ”¶å¯é¿å…å®ƒä»¬ä»¥ã€Œç”Ÿæˆä¸­ã€çš„状态一直挂在界面上(否则要等到下一次定时清理)。
     */
    @EventListener(ApplicationReadyEvent.class)
    public void recycleOnStartup() {
        try {
            int tasks = taskService.recycleStaleTasks();
            if (tasks > 0) {
                log.info("[recycleOnStartup][启动回收残留二维码导出任务 {} ä¸ª]", tasks);
            }
        } catch (Exception e) {
            log.error("[recycleOnStartup][启动回收二维码导出任务失败]", e);
        }
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeService.java
@@ -165,6 +165,44 @@
    List<MesProBagCodeDO> getPrintableBagCodeListByUuid(String uuid);
    /**
     * ç»Ÿè®¡æŸç”Ÿæˆç»„下可印刷的袋码数量
     * <p>
     * å¯¼å‡ºå‰æ®æ­¤åˆ¤æ–­èµ°åŒæ­¥ä¸‹è½½è¿˜æ˜¯åŽå°ä»»åŠ¡ï¼Œé¿å…ä¸ºäº†æ‹¿åˆ°æ•°é‡è€ŒæŠŠå…¨éƒ¨è¢‹ç åŠ è½½è¿›å†…å­˜ã€‚
     *
     * @param uuid ç”Ÿæˆç»„ UUID
     * @return å¯å°åˆ·çš„袋码数量
     */
    Long getPrintableBagCodeCountByUuid(String uuid);
    /**
     * ç»Ÿè®¡æŸæ‰¹æ¬¡ä¸‹å¯å°åˆ·çš„袋码数量
     *
     * @param batchId æ‰¹æ¬¡ç¼–号
     * @return å¯å°åˆ·çš„袋码数量
     */
    Long getPrintableBagCodeCountByBatchId(Long batchId);
    /**
     * åˆ†é¡µèŽ·å¾—æŸç”Ÿæˆç»„ä¸‹å¯å°åˆ·çš„è¢‹ç ï¼ˆåŽå°å¤§æ‰¹é‡å¯¼å‡ºç”¨ï¼Œé€é¡µå–ä»¥æŽ§åˆ¶å†…å­˜ï¼‰
     *
     * @param uuid     ç”Ÿæˆç»„ UUID
     * @param pageNo   é¡µç ï¼Œä»Ž 1 å¼€å§‹
     * @param pageSize æ¯é¡µæ•°é‡
     * @return å¯å°åˆ·çš„袋码列表(按袋序号升序)
     */
    List<MesProBagCodeDO> getPrintableBagCodePageByUuid(String uuid, int pageNo, int pageSize);
    /**
     * åˆ†é¡µèŽ·å¾—æŸæ‰¹æ¬¡ä¸‹å¯å°åˆ·çš„è¢‹ç ï¼ˆåŽå°å¤§æ‰¹é‡å¯¼å‡ºç”¨ï¼‰
     *
     * @param batchId  æ‰¹æ¬¡ç¼–号
     * @param pageNo   é¡µç ï¼Œä»Ž 1 å¼€å§‹
     * @param pageSize æ¯é¡µæ•°é‡
     * @return å¯å°åˆ·çš„袋码列表(按袋序号升序)
     */
    List<MesProBagCodeDO> getPrintableBagCodePageByBatchId(Long batchId, int pageNo, int pageSize);
    /**
     * æ ‡è®°è¢‹ç ä¸ºå·²å‡ºåº“(记录销售出库单编号和出库时间)
     *
     * @param id      è¢‹ç ç¼–号
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeServiceImpl.java
@@ -596,6 +596,26 @@
    }
    @Override
    public Long getPrintableBagCodeCountByUuid(String uuid) {
        return bagCodeMapper.selectPrintableCountByUuid(uuid);
    }
    @Override
    public Long getPrintableBagCodeCountByBatchId(Long batchId) {
        return bagCodeMapper.selectPrintableCountByBatchId(batchId);
    }
    @Override
    public List<MesProBagCodeDO> getPrintableBagCodePageByUuid(String uuid, int pageNo, int pageSize) {
        return bagCodeMapper.selectPrintablePageByUuid(uuid, pageNo, pageSize);
    }
    @Override
    public List<MesProBagCodeDO> getPrintableBagCodePageByBatchId(Long batchId, int pageNo, int pageSize) {
        return bagCodeMapper.selectPrintablePageByBatchId(batchId, pageNo, pageSize);
    }
    @Override
    public void markOutbound(Long id, Long salesId) {
        bagCodeMapper.markOutbound(id, salesId, LocalDateTime.now());
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrExportRunner.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,223 @@
package cn.iocoder.yudao.module.mes.service.pro.qrexport;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.qrexport.MesProQrExportTaskDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.qrexport.MesProQrExportTaskMapper;
import cn.iocoder.yudao.module.mes.enums.pro.MesProQrExportTaskStatusEnum;
import cn.iocoder.yudao.module.mes.framework.qrcode.QrCodeImageUtils;
import cn.iocoder.yudao.module.mes.framework.qrcode.QrLabelInfo;
import cn.iocoder.yudao.module.mes.framework.qrexport.MesProQrExportProperties;
import cn.iocoder.yudao.module.mes.framework.qrexport.MesProQrExportTaskRegistry;
import cn.iocoder.yudao.module.mes.framework.qrexport.SystemCpuLoadMonitor;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.zip.ZipOutputStream;
/**
 * äºŒç»´ç å¯¼å‡ºä»»åŠ¡æ‰§è¡Œå™¨
 * <p>
 * åœ¨åŽå°çº¿ç¨‹å†…把标签逐张写入临时 ZIP,并做三件事:回写进度、响应取消、在 CPU é«˜æ°´ä½æ—¶è®©å‡º CPU。
 * <p>
 * è¿™é‡Œåˆ»æ„ä¸ä¸€æ¬¡æ€§è£…配全部标签,而是按页取、逐张写:单批次可达成千上万张,一次性装配会让堆占用
 * éšå¯¼å‡ºé‡çº¿æ€§å¢žé•¿ï¼Œé‡çº§å†å¾€ä¸Šå°±ä¼š OOM。
 */
@Slf4j
@Component
public class MesProQrExportRunner {
    /**
     * æ¯æ¬¡å‘ ZIP å–数的页大小
     */
    private static final int PAGE_SIZE = 500;
    @Resource
    private MesProQrExportTaskMapper taskMapper;
    @Resource
    private MesProQrExportProperties properties;
    @Resource
    private SystemCpuLoadMonitor cpuMonitor;
    @Resource
    private MesProQrExportTaskRegistry registry;
    /**
     * æ‰§è¡Œå¯¼å‡ºä»»åŠ¡ï¼ˆç”±çº¿ç¨‹æ± è°ƒç”¨ï¼Œå‹¿åœ¨è¯·æ±‚çº¿ç¨‹å†…ç›´æŽ¥è°ƒç”¨ï¼‰
     *
     * @param taskId  ä»»åŠ¡ç¼–å·
     * @param source  æ ‡ç­¾æ•°æ®æº
     */
    public void run(Long taskId, MesProQrLabelSource source) {
        // å–消标记在提交时已注册,这里只取用;取不到说明注册环节被绕过,补一个以保证取消可用
        AtomicBoolean cancelFlag = registry.get(taskId);
        if (cancelFlag == null) {
            cancelFlag = registry.register(taskId);
        }
        File target = null;
        try {
            taskMapper.updateStatus(taskId, MesProQrExportTaskStatusEnum.RUNNING.getStatus(),
                    null, null, null, null);
            target = resolveTargetFile(taskId);
            int written = writeZip(target, source, taskId, cancelFlag);
            if (cancelFlag.get()) {
                deleteQuietly(target);
                taskMapper.updateProgress(taskId, written);
                taskMapper.updateStatus(taskId, MesProQrExportTaskStatusEnum.CANCELED.getStatus(),
                        null, null, null, LocalDateTime.now());
                log.info("[run][导出任务 {} å·²å–消,已生成 {} å¼ ]", taskId, written);
                return;
            }
            // è¿›åº¦æ˜¯æ¯è‹¥å¹²å¼ å›žå†™ä¸€æ¬¡ï¼Œæ”¶å°¾æ—¶å¿…须补一次真实的已生成张数,
            // å¦åˆ™ä»»åŠ¡å·²å®Œæˆå´åœåœ¨ 83% è¿™ç±»ä¸­é—´å€¼ä¸Šï¼Œç•Œé¢ä¸Žè®°å½•都会误导使用者
            taskMapper.updateProgress(taskId, written);
            taskMapper.updateStatus(taskId, MesProQrExportTaskStatusEnum.FINISHED.getStatus(),
                    target.getAbsolutePath(), target.length(), null, LocalDateTime.now());
            log.info("[run][导出任务 {} ç”Ÿæˆå®Œæˆï¼Œå…± {} å¼ ï¼Œæ–‡ä»¶ {} å­—节]",
                    taskId, written, target.length());
        } catch (QrExportCancelledException e) {
            deleteQuietly(target);
            taskMapper.updateStatus(taskId, MesProQrExportTaskStatusEnum.CANCELED.getStatus(),
                    null, null, null, LocalDateTime.now());
            log.info("[run][导出任务 {} å·²å–消]", taskId);
        } catch (Exception e) {
            deleteQuietly(target);
            taskMapper.updateStatus(taskId, MesProQrExportTaskStatusEnum.FAILED.getStatus(),
                    null, null, truncateError(e), LocalDateTime.now());
            log.error("[run][导出任务 {} ç”Ÿæˆå¤±è´¥]", taskId, e);
        } finally {
            registry.unregister(taskId);
            registry.release();
        }
    }
    /**
     * é€é¡µå–标签、逐张写入 ZIP
     *
     * @return å®žé™…写入的标签张数
     */
    private int writeZip(File target, MesProQrLabelSource source, Long taskId,
                         AtomicBoolean cancelFlag) throws IOException {
        long deadline = System.currentTimeMillis()
                + Duration.ofMinutes(Math.max(1, properties.getMaxRuntimeMinutes())).toMillis();
        int written = 0;
        int pageNo = 1;
        try (OutputStream fileOut = new FileOutputStream(target);
             ZipOutputStream zip = new ZipOutputStream(new BufferedOutputStream(fileOut))) {
            while (true) {
                List<QrLabelInfo> page = source.fetchPage(pageNo, PAGE_SIZE);
                if (page.isEmpty()) {
                    break;
                }
                for (QrLabelInfo label : page) {
                    // å–消检查放在每张之前:原子读开销可忽略,换来取消的及时响应
                    if (cancelFlag.get()) {
                        throw new QrExportCancelledException();
                    }
                    QrCodeImageUtils.writeZipEntry(zip, label);
                    written++;
                    if (written % Math.max(1, properties.getProgressCheckInterval()) == 0) {
                        afterEachInterval(taskId, written, deadline);
                    }
                }
                pageNo++;
            }
            zip.finish();
        }
        return written;
    }
    /**
     * æ¯ç”Ÿæˆè‹¥å¹²å¼ åŽçš„例行动作:回写进度、检查运行超时、按 CPU æ°´ä½è®©å‡º CPU
     */
    private void afterEachInterval(Long taskId, int written, long deadline) throws IOException {
        taskMapper.updateProgress(taskId, written);
        if (System.currentTimeMillis() > deadline) {
            throw new IOException("导出任务超过最长运行时间(" + properties.getMaxRuntimeMinutes() + " åˆ†é’Ÿï¼‰");
        }
        throttleIfCpuBusy();
    }
    /**
     * CPU é«˜äºŽé«˜æ°´ä½æ—¶è®©å‡º CPU
     * <p>
     * ä»»åŠ¡ä¸€æ—¦å¼€å§‹å°±å¿…é¡»è·‘å®Œï¼Œåªé æäº¤æ—¶æ‹’ç»æŒ¡ä¸ä½ã€Œæäº¤æ—¶ CPU ä½Žã€è·‘起来把同机业务拖卡」,
     * å› æ­¤ç”Ÿæˆè¿‡ç¨‹ä¸­æŒç»­è®©è·¯ï¼šå¯¼å‡ºå˜æ…¢æ˜¯å¯ä»¥æŽ¥å—的,业务页面卡顿不可接受。
     */
    private void throttleIfCpuBusy() {
        if (!cpuMonitor.isAvailable()) {
            return;
        }
        double load = cpuMonitor.getCpuLoad();
        if (load < properties.getCpuThrottleThreshold()) {
            return;
        }
        try {
            Thread.sleep(Math.max(1L, properties.getCpuThrottleSleepMillis()));
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new QrExportCancelledException();
        }
    }
    /**
     * è§£æžä¸´æ—¶æ–‡ä»¶è·¯å¾„,并确保目录存在
     */
    private File resolveTargetFile(Long taskId) throws IOException {
        File dir = new File(properties.resolveTempDir());
        if (!dir.exists() && !dir.mkdirs() && !dir.exists()) {
            throw new IOException("无法创建二维码导出临时目录:" + dir.getAbsolutePath());
        }
        // æ–‡ä»¶ååªç”¨ä»»åŠ¡ç¼–å·ï¼Œé¿å…ä¸šåŠ¡ç å€¼ä¸­çš„ç‰¹æ®Šå­—ç¬¦è¿›å…¥æ–‡ä»¶ç³»ç»Ÿ
        return new File(dir, "qr-export-" + taskId + ".zip");
    }
    private void deleteQuietly(File file) {
        if (file == null || !file.exists()) {
            return;
        }
        try {
            Files.deleteIfExists(file.toPath());
        } catch (IOException e) {
            log.warn("[deleteQuietly][删除临时文件失败 {}]", file.getAbsolutePath(), e);
        }
    }
    private String truncateError(Exception e) {
        String message = e.getMessage();
        if (message == null || message.isBlank()) {
            message = e.getClass().getSimpleName();
        }
        return message.length() > 500 ? message.substring(0, 500) : message;
    }
    /**
     * å–消信号:生成过程中检测到取消时抛出,由 {@link #run} ç»Ÿä¸€æ”¶å°¾
     */
    static class QrExportCancelledException extends RuntimeException {
        QrExportCancelledException() {
            super("导出任务已取消");
        }
    }
    /**
     * ä¾›æ¸…理任务复用:删除任务遗留的临时文件
     */
    void deleteTaskFile(MesProQrExportTaskDO task) {
        if (task.getFilePath() == null || task.getFilePath().isBlank()) {
            return;
        }
        deleteQuietly(new File(task.getFilePath()));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrExportTaskService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,81 @@
package cn.iocoder.yudao.module.mes.service.pro.qrexport;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.qrexport.MesProQrExportTaskDO;
import cn.iocoder.yudao.module.mes.enums.pro.MesProQrExportBizTypeEnum;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
 * MES äºŒç»´ç å¯¼å‡ºä»»åŠ¡ Service
 * <p>
 * ä»…承担「大批量」这一条路径:数量不超过分界线的导出仍走同步流式下载,不进任务、不排队、不受 CPU é—¸é—¨çº¦æŸï¼Œ
 * ä»¥ä¿è¯å¯¼å‡ ç™¾å¼ æ°¸è¿œç§’出。
 */
public interface MesProQrExportTaskService {
    /**
     * ç»Ÿè®¡å¾…导出的标签张数(供调用方判断走同步还是后台)
     *
     * @param bizType ä¸šåŠ¡ç±»åž‹
     * @param uuid    ç”Ÿæˆç»„ UUID
     * @param batchId ç”Ÿäº§æ‰¹æ¬¡ç¼–号
     * @return æ ‡ç­¾å¼ æ•°
     */
    long countLabels(MesProQrExportBizTypeEnum bizType, String uuid, Long batchId);
    /**
     * æäº¤åŽå°å¯¼å‡ºä»»åŠ¡
     * <p>
     * ä¾æ¬¡ç»è¿‡å¹¶å‘闸门与 CPU é—¸é—¨ï¼Œä»»ä¸€ä¸é€šè¿‡å³æ‹’绝提交并给出原因。
     *
     * @param bizType ä¸šåŠ¡ç±»åž‹
     * @param uuid    ç”Ÿæˆç»„ UUID
     * @param batchId ç”Ÿäº§æ‰¹æ¬¡ç¼–号
     * @return å·²åˆ›å»ºçš„任务
     */
    MesProQrExportTaskDO submit(MesProQrExportBizTypeEnum bizType, String uuid, Long batchId);
    /**
     * èŽ·å¾—å¯¼å‡ºä»»åŠ¡
     *
     * @param id ä»»åŠ¡ç¼–å·
     * @return ä»»åŠ¡ï¼›ä¸å­˜åœ¨æ—¶æŠ›ä¸šåŠ¡å¼‚å¸¸
     */
    MesProQrExportTaskDO getTask(Long id);
    /**
     * å–消导出任务
     * <p>
     * ä»»åŠ¡æ­£åœ¨ç”Ÿæˆæ—¶ä¼šå°½å¿«ä¸­æ–­å¹¶åˆ é™¤ä¸´æ—¶æ–‡ä»¶ï¼›å·²å¤„äºŽç»ˆæ€çš„ä»»åŠ¡ä¸å…è®¸å–æ¶ˆã€‚
     *
     * @param id ä»»åŠ¡ç¼–å·
     */
    void cancel(Long id);
    /**
     * ä¸‹è½½å¯¼å‡ºäº§ç‰©
     * <p>
     * ä¸‹è½½æˆåŠŸåŽç«‹å³åˆ é™¤ä¸´æ—¶ ZIP(避免上 GB çš„临时文件堆积);下载中断则保留至清理任务删除,
     * ä»¥ä¾¿è°ƒç”¨æ–¹åœ¨çŸ­æ—¶é—´å†…重试而不必重新生成。
     *
     * @param id       ä»»åŠ¡ç¼–å·
     * @param response å“åº”
     */
    void download(Long id, HttpServletResponse response) throws IOException;
    /**
     * æ¸…理超期的临时 ZIP æ–‡ä»¶
     *
     * @return æ¸…理的文件数
     */
    int cleanupExpiredFiles();
    /**
     * å›žæ”¶å› è¿›ç¨‹é‡å¯æˆ–超时而残留在非终态的任务
     *
     * @return å›žæ”¶çš„任务数
     */
    int recycleStaleTasks();
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrExportTaskServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,297 @@
package cn.iocoder.yudao.module.mes.service.pro.qrexport;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.qrexport.MesProQrExportTaskDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.qrexport.MesProQrExportTaskMapper;
import cn.iocoder.yudao.module.mes.enums.pro.MesProQrExportBizTypeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProQrExportTaskStatusEnum;
import cn.iocoder.yudao.module.mes.framework.qrexport.MesProQrExportProperties;
import cn.iocoder.yudao.module.mes.framework.qrexport.MesProQrExportTaskRegistry;
import cn.iocoder.yudao.module.mes.framework.qrexport.SystemCpuLoadMonitor;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.util.StreamUtils;
import org.springframework.validation.annotation.Validated;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.RejectedExecutionException;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_BAG_CODE_NOT_EXISTS;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_BATCH_NOT_EXISTS;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_PALLET_NOT_EXISTS;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_SERVER_BUSY;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_TASK_BUSY;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_TASK_FILE_MISSING;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_TASK_NOT_EXISTS;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_TASK_STATUS_INVALID;
/**
 * MES äºŒç»´ç å¯¼å‡ºä»»åŠ¡ Service å®žçް
 */
@Slf4j
@Service
@Validated
public class MesProQrExportTaskServiceImpl implements MesProQrExportTaskService {
    /**
     * éžç»ˆæ€çŠ¶æ€é›†åˆ
     */
    private static final List<Integer> UNFINISHED_STATUSES = List.of(
            MesProQrExportTaskStatusEnum.PENDING.getStatus(),
            MesProQrExportTaskStatusEnum.RUNNING.getStatus());
    /**
     * ç»ˆæ€çŠ¶æ€é›†åˆ
     */
    private static final List<Integer> FINAL_STATUSES = List.of(
            MesProQrExportTaskStatusEnum.FINISHED.getStatus(),
            MesProQrExportTaskStatusEnum.CANCELED.getStatus(),
            MesProQrExportTaskStatusEnum.FAILED.getStatus(),
            MesProQrExportTaskStatusEnum.DOWNLOADED.getStatus());
    @Resource
    private MesProQrExportTaskMapper taskMapper;
    @Resource
    private MesProQrLabelSourceFactory sourceFactory;
    @Resource
    private MesProQrExportRunner runner;
    @Resource
    private MesProQrExportProperties properties;
    @Resource
    private SystemCpuLoadMonitor cpuMonitor;
    @Resource
    private MesProQrExportTaskRegistry registry;
    @Resource(name = "mesQrExportExecutor")
    private ThreadPoolTaskExecutor executor;
    @Override
    public long countLabels(MesProQrExportBizTypeEnum bizType, String uuid, Long batchId) {
        return sourceFactory.count(bizType, uuid, batchId);
    }
    @Override
    public MesProQrExportTaskDO submit(MesProQrExportBizTypeEnum bizType, String uuid, Long batchId) {
        // 1. è§£æžæ•°æ®æºå¹¶ç»Ÿè®¡æ•°é‡ï¼šå‚数不合法或业务数据不存在时,在此直接抛出而不落任务
        MesProQrLabelSource source = sourceFactory.create(bizType, uuid, batchId);
        long total = source.count();
        if (total <= 0) {
            throw exception(resolveEmptyErrorCode(bizType));
        }
        // 2. å¹¶å‘闸门:同一时间只跑一个大批量任务;小批量同步导出不走这里,因此不会被挡住
        if (!registry.tryAcquire()) {
            throw exception(PRO_QR_EXPORT_TASK_BUSY, resolveRunningProgress());
        }
        // ä¸€æ—¦ä»»åŠ¡è¢«äº¤ç»™æ‰§è¡Œå™¨ï¼Œå¹¶å‘è®¸å¯å°±è½¬ç”±æ‰§è¡Œçº¿ç¨‹åœ¨æ”¶å°¾æ—¶å½’è¿˜ï¼›æ­¤å‰çš„ä»»ä½•å¤±è´¥éƒ½å¿…é¡»åœ¨è¿™é‡Œå½’è¿˜
        boolean handedOver = false;
        try {
            // 3. CPU é—¸é—¨ï¼šæœåŠ¡å™¨å·²ç»å¾ˆå¿™æ—¶ä¸å†å åŠ å¯¼å‡ºè´Ÿè½½
            assertCpuAllowSubmit();
            // 4. è½ä»»åŠ¡
            MesProQrExportTaskDO task = MesProQrExportTaskDO.builder()
                    .bizType(bizType.getType())
                    .uuid(uuid)
                    .batchId(batchId)
                    .taskName(source.zipName())
                    .status(MesProQrExportTaskStatusEnum.PENDING.getStatus())
                    .totalCount((int) total)
                    .finishedCount(0)
                    .build();
            taskMapper.insert(task);
            // 5. å…ˆæ³¨å†Œè¿è¡Œæ€å†æŠ•递:避免「已落库但未注册」的窗口被清理任务误回收
            registry.register(task.getId());
            handedOver = true;
            try {
                executor.execute(() -> runner.run(task.getId(), source));
            } catch (RejectedExecutionException e) {
                registry.unregister(task.getId());
                handedOver = false;
                taskMapper.updateStatus(task.getId(), MesProQrExportTaskStatusEnum.FAILED.getStatus(),
                        null, null, "导出线程繁忙,请稍后重试", LocalDateTime.now());
                throw exception(PRO_QR_EXPORT_TASK_BUSY, 0);
            }
            return task;
        } finally {
            if (!handedOver) {
                registry.release();
            }
        }
    }
    @Override
    public MesProQrExportTaskDO getTask(Long id) {
        MesProQrExportTaskDO task = taskMapper.selectById(id);
        if (task == null) {
            throw exception(PRO_QR_EXPORT_TASK_NOT_EXISTS);
        }
        return task;
    }
    @Override
    public void cancel(Long id) {
        MesProQrExportTaskDO task = getTask(id);
        if (isFinalStatus(task.getStatus())) {
            throw exception(PRO_QR_EXPORT_TASK_STATUS_INVALID);
        }
        if (registry.requestCancel(id)) {
            // ç”Ÿæˆçº¿ç¨‹ä¼šè‡ªè¡Œæ”¶å°¾ï¼šåˆ é™¤åŠæˆå“æ–‡ä»¶å¹¶ç½®ä¸ºå·²å–消
            return;
        }
        // ä¸åœ¨æœ¬è¿›ç¨‹è¿è¡Œï¼ˆè¿›ç¨‹é‡å¯åŽçš„æ®‹ç•™ä»»åŠ¡ï¼‰ï¼šç›´æŽ¥æ”¶å°¾
        runner.deleteTaskFile(task);
        taskMapper.updateStatus(id, MesProQrExportTaskStatusEnum.CANCELED.getStatus(),
                null, null, null, LocalDateTime.now());
    }
    @Override
    public void download(Long id, HttpServletResponse response) throws IOException {
        MesProQrExportTaskDO task = getTask(id);
        if (!MesProQrExportTaskStatusEnum.FINISHED.getStatus().equals(task.getStatus())) {
            throw exception(PRO_QR_EXPORT_TASK_STATUS_INVALID);
        }
        File file = resolveTaskFile(task);
        response.setContentType("application/zip");
        response.setHeader("Content-Disposition",
                "attachment;filename=" + HttpUtils.encodeUtf8(task.getTaskName() + ".zip"));
        boolean success = false;
        try (InputStream in = new BufferedInputStream(new FileInputStream(file))) {
            StreamUtils.copy(in, response.getOutputStream());
            response.getOutputStream().flush();
            success = true;
        } finally {
            // ä»…在完整传输后删除;中断时保留,交由清理任务在保留期后删除,便于短时间内重试
            if (success) {
                deleteAfterDownload(task, file);
            }
        }
    }
    @Override
    public int cleanupExpiredFiles() {
        LocalDateTime deadline = LocalDateTime.now()
                .minusMinutes(Math.max(1, properties.getTempFileRetentionMinutes()));
        List<MesProQrExportTaskDO> list = taskMapper.selectExpiredFinalList(FINAL_STATUSES, deadline);
        int count = 0;
        for (MesProQrExportTaskDO task : list) {
            if (StrUtil.isBlank(task.getFilePath())) {
                continue;
            }
            runner.deleteTaskFile(task);
            taskMapper.clearFilePath(task.getId());
            count++;
        }
        return count;
    }
    @Override
    public int recycleStaleTasks() {
        List<MesProQrExportTaskDO> list = taskMapper.selectUnfinishedList(UNFINISHED_STATUSES);
        int count = 0;
        for (MesProQrExportTaskDO task : list) {
            if (registry.isRunning(task.getId())) {
                continue;
            }
            runner.deleteTaskFile(task);
            taskMapper.clearFilePath(task.getId());
            taskMapper.updateStatus(task.getId(), MesProQrExportTaskStatusEnum.FAILED.getStatus(),
                    null, null, "任务因服务重启或异常中断而终止,请重新导出", LocalDateTime.now());
            count++;
        }
        return count;
    }
    // ==================== ç§æœ‰æ–¹æ³• ====================
    /**
     * æ ¡éªŒç³»ç»Ÿ CPU æ˜¯å¦å…è®¸æäº¤å¤§æ‰¹é‡å¯¼å‡º
     * <p>
     * å–不到 CPU æŒ‡æ ‡æ—¶æŒ‰æ”¾è¡Œå¤„理:不能因为读不到监控值就阻断业务。
     */
    private void assertCpuAllowSubmit() {
        if (!cpuMonitor.isAvailable()) {
            return;
        }
        double load = cpuMonitor.getCpuLoad();
        double threshold = properties.getCpuRejectThreshold();
        if (load >= threshold) {
            throw exception(PRO_QR_EXPORT_SERVER_BUSY,
                    Math.round(load * 100), Math.round(threshold * 100));
        }
    }
    /**
     * æ•°æ®ä¸ºç©ºæ—¶çš„错误码:按业务类型给出更准确的提示
     */
    private cn.iocoder.yudao.framework.common.exception.ErrorCode resolveEmptyErrorCode(
            MesProQrExportBizTypeEnum bizType) {
        return switch (bizType) {
            case PALLET_LABEL, PALLET_BUSINESS -> PRO_PALLET_NOT_EXISTS;
            case BATCH_LABEL, BATCH_BUSINESS -> PRO_BATCH_NOT_EXISTS;
            default -> PRO_BAG_CODE_NOT_EXISTS;
        };
    }
    /**
     * æ­£åœ¨è¿è¡Œä»»åŠ¡çš„è¿›åº¦ç™¾åˆ†æ¯”ï¼Œç”¨äºŽã€Œå·²æœ‰ä»»åŠ¡è¿›è¡Œä¸­ã€çš„æç¤º
     */
    private int resolveRunningProgress() {
        List<MesProQrExportTaskDO> tasks = taskMapper.selectUnfinishedList(UNFINISHED_STATUSES);
        return tasks.stream().findFirst().map(this::calcProgress).orElse(0);
    }
    private int calcProgress(MesProQrExportTaskDO task) {
        Integer total = task.getTotalCount();
        if (total == null || total <= 0) {
            return 0;
        }
        int finished = task.getFinishedCount() == null ? 0 : task.getFinishedCount();
        return (int) Math.min(100L, (long) finished * 100 / total);
    }
    private boolean isFinalStatus(Integer status) {
        return status != null && FINAL_STATUSES.contains(status);
    }
    /**
     * å®šä½ä»»åŠ¡çš„ä¸´æ—¶ ZIP,文件缺失时顺手清空路径避免后续反复失败
     */
    private File resolveTaskFile(MesProQrExportTaskDO task) {
        String filePath = task.getFilePath();
        if (StrUtil.isBlank(filePath)) {
            throw exception(PRO_QR_EXPORT_TASK_FILE_MISSING);
        }
        File file = new File(filePath);
        if (!file.isFile()) {
            taskMapper.clearFilePath(task.getId());
            throw exception(PRO_QR_EXPORT_TASK_FILE_MISSING);
        }
        return file;
    }
    /**
     * ä¸‹è½½å®Œæˆå³åˆ é™¤ä¸´æ—¶æ–‡ä»¶ï¼šå•次导出可达上 GB,留着会迅速把磁盘吃满
     */
    private void deleteAfterDownload(MesProQrExportTaskDO task, File file) {
        try {
            Files.deleteIfExists(file.toPath());
        } catch (IOException e) {
            log.warn("[deleteAfterDownload][删除临时文件失败 {}]", file.getAbsolutePath(), e);
        }
        taskMapper.clearFilePath(task.getId());
        taskMapper.updateStatus(task.getId(), MesProQrExportTaskStatusEnum.DOWNLOADED.getStatus(),
                null, null, null, null);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrLabelAssembler.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,130 @@
package cn.iocoder.yudao.module.mes.service.pro.qrexport;
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.pallet.MesProPalletDO;
import cn.iocoder.yudao.module.mes.framework.qrcode.MesProQrLabelBuilder;
import cn.iocoder.yudao.module.mes.framework.qrcode.QrLabelInfo;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.pro.batch.MesProBatchService;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
/**
 * MES äºŒç»´ç æ ‡ç­¾è£…配器
 * <p>
 * ç»Ÿä¸€ã€ŒæŸ¥å‡ºå…³è”数据(批次/品种/责任人)→ ç»„装标签」的逻辑,供同步导出接口与后台导出任务共用,
 * é¿å…åŒä¸€æ¡è£…配规则在多个入口各写一份而出现口径不一致(历史上标签缺项就是这么来的)。
 */
@Component
public class MesProQrLabelAssembler {
    @Resource
    private MesProBatchService batchService;
    @Resource
    private MesMdmItemService mdmItemService;
    @Resource
    private AdminUserApi adminUserApi;
    /**
     * æž„建袋码二维码标签(对外追溯标签,含国标四要素)
     */
    public List<QrLabelInfo> buildBagCodeLabels(List<MesProBagCodeDO> list) {
        if (list.isEmpty()) {
            return Collections.emptyList();
        }
        // 1. èŽ·å¾—å…³è”æ•°æ®ï¼ˆæ‰¹æ¬¡ã€å“ç§ã€è´£ä»»äººï¼‰ï¼›å“ç§é›†åˆå«è¢‹ç è‡ªèº«å“ç§ä¸ŽåŽ†å²æ‰¹æ¬¡å“ç§
        Map<Long, MesProBatchDO> batchMap = convertMap(
                batchService.getBatchList(convertSet(list, MesProBagCodeDO::getBatchId)), MesProBatchDO::getId);
        Set<Long> itemIds = convertSet(list, MesProBagCodeDO::getItemId);
        itemIds.addAll(convertSet(batchMap.values(), MesProBatchDO::getItemId));
        Map<Long, MesMdmItemDO> itemMap = mdmItemService.getItemMap(itemIds);
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
                convertSet(batchMap.values(), MesProBatchDO::getOwnerUserId));
        // 2. æž„建标签
        List<QrLabelInfo> labels = new ArrayList<>(list.size());
        for (MesProBagCodeDO bagCode : list) {
            labels.add(MesProQrLabelBuilder.buildBagCodeLabel(
                    bagCode, batchMap.get(bagCode.getBatchId()), itemMap, userMap));
        }
        return labels;
    }
    /**
     * æž„建袋码业务二维码标签(二维码仅编码内部业务码值)
     */
    public List<QrLabelInfo> buildBagBusinessLabels(List<MesProBagCodeDO> list) {
        return list.stream().map(MesProQrLabelBuilder::buildBagBusinessCodeLabel).toList();
    }
    /**
     * æž„建托盘二维码标签
     */
    public List<QrLabelInfo> buildPalletLabels(List<MesProPalletDO> list) {
        if (list.isEmpty()) {
            return Collections.emptyList();
        }
        Map<Long, MesProBatchDO> batchMap = convertMap(
                batchService.getBatchList(convertSet(list, MesProPalletDO::getBatchId)), MesProBatchDO::getId);
        Map<Long, MesMdmItemDO> itemMap = mdmItemService.getItemMap(
                convertSet(batchMap.values(), MesProBatchDO::getItemId));
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
                convertSet(batchMap.values(), MesProBatchDO::getOwnerUserId));
        List<QrLabelInfo> labels = new ArrayList<>(list.size());
        for (MesProPalletDO pallet : list) {
            labels.add(MesProQrLabelBuilder.buildPalletLabel(
                    pallet, batchMap.get(pallet.getBatchId()), itemMap, userMap));
        }
        return labels;
    }
    /**
     * æž„建托盘业务二维码标签
     */
    public List<QrLabelInfo> buildPalletBusinessLabels(List<MesProPalletDO> list) {
        return list.stream().map(MesProQrLabelBuilder::buildPalletBusinessCodeLabel).toList();
    }
    /**
     * æž„建批次二维码标签:批次图 + è¯¥æ‰¹æ¬¡å…¨éƒ¨æ‰˜ç›˜å›¾
     * <p>
     * æ‰¹æ¬¡ä¸Žæ‰˜ç›˜åŒå±žä¸€æ‰¹ï¼Œå“ç§/责任人共用同一份关联数据。
     */
    public List<QrLabelInfo> buildBatchLabels(MesProBatchDO batch, List<MesProPalletDO> pallets) {
        Map<Long, MesMdmItemDO> itemMap = mdmItemService.getItemMap(
                convertSet(Collections.singletonList(batch), MesProBatchDO::getItemId));
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
                convertSet(Collections.singletonList(batch), MesProBatchDO::getOwnerUserId));
        List<QrLabelInfo> labels = new ArrayList<>(pallets.size() + 1);
        labels.add(MesProQrLabelBuilder.buildBatchLabel(batch, itemMap, userMap));
        for (MesProPalletDO pallet : pallets) {
            labels.add(MesProQrLabelBuilder.buildPalletLabel(pallet, batch, itemMap, userMap));
        }
        return labels;
    }
    /**
     * æž„建批次业务二维码标签:批次业务码 + å…¨éƒ¨æ‰˜ç›˜ä¸šåŠ¡ç 
     */
    public List<QrLabelInfo> buildBatchBusinessLabels(MesProBatchDO batch, List<MesProPalletDO> pallets) {
        List<QrLabelInfo> labels = new ArrayList<>(pallets.size() + 1);
        labels.add(MesProQrLabelBuilder.buildBatchBusinessCodeLabel(batch));
        for (MesProPalletDO pallet : pallets) {
            labels.add(MesProQrLabelBuilder.buildPalletBusinessCodeLabel(pallet));
        }
        return labels;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrLabelSource.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
package cn.iocoder.yudao.module.mes.service.pro.qrexport;
import cn.iocoder.yudao.module.mes.framework.qrcode.QrLabelInfo;
import java.util.List;
/**
 * äºŒç»´ç æ ‡ç­¾æ•°æ®æº
 * <p>
 * åŽå°å¯¼å‡ºä»»åŠ¡æŒ‰é¡µå–æ ‡ç­¾ï¼Œè€Œä¸æ˜¯ä¸€æ¬¡æ€§æŠŠå…¨éƒ¨æ ‡ç­¾è£…é…è¿›å†…å­˜ï¼šå•æ‰¹æ¬¡å¯è¾¾æˆåƒä¸Šä¸‡å¼ ï¼Œ
 * ä¸€æ¬¡æ€§è£…配会随导出量线性占用堆内存,量级再往上就会 OOM。
 */
public interface MesProQrLabelSource {
    /**
     * æ ‡ç­¾æ€»å¼ æ•°ï¼ˆç”¨äºŽè¿›åº¦è®¡ç®—与分流判断)
     */
    long count();
    /**
     * åˆ†é¡µèŽ·å–æ ‡ç­¾
     *
     * @param pageNo   é¡µç ï¼Œä»Ž 1 å¼€å§‹
     * @param pageSize æ¯é¡µæ•°é‡
     * @return è¯¥é¡µæ ‡ç­¾ï¼›è¿”回空列表表示已取完
     */
    List<QrLabelInfo> fetchPage(int pageNo, int pageSize);
    /**
     * ZIP æ–‡ä»¶åï¼ˆä¸å« {@code .zip} åŽç¼€ï¼‰
     */
    String zipName();
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrLabelSourceFactory.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,180 @@
package cn.iocoder.yudao.module.mes.service.pro.qrexport;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.pallet.MesProPalletDO;
import cn.iocoder.yudao.module.mes.enums.pro.MesProPalletStatusEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProQrExportBizTypeEnum;
import cn.iocoder.yudao.module.mes.framework.qrcode.QrLabelInfo;
import cn.iocoder.yudao.module.mes.service.pro.bagcode.MesProBagCodeService;
import cn.iocoder.yudao.module.mes.service.pro.batch.MesProBatchService;
import cn.iocoder.yudao.module.mes.service.pro.pallet.MesProPalletService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_BATCH_NOT_EXISTS;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_PALLET_NOT_EXISTS;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_PARAMS_INVALID;
/**
 * äºŒç»´ç æ ‡ç­¾æ•°æ®æºå·¥åŽ‚
 * <p>
 * æŠŠã€Œä¸šåŠ¡ç±»åž‹ + å¯¼å‡ºå‚数」翻译成统一的数据源,使后台导出任务不必关心标签是怎么来的,
 * ä¹Ÿè®©åŒæ­¥å¯¼å‡ºè·¯å¾„与后台路径共用同一套取数与装配口径。
 */
@Component
public class MesProQrLabelSourceFactory {
    @Resource
    private MesProBagCodeService bagCodeService;
    @Resource
    private MesProPalletService palletService;
    @Resource
    private MesProBatchService batchService;
    @Resource
    private MesProQrLabelAssembler assembler;
    /**
     * åˆ›å»ºæ ‡ç­¾æ•°æ®æºï¼›å‚数不合法时直接抛出业务异常
     *
     * @param bizType ä¸šåŠ¡ç±»åž‹
     * @param uuid    ç”Ÿæˆç»„ UUID(袋码类导出可用)
     * @param batchId ç”Ÿäº§æ‰¹æ¬¡ç¼–号(袋码类可用,其余类型必填)
     */
    public MesProQrLabelSource create(MesProQrExportBizTypeEnum bizType, String uuid, Long batchId) {
        if (bizType == null) {
            throw exception(PRO_QR_EXPORT_PARAMS_INVALID);
        }
        return switch (bizType) {
            case BAG_LABEL -> bagSource(uuid, batchId, false);
            case BAG_BUSINESS -> bagSource(uuid, batchId, true);
            case PALLET_LABEL -> palletSource(requireBatchId(batchId), false);
            case PALLET_BUSINESS -> palletSource(requireBatchId(batchId), true);
            case BATCH_LABEL -> batchSource(requireBatchId(batchId), false);
            case BATCH_BUSINESS -> batchSource(requireBatchId(batchId), true);
        };
    }
    /**
     * ç»Ÿè®¡æ ‡ç­¾å¼ æ•°ï¼ˆåˆ†æµåˆ¤æ–­ç”¨ï¼Œä¸è£…配标签内容)
     */
    public long count(MesProQrExportBizTypeEnum bizType, String uuid, Long batchId) {
        return create(bizType, uuid, batchId).count();
    }
    private Long requireBatchId(Long batchId) {
        if (batchId == null) {
            throw exception(PRO_QR_EXPORT_PARAMS_INVALID);
        }
        return batchId;
    }
    // ==================== å„业务类型的数据源 ====================
    /**
     * è¢‹ç ç±»æ•°æ®æºï¼ˆæ ‡ç­¾ / ä¸šåŠ¡ç ï¼‰
     */
    private MesProQrLabelSource bagSource(String uuid, Long batchId, boolean business) {
        if (StrUtil.isBlank(uuid) && batchId == null) {
            throw exception(PRO_QR_EXPORT_PARAMS_INVALID);
        }
        boolean byUuid = StrUtil.isNotBlank(uuid);
        String zipName = (business ? "袋码业务二维码-" : "袋码二维码-") + (byUuid ? uuid : batchId);
        return new MesProQrLabelSource() {
            @Override
            public long count() {
                return byUuid ? bagCodeService.getPrintableBagCodeCountByUuid(uuid)
                        : bagCodeService.getPrintableBagCodeCountByBatchId(batchId);
            }
            @Override
            public List<QrLabelInfo> fetchPage(int pageNo, int pageSize) {
                List<MesProBagCodeDO> bags = byUuid
                        ? bagCodeService.getPrintableBagCodePageByUuid(uuid, pageNo, pageSize)
                        : bagCodeService.getPrintableBagCodePageByBatchId(batchId, pageNo, pageSize);
                return business ? assembler.buildBagBusinessLabels(bags) : assembler.buildBagCodeLabels(bags);
            }
            @Override
            public String zipName() {
                return zipName;
            }
        };
    }
    /**
     * æ‰˜ç›˜ç±»æ•°æ®æº
     * <p>
     * ä¸€ä¸ªæ‰¹æ¬¡ä¸‹çš„æ‰˜ç›˜æ•°é‡æœ‰ä¸Šç•Œï¼ˆå—每托袋数约束),故一次性取出后按页切片,无需真分页查询。
     */
    private MesProQrLabelSource palletSource(Long batchId, boolean business) {
        List<MesProPalletDO> pallets = palletService.getPalletListByBatchId(batchId);
        if (business) {
            // ä¸šåŠ¡ç å¯¼å‡ºéœ€æŽ’é™¤å·²ä½œåºŸæ‰˜ç›˜ï¼Œé¿å…ä½œåºŸç è¢«å°å‡º
            pallets = pallets.stream()
                    .filter(record -> !MesProPalletStatusEnum.CANCELED.getStatus().equals(record.getStatus()))
                    .toList();
        }
        if (pallets.isEmpty()) {
            throw exception(PRO_PALLET_NOT_EXISTS);
        }
        List<QrLabelInfo> labels = business ? assembler.buildPalletBusinessLabels(pallets)
                : assembler.buildPalletLabels(pallets);
        String zipName = business ? "托盘业务二维码" : "托盘码二维码-" + batchId;
        return fixedSource(labels, zipName);
    }
    /**
     * æ‰¹æ¬¡ç±»æ•°æ®æºï¼šæ‰¹æ¬¡å›¾ + è¯¥æ‰¹æ¬¡å…¨éƒ¨æ‰˜ç›˜å›¾
     */
    private MesProQrLabelSource batchSource(Long batchId, boolean business) {
        MesProBatchDO batch = batchService.getBatch(batchId);
        if (batch == null) {
            throw exception(PRO_BATCH_NOT_EXISTS);
        }
        List<MesProPalletDO> pallets = palletService.getPalletListByBatchId(batchId);
        if (business) {
            pallets = pallets.stream()
                    .filter(record -> !MesProPalletStatusEnum.CANCELED.getStatus().equals(record.getStatus()))
                    .toList();
        }
        List<QrLabelInfo> labels = business ? assembler.buildBatchBusinessLabels(batch, pallets)
                : assembler.buildBatchLabels(batch, pallets);
        String zipName = business ? "批次业务二维码-" + batch.getBusinessCode()
                : "批次二维码-" + StrUtil.blankToDefault(batch.getCode(), batch.getTempCode());
        return fixedSource(labels, zipName);
    }
    /**
     * ç”±å·²è£…配好的标签列表构造数据源(适用于总量有上界的场景)
     */
    private MesProQrLabelSource fixedSource(List<QrLabelInfo> labels, String zipName) {
        return new MesProQrLabelSource() {
            @Override
            public long count() {
                return labels.size();
            }
            @Override
            public List<QrLabelInfo> fetchPage(int pageNo, int pageSize) {
                int from = (pageNo - 1) * pageSize;
                if (from >= labels.size()) {
                    return List.of();
                }
                return labels.subList(from, Math.min(from + pageSize, labels.size()));
            }
            @Override
            public String zipName() {
                return zipName;
            }
        };
    }
}
yudao-server/src/main/resources/application.yaml
@@ -13,6 +13,23 @@
  # æ˜¯å¦å…è®¸äºŒç»´ç ç¼–码纯码值(不带溯源 URL),仅供本地兼容历史码,生产环境必须为 false
  allow-plain-qr-code: false
  # äºŒç»´ç æ‰¹é‡å¯¼å‡ºï¼šå°æ‰¹é‡åŒæ­¥ç§’出,大批量转后台任务(无数量上限)
  qr-export:
    # åŒæ­¥/后台分界线(张)。不超过则同步下载,不排队、不校验 CPU,保证导几百张永远秒出
    sync-threshold: 2000
    # æäº¤åŽå°ä»»åŠ¡æ—¶çš„ç³»ç»Ÿ CPU æ‹’绝阈值(0~1),达到即拒绝新建任务
    cpu-reject-threshold: 0.80
    # ç”Ÿæˆè¿‡ç¨‹ä¸­çš„系统 CPU èŠ‚æµé˜ˆå€¼ï¼ˆ0~1),达到即让出 CPU(降速但不中断任务)
    cpu-throttle-threshold: 0.85
    # åŒæ—¶å…è®¸è¿è¡Œçš„后台导出任务数(仅约束大批量,小批量同步导出不受限)
    max-concurrent-tasks: 1
    # å•个后台任务最长运行时间(分钟),超时自动取消并清理
    max-runtime-minutes: 30
    # ä¸´æ—¶ ZIP ä¿ç•™æ—¶é—´ï¼ˆåˆ†é’Ÿï¼‰ã€‚下载完成后立即删除,此处仅兜底「下载中断/始终未下载」的残留
    temp-file-retention-minutes: 10
    # ä¸´æ—¶ ZIP ç›®å½•,留空则使用 ${java.io.tmpdir}/mes-qr-export
    temp-dir: ''
spring:
  application:
    name: yudao-server