1. 为什么你需要了解WPS V3三阶段保存如果你正在开发一个需要在线编辑文档的企业应用比如OA系统、知识库或者合同管理平台那你肯定遇到过这个问题用户在线编辑完文档后怎么把最新的内容安全、可靠地保存回我们自己的服务器总不能一直让文件留在WPS的临时空间里吧。这就是WPS V3三阶段保存接口要解决的核心问题。简单来说它是一套WPS官方定义的“交接协议”专门用于处理已有文件在线编辑后的保存流程。我刚开始接触时也迷糊过以为它是用来上传新文件的结果调试了半天没反应。这里必须划重点三阶段保存只针对“已有文件”的更新操作你系统里得先有一份文件用户打开这份文件进行编辑保存时才会触发这个流程。我把它想象成一场精心安排的“文件交接仪式”。WPS是编辑方我们的Java后端是接收保管方。整个过程分为三个阶段外加一个我们自己掌控的“2.5阶段”一阶段准备双方打个招呼确认一下交接的“暗号”比如用SHA1校验文件完整性。二阶段获取地址我们告诉WPS“等会儿你把新文件送到这个地址URL并且要带上这些凭证Headers/Params。”2.5阶段核心交付这是我们自己的接口WPS会按照上一步的指示把编辑好的新文件内容二进制流送到这里。我们在这里完成文件存储、版本记录等所有核心业务逻辑。三阶段完成通知文件送完了WPS通知我们一声“货已送到。”我们则回复最新的文件信息如新版本号、大小等整个流程圆满结束。这套机制的优势非常明显。它将文件内容的传输2.5阶段和业务逻辑版本管理、存储彻底解耦让我们可以在自己可控的服务器上安心处理文件再也不用担心文件流转中的安全性和一致性问题。接下来我就带你一步步拆解用Java把这套流程跑通。2. 动手之前环境与配置准备在开始写代码之前有几项准备工作是必须完成的这能让你后续的开发过程事半功倍。我自己就曾因为配置没搞对在调试时浪费了不少时间。首先你需要拥有WPS开放平台的开发者身份。前往WPS开放平台创建你的应用。成功创建后你会获得两把至关重要的“钥匙”AppId(有时也叫client_id) 和AppSecret(有时也叫client_secret)。这两个参数是你所有服务端接口与WPS云服务进行安全通信的凭证一定要妥善保管。通常我会把它们放在项目的配置文件中比如application.ymlwps: app-id: your_app_id_here app-secret: your_app_secret_here # 你的回调域名用于拼接完整的回调地址 callback-domain: https://your-domain.com其次准备好你的文件存储服务。三阶段保存的本质是文件更新新文件必须有个地方存。无论是阿里云OSS、腾讯云COS还是自建的FastDFS、MinIO甚至服务器本地磁盘你都需要提前集成好对应的SDK并确保你的应用有权限进行文件的上传和覆盖操作。在本文的示例中我会以阿里云OSS为例但逻辑是通用的。最后也是新手最容易忽略的一点确保你的回调地址是公网可访问的。WPS的服务器需要能通过网络调用你提供的接口。在开发阶段你可以使用内网穿透工具如ngrok、花生壳将本机的服务暴露到一个临时的公网地址用于调试。生产环境则必须是备案过的正式域名。准备好这些我们的代码之旅就可以正式开始了。3. 第一阶段准备上传确认握手协议一阶段是整个保存流程的起点非常简单主要目的是进行一个简单的“能力协商”。WPS会问你的服务“我准备上传文件了你支持哪种文件完整性校验方式” 目前WPS主要支持sha1校验。这个接口的实现非常直接。你需要提供一个GET接口路径规范通常为/v3/3rd/files/{file_id}/upload/prepare。其中{file_id}是你自己业务系统中文件的唯一标识WPS会在回调时原样传回让你知道是哪个文件要保存。来看一下我的实现代码/** * 一阶段准备上传 * 注意此接口仅用于已有文件的更新保存流程 * param fileId 业务系统中的文件ID */ GetMapping(/v3/3rd/files/{file_id}/upload/prepare) public ResultMapString, Object prepareUpload(PathVariable(file_id) String fileId) { // 1. 这里可以加入业务校验例如检查fileId是否存在用户是否有权限编辑等 // SysWpsFile file fileService.getById(fileId); // if (file null) { throw new BusinessException(文件不存在); } // 2. 构造响应告知WPS我们支持sha1摘要校验 MapString, Object response new HashMap(); ListString digestTypes new ArrayList(); digestTypes.add(sha1); response.put(digest_types, digestTypes); // 3. 返回成功响应 return Result.success(response); }这个接口的响应体固定格式是一个JSON对象包含一个digest_types数组。虽然目前似乎只支持sha1但按照规范返回数组是一种更稳妥的做法。这个阶段本身不执行任何文件操作但它是一个重要的信号表示你的服务已就绪可以开始接收文件更新请求了。有时候调试时发现流程没走下去首先就应该检查这个一阶段接口是否被正确调用和响应。4. 第二阶段提供上传地址与参数如果说一阶段是打招呼那么二阶段就是下达具体的“送货指令”。这是整个流程中信息编排的关键一步。在这个接口里你需要告诉WPS三件事1. 把新文件送到哪里URL2. 用什么方式送HTTP Method3. 送货时需要携带哪些“单据”Headers 或 Query Params。这个接口是一个POST请求路径类似/v3/3rd/files/{file_id}/upload/address。WPS会传来一个请求体包含新文件的大小 (size)、名称 (name) 等信息。我们的核心任务就是构造一个详细的“送货单”返回去。下面是我在实际项目中使用的代码其中包含了对中文文件名等常见痛点的处理/** * 二阶段获取上传地址及参数 * param fileId 业务文件ID * param requestDTO WPS传来的请求参数包含新文件信息 */ PostMapping(/v3/3rd/files/{file_id}/upload/address) public ResultFileUploadAddressVO getUploadAddress(PathVariable(file_id) String fileId, RequestBody FileUploadAddressRequestDTO requestDTO, HttpServletRequest httpRequest) { // 1. 查询原文件信息 SysWpsFile originalFile fileService.getById(fileId); if (originalFile null) { throw new RuntimeException(文件不存在); } // 2. 构建响应VO FileUploadAddressVO vo new FileUploadAddressVO(); // 3. 核心设置WPS回调我们自有接口的URL (2.5阶段接口) // 这个地址必须是公网可访问的完整URL String customUploadUrl wpsProperties.getCallbackDomain() /api/wps/v3/uploadFile/ fileId; vo.setUrl(customUploadUrl); vo.setMethod(PUT); // 通常使用PUT方法接收文件流 // 4. 精心构造请求头Headers解决中文文件名等难题 MapString, String headers new HashMap(); // 生成一个唯一的OSS存储文件名避免覆盖 String uniqueOssFileName generateUniqueFileName(originalFile.getName()); try { // 【关键技巧】对中文文件名进行URL编码后再放入Header // 因为HTTP Header直接传输中文可能在不同网关、代理层出现乱码 String encodedOriginalName URLEncoder.encode(originalFile.getName(), UTF-8); String encodedOssFileName URLEncoder.encode(uniqueOssFileName, UTF-8); headers.put(X-File-Id, fileId); headers.put(X-File-Name, encodedOriginalName); // 编码后的原始文件名 headers.put(X-File-Type, originalFile.getType()); headers.put(X-Oss-File-Name, encodedOssFileName); // 编码后的OSS存储名 headers.put(X-File-Size, String.valueOf(requestDTO.getSize())); // 携带Token用于后续接口鉴权从初始化时的Token获取 headers.put(X-Auth-Token, extractTokenFromRequest(httpRequest)); } catch (UnsupportedEncodingException e) { throw new RuntimeException(文件名编码失败, e); } vo.setHeaders(headers); // 5. 设置Query参数本例中不需要所有信息通过Header传递 vo.setParams(new HashMap()); // 6. 设置回传参数send_back_params // 这个字段内的参数WPS会在第三阶段的回调请求体中原样带回给你。 // 适合放一些不需要在2.5阶段Header中暴露但又需要在最终确认时使用的信息。 MapString, String sendBackParams new HashMap(); sendBackParams.put(originalFileId, fileId); sendBackParams.put(operator, getCurrentUserId()); // 记录操作者 vo.setSend_back_params(sendBackParams); return Result.success(vo); }这里有几个我踩过坑后总结的实战要点URL编码是必须的直接将中文文件名放入HTTP Header是危险的可能在某些网络环节被错误处理。在第二阶段编码在2.5阶段解码这是最稳妥的做法。send_back_params的妙用这个参数里的内容不会出现在2.5阶段的HTTP请求中而是会在第三阶段通知你时放在回调请求体里还给你。你可以用它传递一些敏感或辅助信息避免放在Header中被暴露。Token的传递如何将前端初始化WPS时生成的、代表用户身份的Token安全地传递到2.5阶段接口我的做法是在第二阶段从当前请求通常Token在Header里中取出然后放到返回给WPS的Headers里。这样当WPS请求2.5接口时就会带着这个Token过来方便我们做权限验证。5. 核心的2.5阶段接收文件与业务处理2.5阶段是整个三阶段保存的心脏。它不是一个WPS定义的阶段而是我们根据第二阶段返回的URL自己实现的一个文件接收接口。WPS会按照你上一阶段给的“指令”将用户编辑后的完整文件二进制流通过PUT或POST请求推送到这个接口。这个接口的任务很重接收文件流、存储到OSS、更新数据库记录、创建版本历史。所有这些操作都需要保证事务性和幂等性防止重复提交。下面是我的实现/** * 2.5阶段自定义文件接收与处理接口 * 注意此接口的URL、方法必须与第二阶段返回的完全一致 * param fileId 路径中的文件ID * param fileBytes 请求体中的文件二进制数据 */ PutMapping(/v3/uploadFile/{file_id}) public ResultString handleFileUpload(PathVariable(file_id) String fileId, RequestBody byte[] fileBytes, HttpServletRequest request) { // 1. 从Header中获取第二阶段设置的参数并解码中文 String encodedFileName request.getHeader(X-File-Name); String encodedOssName request.getHeader(X-Oss-File-Name); String fileType request.getHeader(X-File-Type); String authToken request.getHeader(X-Auth-Token); // 权限校验验证Token是否有效 if (!tokenService.validate(authToken)) { throw new SecurityException(无效的请求令牌); } String originalFileName; String ossFileName; try { originalFileName URLDecoder.decode(encodedFileName, UTF-8); ossFileName URLDecoder.decode(encodedOssName, UTF-8); } catch (UnsupportedEncodingException e) { throw new RuntimeException(文件名解码失败, e); } // 2. 将字节数组转换为输入流准备上传至OSS InputStream fileInputStream new ByteArrayInputStream(fileBytes); Long fileSize (long) fileBytes.length; // 3. 调用OSS服务上传文件获取文件的访问URL等信息 // 这里使用了一个自定义的OSS工具类 OssUploadResult uploadResult ossClient.uploadStream( fileInputStream, ossFileName, // 存储在OSS上的唯一文件名 originalFileName, // 文件原始名用于设置Content-Disposition fileType, fileSize ); // 4. 【核心业务】更新文件记录并创建版本历史 // 为了保证数据一致性将查询、创建历史、更新当前文件放在一个事务中 transactionTemplate.execute(status - { // 4.1 查询当前文件信息 SysWpsFile currentFile fileService.getByIdForUpdate(fileId); // 建议使用排他锁 // 4.2 将当前文件信息存入历史版本表 SysWpsFileHistory history new SysWpsFileHistory(); BeanUtils.copyProperties(currentFile, history); history.setId(null); // 生成新的历史记录ID history.setFileId(currentFile.getId()); history.setCreateTime(new Date()); fileHistoryService.save(history); // 4.3 更新当前文件信息为新版本 currentFile.setVersion(currentFile.getVersion() 1); currentFile.setName(originalFileName); // 文件名可能被用户修改 currentFile.setSize(fileSize.intValue()); currentFile.setFileUrl(uploadResult.getFileUrl()); currentFile.setOssObjectKey(uploadResult.getObjectKey()); currentFile.setUpdateTime(new Date()); currentFile.setUpdateBy(getUserIdFromToken(authToken)); fileService.updateById(currentFile); return null; }); // 5. 返回成功响应给WPS // 注意此响应内容WPS可能不处理但必须返回成功的HTTP状态码如200 return Result.success(文件上传并更新成功); }这个阶段是最容易出问题的地方我遇到过的主要坑点有文件流获取确保你的Controller能正确接收二进制流。使用RequestBody byte[]是最简单的方式但对于超大文件建议使用InputStream流式接收避免内存溢出。事务边界创建历史版本和更新当前文件必须是原子操作。我使用了Spring的TransactionTemplate进行编程式事务管理确保两步操作同时成功或失败防止出现有历史记录但当前文件未更新的中间状态。幂等性考虑理论上WPS可能会重试请求。虽然概率低但更严谨的做法是在接口入口根据请求头中的唯一标识如文件ID版本号做幂等判断避免重复存储和更新。性能文件上传和数据库更新可能比较耗时。如果担心HTTP超时可以在接收文件流后立即返回一个“已接收”的响应202 Accepted然后将存储和更新操作放入消息队列异步执行。但这需要更复杂的逻辑来保证最终一致性。6. 第三阶段最终确认与信息同步当WPS成功将文件推送到你的2.5阶段接口并收到成功响应后它就会发起第三阶段的调用通知你整个上传流程已经完成。这个阶段是一个“确认”环节你的服务需要返回更新后文件的最新元信息。这个接口是POST请求路径为/v3/3rd/files/{file_id}/upload/complete。WPS的请求体里会包含一些信息比如之前第二阶段你通过send_back_params传过去的参数。我们需要做的是查询刚刚更新好的文件将其最新状态返回。/** * 三阶段上传完成通知 * param fileId 文件ID * param completeDTO WPS的完成通知请求体 */ PostMapping(/v3/3rd/files/{file_id}/upload/complete) public ResultFileInfoVO uploadComplete(PathVariable(file_id) String fileId, RequestBody FileUploadCompleteDTO completeDTO) { // 1. 可以从completeDTO中获取第二阶段设置的send_back_params // MapString, String backParams completeDTO.getSend_back_params(); // String operator backParams.get(operator); // 2. 从数据库获取已更新的文件信息 SysWpsFile updatedFile fileService.getById(fileId); if (updatedFile null) { // 理论上不会发生因为2.5阶段刚更新完 throw new RuntimeException(文件状态异常); } // 3. 构造WPS期望的响应格式 FileInfoVO vo new FileInfoVO(); vo.setId(updatedFile.getId()); vo.setName(updatedFile.getName()); vo.setVersion(updatedFile.getVersion()); // 重点返回最新的版本号 vo.setSize(updatedFile.getSize()); vo.setCreate_time(updatedFile.getCreateTime().getTime() / 1000); // 通常需要秒级时间戳 vo.setModify_time(updatedFile.getUpdateTime().getTime() / 1000); // 根据你的业务可能还需要返回创建者、修改者等信息 vo.setCreator(getUserInfo(updatedFile.getCreateBy())); vo.setModifier(getUserInfo(updatedFile.getUpdateBy())); // 4. 返回文件信息 return Result.success(vo); }第三阶段的响应内容会被WPS用于更新其界面上的文件信息例如文档的“最后修改时间”和“版本”。所以确保这里返回的数据是准确且及时的。至此一个完整的三阶段保存闭环就完成了。用户在前端点击保存后文件安全地回到了你的服务器版本号1历史记录也已生成所有状态都同步更新。7. 进阶无缝集成文件版本管理三阶段保存机制天然适合做版本管理。每一次保存都是一次新版本的生成。在2.5阶段我们已经演示了如何创建一条历史记录。但一个完整的版本管理系统还需要考虑更多。首先是版本数据的存储设计。我的SysWpsFileHistory表几乎和主文件表SysWpsFile结构一致包含了文件的所有关键属性名称、大小、在OSS中的存储路径、版本号、操作人、操作时间。此外我还会额外存储一个file_id外键关联主文件以及一个change_summary字段暂时留空未来可通过WPS的差异服务或手动填写来记录本次修改的概要。其次是版本列表的获取与回滚功能。你需要提供一个接口根据file_id查询出所有的历史版本列表按版本号或时间倒序排列。更关键的是“版本回滚”功能。回滚操作本质上是一次特殊的“保存”它不是从WPS编辑而来而是将某个历史版本的文件内容复制出来作为当前文件的最新版本。/** * 版本回滚接口 * param fileId 文件ID * param historyId 要回滚到的历史版本ID */ PostMapping(/file/{file_id}/rollback/{history_id}) public ResultString rollbackVersion(PathVariable String fileId, PathVariable Long historyId) { // 1. 查询历史版本记录 SysWpsFileHistory targetHistory historyService.getById(historyId); if (targetHistory null || !targetHistory.getFileId().equals(fileId)) { throw new BusinessException(历史版本不存在); } // 2. 从OSS获取历史版本的文件内容 // 注意这里假设历史文件在OSS中仍然存在未删除 InputStream historyFileStream ossClient.download(targetHistory.getOssObjectKey()); // 3. 模拟一次“保存”生成新的OSS存储名上传文件流 String newOssName generateUniqueFileName(targetHistory.getName()); OssUploadResult newUploadResult ossClient.uploadStream(historyFileStream, newOssName, ...); // 4. 在事务中创建新的历史记录并更新当前文件 transactionTemplate.execute(status - { // 4.1 将当前文件状态存入历史作为回滚前的一个版本 SysWpsFile currentFile fileService.getById(fileId); fileHistoryService.saveCurrentAsHistory(currentFile); // 4.2 更新当前文件信息为历史版本的内容但版本号1 currentFile.setVersion(currentFile.getVersion() 1); currentFile.setSize(targetHistory.getSize()); currentFile.setFileUrl(newUploadResult.getFileUrl()); currentFile.setOssObjectKey(newUploadResult.getObjectKey()); currentFile.setUpdateTime(new Date()); fileService.updateById(currentFile); return null; }); // 5. 可选触发一个事件通知前端文档已更新可能需要刷新 return Result.success(版本回滚成功); }最后是存储空间的优化。每次保存都存一个完整文件OSS成本可能会快速增长。一个优化策略是在保存新版本时使用OSS的“复制对象”功能将旧版本的文件复制为新名称而不是重新上传WPS传来的流如果文件未改动或改动很小WPS可能会传输整个文件。更高级的方案是探索WPS是否支持增量差异但目前V3保存接口传输的是全量文件。8. 调试技巧与常见问题排查集成过程中调试是不可避免的。WPS的接口调用发生在它的服务器和你的服务器之间不能像前端调试一样在浏览器里打断点。我总结了一套高效的调试方法。首先日志记录是生命线。在每个阶段的接口入口处详细打印请求参数和Headers。我特别喜欢用MDCMapped Diagnostic Context将同一个请求的多个阶段日志通过一个唯一TraceID串联起来。PostMapping(/v3/3rd/files/{file_id}/upload/address) public Result getUploadAddress(...) { String traceId UUID.randomUUID().toString(); MDC.put(traceId, traceId); log.info([二阶段开始] fileId: {}, requestDTO: {}, fileId, JSON.toJSONString(requestDTO)); // ... 业务逻辑 log.info([二阶段结束] 返回地址: {}, customUploadUrl); MDC.remove(traceId); return Result.success(vo); }其次善用网络请求工具模拟调用。在开发阶段你可以使用 Postman 或 curl 直接模拟WPS服务器来调用你的2.5阶段接口验证文件接收和处理逻辑是否正确。构造一个包含正确Headers的PUT请求Body里放一个测试文件看你的接口能否正确解码、存储和更新数据库。最后针对几个高频问题这是我的排查清单问题一阶段/二阶段接口调不通返回404或403。检查确认你的回调地址在WPS开放平台应用配置中已正确填写且公网可访问。检查Spring Boot的Controller路径是否与配置的完全一致包括/v3/3rd/这个前缀。检查确认你的服务端接口是否做了统一的权限拦截如Spring Security导致WPS的请求被拦截。需要将WPS的回调路径加入白名单。问题二阶段返回了地址但WPS没有调用2.5阶段接口。检查查看二阶段接口的响应体格式是否正确尤其是url、method、headers字段的拼写和结构是否严格符合WPS文档要求。一个字母错误都可能导致WPS解析失败。检查WPS控制台或你的应用日志看二阶段是否返回了成功的HTTP状态码200。如果返回了4xx或5xx错误WPS会中止流程。问题2.5阶段接口收到请求但中文文件名乱码。检查确保在第二阶段使用URLEncoder.encode(name, UTF-8)进行编码在2.5阶段使用URLDecoder.decode(encodedName, UTF-8)进行解码。这是最可靠的方案。检查你的HTTP客户端或网关如Nginx是否对Header有额外的字符处理确保它们支持UTF-8。问题文件保存成功但版本号没变或历史记录没生成。检查2.5阶段的事务管理。确保“查询原文件”、“保存历史”、“更新当前文件”这三个步骤在一个数据库事务中。检查事务注解Transactional是否生效或者像我一样使用TransactionTemplate。检查并发编辑场景。如果两个用户同时保存同一文件可能会引发版本错乱。考虑在更新文件记录时使用乐观锁基于版本号或悲观锁select ... for update。整个集成过程就像搭积木每一步都要严丝合缝。从配置回调地址开始到三个阶段接口的逐个实现再到版本管理的融入每一步的日志和验证都至关重要。当你第一次看到用户在前端编辑文档点击保存后后台日志流畅地走完三个阶段文件在OSS中更新数据库版本号自动增加时那种成就感是非常实在的。这套机制一旦跑通就为你企业的在线文档协作能力打下了一个坚实可靠的基础。