Tio Boot DocsTio Boot Docs
Home
文档导航
  • java-db
  • api-table
  • jooq
  • mysql
  • postgresql
  • oceanbase
  • Enjoy
  • Tio Boot Admin
  • java-openai
  • ai_agent
  • knowlege_base
  • voice-agent
  • ai-search
  • ai-coding
  • ai-browser
  • 案例
Abount
AI 检索
  • Github
  • Gitee
Home
文档导航
  • java-db
  • api-table
  • jooq
  • mysql
  • postgresql
  • oceanbase
  • Enjoy
  • Tio Boot Admin
  • java-openai
  • ai_agent
  • knowlege_base
  • voice-agent
  • ai-search
  • ai-coding
  • ai-browser
  • 案例
Abount
AI 检索
  • Github
  • Gitee
  • 入门

    • 01 · 入门

      • 01_introduction
      • tio-boot:新一代高性能 Java Web 开发框架
      • tio-boot 入门示例
      • Tio-Boot 配置 : 现代化的配置方案
      • tio-boot 整合 hotswap-classloader 实现热加载
      • 自行编译 tio-boot
      • 最新版本
      • 开发规范
  • 部署与日志

    • 02 · 部署

      • 02_deployment
      • 使用 Maven Profile 实现分环境打包 tio-boot 项目
      • Maven 项目配置详解:依赖与 Profiles 配置
      • tio-boot 打包成 FatJar
      • 使用 GraalVM 构建 tio-boot Native 程序
      • 使用 Docker 部署 tio-boot
      • 部署到 Fly.io
      • 部署到 AWS Lambda
      • 到阿里云云函数
      • 使用 Deploy 工具部署
      • 使用Systemctl启动项目
      • 使用 Jenkins 部署 Tio-Boot 项目
      • 使用 Nginx 反向代理 Tio-Boot
      • 使用 Supervisor 管理 Java 应用
      • 历史部署页与替代方案
      • 胖包与瘦包的打包与部署
      • 使用 JDK 8 构建基础库与验证运行环境
    • 03 · 日志

      • 日志
      • tio-boot 整合 Logback
  • 核心开发

    • 04 · 配置

      • 04_configuration
      • 配置参数
      • 服务器监听器
      • 内置缓存系统 AbsCache
      • 使用 Redis 作为内部 Cache
      • 静态文件处理器
      • 基于域名的静态资源隔离
      • DecodeExceptionHandler
      • 开启虚拟线程(Virtual Thread)
      • 框架级错误通知
    • 05 · JSON

      • 05_json
      • Json
      • 接受 JSON 和响应 JSON
      • 响应实体类
    • 06 · Web 开发

      • Web 开发
      • 路由与参数

        • 概述
        • 添加 Controller
        • handler入门
        • HttpRequestFunction:直接注册 Service 方法
        • Handler 的请求方法与错误响应
        • 接收请求参数
        • 接收日期参数
        • 接收数组参数
        • HttpRequest
        • 请求校验与响应转换
      • 响应与文件

        • 返回字符串
        • 返回文本数据
        • 返回网页
        • 请求和响应字节
        • 文件上传
        • 文件下载
        • 返回视频文件并支持断点续传
        • HttpResponse
        • Resps
        • RespBodyVo
        • 动态 返回 CSS 实现
        • 返回图片
        • 返回 multipart
        • 使用零拷贝发送大文件
        • 分片上传
        • WebJars
      • 会话与请求控制

        • http Session
        • Cookie 读取与设置
        • 重定向和转发
        • Controller拦截器
        • 请求拦截器
        • LoggingInterceptor
        • 全局异常处理器
        • 跨域
        • 自定义 Handler 转发请求
        • 使用 HttpForwardHandler 转发所有请求
        • HTTP Basic 认证
        • Http响应加密
      • 异步与流式输出

        • 异步处理
        • Transfer-Encoding: chunked 实时音频播放
        • Server-Sent Events (SSE)
      • 工具与监控

        • 常用工具类
        • 接口访问统计
        • 接口请求和响应数据记录
        • JProtobuf
        • 测速
        • Gzip Bomb:使用压缩炸弹防御恶意爬虫
    • 07 · 参数校验

      • 07_validation
      • 数据紧校验规范
      • 参数校验
    • 08 · WebSocket 应用开发

      • 08_websocket
      • 使用 tio-boot 搭建 WebSocket 服务
      • WebSocket 聊天室项目示例
    • 09 · AOP

      • 09_aop
      • JFinal-aop
      • Aop 工具类
      • 配置
      • 独立使用 JFinal Aop
      • @AImport
      • 自定义注解拦截器
      • 原理解析
    • 10 · 国际化

      • 10_i18n
      • i18n
    • 11 · Enjoy 模板

      • 11_enjoy
      • tio-boot 整合 Enjoy 模版引擎文档
      • Tio-Boot 整合 Java-DB 与 Enjoy 模板引擎示例
      • 引擎配置
      • 表达式
      • 指令
      • 注释
      • 原样输出
      • Shared Method 扩展
      • Shared Object 扩展
      • Extension Method 扩展
      • Spring boot 整合
      • 独立使用 Enjoy
      • tio-boot enjoy 自定义指令 localeDate
      • PromptEngine
      • Enjoy 入门示例-擎渲染大模型请求体
      • Tio Boot + Enjoy:分页与 SEO 实战指南
      • TioBoot + Enjoy 生成 robots.txt 与 sitemap.xml:实战与SEO指南
      • Enjoy 使用示例
    • 12 · 定时任务

      • 12_scheduling
      • Quartz 定时任务集成指南
      • 分布式定时任务 xxl-jb
      • cron4j 使用指南
    • 13 · 测试

      • 13_testing
      • TioBootTest:环境与 AOP 初始化
      • 真实 HTTP 集成测试
      • 数据库集成测试与隔离
    • 14 · tio-utils

      • 14_tio-utils
      • tio-utils
      • EnvUtils 配置工具
      • Notification
      • Email
      • JSON
      • File
      • Base64
      • 上传和下载
      • Http
      • Telegram
      • RsaUtils
      • HttpUtils
      • ByteBufferUtils
      • 系统监控
      • 线程
      • 虚拟线程
      • 毫秒并发 ID (MCID) 生成方案
      • Base64 输入与锁等待
      • Gzip 与字节缓冲区
      • 字符串查找与随机整数
      • 日期转换与文件名解析
      • JSON 泛型转换与输出配置
      • JWT 签发与校验
      • 集合分页 PageUtils
      • URL 与流复制工具
  • 数据库与数据访问

    • 15 · java-db

      • 15_java-db
      • Db 工具类
      • java‑db
      • 操作数据库入门示例
      • SQL 模板 (SqlTemplates)
      • 数据源配置与使用
      • ActiveRecord
      • Db 工具类
      • 批量操作
      • Model
      • Model生成器
      • 注解
      • 异常处理
      • 数据库事务处理
      • Cache 缓存
      • Dialect 多数据库支持
      • 表关联操作
      • 复合主键
      • Oracle 支持
      • Enjoy SQL 模板
      • 整合 Enjoy 模板最佳实践
      • 多数据源支持
      • 独立使用 ActiveRecord
      • 调用存储过程
      • java-db 整合 Guava 的 Striped 锁优化
      • 生成 SQL
      • 通过实体类操作数据库
      • java-db 读写分离
      • Spring Boot 整合 Java-DB
      • like 查询
      • 常用操作示例
      • Druid 监控集成指南
      • SQL 统计
      • Db 与 PostgreSQL 业务实践
      • 查询资源自动管理
      • Row 展示转换与表格导出
      • 支付回执与权益事务
      • 商业库存与按量履约事务
    • 16 · api-table

      • 16_api-table
      • ApiTable 概述
      • 使用 ApiTable 连接 SQLite
      • 使用 ApiTable 连接 Mysql
      • 使用 ApiTable 连接 Postgres
      • 使用 ApiTable 连接 TDEngine
      • 使用 api-table 连接 oracle
      • 使用 api-table 连接 mysql and tdengine 多数据源
      • EasyExcel 导出
      • EasyExcel 导入
      • ApiTable 的权限与业务边界
      • ApiTable 联调与故障定位
      • ApiTable 实现增删改查
      • 数组类型
      • 单独使用 ApiTable
      • TQL(Table SQL)前端输入规范
    • 17 · MyBatis

      • 17_mybatis
      • Tio-Boot 整合 MyBatis
      • 使用配置类方式整合 MyBatis
      • 整合数据源
      • 使用 mybatis-plus 整合 tdengine
      • 整合 mybatis-plus
    • 18 · jOOQ

      • 18_jooq
      • 使用配置类方式整合 jOOQ
      • tio-boot + jOOQ 事务管理
      • 批量操作与性能优化
      • 整合agroal
      • 代码生成与类型安全
      • 基于 Record / POJO 增删改查
      • UPSERT、批量更新、返回主键与高级 SQL
      • 的多表关联查询、DTO 投影、聚合统计与视图封装
      • 的窗口函数、CTE、JSON 查询与 PostgreSQL 高级 SQL 实战
      • tio-boot + jOOQ 的审计字段、乐观锁、数据权限与企业级 Repository 设计
      • 测试策略、SQL 日志、性能诊断与生产排障
      • 多租户、读写分离与多数据源设计
      • 代码生成治理、数据库迁移与团队协作规范实战
    • 19 · PostgreSQL

      • 19_postgresql
      • PostgreSQL 安装
      • PostgreSQL 主键自增
      • PostgreSQL 日期类型
      • Postgresql 金融类型
      • PostgreSQL 数组类型
      • 索引
      • PostgreSQL 查询优化
      • 获取字段类型
      • PostgreSQL 全文检索
      • PostgreSQL 向量
      • PostgreSQL 优化向量查询
      • PostgreSQL 其他
    • 20 · MySQL

      • 20_mysql
      • 使用 Docker 运行 MySQL
      • 常见问题
    • 21 · OceanBase

      • 21_oceanbase
      • 快速体验 OceanBase 社区版
      • 快速上手 OceanBase 数据库单机部署与管理
      • 诊断集群性能
      • 优化 SQL 性能指南
      • 待定
    • 22 · Oracle

      • 22_oracle
      • Oracle
    • 23 · SQL Server

      • 23_sqlserver
      • SQL Server
    • 24 · SQLite

      • 24_sqlite
      • SQLite
    • 25 · MongoDB

      • 25_mongodb
      • tio-boot 使用 mongo-java-driver 操作 mongodb
    • 26 · Elasticsearch

      • 26_elasticsearch
      • Elasticsearch
      • JavaDB 整合 ElasticSearch
      • Elastic 工具类使用指南
      • Elastic-search 注意事项
      • ES 课程示例文档
  • 缓存与消息队列

    • 27 · Cache

      • 27_cache
      • Caffeine
      • CacheUtils 工具类
      • 使用 java-db 整合 ehcache
    • 28 · Redis

      • 28_redis
      • 使用 Docker 安装 Redis
      • 使用 java-db 整合 Redis
      • Java DB Redis 相关 Api
      • redis 使用示例
      • 和 RedisTemplate 协作
      • 使用 Jedis 连接池接入 Redis
      • hutool RedisDS
      • Redisson
      • Caffeine 与 Redis 两级缓存
      • 使用 CacheUtils 整合 caffeine 和 redis 实现的两级缓存
    • 29 · 消息队列

      • 29_mq
      • Mica-mqtt
      • EMQX
      • Disruptor
    • 30 · Kafka

      • 30_kafka
      • Kafka
      • AWS MSK
  • 认证与账号体系

    • 31 · 认证与权限

      • 31_authentication
      • FixedTokenInterceptor
      • TokenManager
      • 数据表
      • 匿名登录
      • 个人中心
      • 权限校验注解
      • Sa-Token
      • sa-token 登录注册
      • StpUtil.isLogin() 源码解析
    • 32 · 第三方登录注册

      • 32_third-party-auth
      • 邮箱登录和注册
      • 邮箱重置密码
      • 腾讯云短信登录注册
      • 腾讯云短信重置密码
      • 阿里云短信登录和注册
      • 阿里云短信重置密码
      • 微信登录与绑定手机号
      • 支付宝登录与绑定手机号
      • 微信小程序手机号快捷登录
      • Google登录
      • 阿里云邮件推送验证邮箱
  • 网络通信

    • 33 · AIO

      • 33_aio
      • ByteBuffer
      • AIO HTTP 服务器
      • 自定义和线程池和池化 ByteBuffer
      • AioHttpServer 应用示例 IP 属地查询
      • 手写 AIO Http 服务器
      • Java 21 中的虚拟线程与 AIO
    • 34 · t-io

      • 34_tio
      • 认识 t-io

        • t-io 核心优势与应用价值
        • t-io 消息处理流程
      • 快速上手

        • TioBootServer
        • 独立端口启动 TCP 服务器
        • 内置 TCP 处理器
        • 独立启动 UDPServer
        • 使用内置 UDPServer
      • 核心概念

        • TioConfig
        • ChannelContext
        • Packet
        • Tio 工具类
      • 消息与文件传输

        • 发送数据
        • HTTP 长连接与高效文件传输
        • 使用 AsynchronousSocketChannel 响应数据
      • 连接管理

        • 业务数据绑定
        • 业务数据解绑
        • 关闭连接
        • 资源共享
        • 成员排序
        • 拉黑 IP
      • 加密通信

        • SSL
        • Https建立连接过程
      • 心跳与监控

        • 监控: 心跳
        • 监控: 客户端的流量数据
        • 监控: 单条 TCP 连接的流量数据
        • 监控: 端口的流量数据
        • 单条通道统计: ChannelStat
        • 所有通道统计: GroupStat
      • 深入原理

        • tio-运行原理详解
        • DecodeRunnable
        • t-io 稳定性设计与资源管理
        • 深入解析 Tio 源码:构建高性能 Java 网络应用
        • HTTP、WebSocket 与 TCP 的缓冲区复用
    • 35 · tio-http-server

      • 35_tio-http-server
      • 使用 Tio-Http-Server 搭建简单的 HTTP 服务
      • tio-boot 添加 HttpRequestHandler
      • 在 Android 上使用 tio-boot 运行 HTTP 服务
      • tio-http-server-native
      • handler 常用操作
      • tio-http-server 与 tio-boot 的使用边界
    • 36 · tio-websocket

      • 36_tio-websocket
      • WebSocket 服务器
      • WebSocket Client
      • TCP 数据转发
    • 37 · Netty

      • 37_netty
      • Netty TCP Server
      • Netty Web Socket Server
      • 使用 protoc 生成 Java 包文件
      • Netty WebSocket Server 二进制数据传输
      • Netty 组件详解
    • 38 · netty-boot

      • 38_netty-boot
      • Netty-Boot
      • 原理解析
      • 整合 Hot Reload
      • 整合 数据库
      • 整合 Redis
      • 整合 Elasticsearch
      • 整合 Dubbo
      • Listener
      • 文件上传
      • 拦截器
      • Spring Boot 整合 Netty-Boot
      • SSL 配置指南
      • ChannelInitializer
      • Reserve
  • 集成与扩展

    • 39 · 第三方集成

      • 39_integrations
      • 整合 okhttp
      • 整合 GrpahQL
      • 集成 Mailjet
      • 整合 ip2region
      • 整合 GeoLite 离线库
      • 整合 Lark 机器人指南
      • 集成 Lark Mail 实现邮件发送
      • Thymeleaf
      • Swagger
      • Clerk 验证
      • 集成datadog
    • 40 · Magic Script

      • 40_magic-script
      • tio-boot 与 magic-script 集成指南
    • 41 · Groovy

      • 41_groovy
      • tio-boot 整合 Groovy
      • 调试常用脚本
    • 42 · 爬虫

      • 42_crawling
      • jsoup
      • 爬取 z-lib.io 数据
      • 整合 WebMagic
      • WebMagic 示例:爬取学校课程数据
      • Playwright
      • Flexmark (Markdown 处理器)
      • tio-boot 整合 Playwright
      • 缓存网页数据
    • 43 · Dubbo

      • 43_dubbo
      • 概述
      • dubbo 2.6.0
      • dubbo 2.6.0 调用过程
      • dubbo 3.2.0
    • 44 · Spring

      • 44_spring
      • Spring Boot Web 整合 Tio Boot
      • spring-boot-starter-webflux 整合 tio-boot
      • tio-boot 整合 spring-boot-starter
      • Tio Boot 整合 Spring Boot Starter db
      • Tio Boot 整合 Spring Boot Starter Data Redis 指南
    • 45 · Spring Cloud

      • 45_spring-cloud
      • tio-boot spring-cloud
    • 46 · Quarkus

      • 46_quarkus
      • Quarkus(无 HTTP)整合 tio-boot(有 HTTP)
      • tio-boot + Quarkus + Hibernate ORM Panache
      • tio-boot + Quarkus + Hibernate ORM Panache + jOOQ 整合方案
    • 47 · Telegram4J

      • 47_telegram4j
      • 数据库设计
      • 基于 HTTP 协议开发 Telegram 翻译机器人
      • 基于 MTProto 协议开发 Telegram 翻译机器人
      • 过滤旧消息
      • 保存机器人消息
      • 定时推送
      • 增加命令菜单
      • 使用 telegram-Client
      • 使用自定义 StoreLayout
      • 延迟测试
      • Reactor 错误处理
      • Telegram4J 常见错误处理指南
      • 处理回调查询
      • Reactor
      • 文档翻译
      • 使用 Tio-Boot 整合 tdlight
      • tio-boot 整合 TelegramBots
      • tio-boot 整合 Telegram-Bot-Utils
      • Telegram-Bot-Utils 使用指南
    • 48 · Telegram Bots

      • 48_telegram-bots
      • TelegramBots 入门指南
      • 使用工具库 telegram-bot-base 开发翻译机器人
    • 49 · 文件存储

      • 49_file-storage
      • 文件上传数据表
      • 本地存储
      • 存储到 亚马逊 S3
      • 存储到 Cloudflare R2
      • 存储到 腾讯 COS
      • 上传文件到阿里云 OSS
    • 50 · 支付

      • 支付集成
      • 微信小程序支付:普通支付
      • 微信支付:Native 扫码支付(PC 网页扫码)
      • 支付宝:电脑网站支付接入指南
      • 支付宝:使用 page.pay 二维码前置模式实现网页内扫码支付
  • Firebase 与 Clerk

    • 51 · Firebase

      • 51_firebase
      • 整合 google firebase
      • Firebase Storage
      • Firebase Authentication
      • 使用 Firebase Admin SDK 进行匿名用户管理与自定义状态标记
      • 导出用户
      • 登录注册
      • 注册回调
    • 52 · Clerk

      • 52_clerk
      • Clerk
  • 多媒体

    • 53 · 音视频处理

      • 53_media
      • JAVE 提取视频中的声音
      • Jave 提取视频中的图片
      • 待定
    • 54 · 语音识别

      • 54_asr
      • Whisper-JNI
    • 55 · 语音合成

      • 55_tts
    • 56 · 文字识别

      • 56_ocr
    • 57 · Native Media

      • 57_native-media
      • java-native-media
      • JNI 入门示例
      • mp3 拆分
      • mp4 转 mp3
      • 使用 libmp3lame 实现高质量 MP3 编码
      • Linux 编译
      • macOS 编译
      • 从 JAR 包中加载本地库文件
      • 支持的音频和视频格式
      • 任意格式转为 mp3
      • 通用格式转换
      • 通用格式拆分
      • 视频合并
      • VideoToHLS
      • split_video_to_hls 支持其他语言
      • 持久化 HLS 会话
      • 获取视频长度
      • 保存视频的最后一帧
      • 添加水印
      • linux版本
    • 58 · 计算机视觉

      • 58_computer-vision
      • 使用 Java 运行 YOLOv8 ONNX 模型进行目标检测
      • tio-boot整合yolo
      • ONNX Runtime 推理说明
      • Paddle Structure
      • tio-boot 整合 Paddle Structure
      • tio-boot整合Paddle Structure 提取图片
      • U2Net 图片去背景原理
      • tio-boot 整合 U2Net 实现图片去背景
  • AI 开发

    • 59 · java-openai

      • 59_java-openai
      • 简介
      • 流式生成
      • 图片多模态输入
      • Google Gemini接入
      • google Vertex AI 接入
      • Perplexity API
      • WhisperClient 语音识别
      • SupadataClient 获取视频字幕
      • GiteeClient 文档解析与图片 OCR
      • DeepSeekClient 官方模型查询
      • BailianTTSClient 语音合成
      • 为单次生成配置独立传输策略
      • 按业务档位选模型
      • OpenAI、Claude 与 DeepSeek 联网搜索
      • UniChatClient 与调用方 HTTP 客户端
    • 60 · AI Agent

      • 60_ai-agent
      • 数据库设计
      • 示例问题管理
      • 会话管理
      • 历史记录
      • 意图识别
      • 智能问答
      • 文件上传与解析文档
      • 翻译
      • 名人搜索功能实现
      • Ai studio gemini youbue 问答使用说明
      • 自建 YouTube 字幕问答系统
      • 自建 获取 youtube 字幕服务
      • 使用 OpenAI ASR 实现语音识别接口(Java 后端示例)
      • 定向搜索
      • 16
      • 17
      • 18
      • 在 tio-boot 应用中整合 ai-agent
      • 接口文档
      • 自定义 ChatAskService
      • 请求记录
      • 限流和错误处理
      • 增强检索(RAG)
      • 结构化数据检索
      • AI 问答
      • 连接代码执行器
      • 待定
      • 模型编程能力评测
      • 音频会话 SDP 示例
    • 61 · 知识库

      • Java MossKB 知识库文档
      • 学术论文
      • 数据库设计
      • 用户登录实现
      • 模型管理
      • 知识库管理
      • 文档拆分
      • 片段向量
      • 命中测试
      • 文档管理
      • 片段管理
      • 问题管理
      • 应用管理
      • 向量检索
      • 推理问答
      • 问答模块
      • 统计分析
      • 用户管理
      • VL模型文档解析方案和费用对比
      • 文档解析模型实测
      • 文档解析优化
      • 片段汇总
      • 段落分块与检索
      • 多文档解析
      • 检索性能优化
      • 对话日志
      • 创建独立用户与账号资源管理
      • api 管理
      • 独立对话页
      • 嵌入第三方web系统
      • 分段预览
      • 文档预览
      • 多轮检索
      • 上下文压缩
      • 支持自定义模型
      • 爬取网页数据
      • 隔离 Python 执行环境配置
      • 隔离 Python 执行器
      • 函数库与调用接口
      • Windows 与 Linux 部署
      • 离线运行向量模型
      • Milvus
      • 存储文件到云存储
      • 文档解析异步化
    • 62 · AI 搜索

      • 62_ai-search
      • ai-search 项目简介
      • ai-search 数据库文档
      • ai-search SearxNG 搜索引擎
      • ai-search Jina Reader API
      • ai-search Jina Search API
      • ai-search 搜索、重排与读取内容
      • ai-search PDF 文件处理
      • ai-search 推理问答
      • Google Custom Search JSON API
      • ai-search 意图识别
      • ai-search 问题重写
      • ai-search 系统 API 接口 WebSocket 版本
      • ai-search 搜索代码实现 WebSocket 版本
      • ai-search 生成建议问
      • ai-search 生成问题标题
      • ai-search 历史记录
      • Discover API
      • 翻译
      • Tavily Search API 文档
      • 对接 Tavily Search
      • 火山引擎 DeepSeek
      • 对接 火山引擎 DeepSeek
      • ai-search 搜索代码实现 SSE 版本
      • jar 包部署
      • Docker 部署
      • 爬取一个静态网站的所有数据
      • 网页数据预处理
      • 网页数据检索与问答流程整合
    • 63 · 语音 Agent

      • 63_voice-agent
      • 整合Gemini realtime模型
      • Voice Agent 前端接入接口文档
      • 整合千问realtime模型
      • 打断支持
      • 主动介入
      • eleven labs
      • 基于 tio-boot + ElevenLabs 构建实时语音 Agent(支持打断与主动介入)
    • 64 · AI Coding

      • 64_ai-coding
      • Cline 提示词
      • Cline 提示词-中文版本
    • 65 · AI Browser

      • deepseek-browser-use:从入门到源码
      • deepseek-browser-use:概念与学习路线
      • 安装、启动与健康检查
      • 第一个任务:打开页面、读取结果与关闭
      • 客户端:dsb 命令行与结构化留档
      • 统一命令接口与人机协作
      • 浏览器、profile 与登录态
      • 窗口尺寸与页面视口
      • 接入智能体:观察、执行与验证
      • 表单与多层弹窗排障:防止重复提交
      • 调用追踪、页面留档与文件上传
      • 站点配方、技能与异步作业
      • Windows OCR:本地图片与页面文字识别
      • 配置项与运维自省
      • 命令清单
      • 源码教程:从 HTTP 请求到命令执行
      • DOM 原理:前端如何生成可交互快照
      • DOM 原理:Java 模型与跨 Frame 索引
      • 正文提取与上层结构化处理
      • 源码教程:生命周期、导航与页签
      • 源码教程:DOM、页面状态与元素读取
      • 源码教程:点击、输入、键盘与鼠标
      • 源码教程:等待条件与 JavaScript 执行
      • 源码教程:文件上传、截图、PDF 与 OCR
      • 源码教程:Cookie、存储与页面设置
      • 源码教程:网络记录、请求拦截与控制台
      • 源码教程:原生对话框、DOM 弹窗与人机协作
      • 源码教程:批量、配方、后台作业与维护
      • 源码教程:Chrome 走 CDP 与 CDP 客户端
      • 请求响应关联与线程约束
      • DeepSeek Harness 插件开发入门:从入口到第一个工具
      • 开发 dsh-plugin-deepseek-browser-use:HTTP 客户端与基础工具
      • 开发 dsh-plugin-deepseek-browser-use:会话、作业、文件与图片
      • 把 dsh-plugin-deepseek-browser-use 构建并安装到 DeepSeek Harness
      • DeepSeek Harness 原生浏览器插件:安装、配置与使用
      • DeepSeek Harness 原生浏览器插件:源码、生命周期与测试
      • 后端自动管理:安装 Java 与 Git、更新源码、编译和启动
      • 页面正在导航与主 Frame 缺失:PAGE_NAVIGATING
      • dsb 客户端:Go 版本、多目标与后端服务管理
      • 可靠的数据采集:上下文校验、UTF-8 留档与证据口径
      • 浏览器 profile:列表、克隆与选择
  • 项目实战

    • 66 · java-uni-ai-server

      • 66_java-uni-ai-server
      • 语音合成系统
      • Fish.audio TTS 接口说明文档与 Java 客户端封装
      • 整合 fishaudio 到 java-uni-ai-server 项目
      • 待定
    • 67 · java-llm-proxy

      • 67_java-llm-proxy
      • 使用tio-boot搭建多模型LLM代理服务
    • 68 · java-kit-server

      • 68_java-kit-server
      • Java 执行 python 代码
      • 通过大模型执行 Python 代码
      • 执行 Python (Manim) 代码
      • 待定
      • 待定
      • 待定
      • 视频下载增加水印说明文档
    • 69 · tio-im

      • 69_tio-im
      • 通讯协议文档
      • ChatPacket.proto 文档
      • java protobuf
      • 数据表设计
      • 创建工程
      • 登录
      • 历史消息
      • 发消息
    • 70 · tio-mail-wing

      • 70_tio-mail-wing
      • tio-mail-wing简介
      • 任务1:实现POP3系统
      • 使用 getmail 验证 tio-mail-wing POP3 服务
      • 任务2:实现 SMTP 服务
      • 数据库初始化文档
      • 用户管理
      • 邮件管理
      • 任务3:实现 SMTP 服务 数据库版本
      • 任务4:实现 POP3 服务(数据库版本)
      • IMAP 协议
      • 拉取多封邮件
      • 任务5:实现 IMAP 服务(数据库版本)
      • IMAP实现讲解
      • IMAP 手动测试脚本
      • IMAP 认证机制
      • 主动推送
      • namesapce
      • CONDSTORE and QRESYNC
    • 71 · tio-mcp-server

      • 71_tio-mcp-server
      • 实现 MCP Server 开发指南
      • MCP 协议
      • /zh/71_tio-mcp-server/11.html
    • 72 · tio-log-server

      • 72_tio-log-server
      • 简介
      • 收集 docker 日志
      • 入库
    • 73 · tio-sip

      • 73_tio-sip
      • SIP Server 第一版原理说明
      • SIP Server 第一版实战
      • 一、Windows 平台测试
      • SIP Server 第二版实战
      • SIP Server 第三版实战
      • 性能优化
      • 基于 MediaProcessor 对接 Realtime 模型说明
      • 对接大语言模型
      • 支持 G722 宽带语音
      • G722编码和解码
      • 会话级采样率转换
      • 增加 9196 回声测试分机
      • 语音系统链路说明
      • 一、Gemini Realtime 的打断机制
    • 74 · tio-boot-admin

      • 74_tio-boot-admin
      • 入门指南:使用框架内置配置
      • 手动初始化数据库
      • 配置职责、生效条件与扩展边界
      • 整合数据库
      • 与前端集成
      • 文件上传
      • 网络请求
      • 单图片管理(只读模式)
      • 多图片管理
      • 布尔值管理
      • 字段联动
      • Word 管理
      • PDF 管理
      • 文章管理
      • 富文本编辑器
      • 整合 Enjoy 模版引擎
      • 历史可选方案:Token 存储与 Sa-Token
      • 业务 API 与 H5 / 小程序联调
      • 方法路由与业务鉴权
      • 整合 Redis
      • 整合 Elasticsearch
      • 后端开发规范:tio-boot、java-db 与 Kv
      • 多表实现文件数据存储
      • 受限事项与原操作闭环
      • 具名业务后台与受控命令集成
      • 固定用途查询、受控导出与删除证明
      • 登录与认证
    • 75 · 案例

      • 75_examples
      • 封装 IP 查询服务
      • tio-boot 案例 - 全局异常捕获与企业微信群通知
      • tio-boot 案例 - 文件上传和下载
      • tio-boot 案例 - 整合 ant design pro 增删改查
      • tio-boot 案例 - 流失响应
      • tio-boot 案例 - 增强检索
      • tio-boot 案例 - 整合 function call
      • tio-boot 案例 - 定时任务 监控 PostgreSQL、Redis 和 Elasticsearch
      • Tio-Boot 案例:使用 SQLite 整合到登录注册系统
      • tio-boot 案例 - 执行 shell 命令
      • /zh/75_examples/11.html
      • /zh/75_examples/12.html
      • /zh/75_examples/13.html
  • 性能、原理与源码

    • 76 · 性能测试

      • 76_performance
      • 压力测试 - tio-http-serer
      • 压力测试 - tio-boot
      • 压力测试 - tio-boot-native
      • 压力测试 - netty-boot
      • 性能测试对比
      • TechEmpower FrameworkBenchmarks
      • 压力测试 - tio-boot 12 C 32G
      • HTTP/1.1 Pipelining 性能测试报告
      • tio-boot vs Quarkus 性能对比测试报告
    • 77 · 原理

      • 77_internals
      • 生命周期
      • 请求处理流程
      • 重要的类
    • 78 · 源码解析

      • 78_source-code
      • 源码阅读入口
      • Swagger 整合到 Tio-Boot 中的指南
      • 启动与关闭生命周期
      • HTTP 请求分发与路由优先级
      • 高性能网络编程中的 ByteBuffer 分配与回收策略
      • TioBootServerHandler 源码解析
      • HTTP 请求与连接生命周期

多文档解析

  • 当前实现与接口说明
  • 文档检测与解析策略
    • 已实现能力一览
    • 容量与安全边界
  • 解析器:DocumentParsingService
    • PDF:逐页在文本与 OCR 之间选择
    • OCR:远程任务、轮询与缓存
    • OCR 失败重试
    • DOCX:段落、表格与内嵌图片
    • 表格、旧版 DOC 与 ZIP
    • 文本与 HTML
  • 远端 OCR 依赖的接口形态
  • 分段与解析预览
  • 接口一:解析预览
  • 接口二:分段确认请求
  • 前端接入
    • 上传校验
    • 触发解析预览
    • 预览与提交
  • 测试覆盖
  • 结论

当前实现与接口说明

Java 后端先检测文件实际内容,再选择解析策略。当前处理 PDF、DOCX、DOC、XLS/XLSX、常用文本和 HTML、可识别图片及受限 ZIP 文档集合。不要仅凭文件扩展名决定解析方式,也不要将尚未实现的格式列为已支持。

文字 PDF 使用文本提取,扫描页使用 Gitee PaddleOCR-VL-1.5,混合 PDF 逐页选择;DOCX 保留正文和表格,正文极少且含图片时对内嵌图片 OCR;表格按工作表提取。完整格式、容量限制和策略标识

文档检测与解析策略

解析服务先检查内容,再选择路径。PDF 通过文件头识别,即使扩展名不正确也能识别。

文档特征策略结果标识
可提取有效文字的 PDF 页面按阅读顺序提取文字,保留页码pdf-text
扫描 PDF 页面或无法可靠提取文字的页面调用 PaddleOCR,按原页序合并pdf-ocr
同时含文字页和扫描页的 PDF逐页选择提取或 OCRpdf-mixed
有页面重试后仍然 OCR 失败同上,标识加后缀,失败页内容为空pdf-ocr-partial、pdf-mixed-partial
DOCX 正文与表格提取段落、表格结构docx-structure
DOCX 正文极少但含图片提取内嵌图片并 OCRdocx-ocr
DOC提取正文doc-text
XLS、XLSX按工作表和行提取显示值spreadsheet-structure
TXT、Markdown、日志、CSV、JSON、HTML文本解码,HTML 提取可见正文text
PNG、JPEG、TIFF 等可识别图片OCRimage-ocr
ZIP在内存中读取文档并合并,不解压到磁盘zip-documents

空文件会被拒绝。单个上传文件最多 100 MB;ZIP 最多 50 个成员文件,解压后总量最多 100 MB,不支持嵌套 ZIP。DOCX 正文和图片另有解析容量限制。PDF 文字页检测采用启发式规则,复杂排版、文字层与图片混排的文档仍应检查分段预览。

OCR 结果按内容摘要缓存,空白结果不写缓存。正文用于分段和检索,不把 OCR 服务临时图片地址作为知识片段的必要内容。解析预览返回 parse_strategy、page_count、ocr_page_count、failed_page_count 和文件 id。

解析预览返回文件 id、策略与页数。前端提交确认后的分段时必须回传该文件 ID,后端据此建立文档关联。解析结果应由用户检查后再入库。上传接口只建任务并立即返回任务 ID,分段结果通过状态接口轮询取得,见文档解析异步化。

已实现能力一览

输入判定依据解析方式策略标识
文字型 PDF头部 %PDF-PDFBox 逐页文本提取pdf-text
扫描型 PDF头部 %PDF-,各页可读字符不足逐页渲染后远程 OCRpdf-ocr
混合型 PDF头部 %PDF-,部分页有文本逐页在文本提取与 OCR 之间选择pdf-mixed
DOCXZIP 头 PK 且文件名以 .docx 结尾解析 word/document.xml 的段落与表格docx-structure
图片型 DOCX同上,但正文可读字符不足 40 且含 word/media/ 图片结构文本 + 内嵌图片 OCRdocx-ocr
XLS / XLSX文件名后缀按工作表逐行导出为 Markdown 表格spreadsheet-structure
DOC文件名后缀WordExtractor 提取纯文本doc-text
ZIP文件名后缀逐条目递归调用同一解析器zip-documents
TXT / MD / LOG / CSV / JSON文件名后缀UTF-8 解码,失败回退 GB18030text
HTML / HTM文件名后缀jsoup 去标签取正文text
PNG / JPG / JPEG / TIFF 等ImageIO.read 可解码直接送入远程 OCRimage-ocr

容量与安全边界

限制项阈值触发结果
HTTP 请求体http.multipart.max-request-size,默认部署写 105 MB连接在解析请求头阶段被断开,日志为 Request body exceeds the configured limit
单个上传文件100 MB(前后端各校验一次)文件不能超过100MB
ZIP 内文件个数50 个,且不允许嵌套 ZIPZIP最多包含50个文件,不支持嵌套ZIP
ZIP 解压后总大小100 MBZIP解压后的内容不能超过100MB
DOCX 内嵌图片50 张,单张 20 MBDOCX内嵌图片过多或过大
表格10 万行、500 列、输出 20 MB表格内容过大,请拆分后上传
PDF 加密PDFBox isEncrypted()请先解密PDF文件
解析结果为空result.text().isBlank()未提取到内容,请检查文档或使用扫描PDF
每一页 OCR 都失败ocrPages == failedPages扫描页OCR识别失败,请稍后重试

DOCX 的 XML 解析关闭了 DTD 与外部实体,ZIP 采用流式读取且从不落地解压路径,因此压缩包里的相对路径不会被写入磁盘。

解析器:DocumentParsingService

解析入口是 parse(byte[] data, String filename),返回一个包含策略、正文、页数和 OCR 统计的记录对象;需要观察进度时用带回调的重载:

package nexus.io.mosskb.service.kb;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.zip.*;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.*;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import nexus.io.gitee.*;
import nexus.io.db.activerecord.Db;
import nexus.io.tio.utils.crypto.Md5Utils;

/** Detect the actual document structure before selecting text extraction or OCR. */
public class DocumentParsingService {

  /** 一个页面提取到的内容。 */
  private record PageResult(int page, String text, boolean viaOcr, boolean failed) {}

  /**
   * 解析结果。
   *
   * @param strategy    解析策略
   * @param text        正文
   * @param pages       物理页数,非分页格式为 0
   * @param ocrPages    走 OCR 的页数
   * @param failedPages 尝试多次后仍然失败的页数
   */
  public record Parsed(String strategy, String text, int pages, int ocrPages, int failedPages) {
    /** 兼容只关心策略、正文和页数的调用方。 */
    public Parsed(String strategy, String text, int pages) {
      this(strategy, text, pages, 0, 0);
    }
  }

  /** 解析进度:已完成页数与总页数。 */
  public record Progress(int completed, int total) {
  }

  public Parsed parse(byte[] data, String filename) throws Exception {
    return parse(data, filename, null);
  }

  public Parsed parse(byte[] data, String filename, java.util.function.Consumer<Progress> progress) throws Exception {
    if (data == null || data.length == 0) {
      throw new IllegalArgumentException("文件为空");
    }
    if (data.length > 100 * 1024 * 1024) {
      throw new IllegalArgumentException("文件不能超过100MB");
    }
    String name = filename.toLowerCase(Locale.ROOT);
    Parsed result;
    if (new String(data, 0, Math.min(5, data.length), StandardCharsets.US_ASCII).equals("%PDF-")) {
      // PDF:逐页判断
    } else if (data.length > 4 && data[0] == 'P' && data[1] == 'K' && name.endsWith(".docx")) {
      // DOCX:结构提取,必要时补图片 OCR
    } else if (name.endsWith(".xlsx") || name.endsWith(".xls")) {
      // 表格:按工作表提取
    } else if (name.endsWith(".doc")) {
      // 旧版 Word
    } else if (name.endsWith(".zip")) {
      // 受限 ZIP 文档集合
    } else if (name.matches(".*\\.(txt|md|log|csv|json|html|htm)$")) {
      // 文本与 HTML
    } else if (javax.imageio.ImageIO.read(new ByteArrayInputStream(data)) != null) {
      // 图片:直接 OCR
      result = new Parsed("image-ocr", ocr(data, filename), 1, 1, 0);
    } else {
      throw new IllegalArgumentException("不支持的文件格式,请转换为PDF、DOCX、TXT或图片后上传");
    }
    if (result.text().isBlank()) {
      // 全部走 OCR 的文档如果每一页都失败,原因是远程服务不可用,不是文档本身没有内容。
      if (result.ocrPages() > 0 && result.ocrPages() == result.failedPages()) {
        throw new IOException("扫描页OCR识别失败,请稍后重试");
      }
      throw new IllegalArgumentException("未提取到内容,请检查文档或使用扫描PDF");
    }
    return result;
  }
}

判定顺序决定了一个重要行为:PDF 与 DOCX 走魔数判定,其余格式才看扩展名。因此把扫描版 PDF 改名为 .txt 依旧按 PDF 处理,这也是单元测试里刻意覆盖的场景。

PDF:逐页在文本与 OCR 之间选择

PDF 不做整篇二选一,而是逐页判定。单页去掉空白字符后剩余不足 40 个字符、或出现替换字符 \uFFFD,就认为该页需要 OCR。页与页之间没有依赖,所以并发提取、按页码回填,正文顺序与串行时一致:

  /** 并发提取的页面数:OCR 是网络调用,页与页之间并发即可,不需要按 CPU 核数拉满。 */
  private static final int OCR_PAGE_PARALLELISM = Math.max(2, Math.min(4, Runtime.getRuntime().availableProcessors()));

  /** 逐页提取 PDF:文本层优先,扫描页走 OCR,页面之间并发处理但按原页序输出。 */
  private Parsed parsePdf(byte[] data, java.util.function.Consumer<Progress> progress) throws Exception {
    try (PDDocument pdf = PDDocument.load(data)) {
      if (pdf.isEncrypted()) {
        throw new IllegalArgumentException("请先解密PDF文件");
      }
      int totalPages = pdf.getNumberOfPages();
      PDFTextStripper stripper = new PDFTextStripper();
      stripper.setSortByPosition(true);
      String sourceHash = Md5Utils.md5Hex(data);
      PageResult[] results = new PageResult[totalPages];
      AtomicInteger completed = new AtomicInteger();
      ForkJoinPool pool = new ForkJoinPool(OCR_PAGE_PARALLELISM);
      try {
        List<ForkJoinTask<?>> tasks = new ArrayList<>(totalPages);
        for (int page = 1; page <= totalPages; page++) {
          int pageNumber = page;
          tasks.add(pool.submit(() -> {
            try {
              results[pageNumber - 1] = extractPdfPage(pdf, stripper, sourceHash, pageNumber);
            } catch (IOException e) {
              throw new UncheckedIOException("解析第 " + pageNumber + " 页失败", e);
            }
            if (progress != null) {
              progress.accept(new Progress(completed.incrementAndGet(), totalPages));
            }
          }));
        }
        for (ForkJoinTask<?> task : tasks) {
          task.join();
        }
      } finally {
        pool.shutdown();
      }
      StringBuilder text = new StringBuilder();
      int ocrPages = 0;
      int nativePages = 0;
      int failedPages = 0;
      for (PageResult pageResult : results) {
        if (pageResult.viaOcr()) {
          ocrPages++;
        } else {
          nativePages++;
        }
        if (pageResult.failed()) {
          failedPages++;
        }
        if (!pageResult.text().isBlank()) {
          text.append("\n\n> Page ").append(pageResult.page()).append("\n\n").append(pageResult.text());
        }
      }
      String suffix = failedPages == 0 ? "" : "-partial";
      String strategy;
      if (ocrPages == 0) {
        strategy = "pdf-text";
      } else if (nativePages == 0) {
        strategy = "pdf-ocr" + suffix;
      } else {
        strategy = "pdf-mixed" + suffix;
      }
      return new Parsed(strategy, text.toString(), totalPages, ocrPages, failedPages);
    }
  }

  /** 提取单页:先尝试文本层,文本不足时把该页单独保存后交给 OCR。 */
  private PageResult extractPdfPage(PDDocument pdf, PDFTextStripper stripper, String sourceHash, int pageNumber)
      throws IOException {
    String page;
    // PDFTextStripper 与 PDDocument 都不是线程安全的,逐页提取时串行访问。
    synchronized (pdf) {
      stripper.setStartPage(pageNumber);
      stripper.setEndPage(pageNumber);
      page = stripper.getText(pdf);
    }
    if (page.replaceAll("\\s", "").length() >= 40 && !page.contains("\uFFFD")) {
      return new PageResult(pageNumber, page, false, false);
    }
    byte[] pageData;
    try (PDDocument single = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
      synchronized (pdf) {
        single.importPage(pdf.getPage(pageNumber - 1));
      }
      single.save(out);
      pageData = out.toByteArray();
    }
    // OCR 结果为空既可能是空白页,也可能是服务没能识别,两种都按空页处理并保证页码占位。
    boolean[] failed = new boolean[1];
    String[] recognized = new String[1];
    boolean ok = withOcrRetry(() -> ocrPdfPage(sourceHash, pageNumber, pageData), recognized, failed,
        sourceHash + ":page:" + pageNumber + ":" + OCR_CACHE_VERSION);
    return new PageResult(pageNumber, ok ? recognized[0] : "", true, failed[0]);
  }

要点:

  • 页序不会被并发打乱。每页的结果按页码回填数组,正文严格按物理页码顺序拼接,与串行实现的结果一致。并发只发生在页与页之间。
  • 每页都写入 > Page N 标记,让后续分段与大模型引用能定位到页。
  • 单页导出会生成新的 PDF 内部对象 ID,所以缓存键不能用导出后的字节,而要用「原始文件内容 + 页码」,见下节。
  • strategy 由「原生页数量」推导:全部原生为 pdf-text,全部走 OCR 为 pdf-ocr,其余为 pdf-mixed;有页重试后仍失败时加 -partial 后缀。
  • PDDocument 与 PDFTextStripper 都加锁,只把耗时的网络 OCR 放在锁外并发,避免共享对象被多线程同时读写。

OCR:远程任务、轮询与缓存

OCR 统一走 ocrWithCache,先查缓存,未命中才提交远程任务;同一份内容并发出现时按内容加锁,后到的调用等第一次的结果落库后直接读缓存:

  protected String ocr(byte[] data, String filename) throws Exception {
    return ocrWithCache(data, filename, Md5Utils.md5Hex(data) + ":" + OCR_CACHE_VERSION);
  }

  protected String ocrPdfPage(String sourceHash, int page, byte[] pageData) throws Exception {
    // Serializing a PDF page can generate a fresh internal document ID. Cache by
    // original file content and page number instead of the transient PDF bytes.
    return ocrWithCache(pageData, "page.pdf", sourceHash + ":page:" + page + ":" + OCR_CACHE_VERSION);
  }

  private String ocrWithCache(byte[] data, String filename, String hash) throws Exception {
    String cached = readOcrCache(hash);
    if (cached != null && !cached.isBlank()) {
      return textOnly(cached);
    }
    synchronized (OCR_LOCK_STRIPES[Math.floorMod(hash.hashCode(), OCR_LOCKS)]) {
      // 同一份内容的第一个请求可能已经写好缓存,这里再查一次就能省掉重复的付费调用。
      cached = readOcrCache(hash);
      if (cached != null && !cached.isBlank()) {
        return textOnly(cached);
      }
      String markdown = recognize(data, filename);
      if (markdown != null && !markdown.isBlank()) {
        writeOcrCache(hash, markdown);
      }
      return markdown;
    }
  }

  protected String readOcrCache(String hash) {
    return Db.queryStr("select content from moss_kb_document_markdown_cache where id=?", hash);
  }

  protected void writeOcrCache(String hash, String markdown) {
    Db.update("insert into moss_kb_document_markdown_cache(id,content) values(?,?) "
        + "on conflict(id) do update set content=excluded.content", hash, markdown);
  }

缓存表结构极简,主键即缓存键:

CREATE TABLE "public"."moss_kb_document_markdown_cache" (
  "id" VARCHAR PRIMARY KEY,
  "target" VARCHAR,
  "content" TEXT
);

空白结果不写缓存:远程 OCR 对空白页会正常返回空正文,如果把空字符串也写进缓存,这一页就再也不会被重新识别;不写缓存则下次请求会重新提交。

真实提交与轮询逻辑如下,供应商返回的是异步任务,必须轮询到终态:

  protected String recognize(byte[] data, String filename) throws Exception {
    GiteeClient client = new GiteeClient();
    GiteeDocumentParseRequest request = new GiteeDocumentParseRequest();
    request.setModel(OCR_MODEL);
    request.setPrompt(GiteePromptConst.pdf_to_markdown_prompt);
    request.setInclude_image(false);
    request.setInclude_image_base64(false);
    GiteeTaskResponse task = client.parseDocument(data, filename, request);
    long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.MINUTES.toNanos(8);
    while (!Arrays.asList("success", "succeeded", "completed")
        .contains(String.valueOf(task.getStatus()).toLowerCase(Locale.ROOT))) {
      if (task.getStatus() == null || task.getTask_id() == null) {
        throw new IOException("OCR服务未返回有效任务状态");
      }
      if (Arrays.asList("failed", "failure", "cancelled", "canceled")
          .contains(task.getStatus().toLowerCase(Locale.ROOT))) {
        throw new IOException("OCR任务失败: " + task.getTask_id());
      }
      if (System.nanoTime() > deadline) {
        throw new IOException("OCR任务超时: " + task.getTask_id());
      }
      Thread.sleep(2000);
      task = client.getTask(task.getTask_id());
    }
    // 扫描页可能是空白页或整页图片,服务会正常返回空正文,这里按空内容处理,由调用方决定是否记录。
    String markdown = textOnly(GiteeSimpleMarkdownUtils.toMarkdown(task.getOutput(), null));
    if (markdown == null || markdown.isBlank()) {
      log.info("OCR returned no text for {}", filename);
      return "";
    }
    return markdown;
  }

  private String textOnly(String markdown) {
    if (markdown == null) {
      return "";
    }
    return markdown.replaceAll("(?is)<img\\b[^>]*>", "")
        .replaceAll("(?is)<div[^>]*>\\s*</div>", "").replaceAll("!\\[[^\\]]*\\]\\([^)]*\\)", "");
  }

设计取舍:

  • 只取正文:include_image 与 include_image_base64 都关闭,返回结果再剥掉 <img>、空 <div> 与 Markdown 图片语法,避免把 base64 图片带进分段和向量。
  • 超时 8 分钟:超时或失败都抛出带 task_id 的异常,交给重试处理。
  • 模型名使用常量:OCR_MODEL 指向 PaddleOCR-VL-1.5 而不是裸字符串,便于后续切换 OCR 模型,缓存版本号同步跟着换,旧缓存不会被复用。

OCR 失败重试

远程 OCR 是外部服务,偶发失败、超时和限流都会发生。单页失败按 1 秒、2 秒退避重试,最多三次;三次都失败就记为空页并继续解析其余页面:

  /** 单个页面 OCR 的最大尝试次数,用于兜底偶发网络与服务端错误。 */
  private static final int OCR_MAX_ATTEMPTS = 3;

  /**
   * OCR 重试:失败时按 1s、2s 退避重试,最后一次仍失败就记为失败页。
   *
   * <p>“未识别到内容”是正常返回,不是异常,因此不在这里重试。
   *
   * @return 是否拿到结果
   */
  private boolean withOcrRetry(OcrCall call, String[] result, boolean[] failed, String describe) {
    for (int attempt = 1; attempt <= OCR_MAX_ATTEMPTS; attempt++) {
      try {
        result[0] = call.recognize();
        return true;
      } catch (Exception e) {
        log.warn("OCR attempt {}/{} failed for {}: {}", attempt, OCR_MAX_ATTEMPTS, describe, e.getMessage());
        if (attempt < OCR_MAX_ATTEMPTS) {
          try {
            Thread.sleep(1000L * attempt);
          } catch (InterruptedException interrupted) {
            Thread.currentThread().interrupt();
            failed[0] = true;
            return false;
          }
        }
      }
    }
    log.warn("OCR gave up for {}", describe);
    failed[0] = true;
    return false;
  }

  /** 一次 OCR 调用,用于把重试逻辑包在可覆盖的 OCR 方法外面。 */
  private interface OcrCall {
    String recognize() throws Exception;
  }

“未识别到内容”与“服务失败”是两件事:空白扫描页返回空正文属于正常结果,不重试也不影响整份文档;无效任务状态、任务失败、超时属于服务问题,才进入重试。

  • 缓存键带模型后缀:md5(content) + ":" + OCR_CACHE_VERSION,换模型或换版本后旧缓存不会污染新结果。
  • 重复上传不重复计费:同一文件改名后再次解析,整篇缓存命中;PDF 的每一页也各有一条缓存,中断重试只需补齐缺失页。
  • 失败页单独记账:重试后仍失败的页计入 failedPages,策略标识加 -partial 后缀,failed_page_count 一并返回给前端,便于判断是「文档本身空白」还是「OCR 服务不稳定」。

DOCX:段落、表格与内嵌图片

DOCX 不走 ZIP 集合解析,而是单独读取 word/document.xml,同时关闭 DTD 与外部实体:

  private static final int DOCX_XML_LIMIT = 20 * 1024 * 1024;

  private String docx(byte[] data) throws Exception {
    try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(data))) {
      ZipEntry entry;
      while ((entry = zip.getNextEntry()) != null) {
        if (!entry.getName().equals("word/document.xml")) {
          continue;
        }
        byte[] xml = zip.readNBytes(DOCX_XML_LIMIT + 1);
        if (xml.length > DOCX_XML_LIMIT) {
          throw new IllegalArgumentException("DOCX内容过大");
        }
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        factory.setNamespaceAware(true);
        factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
        factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
        factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
        Document doc = factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml));
        StringBuilder out = new StringBuilder();
        Node body = doc.getElementsByTagNameNS("*", "body").item(0);
        for (Node node = body.getFirstChild(); node != null; node = node.getNextSibling()) {
          if ("p".equals(node.getLocalName())) {
            out.append(text(node)).append("\n\n");
          }
          if ("tbl".equals(node.getLocalName())) {
            NodeList rows = ((Element) node).getElementsByTagNameNS("*", "tr");
            for (int r = 0; r < rows.getLength(); r++) {
              NodeList cells = ((Element) rows.item(r)).getElementsByTagNameNS("*", "tc");
              out.append('|');
              for (int c = 0; c < cells.getLength(); c++) {
                out.append(text(cells.item(c)).replace("|", "\\|")).append('|');
              }
              out.append('\n');
              if (r == 0) {
                out.append('|');
                for (int c = 0; c < cells.getLength(); c++) {
                  out.append("---|");
                }
                out.append('\n');
              }
            }
            out.append('\n');
          }
        }
        return out.toString();
      }
    }
    throw new IllegalArgumentException("DOCX缺少正文");
  }

  private String text(Node node) {
    StringBuilder out = new StringBuilder();
    NodeList texts = ((Element) node).getElementsByTagNameNS("*", "t");
    for (int i = 0; i < texts.getLength(); i++) {
      out.append(texts.item(i).getTextContent());
    }
    return out.toString();
  }
  • 段落转成 Markdown 段,表格转成带表头分隔线的 Markdown 表格,单元格内的 | 会转义,列数不会错位。
  • 内嵌图片走 OCR 只在正文可读字符不足 40 时触发,判断与图片收集都在 parse 分支里完成:
    } else if (data.length > 4 && data[0] == 'P' && data[1] == 'K' && name.endsWith(".docx")) {
      String extracted = docx(data);
      if (extracted.replaceAll("\\s", "").length() < MIN_NATIVE_CHARS) {
        String images = docxImages(data);
        result = new Parsed(images.isBlank() ? "docx-structure" : "docx-ocr", extracted + images, 0);
      } else {
        result = new Parsed("docx-structure", extracted, 0);
      }
    }

docxImages 只遍历 word/media/ 且能做图片解码的条目,最多 50 张、单张不超过 20 MB,超限直接报错而不是静默截断。

表格、旧版 DOC 与 ZIP

表格用 POI 的 DataFormatter 输出显示值,公式单元格取缓存计算值,避免把公式串写进正文:

  private String spreadsheet(byte[] data) throws Exception {
    try (org.apache.poi.ss.usermodel.Workbook workbook =
        org.apache.poi.ss.usermodel.WorkbookFactory.create(new ByteArrayInputStream(data))) {
      org.apache.poi.ss.usermodel.DataFormatter formatter =
          new org.apache.poi.ss.usermodel.DataFormatter(Locale.ROOT);
      formatter.setUseCachedValuesForFormulaCells(true);
      StringBuilder out = new StringBuilder();
      int rows = 0;
      for (org.apache.poi.ss.usermodel.Sheet sheet : workbook) {
        out.append("\n## ").append(sheet.getSheetName()).append("\n\n");
        for (org.apache.poi.ss.usermodel.Row row : sheet) {
          if (++rows > 100000 || row.getLastCellNum() > 500 || out.length() > 20 * 1024 * 1024) {
            throw new IllegalArgumentException("表格内容过大,请拆分后上传");
          }
          for (int i = 0; i < row.getLastCellNum(); i++) {
            out.append('|').append(formatter.formatCellValue(row.getCell(i))
                .replace("|", "\\|").replace('\n', ' '));
          }
          out.append("|\n");
        }
      }
      return out.toString();
    }
  }

旧版 .doc 用 WordExtractor 取纯文本:

    } else if (name.endsWith(".doc")) {
      try (org.apache.poi.hwpf.HWPFDocument word =
              new org.apache.poi.hwpf.HWPFDocument(new ByteArrayInputStream(data));
          org.apache.poi.hwpf.extractor.WordExtractor extractor =
              new org.apache.poi.hwpf.extractor.WordExtractor(word)) {
        result = new Parsed("doc-text", extractor.getText(), 0);
      }
    }

ZIP 只作为「文档集合」处理,递归调用同一个解析器,因此包内的 PDF、DOCX、表格都能各自按真实内容解析;包内出现 ZIP 直接拒绝:

  private String archive(byte[] data) throws Exception {
    StringBuilder out = new StringBuilder();
    try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(data))) {
      ZipEntry entry;
      int count = 0;
      long total = 0;
      while ((entry = zip.getNextEntry()) != null) {
        if (entry.isDirectory()) {
          continue;
        }
        String name = entry.getName().replace('\\', '/');
        if (name.startsWith("__MACOSX/") || name.endsWith(".DS_Store")) {
          continue;
        }
        if (name.toLowerCase(Locale.ROOT).endsWith(".zip") || ++count > 50) {
          throw new IllegalArgumentException("ZIP最多包含50个文件,不支持嵌套ZIP");
        }
        byte[] bytes = zip.readNBytes(100 * 1024 * 1024 + 1);
        total += bytes.length;
        if (total > 100 * 1024 * 1024) {
          throw new IllegalArgumentException("ZIP解压后的内容不能超过100MB");
        }
        out.append("\n\n# ").append(name).append("\n\n").append(parse(bytes, name).text());
      }
    }
    return out.toString();
  }

每个条目都会以 # 文件名 作为一级标题插入,解析结果里保留了压缩包内的相对路径,方便用户核对来源。

文本与 HTML

文本类文件优先按 UTF-8 严格解码,遇到非法字节序列再回退 GB18030,覆盖旧版中文文档:

    } else if (name.matches(".*\\.(txt|md|log|csv|json|html|htm)$")) {
      String text;
      try {
        text = StandardCharsets.UTF_8.newDecoder()
            .decode(java.nio.ByteBuffer.wrap(data)).toString();
      } catch (java.nio.charset.CharacterCodingException e) {
        text = new String(data, java.nio.charset.Charset.forName("GB18030"));
      }
      if (name.matches(".*\\.(html|htm)$")) {
        text = org.jsoup.Jsoup.parse(text).wholeText();
      }
      result = new Parsed("text", text, 0);
    }

这里用的是 newDecoder().decode(...)(严格模式)而不是 new String(data, UTF_8),两者的区别正是回退逻辑能否生效的关键。

远端 OCR 依赖的接口形态

OCR 请求对象包含模型、提示词、图片开关与截断页数:

package nexus.io.gitee;

public class GiteeDocumentParseRequest {
  private String model;
  private Boolean include_image;
  private Boolean include_image_base64;
  private Integer end_pages;
  private String output_format;
  private String prompt;
  // getter / setter 省略
}

客户端提供「按文件」「按字节数组」以及「图片 OCR」三种入口,本项目使用字节数组形式,从而不落地临时文件:

  public GiteeTaskResponse parseDocument(byte[] data, String filename, GiteeDocumentParseRequest requestEntity) {
    String suffix = FilenameUtils.getSuffix(filename);
    String contentType = ContentTypeUtils.getContentType(suffix);
    RequestBody fileBody = RequestBody.create(data, MediaType.get(contentType));
    return parseDocument(fileBody, filename, requestEntity);
  }

  public GiteeTaskResponse getTask(String taskId) {
    String url = baseUrl + "/api/v1/task/" + taskId;
    Request request = new Request.Builder().url(url)
        .addHeader("Authorization", "Bearer " + apiKey).get().build();
    return execute(request);
  }

API Key 与地址来自环境配置(GITEE_API_KEY、GITEE_BASE_URL),不写在代码里。任务响应中的 status、task_id、output、created_at、started_at、completed_at 都是轮询与排障时实际使用到的字段。

分段与解析预览

解析完成后交给 MossKbDocumentSplitService 做 token 级分段,并把策略与页数一并返回给前端。分段服务同时提供同步与异步两个入口,异步入口只负责建任务并把解析交给后台线程:

package nexus.io.mosskb.service.kb;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;

import com.jfinal.kit.Kv;

import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.DocumentSplitter;
import dev.langchain4j.data.document.splitter.DocumentSplitters;
import dev.langchain4j.data.segment.TextSegment;
import lombok.extern.slf4j.Slf4j;
import nexus.io.jfinal.aop.Aop;
import nexus.io.model.result.ResultVo;
import nexus.io.model.upload.UploadResult;
import nexus.io.mosskb.utils.ExecutorServiceUtils;
import nexus.io.openai.token.OpenAiTokenizer;

@Slf4j
public class MossKbDocumentSplitService {

  public ResultVo split(byte[] data, UploadResult vo) throws IOException, InterruptedException, ExecutionException {
    String filename = vo.getName();
    DocumentParsingService.Parsed parsed;
    try {
      parsed = new DocumentParsingService().parse(data, filename);
    } catch (Exception e) {
      throw new IOException(e.getMessage(), e);
    }
    return ResultVo.ok(splitParsed(parsed, filename, vo));
  }

  /**
   * 异步拆分:立刻返回 task_id,解析与分段交给后台虚拟线程,前端轮询任务状态取结果。
   *
   * <p>大文档的解析要几分钟,占着请求线程等待既容易超时,也让前端只能干等。
   */
  public Long splitAsync(byte[] data, UploadResult vo, Long userId) {
    MossKbDocumentSplitTaskService taskService = Aop.get(MossKbDocumentSplitTaskService.class);
    Long taskId = taskService.start(userId, vo, data == null ? 0L : (long) data.length);
    ExecutorServiceUtils.getDocumentParseExecutor().execute(() -> runSplit(data, vo, taskId, taskService));
    return taskId;
  }

  /** 后台解析入口:把解析进度与最终分段写回任务表,异常统一落到任务失败原因。 */
  private void runSplit(byte[] data, UploadResult vo, Long taskId, MossKbDocumentSplitTaskService taskService) {
    String filename = vo.getName();
    try {
      DocumentParsingService.Parsed parsed = new DocumentParsingService().parse(data, filename,
          (progress) -> taskService.markProgress(taskId, progress.completed(), progress.total()));
      taskService.markTotal(taskId, parsed.pages());
      taskService.markProgress(taskId, parsed.pages(), parsed.pages());
      taskService.complete(taskId, splitParsed(parsed, filename, vo));
    } catch (Exception e) {
      taskService.fail(taskId, e.getMessage());
    }
  }

  /** 把解析结果按分词器切成段落,并组装成前端提交分段需要的结构。 */
  private List<Kv> splitParsed(DocumentParsingService.Parsed parsed, String filename, UploadResult vo) {
    String markdown = parsed.text();
    List<TextSegment> segments = split(markdown);
    Kv fileSplitResult = Kv.by("name", filename).set("id", vo.getId());
    List<Kv> contents = new ArrayList<>();
    for (TextSegment textSegment : segments) {
      contents.add(Kv.by("title", "").set("content", textSegment.text()));
    }
    fileSplitResult.set("content", contents).set("parse_strategy", parsed.strategy()).set("page_count", parsed.pages());
    if (parsed.ocrPages() > 0) {
      fileSplitResult.set("ocr_page_count", parsed.ocrPages());
    }
    if (parsed.failedPages() > 0) {
      fileSplitResult.set("failed_page_count", parsed.failedPages());
    }
    return List.of(fileSplitResult);
  }

  public List<TextSegment> split(String markdown) {
    Document document = new Document(markdown);
    // 使用较大的块大小(2000)和相同的重叠(400)
    DocumentSplitter splitter = DocumentSplitters.recursive(2000, 400, new OpenAiTokenizer());
    List<TextSegment> segments = splitter.split(document);
    return segments;
  }
}

id 直接取自上传结果,parse_strategy 与 page_count 用于排查「为什么这篇文档没解析出内容」,page_count 对非 PDF 为 0;ocr_page_count 与 failed_page_count 分别表示走 OCR 的页数和重试后仍失败的页数。分段确认必须回传 id,后端据此建立文档与文件、分段的关联。

异步化的任务表、状态接口与前端轮询见文档解析异步化。

接口一:解析预览

POST /api/dataset/document/split
Authorization: Bearer <JWT_TOKEN>
Content-Type: multipart/form-data

控制器一次接收多个文件,逐个上传并建任务,立即返回任务 ID;解析在后台进行,前端轮询状态接口取分段:

package nexus.io.mosskb.controller;

import java.util.ArrayList;
import java.util.List;

import com.jfinal.kit.Kv;

import nexus.io.annotation.Get;
import nexus.io.annotation.Post;
import nexus.io.annotation.RequestPath;
import nexus.io.jfinal.aop.Aop;
import nexus.io.mosskb.service.SystemFileService;
import nexus.io.mosskb.service.kb.MossKbDocumentSplitService;
import nexus.io.mosskb.service.kb.MossKbDocumentSplitTaskService;
import nexus.io.mosskb.vo.MossKbDocumentSplitTaskVo;
import nexus.io.model.result.ResultVo;
import nexus.io.model.upload.UploadFile;
import nexus.io.model.upload.UploadResult;
import nexus.io.tio.boot.http.TioRequestContext;

@RequestPath("/api/dataset/document")
public class ApiDatasetDocumentController {

  /**
   * 分段预览:只负责上传与建任务,解析在后台线程逐页推进。
   *
   * <p>大文档解析要几分钟,请求线程不再等待,前端拿 task_id 轮询 {@link #splitTask(Long)} 取结果。
   */
  @Post("/split")
  public ResultVo split(nexus.io.tio.http.common.HttpRequest request) {
    Object[] files = request.getParams().get("file");
    if (files == null || files.length == 0) {
      return ResultVo.fail("请求体中未找到文件");
    }
    Long userId = TioRequestContext.getUserIdLong();
    List<Long> taskIds = new ArrayList<>();
    List<Kv> failures = new ArrayList<>();
    for (Object item : files) {
      if (!(item instanceof UploadFile)) {
        return ResultVo.fail("文件格式不正确");
      }
      UploadFile file = (UploadFile) item;
      UploadResult uploaded = Aop.get(SystemFileService.class).upload(file, "default", "default");
      if (uploaded == null) {
        failures.add(Kv.by("name", file.getName()).set("message", "文件保存失败"));
        continue;
      }
      try {
        taskIds.add(Aop.get(MossKbDocumentSplitService.class).splitAsync(file.getData(), uploaded, userId));
      } catch (Exception e) {
        failures.add(Kv.by("name", file.getName()).set("message", e.getMessage()));
      }
    }
    if (taskIds.isEmpty()) {
      return ResultVo.fail(failures.isEmpty() ? "没有可解析的文件" : String.valueOf(failures.get(0).get("message")));
    }
    return ResultVo.ok(Kv.by("task_id_list", taskIds).set("failures", failures));
  }

  /**
   * 分段预览任务状态。
   *
   * <p>running 时 data 为空,前端继续轮询;success 时 data 是分段结果;failed 时按失败返回,前端直接提示。
   */
  @Get("/split/task/{taskId}")
  public ResultVo splitTask(Long taskId) {
    Long userId = TioRequestContext.getUserIdLong();
    MossKbDocumentSplitTaskVo task = Aop.get(MossKbDocumentSplitTaskService.class).get(userId, taskId);
    if (task == null) {
      return ResultVo.fail("任务不存在或无权访问");
    }
    if (MossKbDocumentSplitTaskService.STATUS_FAILED.equals(task.getStatus())) {
      return ResultVo.fail(task.getErrorMessage() == null ? "文档解析失败" : task.getErrorMessage());
    }
    if (MossKbDocumentSplitTaskService.STATUS_SUCCESS.equals(task.getStatus())) {
      return ResultVo.ok(task.getResult() == null ? new ArrayList<>() : task.getResult());
    }
    return ResultVo.ok();
  }

public ResultVo split_pattern() { return ResultVo.ok(java.util.List.of( com.jfinal.kit.Kv.by("key", "\n\n").set("value", "段落"), com.jfinal.kit.Kv.by("key", "\n").set("value", "换行"))); } }


返回结构(`data` 为数组,每个元素对应一个上传文件):

```json
[
  {
    "id": "474233893756760066",
    "name": "行政复议法.docx",
    "parse_strategy": "docx-structure",
    "page_count": 0,
    "content": [
      { "title": "", "content": "第一条 为了……" }
    ]
  }
]

解析失败时后端返回的消息会作为错误给到界面,例如 文件不能超过100MB、未提取到内容,请检查文档或使用扫描PDF、扫描页OCR识别失败,请稍后重试。这些文案是给最终用户看的,不要改成内部堆栈信息。

接口二:分段确认请求

POST /api/dataset/{datasetId}/document/_bach
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json
[{"id":"<解析返回的文件ID>","name":"资料.docx","paragraphs":[{"title":"标题","content":"经确认的正文"}]}]

文件 ID 用于关联上传文件,知识库 ID 用于授权和选择向量模型。不要在前端确认时丢弃文件 ID。

后端控制器与请求对象:

package nexus.io.mosskb.controller;

import java.util.List;

import nexus.io.annotation.Post;
import nexus.io.annotation.RequestPath;
import nexus.io.jfinal.aop.Aop;
import nexus.io.mosskb.service.kb.MossKbParagraphSplitService;
import nexus.io.mosskb.vo.ParagraphBatchVo;
import nexus.io.model.result.ResultVo;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.utils.json.JsonUtils;

@RequestPath("/api/dataset")
public class ApiDatasetController {

  @Post("/{id}/document/_bach")
  public ResultVo batch(Long id, HttpRequest request) {
    String bodyString = request.getBodyString();
    Long userId = TioRequestContext.getUserIdLong();
    List<ParagraphBatchVo> list = JsonUtils.parseArray(bodyString, ParagraphBatchVo.class);
    return Aop.get(MossKbParagraphSplitService.class).batch(userId, id, list);
  }
}
package nexus.io.mosskb.vo;

import java.util.List;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
public class ParagraphBatchVo {
  private Long id;
  private String name;
  private List<Paragraph> paragraphs;
}

MossKbParagraphSplitService.batch 会用 id(上传文件 ID)加当前用户去 moss_kb_document 里反查文档:查不到就新建,查到就复用,然后写入段落、汇总句与句子级向量。这条链路见片段汇总与段落分块与检索,本节不再重复。

路径拼写提醒:该接口在前后端都是 _bach(少一个 t),与问题批量的 _batch 不同。改接口时两边要一起改,否则会出现 404。

前端接入

上传校验

文件类型与体积在文件选择阶段先拦一遍,后端再兜底一遍:

<el-upload
  accept=".txt,.md,.log,.doc,.docx,.pdf,.html,.zip,.xlsx,.xls,.csv,.png,.jpg,.jpeg,.tif,.tiff"
/>
// 上传on-change事件
const fileHandleChange = (file: any, fileList: UploadFiles) => {
  //1、判断文件大小是否合法,文件限制不能大于100M
  const isLimit = file?.size / 1024 / 1024 < 100
  if (!isLimit) {
    MsgError(t('views.document.upload.errorMessage1'))
    fileList.splice(-1, 1) //移除当前超出大小的文件
    return false
  }

  if (!isRightType(file?.name, form.value.fileType)) {
    if (file?.name !== '.DS_Store') {
      MsgError(t('views.document.upload.errorMessage2'))
    }
    fileList.splice(-1, 1)
    return false
  }
  if (file?.size === 0) {
    MsgError(t('views.document.upload.errorMessage3'))
    fileList.splice(-1, 1)
    return false
  }
}

触发解析预览

前端把选中的多个文件都塞进同一个 file 字段(同名多值),后端才能一次为整批文件建任务:

import { Result } from '@/request/Result'
import { get, post, del, put, exportExcel, exportFile } from '@/request/index'
import type { Ref } from 'vue'
import type { KeyValue } from '@/api/type/common'
import type { pageRequest } from '@/api/type/common'

const prefix = '/dataset'

/**
 * 分段预览(上传文档)
 *
 * 后端只负责保存文件并建解析任务,立即返回 { task_id_list };分段结果由本方法内部轮询
 * /dataset/document/split/task/{taskId} 取得,调用方拿到的仍然是分段列表。
 */
const postSplitDocument: (data: any) => Promise<Result<any>> = async (data) => {
  const started: any = await post(
    `${prefix}/document/split`,
    data,
    undefined,
    undefined,
    1000 * 60 * 10
  )
  const taskIdList: Array<string> = started?.data?.task_id_list || []
  if (!taskIdList.length) {
    return started
  }
  const lists = await Promise.all(taskIdList.map((taskId) => pollSplitTask(taskId)))
  return { ...started, data: lists.flat() } as Result<any>
}

/** 解析一份大文档要几分钟,所以按 2 秒间隔轮询任务状态,最多等待 60 分钟。 */
const pollSplitTask: (taskId: string) => Promise<Array<any>> = async (taskId) => {
  const deadline = Date.now() + 1000 * 60 * 60
  for (;;) {
    try {
      const res: any = await get(`${prefix}/document/split/task/${taskId}`, {}, undefined, {
        silent: true
      })
      if (Array.isArray(res?.data)) {
        return res.data
      }
    } catch (error: any) {
      throw new Error(error?.message || '文档解析失败')
    }
    if (Date.now() > deadline) {
      throw new Error('文档解析超时,请稍后在文档列表查看解析结果')
    }
    await new Promise((resolve) => setTimeout(resolve, 2000))
  }
}

/**
 * 分段标识列表
 */
const listSplitPattern: (
  loading?: Ref<boolean>
) => Promise<Result<Array<KeyValue<string, string>>>> = (loading) => {
  return get(`${prefix}/document/split_pattern`, {}, loading)
}
function splitDocument() {
  loading.value = true
  let fd = new FormData()
  documentsFiles.value.forEach((item) => {
    if (item?.raw) {
      fd.append('file', item?.raw)
    }
  })
  if (radio.value === '2') {
    Object.keys(form).forEach((key) => {
      if (key == 'patterns') {
        form.patterns.forEach((item) => fd.append('patterns', item))
      } else {
        fd.append(key, form[key])
      }
    })
  }
  documentApi
    .postSplitDocument(fd)
    .then((res: any) => {
      const list = res.data
      list.map((item: any) => {
        if (item.name.length > 128) {
          item.name = cutFilename(item.name, 128)
        }
        if (checkedConnect.value) {
          item.content.map((v: any) => {
            v['problem_list'] = v.title.trim() ? [{ content: v.title.trim() }] : []
          })
        }
      })
      paragraphList.value = list
      loading.value = false
    })
    .catch(() => {
      loading.value = false
    })
}

上传本身的超时放宽到 10 分钟(1000 * 60 * 10),它现在只包含文件传输与建任务,正常在秒级完成;真正的解析时长由轮询的 1 小时上限约束,不再占用请求连接。轮询请求带 silent: true,任务失败时接口返回的就是给用户看的失败原因,请求层已经提示过,轮询循环不重复提示。

当前实现说明:请求里的 patterns、limit、with_filter 属于高级分段参数,前端会随表单一起提交,但后端解析接口并未读取它们——分段固定为 2000 token / 400 token 重叠。文档中不要把这些参数描述成生效中的能力。

预览与提交

预览组件按文件分标签页,content.length 就是分段数:

<el-tabs v-model="activeName" class="paragraph-tabs">
  <template v-for="(item, index) in data" :key="index">
    <el-tab-pane :label="item.name" :name="index">
      <div class="mb-16">
        <el-text type="info">{{ item.content.length }} {{ $t('views.paragraph.title') }}</el-text>
      </div>
      <ParagraphList v-model="item.content" :isConnect="isConnect" />
    </el-tab-pane>
  </template>
</el-tabs>

提交时必须把解析返回的 id、name 和用户确认后的 content 一起回传:

import { ref, computed, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import SetRules from './component/SetRules.vue'
import ResultSuccess from './component/ResultSuccess.vue'
import UploadComponent from './component/UploadComponent.vue'
import documentApi from '@/api/document'
import { MsgConfirm, MsgSuccess } from '@/utils/message'
import { t } from '@/locales'
import useStore from '@/stores'

const { dataset, document } = useStore()

function submit() {
  loading.value = true
  const documents = [] as any
  SetRulesRef.value?.paragraphList.map((item: any) => {
    if (!SetRulesRef.value?.checkedConnect) {
      item.content.map((v: any) => {
        delete v['problem_list']
      })
    }
    documents.push({
      id: item.id,
      name: item.name,
      paragraphs: item.content
    })
  })

  if (id) {
    // 上传文档
    document
      .asyncPostDocument(id as string, documents)
      .then(() => {
        MsgSuccess(t('common.submitSuccess'))
        clearStore()
        router.push({ path: `/dataset/${id}/document` })
      })
      .catch(() => {
        loading.value = false
      })
  }
}

提交接口本身是长耗时操作(要建文档、写段落、生成汇总与句子向量),前端超时同样放宽到 5 分钟:

const postDocument: (
  dataset_id: string,
  data: any,
  loading?: Ref<boolean>
) => Promise<Result<any>> = (dataset_id, data, loading) => {
  return post(`${prefix}/${dataset_id}/document/_bach`, data, {}, loading, 1000 * 60 * 5)
}

三个易错点:

  1. id 不能丢。丢了之后后端 Db.queryLong(sqlDocumentId, userId, fileId) 查不到文档,只能新建,最终表现为重复文档或段落挂错文档。
  2. name 要保留真实文件名。后端用它的后缀写入 moss_kb_document.type,也是页面展示名。
  3. 用户可编辑 content。预览阶段的段落允许增删改,提交的是编辑后的结果,不是解析原文。

测试覆盖

解析器的关键分支都有单元测试,且普通测试不打付费接口:测试通过覆写 ocr / ocrPdfPage / readOcrCache / writeOcrCache / recognize 来替换远端调用。

package nexus.io.mosskb.service.kb;

import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.junit.Test;
import static org.junit.Assert.*;

public class DocumentParsingServiceTest {
  private static class Parser extends DocumentParsingService {
    int ocrCalls;
    @Override
    protected String ocr(byte[] data, String filename) {
      ocrCalls++;
      return "Scanned content " + ocrCalls;
    }
    @Override
    protected String ocrPdfPage(String sourceHash, int page, byte[] data) {
      return ocr(data, "page.pdf");
    }
  }

  @Test
  public void pdfUsesTextAndOcrPerPageInOriginalOrder() throws Exception {
    try (PDDocument pdf = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
      PDPage first = new PDPage();
      pdf.addPage(first);
      try (PDPageContentStream canvas = new PDPageContentStream(pdf, first)) {
        canvas.beginText();
        canvas.setFont(PDType1Font.HELVETICA, 12);
        canvas.newLineAtOffset(40, 700);
        canvas.showText("Native administrative review document with sufficient readable text for extraction.");
        canvas.endText();
      }
      pdf.addPage(new PDPage());
      pdf.save(out);
      Parser parser = new Parser();
      DocumentParsingService.Parsed result = parser.parse(out.toByteArray(), "misleading.txt");
      assertEquals("pdf-mixed", result.strategy());
      assertEquals(2, result.pages());
      assertEquals(1, parser.ocrCalls);
      assertTrue(result.text().indexOf("Native administrative") < result.text().indexOf("Scanned content"));
    }
  }
}

这条用例同时锁定了三件事:内容优先于扩展名(.txt 的扫描混合 PDF 仍解析为 pdf-mixed)、页序不乱(原生页文本出现在 OCR 文本之前)、只对需要 OCR 的页发起调用(ocrCalls == 1)。

缓存复用也有独立用例,模拟两次上传同一份 PDF 但文件名不同:

  @Test
  public void repeatedPdfUploadReusesPageCacheDespitePdfSerializationIds() throws Exception {
    class CachedParser extends DocumentParsingService {
      final java.util.Map<String, String> cache = new java.util.HashMap<>();
      final java.util.concurrent.atomic.AtomicInteger calls = new java.util.concurrent.atomic.AtomicInteger();
      @Override
      protected String readOcrCache(String key) {
        synchronized (cache) {
          return cache.get(key);
        }
      }
      @Override
      protected void writeOcrCache(String key, String text) {
        synchronized (cache) {
          cache.put(key, text);
        }
      }
      @Override
      protected String recognize(byte[] data, String filename) {
        return "Scanned page content " + calls.incrementAndGet();
      }
    }
    try (PDDocument pdf = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
      pdf.addPage(new PDPage());
      pdf.addPage(new PDPage());
      pdf.save(out);
      CachedParser parser = new CachedParser();
      DocumentParsingService.Parsed first = parser.parse(out.toByteArray(), "first.pdf");
      int callsAfterFirstUpload = parser.calls.get();
      DocumentParsingService.Parsed second = parser.parse(out.toByteArray(), "renamed.pdf");
      assertEquals(first.text(), second.text());
      // 缓存键是“原文件内容 + 页码”,与序列化单页产生的临时文档 ID 无关:
      // 同一份文件第二次上传必须全部命中缓存,不能再调 OCR。
      assertEquals("重复上传时不应再次调用 OCR", callsAfterFirstUpload, parser.calls.get());
      assertTrue(parser.cache.size() >= 1);
    }
  }

异步化之后新增的用例覆盖空白页、重试与进度:

  /** 空白扫描页返回空正文属于正常结果,不能让它抛异常作废整份文档。 */
  @Test
  public void blankScannedPageDoesNotFailTheWholeDocument() throws Exception {
    class BlankPageParser extends DocumentParsingService {
      final java.util.List<Integer> pages = new java.util.ArrayList<>();
      @Override
      protected String readOcrCache(String key) {
        return null;
      }
      @Override
      protected void writeOcrCache(String key, String text) {
      }
      @Override
      protected String ocrPdfPage(String sourceHash, int page, byte[] pageData) {
        pages.add(page);
        // 第 2 页是空白页,其余页有正文,模拟扫描件里夹杂的空白页。
        return page == 2 ? "" : "第" + page + "页正文";
      }
    }
    BlankPageParser parser = new BlankPageParser();
    DocumentParsingService.Parsed result = parser.parse(blankPdf(3), "scan.pdf");
    assertEquals("pdf-ocr", result.strategy());
    assertEquals(3, result.ocrPages());
    assertEquals(0, result.failedPages());
    assertTrue(result.text().contains("第1页正文"));
    assertTrue(result.text().contains("第3页正文"));
    assertFalse(result.text().contains("第2页正文"));
    // 正文按物理页顺序输出。
    assertTrue(result.text().indexOf("第1页正文") < result.text().indexOf("第3页正文"));
  }

这条用例同时说明了两件事:空白页不再作废整份文档,以及并发提取后正文仍按物理页顺序拼接。

结论

多文档解析的核心是先看内容再选策略:PDF 与 DOCX 靠魔数判定,PDF 逐页在文本提取与 OCR 之间切换,DOCX 在结构提取与内嵌图片 OCR 之间切换,表格与旧版 Word 各走独立通道,ZIP 只作为受限的文档集合递归解析。所有远程 OCR 结果按「内容 MD5 + 模型」或「原始文件 + 页码」缓存,空白页按空页处理,真正的服务失败按退避重试并单独记账。

调度上分成两段:上传接口只保存文件并建任务,解析在后台虚拟线程里逐页推进,前端轮询任务状态取分段。这样长耗时不再占用请求连接,单页失败也不会作废整份文档。

落地时按顺序核对:上传阶段前后端各拦一次 100 MB,并确认 http.multipart.max-request-size 写在本地配置文件里;解析阶段确认 parse_strategy 与 failed_page_count 符合预期;预览阶段确认分段数与页序正常;提交阶段确认 id 与 name 原样回传。这样从上传到入库的链路就完整了。

Edit this page
Last Updated: 10/12/26, 3:25 AM
Contributors: litongjava
Prev
段落分块与检索
Next
检索性能优化