Quill富文本图片视频自定义上传方案:Spring Boot后端实现全解析
前段时间在做一个内容管理后台编辑器选了 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.xorg.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 ResultString uploadImage(RequestParam(file) MultipartFile file) { MapString, Object extra fileStorageService.upload(file, image); return Result.success(extra); } /** * 视频上传支持 mp4/webm/ogg */ PostMapping(/video) public ResultString uploadVideo(RequestParam(file) MultipartFile file) { MapString, Object extra fileStorageService.upload(file, video); return Result.success(extra); } }这个接口层只做路由转发真正的文件校验、存储逻辑都在FileStorageService里。我这样做是有意为之——如果后续要扩展“上传附件”“上传音频”只需要加一个路由方法核心逻辑不用改。FileStorageService的关键代码是这样的Override public MapString, 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 typefile也可以在点击时用 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 堆栈里这样用户按 CtrlZ 是可以撤销上传操作的。如果传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后端接口设置为异步任务或者直接调长调用超时这里如果你的服务通过网关对外暴露网关的超时时间也要一并检查。下面给一个排查速查表现象可能原因解决方向接口返回 413Nginxclient_max_body_size太小或 Spring 的max-file-size太小两边同时调大并重启后端提示文件为空前端 FormData 的 key 与后端RequestParam不一致统一为file接口 500 且日志显示目录不存在存储目录没有提前创建落盘前mkdirs()上传成功但浏览器访问 404静态资源映射没配或 Nginx 转发路径不对检查/files/**映射配置回显时视频不显示video标签被 Quill 过滤添加videomatcher插入图片后按 CtrlZ 无法撤销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处理不过更好的办法是在后端存储时就保证文件名是纯 ASCIIUUID这样 URL 天然干净。还有一种常见情况是图片插入成功编辑器能显示但再次编辑回显时图片丢失。这个通常是因为保存到数据库的内容经过了某些 XSS 过滤组件比如你把富文本交给一个白名单过滤器里面的img标签或者src属性被清了。这种问题直接查过滤器的白名单配置把img和src加进去。5.3 上传视频格式正常但无法播放能上传成功、能回显、但就是播放不了这种问题通常不是你的代码问题而是浏览器解码能力限制。目前主流浏览器对视频格式的支持是MP4H.264 编码兼容性最佳WebM 在 Chrome/Firefox 表现好但 Safari 对 WebM 支持不佳。如果你上传的视频是 MP4 但编码是 HEVC/H.265那么大多数浏览器都无法播放后端校验只看了扩展名和 MIME 类型并不能识别编码格式。业内最稳的推荐后台系统的视频上传统一只收 MP4H.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 提供了很好的扩展基底但“自定义上传”这一环自己动手才是最可控的方案。

相关新闻

Unity 2D地图Demo全流程:Tilemap坐标换算与性能优化实践

Unity 2D地图Demo全流程:Tilemap坐标换算与性能优化实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/17 1:05:15 阅读更多 →
ROS Melodic 离线安装方案:Ubuntu 18.04 下 rosdep 本地化与依赖处理

ROS Melodic 离线安装方案:Ubuntu 18.04 下 rosdep 本地化与依赖处理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/17 1:04:15 阅读更多 →
多段图最短路径:动态规划状态设计与Python实现

多段图最短路径:动态规划状态设计与Python实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/17 1:04:15 阅读更多 →

最新新闻

Lokinet多平台部署实战:Windows、Linux、MacOS完整安装教程

Lokinet多平台部署实战:Windows、Linux、MacOS完整安装教程

Lokinet多平台部署实战:Windows、Linux、MacOS完整安装教程 Lokinet是一个匿名、去中心化且基于IP的互联网覆盖网络,让用户能够在保护隐私的同时安全地访问互联网。本教程将详细介绍如何在Windows、Linux和MacOS三大主流操作系统上部署Lokinet&#xff…

2026/10/17 1:48:27 阅读更多 →
软考 系统架构设计师历年真题集萃(154)

软考 系统架构设计师历年真题集萃(154)

接前一篇文章:软考 系统架构设计师系列知识点之杂项集萃(153) 第291题 EJB是企业级Java构件,用于开发和部署结构的、分布式的、面向对象的Java应用。其中,()负责完成服务端与客户端的交互;()用于数据持久化来简化数据库开发工作;()主要用来处理并发和异步访问操作…

2026/10/17 1:48:27 阅读更多 →
就地型馈线自动化技术原则与整定避坑指南:四种模式详解

就地型馈线自动化技术原则与整定避坑指南:四种模式详解

简介:这份名为《附件8:就地型馈线自动化技术原则(试行)》的技术文档,系统阐述了自适应综合型馈线自动化技术,面向配电网规划运行、自动化改造及运维检修人员,重点解决多分支多联络配电网中故障定…

2026/10/17 1:48:27 阅读更多 →
Spark-X2.5 全面解析:4B 参数 + 1M 上下文 + 200 语言,端侧 Agent 模型新王如何炼成

Spark-X2.5 全面解析:4B 参数 + 1M 上下文 + 200 语言,端侧 Agent 模型新王如何炼成

【免费下载链接】Spark-X2.5-4B-FP8 项目地址: https://ai.gitcode.com/SparkLLM/Spark-X2.5-4B-FP8 点击查看 免费下载 Spark-X2.5-4B-FP8 是一款面向端侧部署的 4B 参数大语言模型,原生支持 1M token 上下文窗口和 200 多种语言,主打 Agen…

2026/10/17 1:48:27 阅读更多 →
CKEditor 4 在 iOS 移动端工具栏按钮 Hover 样式的正确修复与手动验收测试指南

CKEditor 4 在 iOS 移动端工具栏按钮 Hover 样式的正确修复与手动验收测试指南

前端UI组件 【免费下载链接】ckeditor4 The best enterprise-grade WYSIWYG editor. Fully customizable with countless features and plugins. 项目地址: https://gitcode.com/gh_mirrors/ck/ckeditor4 点击查看 免费下载 导读 本文基于 CKEditor 4 官方仓库中的…

2026/10/17 1:48:27 阅读更多 →
云上运维系统学习路线:VPC、Terraform与K8s实操指南

云上运维系统学习路线:VPC、Terraform与K8s实操指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/17 1:47:27 阅读更多 →

日新闻

DeepSeek选型与部署:V3/R1、Ollama、API全攻略

DeepSeek选型与部署:V3/R1、Ollama、API全攻略

简介:《DeepSeek使用方法.pdf》是一份面向DeepSeek入门及进阶用户的完整使用指南,重点围绕V3与R1两款模型的能力差异与适用场景展开,帮助读者根据任务类型与成本选择合适的模型。资源共1个PDF文件,压缩包大小约311KB,篇…

2026/10/17 0:00:25 阅读更多 →
200+Deepseek润色指令拆解:从底层原理到场景落地的完整指南

200+Deepseek润色指令拆解:从底层原理到场景落地的完整指南

简介:这是一份聚焦DeepSeek提示词优化的《200Deepseek润色指令》PDF文档,共收录两百余条可直接套用的润色指令,涵盖语法修正、风格统一、逻辑梳理、表达精炼等维度,并针对商务写作、学术论文、广告文案等场景给出差异化建议。文档…

2026/10/17 0:00:25 阅读更多 →
OpenCV轮廓法物体计数系统:轻量、可控、可落地的工业级实现

OpenCV轮廓法物体计数系统:轻量、可控、可落地的工业级实现

简介:本资源是一份基于OpenCV的本科课程设计完整报告,面向计算机视觉初学者、图像处理学习者及高校通信/信息类专业学生,解决物体智能计数与检测结果结构化记录的实际问题。项目覆盖图像预处理、轮廓检测、边界框绘制、轨迹栏交互式参数调节、…

2026/10/17 0:00:25 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/16 21:35:51 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/17 1:44:59 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/16 15:04:24 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 21:54:38 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 3:49:43 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 14:23:36 阅读更多 →