From 140aa6f31f63b88232616196091ba9b9cb028126 Mon Sep 17 00:00:00 2001 From: key Date: Wed, 29 Jul 2026 17:55:12 +0800 Subject: [PATCH] fix: allow 500MB async knowledge uploads --- CHANGELOG.md | 1 + .../src/main/resources/application-prod.yml | 2 +- .../aihr/service/AihrUploadQueueService.java | 18 ++++-------- .../service/AihrUploadQueueServiceTest.java | 9 ++++++ docs/API_INTEGRATION.md | 2 +- docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md | 8 ++++- docs/DEV_SETUP.md | 2 +- .../processing-media-retry-contract.test.ts | 8 +++++ frontend/src/views/knowledge/processing.vue | 29 +++++-------------- 9 files changed, 42 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd354a74..d14bdb2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Unreleased +- **2026-07-29,资料处理中心普通文档/图片单文件上限扩展至 500MB,待发布验证**:管理端批量导入与后端异步队列统一允许文档、图片、视频和 ZIP 单文件 `≤500MB`,沿用生产 Spring/Undertow `500MB/520MB` 传输层配置;ZIP 单个受支持子文件同步放宽至 500MB,仍保留最多 1000 个子文件、解压总量 ≤2GB、禁止嵌套 ZIP 等安全边界。同步 SOP 上传和公司文件不在本次范围,继续保持 100MB。新增前后端契约回归,生产发布与大文件实际解析结果需在发布后补记。 - **2026-07-29,资料处理媒体任务恢复已发布并完成生产验证**:修复历史图片/视频在视觉输入不兼容或未提取到文字时长期停在“等待解析”的问题;图片在送入视觉模型前会按方向纠正、限制像素和体积并统一编码,异步队列只有生成至少一个片段才进入完成态,零片段会保留为可重试失败。新增 `POST /api/knowledge/doc/processing-tasks/{attachmentId}/retry`,可从当前租户原 OSS 对象重新暂存并入队;正式迁移 `aihr_20260729_media_reprocess_mysql8.sql` 增加 `source_attach_id` 和 `(tenant_id, source_attach_id, id)` 索引。生产已备份 schema、后端和完整站点,发布管理端与后端 JAR `5376b67b33b171b5c85e22cd6a2f20bbb0bc527fc50a223603d0aec3f6587bac`,移动 H5 未重发;发布脚本在重启后等待公开端点真实就绪再执行预检,服务为 `active`,根站、租户和移动首页均返回 HTTP 200。原卡住任务 `1133/1175/1308/1405` 从原文件重试为队列 `1185–1188`,四项均完成、各生成一个片段且向量存在;资料处理页显示“待重试 0”、Tika `1028/1028`、可引用资料 `1028`。该证据证明本次资料恢复和定向发布成功,不代表移动 H5 与当前工作树完整匹配,也不替代 Android 真机或正式试点验收。 - **2026-07-29,新增移动端 UI token 与渲染态审计,未发布静态资源**:`audit:ui-tokens` 以 `mobile-uni/src/styles/base.css` 为源扫描颜色、字阶、间距和圆角,`audit:ui-rendered` 在 390×844 的真实 Chrome 中审计 H5 构建的六个默认路由并保存截图。两项均把报告写入 Git 已忽略的 `output/ui-ux/`;本地实跑覆盖 53 个源码文件和 6 个渲染路由,渲染路由均无横向溢出或控制台错误,同时如实记录现有 palette 外颜色、孤立字号和非 token 圆角,不能把“脚本执行成功”解释为视觉债务已清零。 - **2026-07-29,移动端固定码认证与身份失败关闭收口,候选升级为 `0.1.13 (113)`,未发布生产**:生产验证只选择已存在 `app_user` 且双向唯一的在职组织主体;固定码请求、登录、非空项目范围、认证首页、训练历史/画像、五通道、我的反馈、大喇叭、考试、案例/全网只读入口、退出、旧令牌拒绝、等待防刷窗口后的再次获取和重新登录全部通过,应用日志确认两次验证码请求均命中“未真实发送短信”分支。脚本不输出手机号、固定码或令牌,不触发自动注册,不提交反馈、训练、附件、ASR/TTS 或 Agent 请求。后端 `/api/aihr/mobile/me` 与认证首页遇到身份缺失、组织查询异常、无在职身份或后台账号时改为失败关闭;前端角色和岗位加载失败清空缓存并展示明确错误,不再回退普通员工、陈旧岗位或公开首页。干净提交 `af8af2f6` 的统一 RC 已达到 `releaseEligible=true`:移动端 199 项、`ruoyi-aihr` 700 项、类型检查、H5/App、管理端构建、后端打包和 APK 深检全部通过,证据在 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`。DCloud 云端证书 APK 大小 `33,921,543` 字节、SHA-256 `FA2D2A6DC84AE759714E6D16D146C1E1552A0F3F53C046069422012361D1D0C0`,API 35、v2 测试签名、禁止明文 HTTP、包内业务资源/登录法务/身份失败关闭和敏感值深检通过;候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD`,生产只读发布计划通过,现网仍为 `46C99CFF…`。当前双提交交付包 `bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip` 大小 `192,948,093` 字节、SHA-256 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个条目及内部哈希逐项通过;严格门禁会校验固定码模式、5/5 场景、30/30 问题证据、五类负责人签认、每个直通角色至少两名有效处理人、生产场景版本/哈希、五通道正向授权/越权拒绝/一次正式回复,以及受控分发、法务例外和测试签名接受。包内 Go/No-Go 已指向当前包,旧 `ops9c53668c` ZIP 只作历史追溯。机器签认当前准确保持正式场景 `0/5`、问题证据 `0/30`、通道验收 `0/5`、负责人签认 `0/5`、受控分发 `PENDING`,五个处理角色有效绑定均为 `0`。本项未发布后端、H5、数据库或生产静态资源;固定码、登录和重新登录不再列为功能缺口。 diff --git a/backend/ruoyi-admin/src/main/resources/application-prod.yml b/backend/ruoyi-admin/src/main/resources/application-prod.yml index 80c7b5e2..5174c955 100644 --- a/backend/ruoyi-admin/src/main/resources/application-prod.yml +++ b/backend/ruoyi-admin/src/main/resources/application-prod.yml @@ -1,6 +1,6 @@ --- # 临时文件存储位置 避免临时文件被系统清理报错 spring.servlet.multipart.location: /ruoyi/server/temp -# 资料处理异步队列支持视频和 ZIP 单文件至 500MB;具体接口仍按各自业务上限校验。 +# 资料处理中心异步队列支持文档、图片、视频和 ZIP 单文件至 500MB;其他接口仍按各自业务上限校验。 spring.servlet.multipart.max-file-size: 500MB spring.servlet.multipart.max-request-size: 520MB diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java index 8b69e147..c3921de2 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java @@ -56,10 +56,8 @@ public class AihrUploadQueueService { private static final String TENANT_ID = "000000"; private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm:ss"); - private static final long MAX_FILE_BYTES = 100L * 1024 * 1024; - /** 视频单独放宽:培训视频普遍大于文档;更大的走服务端目录导入。 */ - private static final long MAX_VIDEO_BYTES = 500L * 1024 * 1024; - private static final long MAX_ARCHIVE_BYTES = 500L * 1024 * 1024; + /** 资料处理中心统一支持单文件 500 MiB;Spring multipart 请求上限需保持不低于该值。 */ + private static final long MAX_FILE_BYTES = 500L * 1024 * 1024; private static final long MAX_ARCHIVE_UNPACKED_BYTES = 2L * 1024 * 1024 * 1024; private static final int MAX_ARCHIVE_ENTRIES = 1000; private static final int MAX_ERROR_CHARS = 480; @@ -125,14 +123,12 @@ public class AihrUploadQueueService { if (!archive && !AihrSopSeedService.supportedFile(fileName)) { throw new ServiceException("仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片/视频/zip文件"); } - long maxBytes = archive ? MAX_ARCHIVE_BYTES - : AihrVideoService.videoFile(fileName) ? MAX_VIDEO_BYTES : MAX_FILE_BYTES; - if (file.getSize() > maxBytes) { + if (file.getSize() > MAX_FILE_BYTES) { throw new ServiceException(archive ? "ZIP不能超过 500MB" : AihrVideoService.videoFile(fileName) ? "视频不能超过 500MB,更大的请放到导入根目录走服务端导入" - : "文件不能超过 100MB"); + : "文件不能超过 500MB"); } ensureTable(); String batch = sanitizeBatchId(batchId); @@ -494,7 +490,6 @@ public class AihrUploadQueueService { if (!AihrSopSeedService.supportedFile(fileName)) { continue; } - long maxBytes = AihrVideoService.videoFile(fileName) ? MAX_VIDEO_BYTES : MAX_FILE_BYTES; Path extracted = stagingPath(fileName); long entryBytes = 0; try (var output = Files.newOutputStream(extracted)) { @@ -503,7 +498,7 @@ public class AihrUploadQueueService { while ((read = zip.read(buffer)) != -1) { entryBytes += read; unpackedBytes += read; - if (entryBytes > maxBytes || unpackedBytes > MAX_ARCHIVE_UNPACKED_BYTES) { + if (entryBytes > MAX_FILE_BYTES || unpackedBytes > MAX_ARCHIVE_UNPACKED_BYTES) { throw new ArchiveException("ZIP解压后文件超过大小限制"); } output.write(buffer, 0, read); @@ -735,12 +730,11 @@ public class AihrUploadQueueService { || object.getFileName() == null || object.getFileName().isBlank()) { throw new ServiceException("MEDIA_RETRY_SOURCE_UNAVAILABLE: 原始文件不存在"); } - long maxBytes = AihrVideoService.videoFile(source.fileName()) ? MAX_VIDEO_BYTES : MAX_FILE_BYTES; OssClient client = OssFactory.instance(object.getService()); Files.createDirectories(target.getParent()); try (InputStream input = client.getObjectContent(object.getFileName()); OutputStream output = Files.newOutputStream(target)) { - copyWithLimit(input, output, maxBytes); + copyWithLimit(input, output, MAX_FILE_BYTES); } catch (RuntimeException | IOException e) { deleteQuietly(target); throw e; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java index 682f354b..dcc0c16c 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java @@ -8,6 +8,7 @@ import org.mockito.ArgumentCaptor; import java.io.IOException; import java.lang.reflect.Constructor; +import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.nio.charset.Charset; @@ -35,6 +36,14 @@ class AihrUploadQueueServiceTest { @TempDir Path tempDir; + @Test + void asyncQueueAllowsOrdinaryDocumentsUpTo500MiB() throws Exception { + Field field = AihrUploadQueueService.class.getDeclaredField("MAX_FILE_BYTES"); + field.setAccessible(true); + + assertEquals(500L * 1024 * 1024, field.getLong(null)); + } + @Test void expandsLegacyGbkZipAtomicallyAndKeepsDuplicateEntries() throws Exception { Path staging = Files.createDirectories(tempDir.resolve("staging")); diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 761018fa..9aebf401 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -129,7 +129,7 @@ SOP 文档上传第三片已经落最小后端边界: | 解析状态聚合 | `GET /api/knowledge/processing/overview` | 聚合 `aihr_knowledge_attach.status`、fragment 数、embedding 数、`sys_oss.ext1.fileSize`,生成资料处理页指标、分类、任务、链路和事件列表 | | 服务端目录导入(运维) | `POST /api/knowledge/doc/import-local` | JSON `{ directory, category, limit }`;`directory` 只能是 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,默认根目录为 `./.data/import`;逐文件复用上传解析链路,同步执行,仅保留给运维/调试,不在资料处理页暴露 | | 服务端导入任务(运维) | `POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 启动后台目录导入并返回任务;任务写入 `aihr_knowledge_import_task`,调用方可查询总数、成功数、失败数、当前文件和进度,运行中任务可取消;仅保留给运维/调试,不在资料处理页暴露 | -| 批量异步上传 | `POST /api/knowledge/doc/upload-async`(multipart `file`+`category`+`batchId`)、`GET /api/knowledge/doc/upload-items?batchId=`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/processing-tasks/{attachmentId}/retry` | 上传只做暂存(`aihr.upload.staging`,默认 `./.data/staging`)+ 写入 `aihr_knowledge_upload_item`(0待处理/1处理中/2完成/3失败)即返回;`source_attach_id` 记录从原 OSS 重试的附件。视频和 ZIP 单文件 ≤500MB,其他文件 ≤100MB。生产 Spring/Undertow multipart 上限为单文件 500MB、请求 520MB;worker 限制最多 1000 个 ZIP 子文件、解压总量 ≤2GB,拒绝嵌套 ZIP/不安全路径并忽略未知格式;ZIP 文件名优先按 UTF-8 解码,旧版中文 Windows ZIP 自动回退 GBK;同名子文件自动追加编号并以单条批量写入入队;只有片段数大于 0 才记为完成并删除暂存文件,零片段或安全媒体错误保持失败态供重试;卡住 30 分钟(视频 120 分钟)由清扫重置,失败暂存文件保留 72h;同步接口 `POST /api/knowledge/doc/upload` 不支持 ZIP,保留给 SOP 页单文件即时预览 | +| 批量异步上传 | `POST /api/knowledge/doc/upload-async`(multipart `file`+`category`+`batchId`)、`GET /api/knowledge/doc/upload-items?batchId=`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/processing-tasks/{attachmentId}/retry` | 上传只做暂存(`aihr.upload.staging`,默认 `./.data/staging`)+ 写入 `aihr_knowledge_upload_item`(0待处理/1处理中/2完成/3失败)即返回;`source_attach_id` 记录从原 OSS 重试的附件。文档、图片、视频和 ZIP 单文件均 ≤500MB,生产 Spring/Undertow multipart 上限为单文件 500MB、请求 520MB;worker 限制最多 1000 个 ZIP 子文件、单个受支持子文件 ≤500MB、解压总量 ≤2GB,拒绝嵌套 ZIP/不安全路径并忽略未知格式;ZIP 文件名优先按 UTF-8 解码,旧版中文 Windows ZIP 自动回退 GBK;同名子文件自动追加编号并以单条批量写入入队;只有片段数大于 0 才记为完成并删除暂存文件,零片段或安全媒体错误保持失败态供重试;卡住 30 分钟(视频 120 分钟)由清扫重置,失败暂存文件保留 72h;同步接口 `POST /api/knowledge/doc/upload` 不支持 ZIP,保留给 SOP 页单文件即时预览且仍按 100MB 契约执行 | | 视频解析 | 走批量异步上传,支持 `.mp4/.mov/.avi/.mkv/.webm/.m4v`,单文件 ≤500MB、时长 ≤60 分钟 | 依赖服务器 ffmpeg/ffprobe;抽音轨按 5 分钟分段调 asr 模型转写(`[mm:ss]` 时间戳),按 30s 间隔(≤40 帧)抽关键帧调 vision 模型提取画面文字(相邻重复画面去重);两类文本合并后走归类/切片/向量化;无音轨、ASR 无文本且 vision 无结果时以 `MEDIA_NO_TEXT` 进入可重试失败态,不冒充仍在排队;视频同时只加工 1 个 | 模型能力第二片已经落最小后端边界: diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index 6a5e93f1..474c56b8 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -18,7 +18,13 @@ - 已备份线上外置 `application-prod.yml`,并将 Spring/Undertow `max-file-size` 设为 `500MB`、`max-request-size` 设为 `520MB` 后重启 `wygj-aihr.service`; - 服务重启后为 `active`,101MB 的无认证回环 multipart 请求已通过解析层并止于鉴权,未写入业务表或 OSS; -- 此配置只让既有异步队列的视频和 ZIP 兑现其 `≤500MB` 契约;普通文档、同步 SOP 上传和公司文件仍按各自的 `100MB` 业务限制执行。 +- 此配置在当时只让既有异步队列的视频和 ZIP 兑现其 `≤500MB` 契约;普通文档、同步 SOP 上传和公司文件仍按各自的 `100MB` 业务限制执行。 + +资料处理中心当前发布约束: + +- 异步批量上传的文档、图片、视频和 ZIP 统一为单文件 `≤500MB`,需要管理端校验和后端 `AihrUploadQueueService` 同批发布; +- 生产 Spring/Undertow 继续保持单文件 `500MB`、单请求 `520MB`,无须进一步放大传输层; +- 同步 SOP 上传和公司文件不属于本次范围,继续保持 `100MB`。 2026-07-25 已完成最新完整包发布,发布源为 `230dfb4d`: diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 3366565c..6941fa81 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -255,7 +255,7 @@ docker compose -f docker-compose.dev.yml stop - 本地组织 seed 带演示手机号;从 `aihr_org_snapshot` 按角色查询测试账号,分别验证员工端岗位识别和主管端项目范围,不在文档保存具体号码 - 生产移动端回归同样由执行者从 `aihr_org_snapshot` 只读选择账号,不等待人工提供手机号:限定目标岗位、`employment_status='active'`、11 位 `person_phone`,并确认手机号与 `ext_party_id` 双向唯一。账号值只注入当前浏览器进程,不输出到终端、截图、报告或仓库。试点固定码开启时,登录顺序仍为先请求 `/resource/sms/code`、再提交固定码;直接提交固定码会得到 `Captcha invalid`,不应误判为账号或功能故障。 - SOP 知识库支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx` 上传到 MinIO 后解析入库,接口为 `POST /api/knowledge/doc/upload`,单文件上限 100MB;管理端上传请求单独放宽到 180s,PDF 解析/归类/向量化较慢时不要改全局 axios 超时。 -- 资料处理中心异步接口支持 `.zip` 和视频单文件 ≤500MB;生产 Spring/Undertow multipart 配置必须保持 `max-file-size=500MB`、`max-request-size=520MB`(源文件 `backend/ruoyi-admin/src/main/resources/application-prod.yml`,线上外置 `/opt/wygj/config/application-prod.yml`),修改后重启 `wygj-aihr.service`。非视频/ZIP 文件仍由队列限制为 100MB;ZIP 最多 1000 个子文件、解压总量 ≤2GB,不支持嵌套 ZIP。 +- 资料处理中心异步接口支持文档、图片、视频和 `.zip` 单文件 ≤500MB;生产 Spring/Undertow multipart 配置必须保持 `max-file-size=500MB`、`max-request-size=520MB`(源文件 `backend/ruoyi-admin/src/main/resources/application-prod.yml`,线上外置 `/opt/wygj/config/application-prod.yml`),修改后重启 `wygj-aihr.service`。ZIP 最多 1000 个子文件、单个受支持子文件 ≤500MB、解压总量 ≤2GB,不支持嵌套 ZIP。同步 SOP 上传和公司文件仍按各自的 100MB 契约执行。 - 视频(`.mp4/.mov/.avi/.mkv/.webm/.m4v`,≤500MB、≤60 分钟)只走资料处理中心批量导入(异步队列):ffmpeg 抽音轨分段调 asr 转写 + 抽关键帧调 vision 提取画面文字,合并后归类切片入库,片段带 `[mm:ss]` 时间戳;**依赖服务器安装 ffmpeg/ffprobe**(macOS `brew install ffmpeg`,Linux `apt install ffmpeg`) - 服务端目录导入接口为 `POST /api/knowledge/doc/import-local-task`,只读取导入根目录下的相对目录,后台逐文件复用同一上传解析链路;`POST /api/knowledge/doc/import-tasks/{id}/cancel` 可取消运行中任务;`POST /api/knowledge/doc/import-local` 保留为同步调试接口。这组接口仅供运维/调试,不在资料处理页提供入口。 - 启用 `aihr_model_config.category='vector'` 的模型配置后,上传会同步写入片段 embedding,并尽力 upsert 到 Qdrant;模型配置页可查看 Qdrant 维度、点数和片段向量数,并在维度不一致时重建索引;未配置或 Qdrant 不可用时只走 MySQL Fulltext/seed fallback,不影响检索。 diff --git a/frontend/src/utils/processing-media-retry-contract.test.ts b/frontend/src/utils/processing-media-retry-contract.test.ts index 3f02a69b..803a2956 100644 --- a/frontend/src/utils/processing-media-retry-contract.test.ts +++ b/frontend/src/utils/processing-media-retry-contract.test.ts @@ -5,6 +5,14 @@ import { resolve } from 'node:path'; const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); describe('资料处理媒体重试契约', () => { + it('批量队列允许普通文档和图片上传至 500 MiB', () => { + const page = read('src/views/knowledge/processing.vue'); + + expect(page).toContain('const maxFileSize = 500 * 1024 * 1024'); + expect(page).toContain('仅支持非空且 500MB 内的 txt/md/pdf/Word/Excel/PPT/图片/视频/ZIP 文件'); + expect(page).toContain('超过 500MB 的文件'); + }); + it('把零文本媒体展示为待重试并从原文件重新入队', () => { const api = read('src/api/aihr/processing.ts'); const page = read('src/views/knowledge/processing.vue'); diff --git a/frontend/src/views/knowledge/processing.vue b/frontend/src/views/knowledge/processing.vue index e229bcf0..f57cf800 100644 --- a/frontend/src/views/knowledge/processing.vue +++ b/frontend/src/views/knowledge/processing.vue @@ -45,7 +45,9 @@ - +