最终实现的效果
1. Docker部署onlyoffice
拉取minio镜像
docker pull minio/minio:RELEASE.2025-04-22T22-12-26Z
拉取onlyoffice镜像
docker pull onlyoffice/documentserver:7.5
docker启动minio
- minio账号:minio 密码:QWEasd123
docker run -p 10000:9000 -p 10001:9001 \
--name minio \
-d --restart=always \
-e "MINIO_ACCESS_KEY=minio" -e "MINIO_SECRET_KEY=QWEasd123" \
-v /opt/minio/data:/data \
-v /opt/minio/config:/root/.minio minio/minio:latest server /data \
--console-address ":9000" -address ":9001"
docker启动onlyoffice
- DO2z9oopirgOJsMBkCacx2TflPTAmRRl
- 这个是密钥,和application.yml中的docservice.security.key保持一致
docker run -i -t -d -p 9000:80 \
--name onlyoffice \
-v /opt/onlyoffice/logs:/var/log/onlyoffice \
-v /opt/onlyoffice/data:/var/www/onlyoffice/Data \
-v /opt/onlyoffice/lib:/var/lib/onlyoffice \
-v /opt/onlyoffice/db:/var/lib/postgresql \
-e JWT_ENABLED=true --env JWT_SECRET=DO2z9oopirgOJsMBkCacx2TflPTAmRRl \
onlyoffice/documentserver:7.5
2. 在minio中创建bucket
登录minio http://192.168.1.35:10000/,账号:minio 密码:QWEasd123
创建bucket http://192.168.1.35:10000/buckets/add-bucket
这里的bucket和application.yml中的minio配置保持一致
将bucket设置成public
3. 搭建SpringBoot项目
jdk1.8
Maven依赖
<dependency>
<groupId>com.onlyoffice</groupId>
<artifactId>docs-integration-sdk</artifactId>
<version>1.6.0</version>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.2.1</version>
</dependency>
Config配置接口和Callback回调接口
/**
* Author: Li
* Description: Config配置接口和Callback回调接口
* 文档地址:https://api.onlyoffice.com/zh-CN/docs/docs-api/usage-api/config/editor
*/
@CrossOrigin("*")
@RestController
@RequestMapping("/office")
@Slf4j
public class OfficeController {
@Resource
private ConfigService configService;
@Resource
private MinioClient minioClient;
@Resource
private CallbackService callbackService;
@Value("${minio.endpoint}")
private String endpoint;
@Value("${minio.bucket-name}")
private String bucketName;
@Resource
private JwtManager jwtManager;
@Resource
private OfficeServiceImpl officeService;
/**
* config配置接口,生成onlyoffice配置并进行jwt签名
*/
@GetMapping("/config")
public ResponseEntity<Config> getConfig(@RequestParam String fileUrl, @RequestParam Mode mode) throws UnsupportedEncodingException {
Config config = this.configService.createConfig(fileUrl, mode, Type.DESKTOP);
return ResponseEntity.ok(config);
}
/**
* onlyfoofice回调接口,开始编辑、保存文件的时候会触发
*/
@PostMapping("/callback")
public String callback(final HttpServletRequest request, // track file changes
@RequestParam("fileUrl") final String fileUrl,
@RequestBody final Callback body) {
log.debug("回调参数{}", JSON.toJSONString(body));
try {
// String authorization = request.getHeader("Authorization");
// if (StringUtils.isEmpty(authorization)) {
// return "{\"error\":1,\"message\":\"Request payload is empty\"}";
// }
// String token = authorization.replace("Bearer ", "");
// Callback callback = this.callbackService.verifyCallback(body, token);
// this.callbackService.processCallback(callback, fileUrl);
this.callbackService.processCallback(body, fileUrl);
} catch (Exception e) {
log.error("", e);
return "{\"error\":1,\"message\":\"Request payload is empty\"}";
}
return "{\"error\":\"0\"}";
}
}
获取onlyoffice文档编辑器配置信息
/**
* Author: Li
* Description: 获取配置信息,该配置决定了页面的按钮及菜单的显示
* 文档地址:https://api.onlyoffice.com/zh-CN/docs/docs-api/usage-api/config/editor
*/
@Service
@Slf4j
public class ConfigServiceImpl implements ConfigService {
@Resource
private JwtManager jwtManager;
@Value("${docservice.security.key}")
private String secretKey;
@Value("${docservice.security.enable}")
private Boolean securityEnable;
@Value("${docservice.callback}")
private String callback;
@Value("${minio.bucket-name}")
private String bucketName;
@Resource
private MinioClient minioClient;
@Value("${minio.endpoint}")
private String endpoint;
public Config createConfig(final String fileUrl, final Mode mode, final Type pageType) throws UnsupportedEncodingException {
// @TODO fileUrl可以是fileId, 在这里可以根据fileId查询数据库中具体的文件信息进行填充
DocumentType documentType = this.getDocumentType(fileUrl);
// 文档配置
Document document = this.getDocument(fileUrl, pageType);
// 编辑器配置
EditorConfig editorConfig = this.getEditorConfig(fileUrl, mode, pageType);
Config config = Config.builder()
.width("100%")
.height("100%")
.type(pageType)
.documentType(documentType)
.document(document)
.editorConfig(editorConfig)
.build();
// 是否开启jwt签名秘钥
if (Boolean.TRUE.equals(this.securityEnable)) {
String token = this.jwtManager.createToken(config, secretKey);
config.setToken(token);
}
return config;
}
/**
* 编辑器配置,设置文档转换服务回调地址
*/
public EditorConfig getEditorConfig(String fileUrl, Mode mode, Type type) throws UnsupportedEncodingException {
Permissions permissions = this.getPermissions();
EditorConfig editorConfig = EditorConfig.builder()
// 设置文档创建的接口地址,这里禁止在编辑器中创建文档,设置为null,文档编辑器将隐藏创建文档的按钮
.createUrl(null)
.lang("zh") // zh | en
.mode(mode)
.user(this.getUser())
.recent(null)
.templates(null)
.customization(this.getCustomization())
.plugins(null)
.build();
if (permissions != null && (Boolean.TRUE.equals(permissions.getEdit()) || Boolean.TRUE.equals(permissions.getFillForms()) || Boolean.TRUE.equals(permissions.getComment()) || Boolean.TRUE.equals(permissions.getReview())) && mode.equals(Mode.EDIT)) {
//!!!编辑时的回调地址,文档转换服务将会调用该接口,完成文档保存更新
String callbackUrl = String.format("%s?fileUrl=%s", this.callback, URLEncoder.encode(fileUrl, "UTF-8"));
editorConfig.setCallbackUrl(callbackUrl);
}
return editorConfig;
}
/**
* 获取文档相关的配置
*/
public Document getDocument(String fileUrl, Type type) {
// 文档标题
String documentName = this.getDocumentName(fileUrl);
/*
* 定义服务用来识别文档的唯一文档标识符。如果发送了已知key,则将从缓存中获取文档。
* 每次编辑和保存文档时,都必须重新生成key。文档 url 可以用作 key,但不能使用特殊字符,长度限制为 128 个符号。
* :::请注意, 对于连接到同一文档服务器的所有独立服务,密钥必须是唯一的。
* 否则,服务可能会从编辑器缓存中打开其他人的文件。如果多个第三方集成商连接到同一文档服务器,他们也必须提供唯一的密钥。
* 可以使用的关键字符: 0-9, a-z, A-Z, -._=。 最大密钥长度为 128 个字符。 :::
*
* !!!!这里获取minio中的文件名+最后修改时间生成hash随机字符串作为key,这样key值和文件版本对应有效利用文档转换服务中的缓存策略
* key的生成策略非常重要!!!,如果每次都生成新的key会造成文件的缓存失效,文档转换服务将重新下载文件,可能导致文件不一致
*/
String key;
try {
String objectName = fileUrl.replace(this.endpoint + "/" + this.bucketName + "/", "");
StatObjectResponse stat = this.minioClient.statObject(StatObjectArgs.builder()
.bucket(this.bucketName)
.object(objectName)
.build());
ZonedDateTime zonedDateTime = stat.lastModified();
// 将 ZonedDateTime 转换为 Instant
Instant instant = zonedDateTime.toInstant();
// 获取时间戳(毫秒)
long timestamp = instant.toEpochMilli();
key = DigestUtils.sha256Hex(objectName + timestamp);
}catch (Exception e) {
key = UUID.randomUUID().toString();
log.error("获取文件信息失败", e);
}
String suffix = this.getExtension(fileUrl);
return Document.builder()
// docx | pptx | xlsx | pdf , 获取文件后缀
.fileType(suffix)
.key(key)
.title(documentName)
// 文档下载地址,这个地址提供给文档转换服务,用于下载文档。
.url(fileUrl)
.info(this.getInfo())
.permissions(this.getPermissions())
.build();
}
public DocumentType getDocumentType(String fileUrl) {
if (Pattern.matches(".*\\.docx$", fileUrl)) {
return DocumentType.WORD;
} else if (Pattern.matches(".*\\.pptx$", fileUrl)) {
return DocumentType.SLIDE;
} else if (Pattern.matches(".*\\.xlsx$", fileUrl)) {
return DocumentType.CELL;
} else if (Pattern.matches(".*\\.pdf$", fileUrl)) {
// !!!这里不是写错了,而是onlyoffice默认读取pdf为word,所以这里返回WORD
return DocumentType.WORD;
} else {
throw new RuntimeException("未知的文件类型");
}
}
private String getDocumentName(String fileUrl) {
String fileName = fileUrl.substring(fileUrl.lastIndexOf("/") + 1);
if (StringUtils.isEmpty(fileName)) {
throw new RuntimeException("文件名不能为空");
}
return fileName.split("\\?")[0];
}
private String getExtension(String fileUrl) {
String suffix = fileUrl.substring(fileUrl.lastIndexOf(".") + 1);
if (StringUtils.isEmpty(suffix)) {
throw new RuntimeException("无法读取文件扩展名");
}
String ext = suffix.split("\\?")[0];
if (!Pattern.matches("docx|pptx|xlsx|pdf", ext)) {
throw new RuntimeException("文件扩展名不合法");
}
return ext;
}
/**
* 包含文档的附加参数(文档所有者、存储文档的文件夹、上传日期、共享设置);
*/
public Info getInfo() {
// @TODO 查询数据库获取当前文档的信息填充
return Info.builder()
.owner("文档所有人")
.favorite(false)
// 文档上传时间
.uploaded("20250607")
.build();
}
/**
* 获取当前用户对文件的权限
*/
public Permissions getPermissions() {
// @TODO 获取当前登录的用户,查询他对该文件的权限
return Permissions.builder()
.chat(false)
.comment(true)
.commentGroups(new CommentGroups())
.copy(true)
.download(true)
.edit(true)
.fillForms(true)
.modifyContentControl(true)
.modifyFilter(true)
.print(true)
.protect(false)
.review(true)
.reviewGroups(new ArrayList<>(0))
.userInfoGroups(null)
.build();
}
/**
* 获取当前登录人的用户信息,作为文档编辑人显示
*/
public User getUser() {
// @TODO 获取当前登录人信息
User user = User.builder()
.id("1")
.name("winter")
.image("")
.build();
return user;
}
/**
* 定义编辑器需要显示的按钮、菜单
*/
public Customization getCustomization() {
Goback goback = Goback.builder()
.url("")
.build();
// 允许自定义编辑器界面,使其看起来像您的其他产品(如果有),并更改附加按钮、链接、更改徽标和编辑器所有者详细信息的显示或不显示
Customization customization = Customization.builder()
.autosave(true) // if the Autosave menu option is enabled or disabled
.comments(true) // if the Comments menu button is displayed or hidden
.compactHeader(false) /* if the additional action buttons are displayed
in the upper part of the editor window header next to the logo (false) or in the toolbar (true) */
.compactToolbar(false) // if the top toolbar type displayed is full (false) or compact (true)
.forcesave(false)/* add the request for the forced file saving to the callback handler
when saving the document within the document editing service */
.help(false) // if the Help menu button is displayed or hidden
.hideRightMenu(false) // if the right menu is displayed or hidden on first loading
.hideRulers(false) // if the editor rulers are displayed or hidden
.feedback(false)
.goback(goback)
.plugins(false)
.build();
return customization;
}
}
处理文档转换服务回调业务
/**
* Author: Li
* Description: 处理文档转换服务回调业务
*/
import com.onlyoffice.service.documenteditor.callback.CallbackService;
@Slf4j
@Service
public class CallbackServiceImpl implements CallbackService {
@Value("${docservice.security.enable}")
private Boolean securityEnable;
@Value("${docservice.security.key}")
private String secretKey;
@Resource
private JwtManager jwtManager;
@Value("${minio.bucket-name}")
private String bucketName;
@Value("${minio.endpoint}")
private String endpoint;
@Resource
private MinioClient minioClient;
@Resource
private ObjectMapper objectMapper;
/**
* 校验回调数据
*
* @param callback 回调数据
* @param authorization 请求头中的授权信息
*/
@Override
public Callback verifyCallback(Callback callback, String authorization) throws JsonProcessingException {
if (!Boolean.TRUE.equals(this.securityEnable)) {
return callback;
}
String token = callback.getToken();
boolean fromHeader = false;
if (StringUtils.isEmpty(token) && StringUtils.isNotEmpty(authorization)) {
token = authorization.replace("Bearer ", "");
fromHeader = true;
}
if (StringUtils.isEmpty(token)) {
throw new SecurityException("Not found authorization token");
}
String payload = this.jwtManager.verify(token);
if (fromHeader) {
JSONObject data = new JSONObject(payload);
JSONObject callbackFromToken = data.getJSONObject("payload");
return this.objectMapper.readValue(callbackFromToken.toString(), Callback.class);
} else {
return this.objectMapper.readValue(payload, Callback.class);
}
}
@Override
public void processCallback(Callback callback, String fileUrl) throws Exception {
switch (callback.getStatus()) {
case EDITING:
this.handlerEditing(callback, fileUrl);
break;
case SAVE:
this.handlerSave(callback, fileUrl);
break;
case SAVE_CORRUPTED:
this.handlerSaveCorrupted(callback, fileUrl);
break;
case CLOSED:
this.handlerClosed(callback, fileUrl);
break;
case FORCESAVE:
this.handlerForcesave(callback, fileUrl);
break;
case FORCESAVE_CORRUPTED:
this.handlerForcesaveCurrupted(callback, fileUrl);
break;
default:
throw new RuntimeException("Callback has no status");
}
}
@Override
public void handlerEditing(final Callback callback, final String fileUrl) {
Action action = callback.getActions().get(0); // get the user ID who is editing the document
if (Type.CONNECTED.equals(action.getType())) { // if this value is not equal to the user ID
String userId = action.getUserid(); // get user ID
if (!callback.getUsers().contains(userId)) { // if this user is not specified in the body
}
}
}
@Override
public void handlerSave(final Callback callback, final String fileUrl) {
// objectName --> /20251227/test.docx
String objectName = fileUrl.replace(this.endpoint + "/" + this.bucketName + "/", "");
String downloadUrl = callback.getUrl();
// 从文件转换服务中下载文件
RestTemplate restTemplate = new RestTemplate();
// 设置请求头
HttpHeaders headers = new HttpHeaders();
headers.set("Accept", "application/octet-stream");
ResponseEntity<byte[]> response = restTemplate.exchange(
downloadUrl,
HttpMethod.GET,
new HttpEntity<>(headers),
byte[].class
);
// 将响应体转换为 InputStream
try (InputStream inputStream = new ByteArrayInputStream(response.getBody())) {
// 将新的文件上传到minio
PutObjectArgs args = PutObjectArgs.builder()
.bucket(this.bucketName)
.object(objectName)
.stream(inputStream, inputStream.available(), -1)
.contentType("application/octet-stream")
.build();
this.minioClient.putObject(args);
log.debug("文档保存成功objectName:{},bucket:{}", objectName, this.bucketName);
} catch (Exception e) {
log.error("", e);
throw new RuntimeException(objectName + "文档保存失败");
}
}
@Override
public void handlerForcesave(final Callback callback, final String fileUrl) {
this.handlerSave(callback, fileUrl);
}
@Override
public void handlerForcesaveCurrupted(Callback callback, String fileUrl) throws Exception {
this.handlerForcesave(callback, fileUrl);
}
@Override
public void handlerSaveCorrupted(Callback callback, String fileUrl) throws Exception {
this.handlerSave(callback, fileUrl);
}
@Override
public void handlerClosed(final Callback callback, final String fileUrl) {
}
}
jwt签名校验,配置信息和回调数据将会使用这个类进行签名 | 校验
/**
* Author: Li
* Description: jwt签名校验,配置信息和回调数据将会使用这个类进行签名 | 校验
*/
@Component
public class DefaultJwtManager implements JwtManager {
@Value("${docservice.security.enable}")
private Boolean securityEnable;
@Value("${docservice.security.key}")
private String secretKey;
@Resource
private ObjectMapper objectMapper;
@Override
public String createToken(Object object) {
Map<String, ?> payloadMap = (Map)this.objectMapper.convertValue(object, Map.class);
return this.createToken(payloadMap, this.secretKey);
}
@Override
public String createToken(Object object, String key) {
Map<String, ?> payloadMap = (Map)this.objectMapper.convertValue(object, Map.class);
return this.createToken(payloadMap, key);
}
@Override
public String createToken(Map<String, ?> payloadMap, String key) {
Algorithm algorithm = Algorithm.HMAC256(key);
String token = JWT.create().withPayload(payloadMap).sign(algorithm);
return token;
}
@Override
public String verify(String token) {
return this.verifyToken(token, this.secretKey);
}
@Override
public String verifyToken(String token, String key) {
Base64.Decoder decoder = Base64.getUrlDecoder();
Algorithm algorithm = Algorithm.HMAC256(key);
// 验证token
DecodedJWT jwt = JWT.require(algorithm).acceptLeeway(1).build().verify(token);
return new String(decoder.decode(jwt.getPayload()));
}
}
minio配置
/**
* Author: Li
* Description: minio配置
*/
@Configuration
public class MinioConfig {
@Value("${minio.endpoint}")
private String minioEndpoint;
@Value("${minio.access-key}")
private String minioAccessKey;
@Value("${minio.secret-key}")
private String minioSecretKey;
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint(minioEndpoint)
.credentials(minioAccessKey, minioSecretKey)
.build();
}
}
SpringBoot配置 application.yml
server:
port: 9002
servlet:
context-path: /api
docservice:
# 文档转换服务地址
url: http://192.168.1.35:9000/
security:
enable: true
key: DO2z9oopirgOJsMBkCacx2TflPTAmRRl
header:
# 回调地址,这个回调地址是当前项目部署后的接口地址,就是我们在OfficeController中定义的callback
callback: http://192.168.1.110:9002/api/office/callback
watermark:
enable: true
minio:
endpoint: http://192.168.1.35:10001
access-key: 1xI27xoiYpAVqO2YZK3x
secret-key: Wcjs4Wf01FxUaZfGZw6jO750j8BXUzAbypJk9epj
bucket-name: winter
4. html测试页面
浏览器打开测试页面,查看最终效果
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no, minimal-ui"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<title>ONLYOFFICE</title>
<style>
html {
height: 100%;
width: 100%;
}
body {
background: #fff;
color: #333;
font-family: Arial, Tahoma, sans-serif;
font-size: 12px;
font-weight: normal;
height: 100%;
margin: 0;
overflow-y: hidden;
padding: 0;
text-decoration: none;
}
.header {
height: 60px;
border: 1px dashed red;
}
.form {
width: 100%;
height: calc(100vh);
/* border: 2px solid red; */
}
div {
margin: 0;
padding: 0;
}
</style>
<!-- @TODO 替换成自己的 -->
<script src="http://192.168.1.35:9000/web-apps/apps/api/documents/api.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
<div class="form">
<div id="editor"></div>
</div>
</body>
</html>
<script>
let docEditor;
let config;
// @TODO 替换成自己的
const fileUrl = "http://192.168.1.35:10001/winter/02施工升降机原始记录(空白).docx";
function innerAlert(message, inEditor) {
console.log(message);
docEditor.showMessage(message);
}
// 编辑器准备就绪
function onAppReady() {
innerAlert("Document editor ready");
}
// 修改文档时调用的函数。使用以下参数调用它:{"data": true} --适用于当前用户正在编辑文档时
var onDocumentStateChange = function (event) {
var title = document.title.replace(/\*$/g, "");
document.title = title + (event.data ? "*" : "");
};
// an error or some other specific event occurs
function onError(event) {
if (event) {
innerAlert(event.data);
}
}
// 显示 错误 后调用的函数,当使用旧的 document.key 值打开文档进行编辑时,该值用于编辑以前的文档版本并成功保存。调用此事件时,必须使用新的 document.key 重新初始化编辑器
function onOutdatedVersion(event) {
location.reload(true);
}
// 通过 meta 命令更改文档的元信息时调用的函数。
// 文档的名称在 data.title 参数中发送。收藏 图标高亮状态在 data.favorite 参数中发送。
// 当用户点击 收藏 图标时,调用 setFavorite 方法更新收藏图标高亮状态信息如果未声明该方法,则收藏图标不会更改。
function onMetaChange(event) {
if (event.data.favorite !== undefined) {
var favorite = !!event.data.favorite;
var title = document.title.replace(/^\☆/g, "");
document.title = (favorite ? "☆" : "") + title;
docEditor.setFavorite(favorite);
}
innerAlert("onMetaChange: " + JSON.stringify(event.data));
}
function onDocumentReady() {
try {
const connector = docEditor.createConnector();
connector.executeMethod("GetCurrentWord", [], (word) => {
console.log(`[METHOD] GetCurrentWord: ${word}`);
});
} catch (e) {
error(e);
}
}
// 监听编辑器事件
function initEvent() {
config.events = {
// 应用程序被加载到浏览器中
onAppReady,
// 文档被加载到文档编辑器中
onDocumentReady,
// 修改文档时调用的函数。使用以下参数调用它:{"data": true} --适用于当前用户正在编辑文档时
onDocumentStateChange,
// 发生错误或其他特定事件时调用的函数。错误消息在 data 参数中发送
onError,
// 显示 错误 后调用的函数,当使用旧的 document.key 值打开文档进行编辑时,该值用于编辑以前的文档版本并成功保存。调用此事件时,必须使用新的 document.key 重新初始化编辑器
onOutdatedVersion,
// 通过 meta 命令更改文档的元信息时调用的函数。
onMetaChange,
};
}
async function init() {
const res = await axios({
url: "http://localhost:9002/api/office/config",
method: "get",
params: {
// 以下文件地址改成自己的minio上传的文件地址
fileUrl,
mode: "EDIT",
},
});
config = res.data.data;
console.log(config);
initEvent();
docEditor = new DocsAPI.DocEditor("editor", config);
}
init();
</script>