吉安感知网项目-后端
linwei
2026-07-11 74dd4584d5949d02263c8df4f9ac4850e275987f
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
package org.sxkj.resource.endpoint;
 
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.springblade.core.oss.model.BladeFile;
import org.springblade.core.tenant.annotation.NonDS;
import org.springblade.core.tool.utils.FileUtil;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.sxkj.common.utils.file.FileNameHarsher;
import org.sxkj.resource.builder.OssBuilder;
import org.sxkj.resource.entity.Attach;
import org.sxkj.resource.service.IAttachService;
 
import javax.servlet.http.HttpServletResponse;
import java.io.InputStream;
 
/**
 * ONLYOFFICE 回调端点
 *
 * @author lw
 * @since 2026-07-08
 */
@Slf4j
@NonDS
@RestController
@AllArgsConstructor
@RequestMapping("/office")
@Api(value = "ONLYOFFICE回调端点", tags = "ONLYOFFICE回调接口")
public class OfficeCallbackEndpoint {
 
    /**
     * 对象存储构建类
     */
    private final OssBuilder ossBuilder;
 
    /**
     * 附件表服务
     */
    private final IAttachService attachService;
 
    /**
     * ONLYOFFICE 保存回调接口
     * <p>
     * 处理ONLYOFFICE文档编辑保存回调,支持以下状态:
     * status=2: 文档编辑完成,关闭编辑器时触发
     * status=3: 文档保存出错时触发
     * status=6: 文档强制保存(forcesave)时触发
     * </p>
     * <p>
     * 对应前端 config.editorConfig.callbackUrl = "http://192.168.1.33:8310/office/callback"
     * </p>
     *
     * @param payload  回调数据对象,包含status、url、key、filetype等字段
     * @param response HTTP响应对象
     * @throws Exception 回调处理异常
     */
    @PostMapping("/callback")
    @ApiOperation(value = "ONLYOFFICE保存回调", notes = "ONLYOFFICE文档编辑保存回调接口")
    public void officeCallback(@RequestBody JSONObject payload, HttpServletResponse response) throws Exception {
        log.info("ONLYOFFICE回调开始,回调数据: {}", JSON.toJSONString(payload));
 
        // 步骤1:获取回调状态码(核心逻辑判断依据:2/3/6 表示需要保存文档)
        int status = payload.getIntValue("status");
        log.info("回调状态status={}", status);
 
        try {
            // 步骤2:判断是否需要保存文档
            // status=2: 文档被关闭,所有用户都退出编辑,需要保存
            // status=3: 文档保存出错时触发
            // status=6: 文档强制保存(forcesave),编辑过程中定时或手动触发
            if (status == 2 || status == 3 || status == 6) {
                String downloadUrl = payload.getString("url");
                String key = payload.getString("key");
                log.info("回调key={}, 下载地址: {}", key, downloadUrl);
 
                // 步骤3:检查文件URL是否为空
                if (downloadUrl == null || downloadUrl.isEmpty()) {
                    log.warn("缺少文件URL,status={}, key={}", status, key);
                    sendErrorResponse(response, "缺少文件URL");
                    return;
                }
 
                // 步骤4:处理保存回调
                handleSaveCallback(payload, key, downloadUrl);
            }
 
            // 步骤5:返回OnlyOffice要求的规范响应(error:0 表示处理成功)
            sendSuccessResponse(response);
        } catch (Exception e) {
            log.error("ONLYOFFICE回调处理失败,status={}, 错误: {}", status, e.getMessage(), e);
            // 步骤6:异常时返回 error:1
            sendErrorResponse(response, e.getMessage());
        }
    }
 
    /**
     * 发送成功响应给OnlyOffice
     *
     * @param response HTTP响应对象
     * @throws Exception IO异常
     */
    private void sendSuccessResponse(HttpServletResponse response) throws Exception {
        JSONObject responseJson = new JSONObject();
        responseJson.put("error", 0);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(responseJson.toString());
    }
 
    /**
     * 发送错误响应给OnlyOffice
     *
     * @param response HTTP响应对象
     * @param message  错误消息
     * @throws Exception IO异常
     */
    private void sendErrorResponse(HttpServletResponse response, String message) throws Exception {
        JSONObject responseJson = new JSONObject();
        responseJson.put("error", 1);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(responseJson.toString());
    }
 
    /**
     * 处理保存回调(status=2或status=6)
     * <p>
     * 从ONLYOFFICE服务器下载编辑后的文件,上传到OSS,
     * 并更新附件表中的link和md5字段。
     * </p>
     *
     * @param payload     回调数据对象
     * @param key         文档唯一标识,对应附件表的md5字段
     * @param downloadUrl 编辑后文件的下载地址
     * @throws Exception 下载、上传或更新数据库时的异常
     */
    private void handleSaveCallback(JSONObject payload, String key, String downloadUrl) throws Exception {
        // 1. 根据key(md5)查询附件记录
        Attach attach = attachService.getOne(
            Wrappers.lambdaQuery(Attach.class).eq(Attach::getMd5, key)
        );
        if (attach == null) {
            log.warn("未找到key={}对应的附件记录,跳过保存", key);
            return;
        }
        log.info("找到附件记录,attachId={}, name={}", attach.getId(), attach.getName());
 
        // 2. 从ONLYOFFICE服务器下载编辑后的文件
        // 对URL进行HTML实体解码(将&amp;转换为&)
        String decodedUrl = downloadUrl.replace("&amp;", "&");
        log.info("开始从ONLYOFFICE下载编辑后文件,解码后URL: {}", decodedUrl);
        byte[] fileContent = downloadFile(decodedUrl);
        log.info("文件下载完成,大小: {} bytes", fileContent.length);
 
        // 3. 上传文件到OSS
        String fileName = attach.getName() != null ? attach.getName() : key + ".docx";
        BladeFile bladeFile = uploadToOss(fileName, fileContent);
        log.info("文件上传OSS完成,新link: {}", bladeFile.getLink());
 
        // 4. 更新附件记录的link和md5
        String newLink = bladeFile.getLink();
        String newMd5 = FileNameHarsher.generateFileHash(newLink);
        attach.setLink(newLink);
        attach.setMd5(newMd5);
        attach.setName(bladeFile.getName());
        attach.setAttachSize((long) fileContent.length);
        attachService.updateById(attach);
        log.info("附件记录更新完成,attachId={}, 新md5={}", attach.getId(), newMd5);
    }
 
    /**
     * 从ONLYOFFICE服务器下载文件
     *
     * @param downloadUrl 文件下载URL
     * @return 文件内容字节数组
     * @throws Exception 下载异常
     */
    private byte[] downloadFile(String downloadUrl) throws Exception {
        // 步骤1:创建HTTP客户端
        try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
            // 步骤2:构建GET请求
            HttpGet httpGet = new HttpGet(downloadUrl);
 
            // 步骤3:执行请求并获取响应
            try (CloseableHttpResponse response = httpClient.execute(httpGet)) {
                // 步骤4:获取响应内容流并读取到字节数组
                InputStream inputStream = response.getEntity().getContent();
                return inputStream.readAllBytes();
            }
        }
    }
 
    /**
     * 上传文件到OSS
     *
     * @param fileName    文件名
     * @param fileContent 文件内容
     * @return BladeFile OSS文件信息
     * @throws Exception 上传异常
     */
    private BladeFile uploadToOss(String fileName, byte[] fileContent) throws Exception {
        // 步骤1:创建输入流
        InputStream inputStream = new java.io.ByteArrayInputStream(fileContent);
 
        // 步骤2:使用ossBuilder上传文件到指定存储桶(estack)
        return ossBuilder.template("estack").putFile(fileName, inputStream);
    }
 
    /**
     * 保存附件信息到数据库
     *
     * @param fileName   文件名
     * @param fileSize   文件大小
     * @param bladeFile  OSS文件信息
     * @return 附件ID
     */
    private Long saveAttachInfo(String fileName, int fileSize, BladeFile bladeFile) {
        // 步骤1:获取文件扩展名
        String fileExtension = FileUtil.getFileExtension(fileName);
 
        // 步骤2:构建附件实体
        Attach attach = new Attach();
        attach.setDomainUrl(bladeFile.getDomain());
        attach.setLink(bladeFile.getLink());
        attach.setName(bladeFile.getName());
        attach.setOriginalName(bladeFile.getOriginalName());
        attach.setAttachSize(Long.valueOf(fileSize));
        attach.setExtension(fileExtension);
 
        // 步骤3:保存附件信息
        attachService.save(attach);
 
        // 步骤4:返回附件ID
        return attach.getId();
    }
 
    /**
     * 根据文件key生成文件名
     *
     * @param fileKey 文件唯一标识
     * @return 文件名
     */
    private String generateFileName(String fileKey) {
        // 步骤1:可根据业务需求从数据库查询原始文件名
        // FileEntity file = fileMapper.selectByKey(fileKey);
        // return file.getFileName();
 
        // 步骤2:默认使用fileKey作为文件名(可根据业务修改)
        return fileKey + ".docx";
    }
}