SpringBoot-Integrate-OnlyOffice
前言
最近有个项目需求是实现前端页面可以对word文档进行编辑,并且可以进行保存,于是一顿搜索,找到开源第三方onlyoffice,实际上onlyOffice有很多功能,例如文档转化、多人协同编辑文档、文档打印等,我们只用到了文档编辑功能。
1、onlyoffice的部署
部署分为docker部署方式和本地直接安装的方式,比较两种部署方式,docker是比较简单的一种,因为只要拉取相关镜像,然后启动时配置好对应的配置文件即可。
由于搜索的时候先看到的是linux本地部署,所以采用了第二种方式,下面我将给出两个参考博客:
- docker的方式: (我未进行尝试,对于是否能成功是不知的)
- ubuntu部署方式: (我按照这个方式走下来是可以走通的)
2、代码逻辑开发
前端使用的element框架vue版本,后端采用springboot
2.1、前端代码
- 参考官方文档APIhttps://api.onlyoffice.com/docs/docs-api/get-started/basic-concepts/
- 参考文档https://api.onlyoffice.com/docs/docs-api/usage-api/advanced-parameters/ 记得添加下面的js文件
<div id="placeholder"></div>
<script type="text/javascript" src="https://documentserver/web-apps/apps/api/documents/api.js"></script>
记得将documentserver替换为部署onlyoffice的地址。
const config = {
document: {
mode: 'edit',
fileType: 'docx',
key: String( Math.floor(Math.random() * 10000)),
title: route.query.name + '.docx',
url: import.meta.env.VITE_APP_API_URL+`/getFile/${route.query.id}`,
permissions: {
comment: true,
download: true,
modifyContentControl: true,
modifyFilter: true,
edit: true,
fillForms: true,
review: true,
},
},
documentType: 'word',
editorConfig: {
user: {
id: 'liu',
name: 'liu',
},
// 隐藏插件菜单
customization: {
plugins: false,
forcesave: true,
},
lang: 'zh',
// callbackUrl: `${import.meta.env.VITE_APP_API_URL} +'/callback' `,
callbackUrl: import.meta.env.VITE_APP_API_URL+`/callback`,
},
height: '100%',
width: '100%',
}
newwindow.DocsAPI.DocEditor('onlyoffice', config)
其中import.meta.env.VITE_APP_API_URL为你实际的后端地址,http:ip:端口号/访问路径,例如我们就是:http:192.168.123.123:8089/getFile/12,其中12为会议号,用于得到文件地址。
其中import.meta.env.VITE_APP_API_URL+/callback为回调函数,即文档有什么操作后,都会通过这个函数进行回调,例如:编辑保存操作。
2.2、后端代码
pom依赖
<!-- httpclient start -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpmime</artifactId>
</dependency>
OnlyOfficeController
package com.ruoyi.web.controller.meetingminutes.onlyoffice;
/**
* @Author 不要有情绪的 ljy
* @Date 2024/10/31 20:26
* @Description:
*/
import com.ruoyi.system.domain.MeetingTable;
import com.ruoyi.system.service.IMeetingTableService;
import com.ruoyi.web.controller.meetingminutes.utils.HttpsKitWithProxyAuth;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.Getter;
import lombok.Setter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.util.Collections;
/**
*
*/
@Api(value = "OnlyOfficeController")
@RestController
public class OnlyOfficeController {
@Autowired
private IMeetingTableService meetingTableService;
//这里仅写死路径测试
// private String meetingMinutesFilePath = "C:\\Users\\qrs-ljy\\Desktop\\王勋\\c1f15837-d8b4-4380-8161-b85e970ad174\\123435_会议纪要(公开).docx"; //这里仅写死路径测试
private String meetingMinutesFilePath;
/**
* 传入参数 会议id,得到会议纪要文件流,并进行打开
*
* @param response
* @param meeting_id
* @return
* @throws IOException
*/
@ApiOperation(value = "OnlyOffice")
@GetMapping("/getFile/{meeting_id}")
public ResponseEntity<byte[]> getFile(HttpServletResponse response, @PathVariable Long meeting_id) throws IOException {
MeetingTable meetingTable = meetingTableService.selectMeetingTableById(meeting_id);
meetingMinutesFilePath = meetingTable.getMeetingMinutesFilePath();
if (meetingMinutesFilePath == null || "".equals(meetingMinutesFilePath)) {
return null; //当会议纪要文件为空的时候,就返回null
}
File file = new File(meetingMinutesFilePath);
FileInputStream fileInputStream = null;
InputStream fis = null;
try {
fileInputStream = new FileInputStream(file);
fis = new BufferedInputStream(fileInputStream);
byte[] buffer = newbyte[fis.available()];
fis.read(buffer);
fis.close();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
// 替换为实际的文档名称
headers.setContentDispositionFormData("attachment", URLEncoder.encode(file.getName(), "UTF-8"));
returnnew ResponseEntity<>(buffer, headers, HttpStatus.OK);
} catch (Exception e) {
thrownew RuntimeException("e -> ", e);
} finally {
try {
if (fis != null) fis.close();
} catch (Exception e) {
}
try {
if (fileInputStream != null) fileInputStream.close();
} catch (Exception e) {
}
}
}
@CrossOrigin(origins = "*", methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.OPTIONS})
@PostMapping("/callback")
public ResponseEntity<Object> handleCallback(@RequestBody CallbackData callbackData) {
//状态监听
//参见https://api.onlyoffice.com/editors/callback
Integer status = callbackData.getStatus();
switch (status) {
case1: {
//document is being edited 文档已经被编辑
break;
}
case2: {
//document is ready for saving,文档已准备好保存
System.out.println("document is ready for saving");
String url = callbackData.getUrl();
try {
saveFile(url); //保存文件
} catch (Exception e) {
System.out.println("保存文件异常");
}
System.out.println("save success.");
break;
}
case3: {
//document saving error has occurred,保存出错
System.out.println("document saving error has occurred,保存出错");
break;
}
case4: {
//document is closed with no changes,未保存退出
System.out.println("document is closed with no changes,未保存退出");
break;
}
case6: {
//document is being edited, but the current document state is saved,编辑保存
String url = callbackData.getUrl();
try {
saveFile(url); //保存文件
} catch (Exception e) {
System.out.println("保存文件异常");
}
System.out.println("save success.");
}
case7: {
//error has occurred while force saving the document. 强制保存文档出错
System.out.println("error has occurred while force saving the document. 强制保存文档出错");
}
default: {
}
}
// 返回响应
return ResponseEntity.<Object>ok(Collections.singletonMap("error", 0));
}
public void saveFile(String downloadUrl) throws URISyntaxException, IOException {
HttpsKitWithProxyAuth.downloadFile(downloadUrl, meetingMinutesFilePath);
}
@Setter
@Getter
public static class CallbackData {
/**
* 用户与文档的交互状态。0:用户断开与文档共同编辑的连接;1:新用户连接到文档共同编辑;2:用户单击强制保存按钮
*/
// @IsArray()
// actions?:IActions[] =null;
/**
* 字段已在 4.2 后版本废弃,请使用 history 代替
*/
Object changeshistory;
/**
* 文档变更的历史记录,仅当 status 等于 2 或者 3 时该字段才有值。其中的 serverVersion 字段也是 refreshHistory 方法的入参
*/
Object history;
/**
* 文档编辑的元数据信息,用来跟踪显示文档更改记录,仅当 status 等于 2 或者 2 时该字段才有值。该字段也是 setHistoryData(显示与特定文档版本对应的更改,类似 Git 历史记录)方法的入参
*/
String changesurl;
/**
* url 字段下载的文档扩展名,文件类型默认为 OOXML 格式,如果启用了 assemblyFormatAsOrigin(https://api.onlyoffice.com/editors/save#assemblyFormatAsOrigin) 服务器设置则文件以原始格式保存
*/
String filetype;
/**
* 文档强制保存类型。0:对命令服务(https://api.onlyoffice.com/editors/command/forcesave)执行强制保存;1:每次保存完成时都会执行强制保存请求,仅设置 forcesave 等于 true 时生效;2:强制保存请求由计时器使用服务器中的设置执行。该字段仅 status 等于 7 或者 7 时才有值
*/
Integer forcesavetype;
/**
* 文档标识符,类似 id,在 Onlyoffice 服务内部唯一
*/
String key;
/**
* 文档状态。1:文档编辑中;2:文档已准备好保存;3:文档保存出错;4:文档没有变化无需保存;6:正在编辑文档,但保存了当前文档状态;7:强制保存文档出错
*/
Integer status;
/**
* 已编辑文档的链接,可以通过它下载到最新的文档,仅当 status 等于 2、3、6 或 7 时该字段才有值
*/
String url;
/**
* 自定义参数,对应指令服务的 userdata 字段
*/
Object userdata;
/**
* 打开文档进行编辑的用户标识列表,当文档被修改时,该字段将返回最后编辑文档的用户标识符,当 status 字段等于 2 或者 6 时有值
*/
String[] users;
/**
* 最近保存时间
*/
String lastsave;
/**
* 加密令牌
*/
String token;
}
}
代码中使用了其他类,这儿贴出(我也是参考的别人的博客,后面会给出参考链接)
HttpsKitWithProxyAuth
package com.ruoyi.web.controller.meetingminutes.utils;
/**
* @Author 不要有情绪的 ljy
* @Date 2024/10/31 20:34
* @Description:
*/
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InterruptedIOException;
import java.net.Authenticator;
import java.net.InetSocketAddress;
import java.net.MalformedURLException;
import java.net.PasswordAuthentication;
import java.net.Proxy;
import java.net.Socket;
import java.net.UnknownHostException;
import java.security.KeyManagementException;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TimerTask;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLHandshakeException;
import org.apache.commons.codec.CharEncoding;
import org.apache.commons.io.IOUtils;
import org.apache.http.Consts;
import org.apache.http.HttpEntity;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.HttpHost;
import org.apache.http.HttpRequest;
import org.apache.http.NameValuePair;
import org.apache.http.NoHttpResponseException;
import org.apache.http.auth.AUTH;
import org.apache.http.auth.AuthState;
import org.apache.http.auth.MalformedChallengeException;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.HttpRequestRetryHandler;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.config.Registry;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.ConnectTimeoutException;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.socket.LayeredConnectionSocketFactory;
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.conn.ssl.TrustStrategy;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.entity.mime.MultipartEntityBuilder;
import org.apache.http.entity.mime.content.InputStreamBody;
import org.apache.http.entity.mime.content.StringBody;
import org.apache.http.impl.auth.BasicScheme;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.message.BasicHeader;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.protocol.HttpContext;
import org.apache.http.ssl.SSLContextBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* httpclient Sock5支持参考:https://blog.csdn.net/weixin_34075268/article/details/92040047
* @author liujh
*
*/
public class HttpsKitWithProxyAuth {
private static Logger logger = LoggerFactory.getLogger(HttpsKitWithProxyAuth.class);
private static final int CONNECT_TIMEOUT = 10000;// 设置连接建立的超时时间为10000ms
private static final int SOCKET_TIMEOUT = 30000; // 多少时间没有数据传输
private static final int HttpIdelTimeout = 30000;//空闲时间
private static final int HttpMonitorInterval = 10000;//多久检查一次
private static final int MAX_CONN = 200; // 最大连接数
private static final int Max_PRE_ROUTE = 200; //设置到路由的最大连接数,
private static CloseableHttpClient httpClient; // 发送请求的客户端单例
private static PoolingHttpClientConnectionManager manager; // 连接池管理类
private static ScheduledExecutorService monitorExecutor;
private static final String APPLICATION_FORM_URLENCODED = "application/x-www-form-urlencoded";
private static final String APPLICATION_JSON = "application/json";
private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36";
private static final Object syncLock = new Object(); // 相当于线程锁,用于线程安全
/**
* 代理相关的变量,
*/
private static final String HTTP = "http";//proxyType的取值之一http
private static final String SOCKS = "socks";//proxyType的取值之一socks
private static boolean needProxy = false; //是否需要代理连接
private static boolean needLogin = false;//代理连接是否需要账号和密码,为true时填上proxyUsername和proxyPassword
private static String proxyType = HTTP; //代理类型,http,socks分别为http代理和sock5代理
private static String proxyHost = "127.0.0.1"; //代理IP
private static int proxyPort = 1080; //代理端口
private static String proxyUsername = "sendi";//代理账号,needLogin为true时不能为空
private static String proxyPassword = "123456";//代理密码,needLogin为true时不能为空
private static RequestConfig requestConfig = RequestConfig.custom()
.setConnectionRequestTimeout(CONNECT_TIMEOUT)
.setConnectTimeout(CONNECT_TIMEOUT)
//.setCookieSpec(CookieSpecs.IGNORE_COOKIES)
.setSocketTimeout(SOCKET_TIMEOUT).build();
static {
/**
* Sock5代理账号和密码设置
* 如果账号和密码都不为空表示需要账号密码认证,因为这个是全局生效,因此在这里直接设置
* 可通过Authenticator.setDefault(null)取消全局配置
* Authenticator.setDefault(Authenticator a)关于a参数的说明如下:
* (The authenticator to be set. If a is {@code null} then any previously set authenticator is removed.)
*/
if(needProxy && SOCKS.equals(proxyType) && needLogin){
//用户名和密码验证
Authenticator.setDefault(new Authenticator(){
protected PasswordAuthentication getPasswordAuthentication(){
PasswordAuthentication p = new PasswordAuthentication(proxyUsername, proxyPassword.toCharArray());
return p;
}
});
}
}
/**
* 设置代理信息,可以在发请求前进行调用,用于替换此类中的代理相关的变量,全局设置一次就可
* needProxy 是否需要代理连接
* needLogin 代理连接是否需要账号和密码,为true时填上proxyUsername和proxyPassword
* proxyType 代理类型,http,socks分别为http代理和sock5代理
* proxyHost 代理IP
* proxyPort 代理端口
* proxyUsername 代理账号,needLogin为true时不能为空
* proxyPassword 代理密码,needLogin为true时不能为空
*/
public static void setProxy(boolean needProxy,boolean needLogin,String proxyType,String proxyHost,int proxyPort,String proxyUserName,String proxyPassword){
HttpsKitWithProxyAuth.needProxy = needProxy;
HttpsKitWithProxyAuth.needLogin = needLogin;
HttpsKitWithProxyAuth.proxyType = proxyType;
HttpsKitWithProxyAuth.proxyHost = proxyHost;
HttpsKitWithProxyAuth.proxyPort = proxyPort;
HttpsKitWithProxyAuth.proxyUsername = proxyUserName;
HttpsKitWithProxyAuth.proxyPassword = proxyPassword;
}
private static CloseableHttpClient getHttpClient() {
if (httpClient == null) {
// 多线程下多个线程同时调用getHttpClient容易导致重复创建httpClient对象的问题,所以加上了同步锁
synchronized (syncLock) {
if (httpClient == null) {
try {
httpClient = createHttpClient();
} catch (KeyManagementException e) {
logger.error("error",e);
} catch (NoSuchAlgorithmException e) {
logger.error("error",e);
} catch (KeyStoreException e) {
logger.error("error",e);
}
// 开启监控线程,对异常和空闲线程进行关闭
monitorExecutor = Executors.newScheduledThreadPool(1);
monitorExecutor.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
// 关闭异常连接
manager.closeExpiredConnections();
// 关闭5s空闲的连接
manager.closeIdleConnections(HttpIdelTimeout,TimeUnit.MILLISECONDS);
//logger.info(manager.getTotalStats().toString());
//logger.info("close expired and idle for over "+HttpIdelTimeout+"ms connection");
}
}, HttpMonitorInterval, HttpMonitorInterval, TimeUnit.MILLISECONDS);
}
}
}
return httpClient;
}
/**
* 构建httpclient实例
* @return
* @throws KeyStoreException
* @throws NoSuchAlgorithmException
* @throws KeyManagementException
*/
private static CloseableHttpClient createHttpClient() throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
SSLContextBuilder builder = new SSLContextBuilder();
// 全部信任 不做身份鉴定
builder.loadTrustMaterial(null, new TrustStrategy() {
@Override
public boolean isTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {
returntrue;
}
});
ConnectionSocketFactory plainSocketFactory = null;
LayeredConnectionSocketFactory sslSocketFactory = null;
/**
* 如果需要进行Sock5代理访问开放如下代码
* */
if(needProxy && SOCKS.endsWith(proxyType)){
plainSocketFactory = new MyConnectionSocketFactory();
sslSocketFactory = new MySSLConnectionSocketFactory(builder.build());
}else {
plainSocketFactory = PlainConnectionSocketFactory.getSocketFactory();
sslSocketFactory = new SSLConnectionSocketFactory(builder.build(), NoopHostnameVerifier.INSTANCE);
}
Registry<ConnectionSocketFactory> registry = RegistryBuilder
.<ConnectionSocketFactory> create()
.register("http", plainSocketFactory)
.register("https", sslSocketFactory).build();
manager = new PoolingHttpClientConnectionManager(registry);
// 设置连接参数
manager.setMaxTotal(MAX_CONN); // 最大连接数
manager.setDefaultMaxPerRoute(Max_PRE_ROUTE); // 路由最大连接数
// 请求失败时,进行请求重试
HttpRequestRetryHandler handler = new HttpRequestRetryHandler() {
@Override
public boolean retryRequest(IOException e, int i, HttpContext httpContext) {
if (i > 3) {
// 重试超过3次,放弃请求
logger.error("retry has more than 3 time, give up request");
return false;
}
if (e instanceof NoHttpResponseException) {
// 服务器没有响应,可能是服务器断开了连接,应该重试
logger.error("receive no response from server, retry");
returntrue;
}
if (e instanceof SSLHandshakeException) {
// SSL握手异常
logger.error("SSL hand shake exception");
return false;