fix: allow 500MB async knowledge uploads

This commit is contained in:
key
2026-07-29 17:55:12 +08:00
parent 6ba7df863f
commit 140aa6f31f
9 changed files with 42 additions and 37 deletions
+1
View File
@@ -4,6 +4,7 @@
## Unreleased ## 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,资料处理媒体任务恢复已发布并完成生产验证**:修复历史图片/视频在视觉输入不兼容或未提取到文字时长期停在“等待解析”的问题;图片在送入视觉模型前会按方向纠正、限制像素和体积并统一编码,异步队列只有生成至少一个片段才进入完成态,零片段会保留为可重试失败。新增 `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,新增移动端 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、数据库或生产静态资源;固定码、登录和重新登录不再列为功能缺口。 - **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、数据库或生产静态资源;固定码、登录和重新登录不再列为功能缺口。
@@ -1,6 +1,6 @@
--- # 临时文件存储位置 避免临时文件被系统清理报错 --- # 临时文件存储位置 避免临时文件被系统清理报错
spring.servlet.multipart.location: /ruoyi/server/temp spring.servlet.multipart.location: /ruoyi/server/temp
# 资料处理异步队列支持视频和 ZIP 单文件至 500MB;具体接口仍按各自业务上限校验。 # 资料处理中心异步队列支持文档、图片、视频和 ZIP 单文件至 500MB;其他接口仍按各自业务上限校验。
spring.servlet.multipart.max-file-size: 500MB spring.servlet.multipart.max-file-size: 500MB
spring.servlet.multipart.max-request-size: 520MB spring.servlet.multipart.max-request-size: 520MB
@@ -56,10 +56,8 @@ public class AihrUploadQueueService {
private static final String TENANT_ID = "000000"; private static final String TENANT_ID = "000000";
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm:ss"); private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm:ss");
private static final long MAX_FILE_BYTES = 100L * 1024 * 1024; /** 资料处理中心统一支持单文件 500 MiB;Spring multipart 请求上限需保持不低于该值。 */
/** 视频单独放宽:培训视频普遍大于文档;更大的走服务端目录导入。 */ private static final long MAX_FILE_BYTES = 500L * 1024 * 1024;
private static final long MAX_VIDEO_BYTES = 500L * 1024 * 1024;
private static final long MAX_ARCHIVE_BYTES = 500L * 1024 * 1024;
private static final long MAX_ARCHIVE_UNPACKED_BYTES = 2L * 1024 * 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_ARCHIVE_ENTRIES = 1000;
private static final int MAX_ERROR_CHARS = 480; private static final int MAX_ERROR_CHARS = 480;
@@ -125,14 +123,12 @@ public class AihrUploadQueueService {
if (!archive && !AihrSopSeedService.supportedFile(fileName)) { if (!archive && !AihrSopSeedService.supportedFile(fileName)) {
throw new ServiceException("仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片/视频/zip文件"); throw new ServiceException("仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片/视频/zip文件");
} }
long maxBytes = archive ? MAX_ARCHIVE_BYTES if (file.getSize() > MAX_FILE_BYTES) {
: AihrVideoService.videoFile(fileName) ? MAX_VIDEO_BYTES : MAX_FILE_BYTES;
if (file.getSize() > maxBytes) {
throw new ServiceException(archive throw new ServiceException(archive
? "ZIP不能超过 500MB" ? "ZIP不能超过 500MB"
: AihrVideoService.videoFile(fileName) : AihrVideoService.videoFile(fileName)
? "视频不能超过 500MB,更大的请放到导入根目录走服务端导入" ? "视频不能超过 500MB,更大的请放到导入根目录走服务端导入"
: "文件不能超过 100MB"); : "文件不能超过 500MB");
} }
ensureTable(); ensureTable();
String batch = sanitizeBatchId(batchId); String batch = sanitizeBatchId(batchId);
@@ -494,7 +490,6 @@ public class AihrUploadQueueService {
if (!AihrSopSeedService.supportedFile(fileName)) { if (!AihrSopSeedService.supportedFile(fileName)) {
continue; continue;
} }
long maxBytes = AihrVideoService.videoFile(fileName) ? MAX_VIDEO_BYTES : MAX_FILE_BYTES;
Path extracted = stagingPath(fileName); Path extracted = stagingPath(fileName);
long entryBytes = 0; long entryBytes = 0;
try (var output = Files.newOutputStream(extracted)) { try (var output = Files.newOutputStream(extracted)) {
@@ -503,7 +498,7 @@ public class AihrUploadQueueService {
while ((read = zip.read(buffer)) != -1) { while ((read = zip.read(buffer)) != -1) {
entryBytes += read; entryBytes += read;
unpackedBytes += 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解压后文件超过大小限制"); throw new ArchiveException("ZIP解压后文件超过大小限制");
} }
output.write(buffer, 0, read); output.write(buffer, 0, read);
@@ -735,12 +730,11 @@ public class AihrUploadQueueService {
|| object.getFileName() == null || object.getFileName().isBlank()) { || object.getFileName() == null || object.getFileName().isBlank()) {
throw new ServiceException("MEDIA_RETRY_SOURCE_UNAVAILABLE: 原始文件不存在"); 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()); OssClient client = OssFactory.instance(object.getService());
Files.createDirectories(target.getParent()); Files.createDirectories(target.getParent());
try (InputStream input = client.getObjectContent(object.getFileName()); try (InputStream input = client.getObjectContent(object.getFileName());
OutputStream output = Files.newOutputStream(target)) { OutputStream output = Files.newOutputStream(target)) {
copyWithLimit(input, output, maxBytes); copyWithLimit(input, output, MAX_FILE_BYTES);
} catch (RuntimeException | IOException e) { } catch (RuntimeException | IOException e) {
deleteQuietly(target); deleteQuietly(target);
throw e; throw e;
@@ -8,6 +8,7 @@ import org.mockito.ArgumentCaptor;
import java.io.IOException; import java.io.IOException;
import java.lang.reflect.Constructor; import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException; import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.nio.charset.Charset; import java.nio.charset.Charset;
@@ -35,6 +36,14 @@ class AihrUploadQueueServiceTest {
@TempDir @TempDir
Path 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 @Test
void expandsLegacyGbkZipAtomicallyAndKeepsDuplicateEntries() throws Exception { void expandsLegacyGbkZipAtomicallyAndKeepsDuplicateEntries() throws Exception {
Path staging = Files.createDirectories(tempDir.resolve("staging")); Path staging = Files.createDirectories(tempDir.resolve("staging"));
+1 -1
View File
@@ -129,7 +129,7 @@ SOP 文档上传第三片已经落最小后端边界:
| 解析状态聚合 | `GET /api/knowledge/processing/overview` | 聚合 `aihr_knowledge_attach.status`、fragment 数、embedding 数、`sys_oss.ext1.fileSize`,生成资料处理页指标、分类、任务、链路和事件列表 | | 解析状态聚合 | `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` | 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/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 个 | | 视频解析 | 走批量异步上传,支持 `.mp4/.mov/.avi/.mkv/.webm/.m4v`,单文件 ≤500MB、时长 ≤60 分钟 | 依赖服务器 ffmpeg/ffprobe;抽音轨按 5 分钟分段调 asr 模型转写(`[mm:ss]` 时间戳),按 30s 间隔(≤40 帧)抽关键帧调 vision 模型提取画面文字(相邻重复画面去重);两类文本合并后走归类/切片/向量化;无音轨、ASR 无文本且 vision 无结果时以 `MEDIA_NO_TEXT` 进入可重试失败态,不冒充仍在排队;视频同时只加工 1 个 |
模型能力第二片已经落最小后端边界: 模型能力第二片已经落最小后端边界:
+7 -1
View File
@@ -18,7 +18,13 @@
- 已备份线上外置 `application-prod.yml`,并将 Spring/Undertow `max-file-size` 设为 `500MB`、`max-request-size` 设为 `520MB` 后重启 `wygj-aihr.service`; - 已备份线上外置 `application-prod.yml`,并将 Spring/Undertow `max-file-size` 设为 `500MB`、`max-request-size` 设为 `520MB` 后重启 `wygj-aihr.service`;
- 服务重启后为 `active`,101MB 的无认证回环 multipart 请求已通过解析层并止于鉴权,未写入业务表或 OSS; - 服务重启后为 `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`: 2026-07-25 已完成最新完整包发布,发布源为 `230dfb4d`:
+1 -1
View File
@@ -255,7 +255,7 @@ docker compose -f docker-compose.dev.yml stop
- 本地组织 seed 带演示手机号;从 `aihr_org_snapshot` 按角色查询测试账号,分别验证员工端岗位识别和主管端项目范围,不在文档保存具体号码 - 本地组织 seed 带演示手机号;从 `aihr_org_snapshot` 按角色查询测试账号,分别验证员工端岗位识别和主管端项目范围,不在文档保存具体号码
- 生产移动端回归同样由执行者从 `aihr_org_snapshot` 只读选择账号,不等待人工提供手机号:限定目标岗位、`employment_status='active'`、11 位 `person_phone`,并确认手机号与 `ext_party_id` 双向唯一。账号值只注入当前浏览器进程,不输出到终端、截图、报告或仓库。试点固定码开启时,登录顺序仍为先请求 `/resource/sms/code`、再提交固定码;直接提交固定码会得到 `Captcha invalid`,不应误判为账号或功能故障。 - 生产移动端回归同样由执行者从 `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 超时。 - 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`) - 视频(`.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` 保留为同步调试接口。这组接口仅供运维/调试,不在资料处理页提供入口。 - 服务端目录导入接口为 `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,不影响检索。 - 启用 `aihr_model_config.category='vector'` 的模型配置后,上传会同步写入片段 embedding,并尽力 upsert 到 Qdrant;模型配置页可查看 Qdrant 维度、点数和片段向量数,并在维度不一致时重建索引;未配置或 Qdrant 不可用时只走 MySQL Fulltext/seed fallback,不影响检索。
@@ -5,6 +5,14 @@ import { resolve } from 'node:path';
const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
describe('资料处理媒体重试契约', () => { 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('把零文本媒体展示为待重试并从原文件重新入队', () => { it('把零文本媒体展示为待重试并从原文件重新入队', () => {
const api = read('src/api/aihr/processing.ts'); const api = read('src/api/aihr/processing.ts');
const page = read('src/views/knowledge/processing.vue'); const page = read('src/views/knowledge/processing.vue');
+8 -21
View File
@@ -45,7 +45,9 @@
</ul> </ul>
<el-table v-if="uploadBatch.length" :data="uploadBatch" size="small" max-height="260" class="batch-table"> <el-table v-if="uploadBatch.length" :data="uploadBatch" size="small" max-height="260" class="batch-table">
<el-table-column prop="fileName" label="文件名" min-width="220" show-overflow-tooltip /> <el-table-column prop="fileName" label="文件名" min-width="220" show-overflow-tooltip />
<el-table-column label="目标空间" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.spaceCodes?.join('、') || '-' }}</template></el-table-column> <el-table-column label="目标空间" min-width="180" show-overflow-tooltip
><template #default="{ row }">{{ row.spaceCodes?.join('、') || '-' }}</template></el-table-column
>
<el-table-column label="状态" width="96"> <el-table-column label="状态" width="96">
<template #default="{ row }"> <template #default="{ row }">
<el-tag size="small" :type="batchItemTagType(row.status)">{{ row.statusLabel }}</el-tag> <el-tag size="small" :type="batchItemTagType(row.status)">{{ row.statusLabel }}</el-tag>
@@ -151,13 +153,7 @@
<el-table-column prop="updatedAt" label="更新时间" width="138" /> <el-table-column prop="updatedAt" label="更新时间" width="138" />
<el-table-column label="操作" width="86" fixed="right"> <el-table-column label="操作" width="86" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button v-if="canRetryProcessingTask(row)" link type="danger" :loading="taskRetryingId === row.id" @click="retryTask(row)">
v-if="canRetryProcessingTask(row)"
link
type="danger"
:loading="taskRetryingId === row.id"
@click="retryTask(row)"
>
重试 重试
</el-button> </el-button>
<span v-else>-</span> <span v-else>-</span>
@@ -281,11 +277,7 @@ import { listPersonalPublishRequests, reviewPersonalPublishRequest, type Persona
const acceptTypes = '.txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v,.zip'; const acceptTypes = '.txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v,.zip';
const supportedPattern = /\.(txt|md|markdown|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|webp|bmp|mp4|mov|avi|mkv|webm|m4v|zip)$/i; const supportedPattern = /\.(txt|md|markdown|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|webp|bmp|mp4|mov|avi|mkv|webm|m4v|zip)$/i;
const videoPattern = /\.(mp4|mov|avi|mkv|webm|m4v)$/i; const maxFileSize = 500 * 1024 * 1024;
const archivePattern = /\.zip$/i;
const maxFileSize = 100 * 1024 * 1024;
const maxVideoSize = 500 * 1024 * 1024;
const maxArchiveSize = 500 * 1024 * 1024;
const browserBatchWarnCount = 20; const browserBatchWarnCount = 20;
const browserBatchWarnSize = 200 * 1024 * 1024; const browserBatchWarnSize = 200 * 1024 * 1024;
@@ -519,18 +511,13 @@ async function uploadFiles(files: File[]) {
ElMessage.warning('请先选择至少一个可管理的知识空间'); ElMessage.warning('请先选择至少一个可管理的知识空间');
return; return;
} }
const readyFiles = files.filter( const readyFiles = files.filter((file) => supportedPattern.test(file.name) && file.size > 0 && file.size <= maxFileSize);
(file) =>
supportedPattern.test(file.name) &&
file.size > 0 &&
file.size <= (videoPattern.test(file.name) ? maxVideoSize : archivePattern.test(file.name) ? maxArchiveSize : maxFileSize)
);
if (readyFiles.length === 0) { if (readyFiles.length === 0) {
ElMessage.warning('仅支持非空的 txt/md/pdf/Word/Excel/PPT/图片(100MB内)、视频或 ZIP(500MB内)文件'); ElMessage.warning('仅支持非空且 500MB 内的 txt/md/pdf/Word/Excel/PPT/图片/视频/ZIP 文件');
return; return;
} }
if (readyFiles.length < files.length) { if (readyFiles.length < files.length) {
ElMessage.warning(`已跳过 ${files.length - readyFiles.length} 个不支持、空文件或超限文件(文档/图片 100MB、视频/ZIP 500MB)`); ElMessage.warning(`已跳过 ${files.length - readyFiles.length} 个不支持、空文件或超过 500MB 的文件`);
} }
const totalSize = readyFiles.reduce((sum, file) => sum + file.size, 0); const totalSize = readyFiles.reduce((sum, file) => sum + file.size, 0);