--- title: "06-知识库系统的入口工程" created: 2026-05-18 aliases: - 知识库系统的入口工程 tags: - 项目 --- # 知识库系统的入口工程 把“上传接口与文档主记录创建”这一块当成整个知识库系统的**入口工程**来学。它不是单纯把文件传上来,而是完成一件更重要的事:**把一份用户上传的原始文件,登记成系统可追踪、可异步处理、可查询状态的文档资产,并投递后续解析任务。** ### **一、先建立整体认知:这个接口在项目里解决什么问题?** 在项目里,知识库不是用户上传一个 PDF 就立刻可以问答。真正可问答之前,文档还要经历很多阶段:格式解析、内容抽取、切块策略推荐、用户确认、父子块切分、向量化、写入向量库、写入 Elasticsearch、构建 Neo4j 文档结构图谱等。 所以“上传接口与文档主记录创建”这一块的核心职责不是做完所有事情,而是完成上传阶段的初始化工作。 它主要做六件事: 1. 接收用户上传的文件和元信息; 2. 校验文件是否合法; 3. 把原始文件保存到 MinIO; 4. 在 MySQL 中创建文档主记录; 5. 创建第一条异步任务记录和任务日志; 6. 事务提交后发送 Kafka 消息,启动后续异步处理流水线。 你可以把它理解为:**用户上传文件后,系统先把这份文件“收进仓库”,再给它创建一张“身份证”和一张“工单”,最后通知后台流水线开始处理。** --- ### **二、这块在整体架构中的位置** 项目整体链路可以简化成这样: ![[image-09d6ba6d.png]] 现在我们只学到这里: ![[image-06620593.png]] 这部分是整个知识库生命周期的第一环,质量非常关键。因为后面的解析、切块、向量化、检索、问答,都依赖这里创建出来的 `documentId` 和任务状态。 ### **三、先看接口入口:Controller 只负责接收请求** 上传接口在 `DocumentManageController` 里,大概是这样的: ```java @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse upload( @RequestPart("file") MultipartFile file, @Valid @RequestPart(value = "meta", required = false) DocumentUploadDto dto) { return ApiResponse.ok( documentManageService.upload(file, dto == null ? new DocumentUploadDto() : dto) ); } ``` 这里要重点理解三个点。 第一,接口使用的是 `multipart/form-data`,不是普通 JSON。因为它同时要上传二进制文件和结构化元信息。 其中: ```text @RequestPart("file") MultipartFile file ``` 负责接收真实文件,例如 PDF、Word、PPT、Excel、Markdown 等。 而: ```text @RequestPart(value = "meta", required = false) DocumentUploadDto dto ``` 负责接收文档的业务元信息,例如文档名称、知识范围、业务分类、标签、操作人 ID 等。 第二,`meta` 是可选的。也就是说,前端可以只传文件,不传元信息。Controller 里做了一个兜底: ```text dto == null ? new DocumentUploadDto() : dto ``` 这样 Service 层就不用反复判断 `dto == null`,代码会更干净。 第三,Controller 不做业务逻辑。它只做参数绑定,然后把请求交给 Service。这个设计是很标准的分层思想: ```text Controller:接收请求、绑定参数、返回响应 Service:编排业务流程 StorageService:处理对象存储 Mapper:处理数据库持久化 KafkaProducer:处理消息投递 ``` > 上传接口采用 multipart/form-data,同时接收文件二进制和可选元信息。Controller 层只负责参数绑定和空 DTO 兜底,实际的文件校验、对象存储、文档入库、任务创建和 Kafka 投递都下沉到 Service 层完成,保证控制器职责单一。 ### **四、Service 第一阶段:文件校验** Service 进入后,第一步一定是校验文件。 核心代码逻辑是: ```java if (file == null || file.isEmpty()) { throw new SuperAgentFrameException(...); } String originalFileName = file.getOriginalFilename(); if (StrUtil.isBlank(originalFileName)) { throw new SuperAgentFrameException(...); } DocumentFileTypeEnum fileType = DocumentFileTypeEnum.fromFileName(originalFileName); if (fileType == null) { throw new SuperAgentFrameException(...); } ``` 这里做了三层校验。 第一层是文件内容不能为空: ```text file == null || file.isEmpty() ``` 这可以拦住两类问题:一种是请求里根本没传文件,另一种是 multipart 字段存在,但文件内容为空。 第二层是原始文件名不能为空。 原始文件名不只是展示用,它还用于后续识别文件类型。例如系统会根据文件名后缀判断是 PDF、DOCX、PPTX、XLSX 还是 Markdown。 第三层是文件类型必须受支持。 ```text DocumentFileTypeEnum.fromFileName(originalFileName) ``` 这里一般会根据后缀名匹配枚举。如果匹配不上,就说明系统暂时不支持这种文件,直接拒绝上传。 这一块的核心思想是:**越靠前发现非法输入,系统成本越低。** 如果不校验,后面可能已经上传到 MinIO、写入数据库,甚至投递 Kafka 了,才发现文件无法解析,这样清理成本就很高。 ### **五、读取文件字节并生成 documentId** 通过校验后,系统会把 `MultipartFile` 一次性读成字节数组: ```java byte[] fileBytes = getFileBytes(file); Long documentId = uidGenerator.getUid(); ``` `getFileBytes()` 里面本质上是: ```java return file.getBytes(); ``` 这样做有两个目的。 第一,后续上传 MinIO 需要文件内容。 第二,文档主记录里要保存文件大小: ```java document.setFileSize((long) fileBytes.length); ``` 所以先读成 `byte[]`,后面可以复用。 然后生成 `documentId`。这个 ID 非常重要,它会贯穿整个文档生命周期: ```text MinIO 对象路径里有 documentId 文档主表主键是 documentId 任务表关联 documentId 任务日志关联 documentId Kafka 消息携带 documentId 后续解析、切块、索引都靠 documentId 串联 ``` 所以这里不是等数据库自增生成 ID,而是服务端提前通过分布式 ID 生成器生成。 在项目描述里,分布式 ID 生成器是基于雪花算法、Lua 和 Redis 实现的。这里文档里提到用 `uidGenerator.getUid()`,本质目标都是一样的:**在上传入口阶段提前生成全局唯一 ID,方便后续跨系统、跨表、跨消息链路追踪。** --- ### **六、上传原始文件到 MinIO** 接下来系统会先把原始文件上传到对象存储: ```text StoredObjectInfo storedObjectInfo = storageService.uploadOriginalFile( documentId, originalFileName, fileBytes, file.getContentType() ); ``` 这里调用的是 `MinioDocumentStorageService.uploadOriginalFile()`。 它会拼接一个对象路径: ```text String objectName = properties.getMinio().getObjectPrefix() + "/" + documentId + "/" + System.currentTimeMillis() + "-" + originalFileName; ``` 路径大概长这样: ```text doc-original/123456789/1714000000000-企业制度.pdf ``` 这个路径设计有几个好处。 第一,按 `documentId` 分目录,方便定位某一份文档的所有相关文件。 第二,前面加时间戳,避免同名文件覆盖。 第三,保留原始文件名,排查问题时更直观。 然后调用真正的上传方法: ```text minioClient.putObject(...) ``` 上传之前还会检查 bucket 是否存在: ```java if (!bucketExists()) { minioClient.makeBucket(...); } ``` 这个设计适合新环境部署。比如测试环境第一次启动时,MinIO 里可能还没有对应的 bucket,系统会自动创建,减少人工初始化步骤。 上传成功后,会返回一个 `StoredObjectInfo`,里面有: ```text bucketName:桶名 objectName:对象路径 objectUrl:完整访问地址 ``` 这三个值后面会保存到文档主表。 这里有一个设计点需要注意:**为什么先上传 MinIO,再写数据库?** 因为文档主表需要保存文件的存储位置。如果不先上传,就拿不到 `bucketName`、`objectName`、`objectUrl`。所以流程上必须先拿到对象存储信息,再构建文档主记录。 不过这样也会带来一个边界问题:如果 MinIO 上传成功,但后面数据库入库失败,MinIO 里可能会留下一个“孤儿文件”。生产项目中一般会通过补偿任务、定时清理或失败回滚清理来处理。 ### **七、构建文档主记录 SuperAgentDocument** 文件上传成功后,系统开始构建文档主记录: ```java SuperAgentDocument document = new SuperAgentDocument(); document.setId(documentId); document.setDocumentName(...); document.setOriginalFileName(originalFileName); document.setFileType(fileType.getCode()); document.setMimeType(file.getContentType()); document.setFileSize((long) fileBytes.length); document.setStorageType(DocumentStorageTypeEnum.MINIO.getCode()); document.setBucketName(storedObjectInfo.getBucketName()); document.setObjectName(storedObjectInfo.getObjectName()); document.setObjectUrl(storedObjectInfo.getObjectUrl()); ``` 这一步是在内存中组装对象,还没有真正写数据库。 文档主记录可以理解为这份文档的“资产档案”。它记录了: ```text 这份文档叫什么? 原始文件名是什么? 文件类型是什么? MIME 类型是什么? 文件大小是多少? 存储在哪里? 当前解析状态是什么? 后续策略状态是什么? 索引状态是什么? 属于哪个知识范围? 属于哪个业务分类? 有哪些标签? ``` 其中,文档名称的取值逻辑很重要: ```text document.setDocumentName( StrUtil.isNotBlank(dto.getDocumentName()) ? dto.getDocumentName() : originalFileName ); ``` 也就是说,如果前端传了展示名称,就用前端传的;否则用原始文件名。 比如用户上传的文件叫: ```text report_final_v3_2024_04_01.pdf ``` 但前端传了: ```text 2024 年一季度经营分析报告 ``` 那么系统展示时就用后者。这样对用户更友好。 ### **八、三个核心状态:parseStatus、strategyStatus、indexStatus** 文档主记录里最关键的是三个状态字段: ```java document.setParseStatus(DocumentParseStatusEnum.PARSING.getCode()); document.setStrategyStatus(DocumentStrategyStatusEnum.WAIT_RECOMMEND.getCode()); document.setIndexStatus(DocumentIndexStatusEnum.WAIT_BUILD.getCode()); ``` 这三个字段代表文档后续处理链路的三个阶段。 #### **1. parseStatus:解析状态** 上传成功后设置为: ```text PARSING ``` 它表示:文档已经进入解析链路,等待异步任务处理。 注意,这里虽然叫 `PARSING`,但不代表当前接口已经完成了解析。它更准确的含义是:**已进入解析中或待解析队列。** #### **2. strategyStatus:切块策略状态** 初始值是: ```text WAIT_RECOMMEND ``` 表示系统还没有推荐切块策略。 后续 Tika 解析完文档文本和结构后,系统才会根据文档类型、结构质量、内容特征推荐切块策略,例如结构化切块、递归切块、语义切块、LLM 智能切块等。 #### **3. indexStatus:索引状态** 初始值是: ```text WAIT_BUILD ``` 表示还没有构建检索索引。 后续用户确认切块策略后,系统才会进行父子块切分、向量化,然后写入 PGVector、Milvus、Elasticsearch、Neo4j 等存储系统。 所以这三个状态其实对应你项目里的异步处理主线: ![[image-67def917.png]] 这几个状态对于前端非常重要。前端可以根据这些状态展示: ```text 上传成功 解析中 等待确认切块策略 索引构建中 处理完成 处理失败 ``` ### **九、业务元信息:知识范围、业务分类、标签** 文档主记录还会保存上传时传入的业务信息: ```java document.setKnowledgeScopeCode(StrUtil.trimToNull(dto.getKnowledgeScopeCode())); document.setKnowledgeScopeName(StrUtil.trimToNull(dto.getKnowledgeScopeName())); document.setBusinessCategory(StrUtil.trimToNull(dto.getBusinessCategory())); document.setDocumentTags(StrUtil.trimToNull(dto.getDocumentTags())); document.setStatus(BusinessStatus.YES.getCode()); ``` 这些字段看起来只是普通元数据,但在项目里很重要。 因为系统设计了三级知识路由漏斗: ```text 领域 → 主题 → 文档 ``` 那么文档上传时保存的知识范围、业务分类、标签,就会成为后续路由、筛选、召回和后台管理的重要依据。 比如一个文档属于: ```text 知识范围:人力资源 业务分类:员工制度 标签:考勤、假期、薪酬 ``` 后面用户问: ```text 年假怎么计算? ``` 系统就可以更容易把问题路由到“人力资源 / 员工制度”相关文档,而不是全库盲搜。 --- ### **十、构建任务记录 SuperAgentDocumentTask** 文档主记录构建完成后,系统还会创建一条任务记录: ```java Long taskId = uidGenerator.getUid(); SuperAgentDocumentTask task = new SuperAgentDocumentTask(); task.setId(taskId); task.setDocumentId(documentId); task.setTaskType(DocumentTaskTypeEnum.PARSE_ROUTE.getCode()); task.setTaskStatus(DocumentTaskStatusEnum.NEW.getCode()); task.setCurrentStage(DocumentTaskStageEnum.FILE_UPLOAD.getCode()); task.setTriggerSource(resolveTriggerSource(operatorId)); task.setRetryCount(0); task.setStatus(BusinessStatus.YES.getCode()); ``` 这里的任务可以理解为一张“工单”。 文档主记录表示: ```text 这份文档是什么? ``` 任务记录表示: ```text 接下来要对这份文档做什么? ``` 上传完成后创建的第一类任务是: ```text PARSE_ROUTE ``` 也就是“解析路由任务”。 它负责把文档交给后续异步链路,让消费方决定下一步怎么解析、怎么推荐切块策略、怎么更新状态。 任务初始状态是: ```text NEW ``` 表示刚创建,还未被消费。 当前阶段是: ```text FILE_UPLOAD ``` 表示它是从文件上传阶段产生的任务。 触发来源通过 `operatorId` 判断: ```java private Integer resolveTriggerSource(Long operatorId) { return operatorId == null ? DocumentTriggerSourceEnum.SYSTEM.getCode() : DocumentTriggerSourceEnum.USER.getCode(); } ``` 如果有操作人 ID,说明是用户主动上传;如果没有,则认为是系统触发。 这里还有一个小设计: ```java private Long parseOptionalLong(String rawValue) { if (StrUtil.isBlank(rawValue)) { return null; } try { Long value = Long.valueOf(rawValue.trim()); return value > 0 ? value : null; } catch (NumberFormatException exception) { return null; } } ``` `operatorId` 是宽松解析的。空字符串、非数字、0、负数都当成 `null`,不会直接让上传失败。 这个设计体现了一个原则:**可选字段不要轻易阻断主流程。** --- ### **十一、最关键的设计:TransactionTemplate 手动控制事务** 接下来是这块最值得学习的地方。 系统没有直接在 `upload()` 方法上加: ```text @Transactional ``` 而是用了: ```java DocumentUploadVo uploadVo = transactionTemplate.execute(status -> { documentMapper.insert(document); taskMapper.insert(task); taskLogService.saveLog(...); return new DocumentUploadVo(...); }); ``` 也就是说,只有这几步放在数据库事务里: ```text 插入文档主表 插入任务表 插入任务日志 组装返回结果 ``` Kafka 发送不在事务里,而是在事务提交之后执行。 为什么要这样设计? 因为如果整个 `upload()` 方法都加 `@Transactional`,然后在方法最后发送 Kafka,可能出现一个问题: ```text 数据库事务还没提交 Kafka 消息已经发出 消费方马上消费消息 消费方根据 documentId 查询数据库 结果查不到文档记录 ``` 这就是典型的“消息先于事务可见”的问题。 所以这里用 `TransactionTemplate` 精确控制事务边界: ```text transactionTemplate.execute() 内部:只做数据库写入 execute() 正常返回:说明数据库事务已经提交 事务提交之后:再发送 Kafka ``` 这个设计非常重要,可以作为项目亮点来讲。 > 上传链路中,没有简单使用方法级 `@Transactional`,而是通过 `TransactionTemplate` 手动收窄事务边界。文档主表、任务表和任务日志在同一个事务内提交,事务提交成功后再发送 Kafka 消息,避免消费者收到消息后查询不到数据库记录的问题。 ### **十二、任务日志:为什么上传阶段也要记日志?** 在事务里除了插入文档和任务,还会记录一条任务日志: ```text taskLogService.saveLog( taskId, documentId, DocumentTaskStageEnum.FILE_UPLOAD.getCode(), DocumentTaskEventTypeEnum.COMPLETE.getCode(), DocumentLogLevelEnum.INFO.getCode(), resolveOperatorType(operatorId), operatorId, "文件上传完成,已进入解析与策略推荐队列。", Map.of("originalFileName", originalFileName, "fileSize", fileBytes.length) ); ``` 这条日志记录的是: ```text 哪个任务? 哪个文档? 当前处于哪个阶段? 发生了什么事件? 日志级别是什么? 谁触发的? 附带了哪些上下文? ``` 这对你的项目“全链路可观测”非常重要。 因为文档处理是异步的,用户上传后并不会马上得到最终结果。如果后续解析失败、切块失败、索引失败,排查问题时就需要知道: ```text 文件是否上传成功? 任务是否创建成功? Kafka 是否投递成功? 消费方是否开始处理? Tika 是否解析失败? 切块策略是否推荐成功? 索引是否写入成功? ``` 所以从上传阶段就记录任务日志,可以形成完整时间线。 ### **十三、事务提交后发送 Kafka 消息** 数据库事务提交成功之后,系统发送 Kafka 消息: ```text kafkaProducer.sendParseRoute( new DocumentParseRouteMessage(documentId, taskId) ); ``` 消息体很简单: ```java public class DocumentParseRouteMessage { private Long documentId; private Long taskId; } ``` 为什么只发 `documentId` 和 `taskId`? 因为详细信息已经保存在数据库了。Kafka 消息只需要告诉消费方: ```text 哪份文档? 哪个任务? ``` 消费方拿到这两个 ID 后,再去数据库查询完整上下文。 这样做有几个好处。 第一,消息体小,不容易因为字段变更导致兼容问题。 第二,数据库是事实来源,消费方读取的是最新状态。 第三,任务状态和日志都可以围绕 `taskId` 做追踪。 发送时,topic 名称会拼接环境前缀: ```text SpringUtil.getPrefixDistinctionName() + "-" + properties.getKafka().getParseTopic() ``` 这样 dev、test、prod 不同环境的消息不会混在一起。 消息 key 使用: ```text String.valueOf(message.getDocumentId()) ``` 这样同一份文档相关的消息会尽量进入同一个 partition,有利于保证同一文档任务的顺序性。 底层发送方法中还用了: ```java kafkaTemplate.send(topic, key, payload).get(); ``` 也就是说,它不是“发出去就不管”,而是同步等待 broker 确认。 好处是:如果 Kafka 发送失败,上传接口会直接报错,而不是告诉用户成功但后台根本没启动处理。 不过这里也有一个边界问题:如果数据库事务已经提交,但 Kafka 发送失败,数据库里已经有文档和任务记录,但异步链路没有启动。 所以生产上通常要配合补偿机制,比如: ```text 定时扫描 parseStatus = PARSING 但长时间没有任务进展的文档 重新投递 Kafka 消息 或者将任务状态标记为待重试 ``` 这个权衡比“消息发了但数据库还没提交”更容易处理。 --- ### **十四、最后返回上传结果** 接口最后返回: ```text return uploadVo; ``` `DocumentUploadVo` 包含: ```text private Long documentId; private Long taskId; private String documentName; private Integer parseStatus; private Integer strategyStatus; private Integer indexStatus; ``` 它没有返回 MinIO 的 `bucketName`、`objectName`、`objectUrl`。 这是一个好设计。因为对象存储路径属于服务端内部细节,不应该暴露给前端。前端真正需要的是: ```text documentId:后续查询文档详情和处理进度 taskId:后续查询任务进度和日志 documentName:展示名称 parseStatus:解析状态 strategyStatus:策略状态 indexStatus:索引状态 ``` 也就是说,上传接口返回的是“业务可感知状态”,不是“底层存储细节”。 ### **十五、把完整流程串起来** 这一块完整流程可以背成一句话: > 上传接口接收 multipart 文件和可选元信息,先进行空文件、原始文件名和文件类型校验,然后读取文件字节并生成全局 documentId;系统将原始文件上传到 MinIO,获得 bucket、objectName 和 objectUrl 后构建文档主记录,同时创建解析路由任务和任务日志;文档主表、任务表和日志表通过 TransactionTemplate 在同一个事务内提交,事务提交成功后再发送 Kafka 消息,触发后续异步解析流水线,最后返回 documentId、taskId 和初始处理状态给前端。 ### **十六、你要重点掌握的设计思想** #### **1. 上传接口不是处理完成,而是异步处理入口** 这个接口不会直接完成解析、切块、向量化和索引构建。它只是把文件接入系统,并创建后续任务。 这体现的是异步架构思想: ```text 同步接口做轻量初始化 重任务交给 Kafka 异步消费 状态表记录进度 前端轮询或订阅进度 ``` #### **2. documentId 是全链路主线** `documentId`贯穿: ```text 对象存储路径 文档主表 任务表 任务日志 Kafka 消息 解析结果 切块结果 向量记录 ES 索引 Neo4j 图谱节点 问答引用来源 ``` 所以它必须全局唯一、提前生成、稳定可靠。 #### **3. 文档主表保存“资产状态”** 文档主表不是只保存文件路径,它还保存处理状态和业务分类。 它是整个文档生命周期的状态中心。 #### **4. 任务表保存“处理动作”** 文档主表表示“文档是什么状态”,任务表表示“当前要执行什么处理”。 这两个表要分开,不要混在一起。 #### **5. TransactionTemplate 的核心价值是控制事务边界** 它解决的是: ```text 数据库事务提交 Kafka 消息发送 消费方查询可见性 ``` 之间的顺序问题。 #### **6. Kafka 消息只传 ID,不传大对象** 详细上下文放数据库,消息只负责触发流程。 这是异步系统里很常见的设计。 #### **7. 任务日志从第一步开始记录** 这为后续全链路可观测、失败排查和状态追踪打基础。 ### **十七、这一块可以怎么写进简历或项目介绍** 可以这样提炼: > 负责设计并实现文档上传与异步解析入口链路,支持 multipart 文件与元信息上传,完成文件合法性校验、MinIO 原始文件存储、文档主记录创建、解析任务创建和任务日志记录。通过 TransactionTemplate 精确控制事务边界,保证文档主表、任务表和日志表事务一致提交,并在事务提交后投递 Kafka 解析路由消息,避免消费方读取未提交数据的问题。上传结果返回 documentId、taskId 和初始处理状态,支撑前端进度追踪和后续异步解析流水线。 如果要突出亮点,可以再加一句: > 针对“数据库提交”和“消息投递”之间的一致性问题,采用“先事务提交、后同步发送 Kafka、失败补偿兜底”的方案,提升异步处理链路的可靠性和可观测性。 ### **十八、面试官可能会问的问题** #### **问题 1:为什么不直接在上传接口里解析文档?** 因为解析文档可能很慢,尤其是 PDF、PPT、Excel 或大文件。如果同步解析,会导致接口响应时间过长,甚至超时。上传接口只做文件接入和任务创建,后续通过 Kafka 异步处理,可以提升用户体验和系统吞吐量。 #### **问题 2:为什么先上传 MinIO 再写数据库?** 因为文档主表需要保存文件的存储位置,包括 bucket、objectName 和 objectUrl。只有文件上传成功后,才能拿到这些信息并写入数据库。 #### **问题 3:如果 MinIO 上传成功,但数据库写入失败怎么办?** 会产生孤儿文件。可以通过失败回滚清理、定时扫描无主对象、或者在数据库写入失败时主动删除 MinIO 文件来补偿。 #### **问题 4:为什么不用方法级 @Transactional?** 如果方法级事务包住整个上传流程,Kafka 可能在数据库事务提交前就发送出去。消费方收到消息后查询数据库,可能查不到刚插入的文档和任务。使用 `TransactionTemplate` 可以把事务范围限定在数据库写入部分,确保事务提交后再发 Kafka。 #### **问题 5:如果事务提交成功,但 Kafka 发送失败怎么办?** 这种情况数据库里已经有文档和任务,但异步处理没有启动。可以通过补偿任务扫描长时间停留在 `PARSING` 或 `NEW` 状态的记录,重新投递 Kafka 或标记为失败待重试。 #### **问题 6:为什么 Kafka 消息只传 documentId 和 taskId?** 因为详细上下文已经在数据库中。消息只负责触发异步流程,消费方通过 ID 查询数据库即可。这样消息更小,也降低了字段变更带来的兼容成本。 #### **问题 7:parseStatus 设置为 PARSING 是否准确?** 从严格语义看,它表示文档已经进入解析流程或解析队列,并不代表当前请求线程正在解析。实际项目中也可以命名为 `WAIT_PARSE` 或 `PARSING`,关键是状态定义要清晰,前后端和消费方要保持一致。 ### **十九、这一节你需要真正记住的代码主线** 你不用死背每一行代码,但要记住主线: ```java public DocumentUploadVo upload(MultipartFile file, DocumentUploadDto dto) { // 1. 校验文件 checkFile(file); // 2. 读取字节,生成 documentId byte[] fileBytes = file.getBytes(); Long documentId = uidGenerator.getUid(); // 3. 上传 MinIO StoredObjectInfo stored = storageService.uploadOriginalFile( documentId, originalFileName, fileBytes, contentType ); // 4. 构建文档主记录 SuperAgentDocument document = buildDocument(...); // 5. 构建任务记录 SuperAgentDocumentTask task = buildTask(...); // 6. 数据库事务:插入文档、任务、日志 DocumentUploadVo vo = transactionTemplate.execute(status -> { documentMapper.insert(document); taskMapper.insert(task); taskLogService.saveLog(...); return buildUploadVo(document, task); }); // 7. 事务提交后发送 Kafka kafkaProducer.sendParseRoute(new DocumentParseRouteMessage(documentId, taskId)); // 8. 返回结果 return vo; } ``` 你看到任何上传链路源码,都可以按这个模板去拆。 --- ### **二十、这一块和后续模块的衔接** 上传接口完成后,系统已经具备了后续异步处理所需的全部上下文: | 数据 | 作用 | | --- | --- | | `documentId` | 串联文档全生命周期 | | `taskId` | 串联异步任务执行过程 | | `objectName` | 定位 MinIO 原始文件 | | `parseStatus` | 标记解析进度 | | `strategyStatus` | 标记切块策略推荐进度 | | `indexStatus` | 标记索引构建进度 | | `taskLog` | 支持进度追踪和问题排查 | | Kafka 消息 | 触发后续解析流水线 | 所以它后面自然会接到: ```text Kafka Consumer 消费解析路由消息 根据 documentId 查询文档 从 MinIO 下载原始文件 使用 Apache Tika 解析 更新 parseStatus 推荐切块策略 等待用户确认 触发切块和向量化 构建 ES、向量库、Neo4j 索引 ``` 也就是说,“上传接口与文档主记录创建”这一块是后面所有知识库能力的起点。没有它,后面的 RAG、ReAct Agent、知识路由、图谱导航、引用来源都没有稳定的数据基础。 这一节先把入口链路、状态设计、事务边界和 Kafka 投递顺序吃透,这就是这个模块的核心。 --- **企业级项目导航**:⬅️ [[05-文本转文档结构树|05-文本转文档结构树]] | 06-知识库系统的入口工程 | ➡️ [[07-结构节点提取的四阶段流水线|07-结构节点提取的四阶段流水线]]