前段时间在做一个内容管理后台,编辑器选了 Quill,后端是 Spring Boot,很快就碰上一个绕不开的需求:富文本里要传图片、传视频,但 Quill 默认只给了一个“只能把图片转成 base64 塞进编辑器”的方案,直接存库会把整张表撑爆。视频更离谱,连默认上传入口都没有。当时找了一圈插件,要么是某公司内部项目,维护基本停滞,要么需要引一堆额外依赖,最后决定自己操刀,给 Quill 写一个自定义的上传链路,图片和视频分开处理,后端在 Spring Boot 里做真正的文件接收、校验和回显。这篇文章就把这套方案完整拆开讲,从思路到代码到踩坑全过程,适合正在做后台管理系统、或者想给富文本编辑器加自定义上传能力的开发者参考,前端后端的代码都会给到。
1. 方案整体设计与拆解
1.1 为什么不用 Quill 默认的图片上传
Quill 的图片处理逻辑很朴素:你把图片丢进编辑器,它默认把图片转成 base64 字符串,然后以 data URL 的形式嵌入<img>标签里。这样做的优点是实现简单,不依赖任何后端接口,但代价极其明显——图片数据会直接膨胀约 33%,一张 5MB 的图片转成 base64 后大概变成 6.7MB 的文本,如果文章里有十几张图,那光一条富文本内容就可能达到几十 MB 甚至上百 MB。
这种方案在数据量小、内容不涉及高清图的内部知识库场景下勉强可以用,但一旦到了正式的内容管理后台,后果会非常直接:数据库存储成本暴涨、页面加载变慢、接口传输耗时增加,甚至可能超过框架默认的请求体大小限制。所以“自定义图片上传”不是锦上添花,而是刚需。
视频的情况和图片不太一样,Quill 官方根本没有视频上传入口。默认的video格式只支持传入一个视频链接地址,它会把链接塞进<iframe>或<video>标签里,但不会帮你做任何文件上传动作。也就是说,如果你不做自定义视频上传,用户就只能手动找一个可公网访问的视频地址贴进去,这对于普通用户来说基本是不可用的。所以视频上传这条路,必须自己铺。
1.2 前后端职责划分与整体交互流程
我在设计这套方案时坚持了一个原则:前端只负责“把文件交给后端、拿到 URL 回填编辑器”,后端只负责“收文件、校验、落盘、映射 URL”,两者不互相越界。
整体交互流程是这样的:
- 用户点击工具栏的“图片”或“视频”按钮,或者直接把文件拖进编辑器。
- 前端拦截这个动作,阻止 Quill 的默认行为,弹出一个文件选择框。
- 用户选中文件后,前端取出
File对象,通过multipart/form-data发送到你指定的后端接口,而不是直接塞进编辑器。 - 后端接口收到文件,做类型校验、大小校验、生成唯一文件名、把文件存储到本地磁盘或云存储,然后返回一个可直接访问的 URL。
- 前端拿到这个 URL,调用 Quill 的
insertEmbed方法,把图片或视频地址插入到光标所在位置,并写上alt、width等可选属性。
这个链路看起来简单,但每一环都有细节坑,尤其是图片和视频的处理方式还不一样。后面我会把每一环都拆开细说。
1.3 技术选型:为什么还是用 Quill 而不是别的编辑器
做这个项目之前我也考虑过换别的富文本编辑器。某开源编辑器功能确实多,但体积偏大,而且生态里的插件质量参差不齐;另一款则太轻量,很多能力要自己拼。Quill 的定位刚好介于两者之间——核心体积小,API 设计清晰,insertEmbed、getContents、clipboard.addMatcher这些接口非常开放,非常适合做定制开发。再加上它默认带的语义化文档模型(Delta),后端正反序列化都方便。
不过 Quill 也有它让人恼火的一面:很多常用功能要自己写,比如“上传本地图片”“上传本地视频”“限制最大尺寸”“拖动调整图片大小”这些官方并没有提供开箱即用的插件。好在它的扩展机制足够灵活,你可以通过自定义 Toolbar、自定义主题、事件拦截来把缺失的能力补回来。这次的图片上传和视频上传,就是完全基于 Quill 的 Toolbar 定制和 Clipboard 拦截来实现的,不需要引入任何一个第三方插件包。
2. 后端接口实现:Spring Boot 如何接收并处理上传文件
2.1 文件上传接口的基础配置
先看 Spring Boot 这边的准备工作。如果你用的是 Spring Boot 2.x 或 3.x,org.springframework.web.multipart.MultipartFile是现成的,依赖里只要包含spring-boot-starter-web即可,它内部已经自动配置了MultipartResolver,不需要额外引入其他东西。
但是有几个默认限制必须提前改掉,否则上传稍微大一点的文件就会遇到FileSizeLimitExceededException。默认的最大单文件大小是 1MB,最大请求大小是 10MB,这对于图片可能勉强够用,但视频完全不够。我在项目的application.yml里是这样配的:
spring: servlet: multipart: max-file-size: 100MB max-request-size: 110MB location: /data/tmp/upload_cache这里我把单文件上限调到 100MB,主要是为了支持视频上传,如果你们的场景只需要图片,那调到 10MB 就足够了。location是临时文件目录,不配的话会用系统默认临时目录,并发量高了会产生碎片,所以建议指定一个固定目录,方便磁盘空间管理和清理。
还有一个隐蔽的坑:如果你用了 Nginx 做反向代理,client_max_body_size默认只有 1MB,前端传一张大图就可能给你返回 413 错误。这个不在代码里改,要改 Nginx 配置,但排查问题的时候很容易被忽略,后面常见问题章节会单独说。
2.2 实现单文件上传接口:设计、代码与参数说明
接下来是上传接口的核心代码。我个人习惯把上传接口做成一个统一的FileUploadController,但图片和视频各走各的路径,避免因为校验规则不同而互相干扰。
@RestController @RequestMapping("/api/file") public class FileUploadController { private final FileStorageService fileStorageService; public FileUploadController(FileStorageService fileStorageService) { this.fileStorageService = fileStorageService; } /** * 图片上传,支持 jpg/png/gif/webp */ @PostMapping("/image") public Result<String> uploadImage(@RequestParam("file") MultipartFile file) { Map<String, Object> extra = fileStorageService.upload(file, "image"); return Result.success(extra); } /** * 视频上传,支持 mp4/webm/ogg */ @PostMapping("/video") public Result<String> uploadVideo(@RequestParam("file") MultipartFile file) { Map<String, Object> extra = fileStorageService.upload(file, "video"); return Result.success(extra); } }这个接口层只做路由转发,真正的文件校验、存储逻辑都在FileStorageService里。我这样做是有意为之——如果后续要扩展“上传附件”“上传音频”,只需要加一个路由方法,核心逻辑不用改。
FileStorageService的关键代码是这样的:
@Override public Map<String, Object> upload(MultipartFile file, String category) { if (file == null || file.isEmpty()) { throw new BizException("上传文件不能为空"); } // 1. 校验扩展名和 MIME 类型 String originalFilename = file.getOriginalFilename(); String ext = FilenameUtils.getExtension(originalFilename); boolean valid = "image".equals(category) ? imageExts.contains(ext.toLowerCase()) : videoExts.contains(ext.toLowerCase()); if (!valid) { throw new BizException("不支持的文件类型: " + ext); } // 2. 校验文件大小 long maxSize = "image".equals(category) ? 10 * 1024 * 1024 : 100 * 1024 * 1024; if (file.getSize() > maxSize) { throw new BizException("文件大小超出限制, 最大允许 " + maxSize / 1024 / 1024 + "MB"); } // 3. 生成存储路径和唯一文件名 String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); String randomName = UUID.randomUUID().toString().replace("-", "") + "." + ext; String relativePath = category + "/" + datePath + "/" + randomName; File dest = new File(basePath + "/" + relativePath); File parentDir = dest.getParentFile(); if (!parentDir.exists()) { parentDir.mkdirs(); } // 4. 落盘 file.transferTo(dest.getAbsoluteFile()); // 5. 构造可访问 URL 并作为 JSON 结构返回 String url = accessPrefix + "/" + relativePath; return new HashMap<>() {{ put("url", url); put("size", file.getSize()); put("name", originalFilename); }}; }有几个地方需要停下来解释为什么这么做:
- 校验文件类型时,我不仅看扩展名,也建议再通过
file.getContentType()做二次校验。因为 Content-Type 是客户端直接带过来的,可能被篡改,扩展名同样可能被改,两个同时校验至少能挡住 90% 的业余操作。更好的做法是读取文件头几个字节做魔数判断,有需要的项目可以做,但普通后台系统用“扩展名 + MIME 双维度校验”已经够用。 - 文件名用 UUID 而不是原始文件名,是为了防止中文文件名乱码问题,也是为了防止同名文件互相覆盖。某开发者之前就遇到过两个用户上传了同名文件,后一个直接把前一个覆盖了,从那以后我再也没用原始名直接落盘。
- 路径按
分类/年/月/日分目录存储,而不是把所有文件堆在同一个目录下,是为了避免单个目录里文件过多导致文件系统检索变慢。Linux ext4 单目录下文件数超过几万后创建文件会有明显性能问题,按日期分目录后,单目录文件数基本不会超过几千,这个分法在维护时也方便——按日期清理过期文件只需要删目录。
方言一下:file.transferTo(dest.getAbsoluteFile())这个方法在小文件时走的是内存复制,大文件则会自动使用临时目录再转移,所以不用自己写FileOutputStream流复制逻辑,省了很多样板代码。但前提是目标目录的父目录必须存在,否则会抛IOException,所以我在落盘前先做了mkdirs()。
2.3 本地存储与访问 URL 的映射:静态资源映射配置
文件落盘之后,URL 怎么和磁盘路径对应上?这是新手最容易卡住的地方。你前台上传成功返回了一个 URL,但浏览器访问却返回 404,排查到最后发现根本没有配置资源映射。
我用的是本地磁盘存储(相对简单,也便于部署),所以需要在 Spring Boot 里把/upload/**这个访问路径映射到磁盘上的存储目录。这样外部通过http://你的域名/api/file/...才能访问到文件。
实际上更合理的做法是把静态资源映射和接口前缀错开。我在WebMvcConfigurer里做静态资源映射:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Value("${file.storage.base-path}") private String basePath; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String location = "file:" + basePath + File.separator; registry.addResourceHandler("/files/**") .addResourceLocations(location); } }这样磁盘上的/data/upload/image/2025/03/01/xxxx.jpg就可以通过http://localhost:8080/files/image/2025/03/01/xxxx.jpg访问到了。注意addResourceLocations的地址必须以/或file:结尾,这是 Spring Boot 的一个老规矩,漏掉会直接报路径匹配失败。
如果你把文件存到的是阿里云 OSS、腾讯云 COS、MinIO 这类对象存储,那这一步就不需要了,接口直接返回存储服务给的外链即可。但对象存储牵扯到凭证管理、分片上传、回调设置,复杂度更高,并不是所有后台项目都有必要一开始就引入。本地磁盘存储配合 Nginx 代理静态资源,是最快见效、最不容易出问题的方案。
顺带说一个生产环境里要注意的点:开发环境直接用 Spring Boot 映射/files/**没问题,但生产环境最好用 Nginx 直接location /files/ { root /data/upload/; }来访问,让静态文件走 Nginx 不走 Java 进程,省掉一层 Servlet 容器的开销,提升并发能力。
3. 前端 Quill 集成:自定义图片上传与视频上传
3.1 Quill 初始化与自定义工具栏的构建
前端部分先理清工具栏。Quill 官方默认的 Toolbar 里并没有“上传视频”这个按钮,所以我需要自己构建一个 toolbar 数组,先注册一个custom-video按钮,再配合图片的image按钮一起完成注入。
初始化代码如下:
const quill = new Quill('#editor', { theme: 'snow', modules: { toolbar: { container: [ [{ header: [1, 2, 3, false] }], ['bold', 'italic', 'underline'], ['blockquote', 'code-block'], [{ list: 'ordered' }, { list: 'bullet' }], ['link', 'image'], ['custom-video'] // 自定义视频按钮 ], handlers: { // 覆盖默认的 image 处理器 'image': imageHandler, // 自定义 video 处理器 'custom-video': videoHandler } } } });注意这里的重点是handlers对象。我们可以通过设置工具栏的image处理器来覆盖默认的图片行为;而custom-video处理器则是在原来不存在的按钮上新增的行为。这里的imageHandler、videoHandler都是在外面定义好的函数,内部会执行文件选择和上传逻辑。
有同学会问:为什么用container + handlers而不是直接用toolbar数组?因为 Quill 的toolbar模块在传入container数组时会自动生成对应的按钮 DOM,如果你把handlers放在同一层结构里,Quill 在构建工具栏时会自动把image等已有按钮的行为绑定到你的自定义逻辑上。这样的写法最干净、最贴近官方推荐方式。
3.2 图片上传的完整实现:选择文件、发送请求、插入编辑器
imageHandler的核心逻辑就三步:唤起文件选择框 → 调用后端接口上传 → 拿到 URL 插入编辑器。这里有一个关键的设计点:如何唤起文件选择框。你可以用隐藏的<input type="file">,也可以在点击时用 JS 创建File对象。我一般用动态创建input的方式,方便控制accept属性,也避免在 DOM 里留一个隐藏输入框。
实际代码如下:
function imageHandler() { const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/png, image/jpeg, image/gif, image/webp'; input.multiple = false; input.onchange = () => { const file = input.files[0]; if (!file) return; uploadFile(file, '/api/file/image', (data) => { const url = data.url; let range = quill.getSelection(true); // 如果用户之前没有光标位置,则定位到末尾 if (!range || range.index == null) { range = quill.getSelection() || { index: quill.getLength() - 1, length: 0 }; } quill.insertEmbed(range.index, 'image', url, 'user'); quill.insertText(range.index + 1, '\n', 'user'); quill.setSelection(range.index + 2, 0); }, (err) => { console.error('图片上传失败', err); alert('图片上传失败,请重试'); }); }; input.click(); }几个细节值得说明:
quill.getSelection(true)里面传true表示只读取选区,不触发 UI 刷新。有时候用户没有点击编辑器内部,getSelection()返回 null,直接往insertEmbed传 null 会报错,所以需要一个兜底逻辑。insertEmbed的最后一个参数'user'代表这次插入是用户主动行为,Quill 会把这次变更记录到 history 堆栈里,这样用户按 Ctrl+Z 是可以撤销上传操作的。如果传'api',变更不会进入撤销栈,用户一旦误操作就无法撤销,体验差别很大。- 插入图片后要额外插一个换行符,否则图片在编辑器里会紧贴底部工具栏,而且最后一个段落没有
<p>标签包裹,Quill 在getContents序列化时会出现奇怪的换行丢失问题。这是 Quill 的老毛病,后面样式那部分还会遇到。
uploadFile是一个公共方法,图片和视频共用:
function uploadFile(file, url, onSuccess, onError) { const formData = new FormData(); formData.append('file', file); fetch(url, { method: 'POST', body: formData, // 不要手动设置 Content-Type,让浏览器自动生成带 boundary 的 multipart 头 }) .then(res => res.json()) .then(data => { if (data.code === 200) { onSuccess(data.data); } else { onError(new Error(data.message || '上传失败')); } }) .catch(err => onError(err)); }这里我特意把fetch不设置Content-Type。因为multipart/form-data请求需要带一个boundary分隔符,这个随机分隔符是由浏览器自动生成的,如果你手动指定Content-Type就会丢失boundary,导致后端解析 multipart 失败。这是上传功能里最常见的一个低级错误,遇到了就会卡很久,推荐直接贴在博客里警醒新人。
3.3 视频上传的完整实现:按钮注册、文件筛选、编辑器回填
视频的逻辑和图片基本同构,但有两个差异点:一是 Quill 没有默认的 video 按钮,需要在工具栏容器里增加;二是视频上传回填时用的 embeds 名称不同,图片是image,视频是video,但 Quill 内部的实现会把video插成一个<iframe>包裹的播放器。
我的videoHandler如下:
function videoHandler() { const input = document.createElement('input'); input.type = 'file'; input.accept = 'video/mp4, video/webm, video/ogg'; input.multiple = false; input.onchange = () => { const file = input.files[0]; if (!file) return; uploadFile(file, '/api/file/video', (data) => { const url = data.url; let range = quill.getSelection(true); if (!range || range.index == null) { range = quill.getSelection() || { index: quill.getLength() - 1, length: 0 }; } quill.insertEmbed(range.index, 'video', url, 'user'); quill.insertText(range.index + 1, '\n', 'user'); quill.setSelection(range.index + 2, 0); }, (err) => { console.error('视频上传失败', err); alert('视频上传失败,请重试'); }); }; input.click(); }注意,insertEmbed的第二个参数传入的是 URL,这个 URL 会被当成video格式的值写入 Delta。如果你希望视频直接以<video>标签呈现而不是<iframe>的话,可以关闭 Quill 默认的 video HTML 生成方式,但考虑到后台管理系统里大多数编辑预览场景用 iframe 也无妨,我就没有深改,直接沿用默认行为。想换成<video>标签的读者,后面会提一句怎么改。
前端accept属性只是给用户一个友好提示,不能作为真正的文件校验手段,因为用户可以绕过文件选择框改成“所有文件”。所以后端的类型、大小校验必须有,这是我这套方案里反复强调的原则。
3.4 粘贴视频链接与粘贴图片的拦截处理
除了点按钮上传,用户在编辑时最常见的操作是从剪贴板直接粘贴图片,或者贴一个视频链接。如果不做拦截,Quill 的默认行为是:粘贴图片时又把 base64 塞进来;粘贴视频链接时则直接当成超链接插入,而不是视频。这两种行为都达不到我们的预期,所以需要借助 Clipboard 模块来拦截。
Quill 的clipboard.addMatcher可以监听粘贴的 DOM 节点或 CSS 选择器。我用它实现了两个拦截:
// 拦截粘贴图片 quill.clipboard.addMatcher('img', (node, delta) => { if (node.src && node.src.startsWith('data:')) { // 拦截 base64 图片,转成文件上传 const file = dataURLtoFile(node.src, 'pasted-image.png'); if (file) { uploadFile(file, '/api/file/image', (data) => { const range = quill.getSelection(true); quill.insertEmbed(range.index, 'image', data.url, 'user'); quill.deleteText(range.index + 1, 1, 'user'); }); } return new Delta(); } return delta; }); // 拦截视频链接粘贴 document.querySelector('#editor').addEventListener('paste', (e) => { const text = e.clipboardData.getData('text/plain'); const videoUrlPattern = /\.(mp4|webm|ogg)(\?.*)?$/i; if (videoUrlPattern.test(text.trim())) { e.preventDefault(); const range = quill.getSelection(true); quill.insertEmbed(range.index, 'video', text.trim(), 'user'); quill.insertText(range.index + 1, '\n', 'user'); } });dataURLtoFile是一个工具函数,把 base64 数据转成File对象再走后端上传。这个拦截很实用,因为大多数用户从网页直接复制图片时,剪贴板里就是 base64 数据,如果不处理就会重蹈默认行为的覆辙。
粘贴视频链接的正则只能匹配一个大概,在实际项目里还可以做得更精细,比如校验域名后缀、匹配常见视频平台链接等,但作为通用后台方案,能截住 mp4/webm/ogg 直链已经够用了。
4. 前端展示优化:图片样式约束与视频回显处理
4.1 编辑器内图片最大宽度与视频容器样式
图片上传成功了,但如果你不约束样式,用户上传一张高清大图,编辑器就会被撑得乱七八糟。在展示层的观看体验同样重要,我在 Quill 初始化之后,通过 CSS 对.ql-editor里的 img 和视频容器做了限制。
#editor .ql-editor img { max-width: 100%; height: auto; display: block; margin: 10px 0; } #editor .ql-editor iframe { max-width: 100%; min-height: 300px; border-radius: 4px; }这里给图片加display: block是为了避免图片默认是 inline 元素,导致它和其他文字内容之间出现莫名的基线间隙。margin: 10px 0则是为了让图片在段落之间有一点呼吸感,不至于贴边。
视频容器这里,Quill 默认生成的<iframe>宽高是固定的,如果超过了容器会被截断。我给出的max-width: 100%可以解决宽屏溢出问题,但要注意 Quill 的 snow 主题自带的样式优先级不低,有时候需要加!important才能覆盖成功。更稳妥的做法是直接修改主题变量,但为了简单,我这里就在项目中加了额外的样式覆盖,实践下来效果很好。
4.2 回显编辑器内容时视频和图片如何处理
富文本编辑器的另一大坑是回显。你在编辑页用quill.setContents(html)或者quill.clipboard.dangerouslyPasteHTML(html)回显之前保存的内容时,如果内容里的图片 URL 是上一步上传接口返回的相对路径或绝对路径,那显示都会正常;但如果之前保存的内容里嵌的是 base64 图片,回显时除非做了特殊处理,否则依然会以 base64 形式渲染,这个问题会直接放大页面体积。
所以我的建议是:保存内容时,强制把当前编辑器里的所有图片 URL 检查一遍,对有data:前缀的图片做一次“二次上传”,把转换后的 URL 替换回 Delta 里再提交后端。这一步可以用 Quill 的getContents加遍历 Delta 的方式实现:
function sanitizeDelta(delta) { const ops = delta.ops.map(op => { if (op.insert && op.insert.image && op.insert.image.startsWith('data:')) { // 这是一个待转化的 base64 图片,需要同步上传并替换 URL const file = dataURLtoFile(op.insert.image, 'inline-image.png'); // 这里做同步或异步上传并替换 } return op; }); return delta; }但这属于优化层面的逻辑,需要异步上传再回填,在保存动作里会比较啰嗦。我个人的取舍是:既然前端在粘贴和上传时都已经拦截了 base64 来源,正常情况下编辑器里不会出现 base64,那么回显逻辑只需要保证 URL 拼接正确即可,不需要花太多精力做二次上传。
回显时还有一个更容易踩到的坑:你保存到数据库的内容里可能有<video>标签,或者<iframe>标签,Quill 的dangerouslyPasteHTML默认会过滤掉部分标签。比如默认配置下,<video>标签可能被剥掉,导致用户重新打开编辑器时视频丢失。解决办法是给 Clipboard 加一个自定义 matcher,保留视频标签:
quill.clipboard.addMatcher('video', (node, delta) => { const src = node.getAttribute('src'); if (src) { return new Delta().insert({ video: src }); } return delta; });这样在回显带<video>标签的内容时,Quill 能正确把标签转回视频 embed,而不是直接丢弃。
5. 常见问题与排查技巧实录
5.1 上传接口报 413、文件大小超限、请求超时
这一类问题最容易在刚部署到测试环境时集中爆发。413 基本上两个原因:一是 Spring Boot 的max-file-size没调大,二是 Nginx 的client_max_body_size没调大。排查思路是先看浏览器开发者工具的网络面板,如果请求发出了但响应直接是 413,那基本就是 Nginx 拦截;如果进入后端日志才报异常,那基本就是 Spring 的 multipart 限制。这两个限制“双管齐下”,必须同步调整。
上传超过一定大小后,前端和后端都会遇到请求超时。如果你用默认配置,大视频上传可能要 3~5 分钟,那么服务端和网络中间层的超时时间都得放宽。我的做法是前端用fetch时不设 AbortController,后端接口设置为异步任务或者直接调长调用超时,这里如果你的服务通过网关对外暴露,网关的超时时间也要一并检查。
下面给一个排查速查表:
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 接口返回 413 | Nginxclient_max_body_size太小或 Spring 的max-file-size太小 | 两边同时调大并重启 |
| 后端提示文件为空 | 前端 FormData 的 key 与后端@RequestParam不一致 | 统一为file |
| 接口 500 且日志显示目录不存在 | 存储目录没有提前创建 | 落盘前mkdirs() |
| 上传成功但浏览器访问 404 | 静态资源映射没配或 Nginx 转发路径不对 | 检查/files/**映射配置 |
| 回显时视频不显示 | <video>标签被 Quill 过滤 | 添加videomatcher |
| 插入图片后按 Ctrl+Z 无法撤销 | insertEmbed的 source 参数传了'api' | 改成'user' |
| 中文文件名乱码 | 直接使用原始文件名存储 | 改用 UUID 重命名 |
5.2 图片插入成功但不显示、显示裂图
遇到图片裂图,八成是 URL 拼接错了。我在一个项目里踩过这个坑:后端接口返回的是完整路径http://localhost:8080/files/xxx.jpg,但前端编辑器是部署在https://cdn.某公司.com域名下的,两者协议不一致导致浏览器阻止了资源加载(混内容问题)。所以如果生产环境用 HTTPS,后端返回的 URL 也必须是 HTTPS,否则浏览器直接拦截。
另一个隐蔽问题是:Quill 的imageembed 默认只支持字符串 URL,如果后端返回的 URL 里带中文参数或者空格,前端是能插入的,但浏览器解析时会异常。解决方式是把 URL 做一次encodeURI处理,不过更好的办法是在后端存储时就保证文件名是纯 ASCII(UUID),这样 URL 天然干净。
还有一种常见情况是图片插入成功,编辑器能显示,但再次编辑回显时图片丢失。这个通常是因为保存到数据库的内容经过了某些 XSS 过滤组件,比如你把富文本交给一个白名单过滤器,里面的img标签或者src属性被清了。这种问题直接查过滤器的白名单配置,把img和src加进去。
5.3 上传视频格式正常但无法播放
能上传成功、能回显、但就是播放不了,这种问题通常不是你的代码问题,而是浏览器解码能力限制。目前主流浏览器对视频格式的支持是:MP4(H.264 编码)兼容性最佳,WebM 在 Chrome/Firefox 表现好,但 Safari 对 WebM 支持不佳。如果你上传的视频是 MP4 但编码是 HEVC/H.265,那么大多数浏览器都无法播放,后端校验只看了扩展名和 MIME 类型,并不能识别编码格式。
业内最稳的推荐:后台系统的视频上传统一只收 MP4(H.264 编码)格式。如果用户传了其他格式,要么在后端做转码,要么直接在前端拦截提示“请上传 MP4 格式”。完整转码方案要引入 FFmpeg,对项目复杂度提升很大,不是所有后台都需要一上来就做。如果现阶段没有转码能力,至少要在发送到播放器之前用video元素的canPlayType做一次检测,给出友好提示,避免用户面对一个无法播放的黑框。
5.4 文件目录权限导致的写入失败
在 Linux 服务器上部署时,一个非常常见的报错是Permission denied或者FileNotFoundException。原因是上传目录的属主不是当前运行 Spring Boot 进程的用户。比如你用 root 账号创建了/data/upload,然后以www-data用户启动服务,那进程对/data/upload没有写权限,transferTo就会抛异常。
排查命令很简单:
ls -ld /data/upload ps -ef | grep java看当前进程用户是谁,然后确认该用户对存储目录是否有写权限。没有的话直接:
chown -R 运行用户:运行用户组 /data/upload设置好之后重启服务。这个坑在很多开发者第一次上服务器时都会踩到,因为本机开发时基本不存在权限问题,一旦上服务器全是权限坑。
6. 最终项目结构示意与代码可复用性思考
整个功能落完之后,项目的结构大致是这样的:
src/main/java └─ com/example/admin ├─ controller │ └─ FileUploadController.java ├─ service │ ├─ FileStorageService.java │ └─ impl │ └─ FileStorageServiceImpl.java └─ config └─ WebMvcConfig.java src/main/resources ├─ application.yml └─ static └─ quill ├─ index.html ├─ app.js └─ style.css这个结构已经把前后端完全解耦了。后端不关心前端用的是 Quill 还是别的编辑器,只要按约定的接口返回带url字段的 JSON 即可;前端也不关心后端文件到底存在本地还是 OSS,只需要拿到 URL 回填。
后续如果要扩展能力,大致方向可以这样走:
- 如果需要多图上传,把
input.multiple = true打开,然后在onchange里循环调用uploadFile,即可实现批量插入。 - 如果需要图片裁剪,可以在上传前用 Canvas 对图片做压缩,再传后端。
- 如果需要视频封面,可以在视频上传接口里额外接收一个
coverFile参数,一并保存。
这套方案的定位就是“最小可用、清晰可扩展”,没有引入任何重量级依赖,前后端代码加起来不过几百行,对任何已有 Spring Boot 项目都能直接搬。如果你正在做后台管理系统,而且需要在 Quill 里实现图片和视频上传,照着这个思路走基本不会再踩到隐蔽的坑。我在实际开发中体会最深的一点是:别指望官方插件帮你解决所有业务需求,Quill 提供了很好的扩展基底,但“自定义上传”这一环,自己动手才是最可控的方案。