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入门
        • 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 语音合成
    • 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 命令行、Python 与 PowerShell
      • 统一命令接口与人机协作
      • 浏览器、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
  • 项目实战

    • 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 请求与连接生命周期

文档预览

  • 一、入口与效果
  • 二、预览方式与支持的文件类型
  • 三、接口约定
  • 四、服务端实现
    • 4.1 控制器方法
    • 4.2 预览链接不需要登录
    • 4.3 三条 SQL
    • 4.4 预览方式判定与分段兜底
    • 4.5 Office 与表格转 HTML
    • 4.6 原文件输出与下载
    • 4.7 预览结果对象与提示码
  • 五、前端实现
    • 5.1 新标签页打开
    • 5.2 三个入口
    • 5.3 预览页与渲染分支
    • 5.4 下载
    • 5.5 国际化
    • 5.6 路由放行
  • 六、服务端解析单元测试
  • 七、验收

分段预览 解决的是「这次回答引用了哪几段」,视角停在分段上。要核对版式、盖章页、表格数字或者扫描件时,分段文本不够用:原文里的表格会被压成一行行文字,图片和印章根本不在分段里。本章补上文档预览:在分段预览界面点一下文档名称或「预览全文」按钮,新标签页打开一个独立的文档预览页,按文件类型把文档全文渲染出来,并可就地下载原文件。

「能支持预览的文档全部支持」是本章的目标:PDF、图片、Word、Excel、CSV、纯文本、Markdown、HTML、ZIP 都在覆盖范围内,解析不了的类型也不会给一个空白页面,而是明确提示「下载后查看」。预览页不需要登录:地址本身就是访问凭据,分享链接里的访客、没有账号的同事都能直接打开。

一、入口与效果

一次问答结束后,回答下方「知识来源」里的每张文档卡片都可以点开分段预览,卡片上的文档名只是标题,不改变卡片的点击行为:

知识来源里的文档卡片,点卡片进入分段预览

分段预览窗口的头部就是全文入口:文档名称可点击,右侧还有一个「预览全文」按钮,两者都在新标签页打开文档预览页:

分段预览头部:文档名称可点击,右侧是预览全文按钮

第三个入口在「知识库引用」弹窗(回答下方的「引用分段」按钮)里,每张引用分段卡片底部的文档名称同样可以点击:

回答 → 引用分段 → 知识库引用弹窗 → 点分段卡片上的文档名称 → 新标签页打开文档预览

每张引用分段卡片底部的文档名称就是入口:

知识库引用弹窗:分段卡片底部的文档名称可以点开文档预览

为什么用新标签页而不是弹窗:核对原文往往要来回对照,预览页可以长时间停在一边,不占用、也不打断正在进行的分段预览;浏览器自己的前进后退、打印、PDF 缩放与搜索都能直接用,地址栏里的链接还能直接发给同事。

Office 文档在服务端解析成 HTML 片段,段落、表格、空行都保留下来,右上角「下载文档」直接保存原文件:

Word 文档预览:段落与表格按原文顺序渲染

PDF 与图片不经过服务端解析,直接交给浏览器自己的阅读器,缩放、翻页、搜索都是原生能力:

PDF 文档预览:浏览器内置阅读器

从点击到看到正文,新标签页里一共两次请求(PDF 与图片是三次,多一次取原文件):

sequenceDiagram
    participant U as 访客 / 应用作者
    participant D as 分段预览(原页面)
    participant P as 文档预览页(新标签页)
    participant B as 服务端
    U->>D: 点击文档名称或「预览全文」
    D->>P: window.open 新标签页
    P->>B: GET /api/application/{应用}/document/{文档}/preview_content
    B->>B: 校验文档属于该应用关联的知识库(不校验登录)
    B->>B: 定位原文件,按后缀决定预览方式
    B-->>P: 预览方式 + 正文(Office/表格转 HTML,文本原样返回)
    opt 预览方式是 pdf 或 image
        P->>B: GET /api/application/{应用}/document/{文档}/file
        B-->>P: 原文件字节流(Content-Disposition: inline)
        P->>P: 生成 blob 地址交给浏览器渲染
    end
    U->>P: 点击「下载文档」
    P->>B: GET …/file?download=true
    B-->>P: 原文件字节流(Content-Disposition: attachment)

二、预览方式与支持的文件类型

服务端只回答一个问题:这份文件用哪种方式给浏览器看。前端按 preview_kind 选一个渲染分支,不做后缀判断,新增类型只要服务端多一个分支。

后缀preview_kind呈现方式
pdfpdf浏览器内置 PDF 阅读器,服务端只负责把原文件读出来
png jpg jpeg gif bmp webp svgimage<img> 按容器宽度等比缩放
docxhtml解析 OOXML 正文,段落转 <p>、表格转 <table>,文本统一转义
dochtml二进制 Word 逐段取文本后转 HTML
xlsx xlshtml每个工作表一张表格,前面带工作表名
csvhtml按引号规则切分单元格后转表格
md markdownmarkdown交给 Markdown 渲染器,标题、列表、表格都保留
txt log json xml yaml yml properties sql java js ts vue py sh bat ini conftext等宽字体,保留换行与空格
html htmweb沙箱 iframe 渲染,页面里的脚本不会执行
ziptext包内文件清单(文件名 + 大小)
其它后缀unsupported页面给出「请下载后查看」的提示,下载按钮仍然可用
原文件已被清理或文档来自网页抓取markdown退回该文档的全部分段正文,按分段顺序拼接

解析与呈现都在保护范围内,避免一份超大文档把浏览器拖垮:

限制取值说明
正文返回上限200000 字符超出后只返回前半部分,truncated 为真,页面顶部弹出提示条
参与解析的原文件大小30 MB超过则直接进入只下载模式,不做解析
表格行列上限2000 行 × 200 列超出部分截断并置 truncated
ZIP 清单条数500 条超出后追加省略号
分段兜底条数2000 个分段原文件缺失时最多拼接的分段数

三、接口约定

两个接口都挂在应用维度,与分段预览共用同一套路径约定:

GET /api/application/{applicationId}/document/{documentId}/preview_content
GET /api/application/{applicationId}/document/{documentId}/file?download={true|false}
参数位置必填说明
applicationId路径是当前对话所属应用,用来校验文档是否属于该应用关联的知识库
documentId路径是文档 ID
download查询串否缺省或 false 按 inline 输出供预览;true 或 1 按 attachment 输出触发下载

两个接口都不要求登录:没有 Authorization 头也能拿到内容。访问控制靠「应用 + 文档」这一对 ID:

  private static final String FIND_APPLICATION_DOCUMENT = """
      select d.id,
             d.name,
             d.type,
             d.file_id,
             d.dataset_id,
             d.char_length,
             d.paragraph_count,
             s.name as dataset_name
        from moss_kb_document d
        join moss_kb_application_dataset_mapping m on m.dataset_id = d.dataset_id
   left join moss_kb_dataset s on s.id = d.dataset_id
       where m.application_id = ?
         and d.id = ?
       limit 1
      """;

也就是说:只有确实挂在该应用关联知识库下的文档才会被读出来,随便换个 documentId 只会得到「文档不存在或不属于该应用的知识库」。文档 ID 是雪花算法生成的 64 位整数,无法猜测;预览链接只出现在对话页与分享出去的地址里。

preview_content 返回体在统一的 ResultVo 信封里,data 结构如下(Word 文档示例):

{
  "code": 200,
  "message": null,
  "data": {
    "document_id": "695639883218788352",
    "document_name": "中华人民共和国行政复议法.docx",
    "document_type": "docx",
    "dataset_id": "695638732989636608",
    "dataset_name": "政法文档验证",
    "paragraph_count": 8,
    "char_length": 13297,
    "file_name": "中华人民共和国行政复议法.docx",
    "file_size": 32517,
    "downloadable": true,
    "preview_kind": "html",
    "content": "<p>中华人民共和国行政复议法</p><p>目  录</p>…",
    "truncated": false,
    "message_code": null,
    "message": null
  }
}
字段说明
document_name / document_type文档名与后缀,前端据此取文件图标并决定标题栏文案
dataset_name知识库名,同名文档分布在不同知识库时用于区分
file_name / file_size原文件名与字节数,下载时作为保存文件名,页面显示为 31.8 KB
downloadable原文件是否还在本地存储里;为 false 时下载按钮置灰
preview_kind预览方式,取值见上一节的表;前端按它选渲染分支
content解析后的正文;pdf 与 image 为 null,正文由 /file 提供
truncated是否因为上限只返回了部分正文
message_code无法预览的机器可读原因(目前只有 unsupported),由前端翻译成本地文案,页面语言是英文时不会冒出中文提示
message同一个原因的中文兜底文案,供不走界面的调用方直接使用

file 接口不返回 JSON,而是一段字节流,响应头由服务端拼好:

Content-Type: application/pdf
Content-Disposition: inline;filename=%E8%A4%9A%E5%BA%99%E4%B9%A1…pdf;filename*=UTF-8''%E8%A4%9A%E5%BA%99%E4%B9%A1…pdf

文件名同时给出 filename(百分号编码)与 filename*(RFC 5987),中文名在下载时不会丢字。文档不存在或不属于该应用时返回 404,读盘失败返回 500,前端拿到的是失败状态,不会把错误 JSON 当成文件保存下来。

四、服务端实现

4.1 控制器方法

两个方法都只做三件事:接住路径变量、取查询串里的下载开关、交给服务层。tio-boot 的注解路由把路径变量直接绑到方法参数上,控制器里不需要手工解析 URL:

package nexus.io.mosskb.controller;

import nexus.io.annotation.Get;
import nexus.io.annotation.RequestPath;
import nexus.io.jfinal.aop.Aop;
import nexus.io.model.result.ResultVo;
import nexus.io.mosskb.service.kb.MossKbDocumentPreviewService;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.http.common.HttpResponse;

@RequestPath("/api/application")
public class ApiApplicationController {

  /** 文档全文预览:按文件类型返回预览方式与正文,无原文件时退回分段正文;预览链接不需要登录。 */
  @Get("/{applicationId}/document/{documentId}/preview_content")
  public ResultVo previewDocumentContent(Long applicationId, Long documentId) {
    return Aop.get(MossKbDocumentPreviewService.class).content(applicationId, documentId);
  }

  /** 文档原文件:默认按 inline 输出用于在线预览,download=true 时按附件下载。 */
  @Get("/{applicationId}/document/{documentId}/file")
  public HttpResponse previewDocumentFile(Long applicationId, Long documentId, HttpRequest request) {
    String download = request.getParam("download");
    boolean asAttachment = "true".equalsIgnoreCase(download) || "1".equals(download);
    return Aop.get(MossKbDocumentPreviewService.class).file(applicationId, documentId, asAttachment, request);
  }
}

4.2 预览链接不需要登录

默认情况下,认证拦截器先解析令牌,解不出身份就直接返回 401。文档预览要在没有登录态时也能打开,所以这两条路径在解析令牌之后立刻放行:

import java.util.regex.Pattern;

public class MossKbAuthInterceptor implements HttpRequestInterceptor {

  /**
   * 文档全文预览与下载原文件:预览链接不需要登录,按分享链接对待,
   * 能否看到内容由服务层按「文档是否属于该应用关联的知识库」判断。
   */
  private static final Pattern DOCUMENT_PREVIEW_PATH = Pattern
      .compile("/api/application/\\d+/document/\\d+/(preview_content|file)");

  @Override
  public HttpResponse doBeforeHandler(HttpRequest request, RequestLine requestLine, HttpResponse responseFromCache) {
    String authorization = request.getHeader("authorization");
    Long userId = Aop.get(MossKbAuthService.class).getIdByToken(authorization);
    String path = requestLine.getPath();

    // 文档预览按分享链接处理:没有登录令牌也放行,登录与否得到的结果一致。
    // 带了有效令牌时仍然记下身份,方便日志与审计。
    if (isDocumentPreviewPath(path)) {
      if (userId != null) {
        TioRequestContext.setUserId(userId);
      }
      return null;
    }

    if (userId == null) {
      HttpResponse response = TioRequestContext.getResponse();
      response.setStatus(HttpResponseStatus.C401);
      return response;
    }
    // …其余路径继续按登录令牌、账号状态与资源归属校验
    return null;
  }

  /** 文档预览与下载原文件的路径,这两个路径不要求登录。 */
  private boolean isDocumentPreviewPath(String path) {
    return DOCUMENT_PREVIEW_PATH.matcher(path).matches();
  }
}

放行是在「解析令牌」之后、不是「跳过令牌」:带了有效令牌仍然会记下身份,日志和审计看得到是谁打开的。也正因为这条路径对所有人一视同仁,不会出现「登录之后反而没权限」这种怪事——服务层不再按身份判权,只看文档与应用的绑定关系。

分段预览 的 /preview 接口保持原样:它需要身份,因为返回的是全部分段正文,属于应用内部资料的完整展开;而文档预览页是给「拿到链接的人看这一份文档」用的,两者的边界在文档里写清楚。

4.3 三条 SQL

文档信息查询把三个条件叠在一条语句里:文档属于该应用关联的知识库、按文档 ID 命中、顺带取知识库名。SQL 用文本块书写,字段与条件各占一行,改条件时不会漏掉逗号:

  private static final String FIND_FILE = """
      select filename,
             bucket_name,
             target_name,
             file_size
        from moss_kb_file
       where id = ?
         and deleted = 0
      """;

  /** 分段没有单独的顺序列,主键由雪花算法递增生成,按主键升序即导入顺序。 */
  private static final String FIND_PARAGRAPHS = """
      select content
        from moss_kb_paragraph
       where document_id = ?
         and deleted = 0
       order by id
       limit ?
      """;

文档表只存 file_id,真实文件信息在文件表里,所以预览要多走一次查询。原文件落盘路径由存储桶名与目标名拼出来,与静态资源目录 server.resources.static-locations=pages 保持一致:

  /** 本地文件存储根目录,与静态资源目录 server.resources.static-locations 保持一致。 */
  private static final String FILE_ROOT = "pages";

  /** 原文件落盘路径:pages/{bucketName}/{targetName}。 */
  private Path storagePath(Row file) {
    String bucketName = file.getStr("bucket_name");
    String targetName = file.getStr("target_name");
    if (bucketName == null || targetName == null) {
      return null;
    }
    Path path = Paths.get(FILE_ROOT, bucketName, targetName).normalize();
    if (!path.startsWith(FILE_ROOT) || !Files.isReadable(path)) {
      return null;
    }
    return path;
  }

normalize() 加上 startsWith(FILE_ROOT) 是一次必要的兜底:即使将来 target_name 里出现 ../,路径也不会跑到 pages 目录外面去。

4.4 预览方式判定与分段兜底

预览方式由后缀决定,整段逻辑就是一次 switch,每个分支只关心「怎么把字节变成能看的内容」:

  /**
   * 按文件名后缀选择预览方式,返回 null 表示原文件不可读、由调用方退回分段正文。
   */
  private Preview extract(Path path, String documentName) {
    try {
      if (!Files.isReadable(path) || Files.size(path) == 0) {
        return null;
      }
      long size = Files.size(path);
      if (size > MAX_PARSE_BYTES) {
        return new Preview(KIND_UNSUPPORTED, null, false);
      }
      return switch (suffix(documentName)) {
        case "pdf" -> new Preview(KIND_PDF, null, false);
        case "png", "jpg", "jpeg", "gif", "bmp", "webp", "svg" -> new Preview(KIND_IMAGE, null, false);
        case "docx" -> html(docxToHtml(Files.readAllBytes(path)));
        case "doc" -> html(docToHtml(Files.readAllBytes(path)));
        case "xlsx", "xls" -> spreadsheetToHtml(Files.readAllBytes(path));
        case "csv" -> csvToHtml(readText(Files.readAllBytes(path)));
        case "md", "markdown" -> limited(KIND_MARKDOWN, readText(Files.readAllBytes(path)), false);
        case "html", "htm" -> limited(KIND_WEB, readText(Files.readAllBytes(path)), false);
        case "zip" -> limited(KIND_TEXT, zipListing(Files.readAllBytes(path)), false);
        default -> textLike(documentName) ? limited(KIND_TEXT, readText(Files.readAllBytes(path)), false)
            : new Preview(KIND_UNSUPPORTED, null, false);
      };
    } catch (Exception e) {
      log.warn("文档预览解析失败, path={}", path, e);
      return new Preview(KIND_UNSUPPORTED, null, false);
    }
  }

预览方式与结果都不写死在前端,而是作为数据返回,这是让「新增一种可预览类型」只改一处的前提。PDF 与图片返回 content = null,因为它们的正文就是原文件本身,服务端不需要读进内存再吐出来。

解析异常一律收敛成 unsupported,不会把 500 抛到前端:一份损坏的 DOCX 只是看不到正文,下载按钮照旧可用。

原文件缺失(网页抓取来的文档、文件被运维清理)时退回分段正文,让「文档全文」在任何情况下都有内容可看:

  /**
   * 原文件缺失时的兜底:把全部分段按顺序拼成 Markdown 正文,仍然看得到文档全文。
   */
  private Preview segments(Long documentId) {
    List<Row> records = Db.find(FIND_PARAGRAPHS, documentId, MAX_FALLBACK_PARAGRAPHS);
    StringBuilder text = new StringBuilder();
    boolean truncated = false;
    for (Row record : records) {
      String content = record.getStr("content");
      if (content == null || content.isBlank()) {
        continue;
      }
      if (text.length() + content.length() > MAX_PREVIEW_CHARS) {
        truncated = true;
        break;
      }
      text.append(content).append("\n\n");
    }
    if (text.length() == 0) {
      return new Preview(KIND_UNSUPPORTED, null, false);
    }
    return new Preview(KIND_MARKDOWN, text.toString(), truncated);
  }

主干方法把所有信息拼成给前端的 Kv,字段名与接口约定里的表一一对应:

  public ResultVo content(Long applicationId, Long documentId) {
    if (applicationId == null || documentId == null) {
      return ResultVo.fail("缺少应用或文档标识");
    }
    Row document = Db.findFirst(FIND_APPLICATION_DOCUMENT, applicationId, documentId);
    if (document == null) {
      return ResultVo.fail("文档不存在或不属于该应用的知识库");
    }

    Row file = file(document);
    Path path = file == null ? null : storagePath(file);
    Preview preview = path == null ? null : extract(path, document.getStr("name"));
    if (preview == null) {
      preview = segments(documentId);
    }

    Kv data = Kv.by("document_id", document.getLong("id"))
        .set("document_name", document.getStr("name"))
        .set("document_type", document.getStr("type"))
        .set("dataset_name", document.getStr("dataset_name"))
        .set("file_name", file == null ? null : file.getStr("filename"))
        .set("file_size", file == null ? null : file.getLong("file_size"))
        .set("downloadable", path != null)
        .set("preview_kind", preview.kind())
        .set("content", preview.content())
        .set("truncated", preview.truncated())
        .set("message_code", KIND_UNSUPPORTED.equals(preview.kind()) ? MESSAGE_CODE_UNSUPPORTED : null)
        .set("message", KIND_UNSUPPORTED.equals(preview.kind()) ? MESSAGE_UNSUPPORTED : null);
    return ResultVo.ok(data);
  }

4.5 Office 与表格转 HTML

DOCX 的正文在 word/document.xml 里,段落是 w:p、表格是 w:tbl、文本被拆在多个 w:t 中。解析器关掉 DOCTYPE 与外部实体,避免文档携带 XXE,然后按节点顺序还原结构:

import java.io.ByteArrayInputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

  /**
   * DOCX 正文转 HTML:段落转 p,表格转 table,文本统一做转义,避免文档内容被当成标签执行。
   */
  public String docxToHtml(byte[] data) throws Exception {
    try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(data))) {
      ZipEntry entry;
      while ((entry = zip.getNextEntry()) != null) {
        if (!"word/document.xml".equals(entry.getName())) {
          continue;
        }
        byte[] xml = zip.readNBytes(20 * 1024 * 1024 + 1);
        if (xml.length > 20 * 1024 * 1024) {
          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));
        Node body = doc.getElementsByTagNameNS("*", "body").item(0);
        if (body == null) {
          throw new IllegalArgumentException("DOCX缺少正文");
        }
        StringBuilder out = new StringBuilder();
        for (Node node = body.getFirstChild(); node != null; node = node.getNextSibling()) {
          if ("p".equals(node.getLocalName())) {
            appendParagraph(out, nodeText(node));
          } else if ("tbl".equals(node.getLocalName())) {
            out.append("<table class=\"preview-table\">");
            NodeList rows = ((Element) node).getElementsByTagNameNS("*", "tr");
            for (int r = 0; r < rows.getLength(); r++) {
              NodeList cells = ((Element) rows.item(r)).getElementsByTagNameNS("*", "tc");
              out.append("<tr>");
              for (int c = 0; c < cells.getLength(); c++) {
                out.append("<td>").append(escape(nodeText(cells.item(c)))).append("</td>");
              }
              out.append("</tr>");
            }
            out.append("</table>");
          }
        }
        return out.toString();
      }
    }
    throw new IllegalArgumentException("DOCX缺少正文");
  }

  /** 取节点下所有文本,DOCX 的文本可能被拆在多个 w:t 里。 */
  private String nodeText(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();
  }

生成的 HTML 是片段而不是整页:前端把它放进预览页正文容器,样式由前端控制,服务端不用关心主题色和字号。

Excel 走 POI 的统一工作簿接口,.xlsx 与 .xls 共用一条路径,DataFormatter 取「单元格显示出来的值」,公式单元格用缓存值,避免预览里出现 #VALUE!:

import java.util.Locale;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;

  /** 表格文档转 HTML 表格,超过行列上限时截断并标记。 */
  public Preview spreadsheetToHtml(byte[] data) throws Exception {
    try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(data))) {
      DataFormatter formatter = new DataFormatter(Locale.ROOT);
      formatter.setUseCachedValuesForFormulaCells(true);
      StringBuilder out = new StringBuilder();
      boolean truncated = false;
      for (Sheet sheet : workbook) {
        out.append("<h4>").append(escape(sheet.getSheetName())).append("</h4>");
        out.append("<table class=\"preview-table\">");
        int rows = 0;
        for (org.apache.poi.ss.usermodel.Row row : sheet) {
          if (rows >= MAX_TABLE_ROWS) {
            truncated = true;
            break;
          }
          rows++;
          int lastCell = row.getLastCellNum();
          if (lastCell > MAX_TABLE_COLUMNS) {
            truncated = true;
          }
          out.append("<tr>");
          for (int i = 0; i < Math.min(lastCell, MAX_TABLE_COLUMNS); i++) {
            Cell cell = row.getCell(i);
            out.append("<td>").append(escape(formatter.formatCellValue(cell))).append("</td>");
          }
          out.append("</tr>");
        }
        out.append("</table>");
      }
      return limited(KIND_HTML, out.toString(), truncated);
    }
  }

CSV 不能用 POI 读,但它只有一条规则:引号内的逗号不是分隔符,两个连续引号表示一个引号。一个小状态机就够了:

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

  /** 单个 CSV 行切分:引号内的逗号属于单元格内容,两个连续引号表示一个引号。 */
  public static List<String> splitCsvLine(String line) {
    List<String> cells = new ArrayList<>();
    StringBuilder cell = new StringBuilder();
    boolean quoted = false;
    for (int i = 0; i < line.length(); i++) {
      char current = line.charAt(i);
      if (current == '"') {
        if (quoted && i + 1 < line.length() && line.charAt(i + 1) == '"') {
          cell.append('"');
          i++;
        } else {
          quoted = !quoted;
        }
      } else if (current == ',' && !quoted) {
        cells.add(cell.toString());
        cell.setLength(0);
      } else {
        cell.append(current);
      }
    }
    cells.add(cell.toString());
    return cells;
  }

文本文件按 UTF-8 解码,失败再退回 GB18030。Windows 上大量公文仍是记事本另存的 GB18030 编码,只按 UTF-8 读会得到一串问号:

import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

  /** 文本文件按 UTF-8 读取,乱码时退回 GB18030,覆盖 Windows 下常见的记事本文件。 */
  public static String readText(byte[] data) {
    try {
      return StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(data)).toString();
    } catch (CharacterCodingException e) {
      return new String(data, Charset.forName("GB18030"));
    }
  }

转义是这一段的硬要求:文档正文里的 <script> 必须以文本形式显示。所有来自文档的字符串都过同一个函数,前端才敢用 v-html 接住这段 HTML:

  /** 文档正文里的 & < > " 必须转义,否则文档内容会被浏览器当成标签执行。 */
  public static String escape(String text) {
    if (text == null) {
      return "";
    }
    return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
  }

只有原始 HTML 文件例外,它的标签是要生效的,所以既不能转义也不能 v-html,而是交给下一节说的沙箱 iframe。

4.6 原文件输出与下载

原文件输出只做三件事:读字节、按后缀定 Content-Type、按 download 决定 inline 还是 attachment。tio-boot 的响应工具已经覆盖了常见类型,取不到的类型按二进制流返回:

import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import nexus.io.tio.http.common.HeaderName;
import nexus.io.tio.http.common.HeaderValue;
import nexus.io.tio.http.common.HttpResponse;
import nexus.io.tio.http.common.HttpResponseStatus;
import nexus.io.tio.http.server.util.Resps;

  public HttpResponse file(Long applicationId, Long documentId, boolean download, HttpRequest request) {
    Row document = Db.findFirst(FIND_APPLICATION_DOCUMENT, applicationId, documentId);
    if (document == null) {
      return json(request, ResultVo.fail("文档不存在或不属于该应用的知识库"), HttpResponseStatus.C404);
    }
    Row file = file(document);
    Path path = file == null ? null : storagePath(file);
    if (path == null) {
      return json(request, ResultVo.fail("原文件不存在或已被清理"), HttpResponseStatus.C404);
    }
    try {
      byte[] bytes = Files.readAllBytes(path);
      String filename = document.getStr("name");
      HttpResponse response = Resps.bytesWithContentType(request, bytes, contentType(suffix(filename)));
      response.addHeader(HeaderName.Content_Disposition, HeaderValue.from(disposition(download, filename)));
      return response;
    } catch (Exception e) {
      log.warn("文档原文件读取失败, documentId={}, path={}", documentId, path, e);
      return json(request, ResultVo.fail("原文件读取失败"), HttpResponseStatus.C500);
    }
  }

  /** 预览用 inline,下载用 attachment;文件名按 UTF-8 百分号编码,兼容中文名。 */
  public static String disposition(boolean download, String filename) {
    String name = filename == null || filename.isBlank() ? "document" : filename;
    String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20");
    return (download ? "attachment" : "inline") + ";filename=" + encoded + ";filename*=UTF-8''" + encoded;
  }

为什么不让浏览器直接去静态资源目录取文件:pages 目录下的文件确实能通过 URL 直接访问,但那条路完全绕过接口——文档 ID 之所以难猜是因为它只出现在接口地址里,而静态路径一旦拼出来就是「谁都能拿」;而且静态资源不会带 Content-Disposition,下载下来的文件名是雪花 ID。走接口输出原文件,文档归属校验、中文文件名、预览与下载两种语义都能一次解决。代价是一次 Files.readAllBytes,对知识库文档这个量级完全可以接受。

失败时返回 404 / 500 而不是 200 包一个错误 JSON,是因为前端拿二进制流时无法解析业务错误码,只有非 2xx 才能被 axios 拦成异常。

4.7 预览结果对象与提示码

三处返回值汇总成一个不可变对象,避免在方法之间传三个变量:

  /** 预览结果:预览方式 + 正文 + 是否被截断。 */
  public record Preview(String kind, String content, boolean truncated) {
  }

  /** 无法在线预览时的提示码,由前端翻译成本地文案,服务端只给出机器可读的原因。 */
  public static final String MESSAGE_CODE_UNSUPPORTED = "unsupported";

  /** 提示码对应的中文兜底文案,供不走界面的调用方直接使用。 */
  private static final String MESSAGE_UNSUPPORTED = "该文件类型暂不支持在线预览,请下载后查看";

  /** 按字符上限截断正文,命中上限时由前端提示用户下载原文件查看。 */
  private Preview limited(String kind, String content, boolean truncated) {
    if (content != null && content.length() > MAX_PREVIEW_CHARS) {
      return new Preview(kind, content.substring(0, MAX_PREVIEW_CHARS), true);
    }
    return new Preview(kind, content, truncated);
  }

提示码与文案一起返回:界面拿 message_code 去查本地文案,非界面调用方(脚本、接口联调)直接读 message。

五、前端实现

5.1 新标签页打开

打开动作收在一个小工具里,三个入口共用;router.resolve 负责按路由名拼地址,避免手写路径:

import router from '@/router'

/**
 * 在新标签页打开文档预览页。
 *
 * 预览接口不需要登录,链接本身就是访问凭据,所以地址里只带应用与文档 ID:
 * 分享链接的访客和已登录用户打开的是同一个地址,登录与否结果一致。
 */
export function openDocumentPreviewTab(applicationId: string, documentId: string) {
  if (!applicationId || !documentId) {
    return
  }
  const path = `#/document-preview/${applicationId}/${documentId}`
  try {
    const { href } = router.resolve({
      name: 'DocumentPreview',
      params: { applicationId, documentId }
    })
    window.open(new URL(href, window.location.href).href, '_blank')
  } catch (e) {
    // 路由还没注册好时退回按路径拼地址,保证调用方(分段预览、知识库引用)不受影响
    window.open(new URL(path, window.location.href).href, '_blank')
  }
}

window.open 必须由真实点击触发:浏览器会拦截「脚本自己弹出来的窗口」。分段预览与知识库引用里的入口都是用户点出来的,所以能正常开;如果哪天想改成页面加载后自动开,需要换成 location.href 或让用户手动点。

5.2 三个入口

分段预览弹窗的头部把文档名做成可点,并补一个带图标的「预览全文」按钮,避免用户看不出名称可以点:

<span
  class="segment-preview-name ellipsis-1"
  :title="$t('chat.documentPreview.clickTip')"
  @click="openDocumentPreview"
>
  {{ detail.document_name }}
</span>
<el-button type="primary" link class="ml-8" @click="openDocumentPreview">
  <AppIcon iconName="app-view" class="mr-4"></AppIcon>
  {{ $t('chat.documentPreview.previewFullText') }}
</el-button>
/** 点击文档名称或「预览全文」按钮,在新标签页打开文档预览页。 */
function openDocumentPreview() {
  if (!currentApplicationId || !detail.value.document_id) {
    return
  }
  openDocumentPreviewTab(currentApplicationId, detail.value.document_id)
}

currentApplicationId 在打开分段预览时记下来,因为预览接口需要应用 ID 做归属校验,而分段预览的响应里只回文档信息。

「知识库引用」弹窗里的文档名称同样可点。应用 ID 从 KnowledgeSource 逐层传下去,不引入全局状态:

<!-- KnowledgeSource.vue:把应用 id 交给引用弹窗 -->
<ParagraphSourceDialog ref="ParagraphSourceDialogRef" :application-id="application?.id" />

<!-- ParagraphSourceDialog.vue:再交给每一张引用分段卡片 -->
<ParagraphCard :data="item" :content="item.content" :index="index" :application-id="props.applicationId" />
<!-- ParagraphCard.vue:文档名称变成入口,正文与相似度都不受影响 -->
<span
  class="paragraph-source-document-name cursor ellipsis-1 break-all"
  :title="data?.document_name?.trim()"
  @click="openDocumentPreview"
>
  {{ data?.document_name?.trim() }}
</span>
/** 点击文档名称在新标签页打开文档预览页。 */
function openDocumentPreview() {
  openDocumentPreviewTab(props.applicationId, props.data?.document_id)
}

对话来源卡片上的文档名刻意保持不可点:那张卡片很小,文档名几乎占满整张卡,名称一旦单独接管点击,用户点卡片就会「莫名其妙跳到预览页」,把原本的分段预览入口挤掉。所以点击优先级是:卡片 → 分段预览;分段预览头部 / 知识库引用里的名称 → 文档预览。

5.3 预览页与渲染分支

预览页是独立路由,不带后台布局,进来就是一个整屏的查看器:

// routes.ts
  // 文档预览:从分段预览或知识库引用里新开标签页打开,独立成页而不是弹窗
  {
    path: '/document-preview/:applicationId/:documentId',
    name: 'DocumentPreview',
    meta: { title: 'chat.documentPreview.title' },
    component: () => import('@/views/document-preview/index.vue')
  }
<template>
  <div class="document-preview-page">
    <DocumentPreview :application-id="applicationId" :document-id="documentId" />
  </div>
</template>

路由表里的菜单文案一律走 $t(...),所以 meta.title 给的是 i18n 键而不是写死的中文;页面主体按 preview_kind 选渲染分支,同一份数据在不同分支下用完全不同的方式呈现:

<div class="document-preview-header flex align-center">
  <span class="document-preview-title">{{ $t('chat.documentPreview.title') }}</span>
  <el-divider direction="vertical" class="mr-8" />
  <img v-if="detail.document_name" :src="getImgUrl(detail.document_name)" alt="" width="20" class="mr-8" />
  <span class="document-preview-name ellipsis-1" :title="detail.document_name">{{ detail.document_name }}</span>
  <el-tag v-if="detail.document_type" class="ml-8" size="small" type="info">
    {{ detail.document_type }}
  </el-tag>
  <span class="ml-8 color-secondary ellipsis-1" :title="detail.dataset_name">{{ detail.dataset_name }}</span>
  <span v-if="fileSizeText" class="ml-16 color-secondary">{{ fileSizeText }}</span>
  <div class="document-preview-space"></div>
  <el-button type="primary" :disabled="!detail.downloadable" :loading="downloading" @click="downloadDocument">
    <AppIcon iconName="app-export" class="mr-4"></AppIcon>
    {{ $t('chat.documentPreview.download') }}
  </el-button>
</div>
<el-alert
  v-if="detail.truncated"
  class="mb-8"
  type="warning"
  :closable="false"
  :title="$t('chat.documentPreview.truncated')"
/>
<div class="document-preview-body">
  <iframe v-if="previewKind === 'pdf'" :src="previewUrl" class="document-preview-frame" />
  <div v-else-if="previewKind === 'image'" class="document-preview-image">
    <img :src="previewUrl" alt="" />
  </div>
  <!-- 原始 HTML 文件不做任何处理,放进沙箱 iframe,页面里的脚本不会执行 -->
  <iframe v-else-if="previewKind === 'web'" :srcdoc="detail.content" sandbox="" class="document-preview-frame" />
  <!-- 服务端已经对文档正文做过转义,这里的 HTML 是表格与段落片段 -->
  <div v-else-if="previewKind === 'html'" class="document-preview-html" v-html="detail.content"></div>
  <MdPreview
    v-else-if="previewKind === 'markdown'"
    editorId="document-preview"
    :modelValue="detail.content"
    noImgZoomIn
  />
  <pre v-else-if="previewKind === 'text'" class="document-preview-text">{{ detail.content }}</pre>
  <el-empty v-else-if="!loading" :description="emptyText" />
</div>

web 分支的 sandbox="" 是关键:原始 HTML 文件里的 <script> 不会执行,也拿不到同源权限,但它自己的样式和内联图片照常显示。html 分支敢用 v-html,是因为服务端已经把文档文本全部转义过。

正文容器用 flex 撑满剩余高度,PDF 与图片因此能占满整屏:

.document-preview {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.document-preview-body {
  flex: 1;
  min-height: 0;
  padding-top: 12px;
  overflow: auto;
}

.document-preview-frame {
  width: 100%;
  height: 100%;
  border: none;
}

PDF 与图片需要原文件本身,取回二进制后生成 blob 地址;地址在页面卸载时释放,避免反复打开预览把内存攒起来。用 axios 取二进制而不是把 URL 直接写进 <iframe src>,是因为接口虽然不要求登录,但带着令牌请求才能让服务端日志记下访问者:

/** PDF、图片需要原文件本身,取回二进制后生成浏览器可直接打开的地址。 */
function loadBinaryPreview() {
  const kind = (detail.value.preview_kind || '').toLowerCase()
  if (kind !== 'pdf' && kind !== 'image') {
    return
  }
  applicationApi
    .getDocumentFile(props.applicationId, props.documentId, false)
    .then((blob: any) => {
      if (blob instanceof Blob) {
        previewUrl.value = window.URL.createObjectURL(blob)
      }
    })
    .catch(() => {
      MsgError(t('chat.documentPreview.loadFailed'))
    })
}

5.4 下载

下载与预览共用同一个接口,只差一个 download=true:

function downloadDocument() {
  if (!detail.value.downloadable) {
    return
  }
  downloading.value = true
  applicationApi
    .getDocumentFile(props.applicationId, props.documentId, true)
    .then((blob: any) => {
      if (blob instanceof Blob) {
        saveAs(blob, detail.value.file_name || detail.value.document_name)
      }
    })
    .catch(() => {})
    .finally(() => {
      downloading.value = false
    })
}

file_name 是服务端给的原文件名(带后缀),比直接用文档名更稳:文档名被改成不带上缀时,下载下来的文件仍然能被系统识别。

5.5 国际化

预览页的所有文案都走 i18n,三套语言各补一份;服务端只回提示码,页面用提示码查本地文案,英文界面不会冒出中文:

  documentPreview: {
    title: '文档预览',
    clickTip: '点击预览文档全文',
    previewFullText: '预览全文',
    download: '下载文档',
    truncated: '文档内容较大,仅展示部分内容,可下载原文件查看',
    unsupported: '该文件类型暂不支持在线预览,请下载后查看',
    loadFailed: '原文件加载失败',
    message: {
      unsupported: '该文件类型暂不支持在线预览,请下载后查看'
    }
  },
const { t, locale } = useI18n({ useScope: 'global' })

/** 无法预览时的提示:服务端只给原因码,文案由界面语言决定。 */
const emptyText = computed(() => {
  if (detail.value.message_code) {
    return t(`chat.documentPreview.message.${detail.value.message_code}`)
  }
  return detail.value.message || t('chat.documentPreview.unsupported')
})

/**
 * 新标签页的标题用文档名,方便在多个预览页之间切换;
 * 文档信息还没回来时用当前界面语言的「文档预览」,语言切换后标题会跟着更新。
 */
watch(
  [() => detail.value.document_name, locale],
  () => {
    document.title = detail.value.document_name || t('chat.documentPreview.title')
  },
  { immediate: true }
)

浏览器标签页标题优先用文档名(它本身不随语言变化),文档信息还没回来时退回当前语言的「文档预览」;locale 放进 watch 的依赖里,切换语言时标题会跟着刷新。

5.6 路由放行

预览页由对话页新开标签页打开,此时 Pinia 里没有身份,路由守卫不能按登录用户拦:

    const { user } = useStore()
    // 文档预览页由对话页新开标签页打开,它的两个接口本身不要求登录,不能按登录用户拦。
    const notAuthRouteNameList = [
      'register',
      'login',
      'forgot_password',
      'reset_password',
      'Chat',
      'DocumentPreview'
    ]

后端这两条接口本身也不校验登录,前端因此不需要在地址里带任何令牌,链接可以直接复制给别人。

六、服务端解析单元测试

后缀到预览方式的映射、Office 与表格转 HTML、转义规则都是纯函数,用单元测试覆盖最划算,不需要起服务也不需要数据库:

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.junit.Test;

public class MossKbDocumentPreviewServiceTest {

  @Test
  public void docxPreviewKeepsParagraphsAndTablesInOrder() throws Exception {
    String xml = "<w:document xmlns:w='urn:test'><w:body>"
        + "<w:p><w:r><w:t>第七十七条</w:t></w:r></w:p>"
        + "<w:tbl><w:tr><w:tc><w:p><w:r><w:t>申请期限</w:t></w:r></w:p></w:tc>"
        + "<w:tc><w:p><w:r><w:t>六十日</w:t></w:r></w:p></w:tc></w:tr></w:tbl>"
        + "</w:body></w:document>";
    String html = new MossKbDocumentPreviewService().docxToHtml(docx(xml));
    assertTrue(html.contains("<p>第七十七条</p>"));
    assertTrue(html.contains("<table class=\"preview-table\">"));
    assertTrue(html.indexOf("第七十七条") < html.indexOf("申请期限"));
  }

  /** 文档正文里的尖括号必须转义,否则预览会把文档内容当成标签执行。 */
  @Test
  public void docxPreviewEscapesDocumentText() throws Exception {
    String xml = "<w:document xmlns:w='urn:test'><w:body>"
        + "<w:p><w:r><w:t>&lt;script&gt;alert(1)&lt;/script&gt;</w:t></w:r></w:p>"
        + "</w:body></w:document>";
    String html = new MossKbDocumentPreviewService().docxToHtml(docx(xml));
    assertTrue(html.contains("&lt;script&gt;"));
    assertFalse(html.contains("<script>"));
  }

  @Test
  public void csvPreviewHandlesQuotedCells() {
    MossKbDocumentPreviewService.Preview preview = new MossKbDocumentPreviewService()
        .csvToHtml("名称,说明\n\"行政复议,申请\",\"含\"\"引号\"\"\"\n");
    assertTrue(preview.content().contains("<td>行政复议,申请</td>"));
    // 单元格里的双引号属于正文,输出 HTML 时按实体转义。
    assertTrue(preview.content().contains("<td>含&quot;引号&quot;</td>"));
  }

  private byte[] docx(String xml) throws Exception {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipOutputStream zip = new ZipOutputStream(out)) {
      zip.putNextEntry(new ZipEntry("word/document.xml"));
      zip.write(xml.getBytes(StandardCharsets.UTF_8));
      zip.closeEntry();
    }
    return out.toByteArray();
  }
}

七、验收

场景期望实测
分段预览点对话来源卡片仍然弹出分段预览,命中分段高亮并可滚动定位通过(本次改动后重新回归)
分段预览头部入口点文档名称或「预览全文」在新标签页打开预览页通过
知识库引用入口引用分段卡片上的文档名称在新标签页打开预览页通过
无需登录清空浏览器全部登录令牌后直接打开预览地址,正文正常渲染通过
无需登录(下载)同一状态下点「下载文档」,/file?download=true 返回 200通过
Word(.docx)页面按原文顺序渲染段落与表格,头部显示类型、知识库、文件大小通过
PDF浏览器内置阅读器,可翻页、缩放、搜索通过
纯文本(.txt)等宽字体保留换行,中文不乱码通过
CSV转成表格,引号包裹的逗号不被拆列通过
文档归属校验换个不属于该应用的文档 ID,返回「文档不存在或不属于该应用的知识库」通过
原文件缺失退回分段正文(preview_kind=markdown),downloadable=false,下载按钮置灰通过
国际化界面切到英文时页面标题为 Document Preview、按钮为 Download,提示文案不出现中文通过
单元测试解析与转义用例全绿通过

未在界面里逐一截图的两类:Excel 与 ZIP 清单,与 Word 共用同一段转换逻辑,由单元测试覆盖;扫描件 PDF 不需要额外处理,PDF 分支直接展示原文件,预览不再触发 OCR,这也是把 PDF 交给浏览器渲染的另一个好处。

Edit this page
Last Updated: 10/6/26, 10:53 AM
Contributors: litongjava
Prev
分段预览
Next
多轮检索