package jnpf.bizcommon.onlyoffice.service.impl;
|
|
import cn.dev33.satoken.stp.StpUtil;
|
import cn.hutool.core.util.IdUtil;
|
import cn.hutool.crypto.digest.DigestUtil;
|
import cn.hutool.http.HttpResponse;
|
import cn.hutool.http.HttpUtil;
|
import cn.hutool.jwt.JWTUtil;
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
import jnpf.base.UserInfo;
|
import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeCallbackParam;
|
import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeConfigParam;
|
import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeProperties;
|
import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeSessionEntity;
|
import jnpf.bizcommon.onlyoffice.mapper.OnlyOfficeSessionMapper;
|
import jnpf.bizcommon.onlyoffice.service.OnlyOfficeService;
|
import jnpf.constant.PermissionConst;
|
import jnpf.exception.DataException;
|
import jnpf.file.FileApi;
|
import jnpf.file.FileUploadApi;
|
import jnpf.util.UserProvider;
|
import lombok.RequiredArgsConstructor;
|
import lombok.extern.slf4j.Slf4j;
|
import org.springframework.stereotype.Service;
|
import org.springframework.util.ObjectUtils;
|
import org.springframework.web.multipart.MultipartFile;
|
|
import java.io.ByteArrayInputStream;
|
import java.io.File;
|
import java.io.IOException;
|
import java.io.InputStream;
|
import java.net.URI;
|
import java.nio.charset.StandardCharsets;
|
import java.nio.file.Files;
|
import java.util.ArrayList;
|
import java.util.Arrays;
|
import java.util.Date;
|
import java.util.HashMap;
|
import java.util.HashSet;
|
import java.util.LinkedHashMap;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.Set;
|
|
/**
|
* 编辑器 config 的签发。
|
*
|
* <p>本类只负责「打开文档」这一段。三段链路的凭据各不相同:打开走用户登录 token,
|
* DS 回源走 securityKey 票据,DS 回调走本类签发的同一把 JWT 密钥。
|
*/
|
@Slf4j
|
@Service
|
@RequiredArgsConstructor
|
public class OnlyOfficeServiceImpl implements OnlyOfficeService {
|
|
private final OnlyOfficeProperties properties;
|
private final OnlyOfficeSessionMapper sessionMapper;
|
private final OnlyOfficePluginResolver pluginResolver;
|
private final OnlyOfficeFillTicketIssuer fillTicketIssuer;
|
private final FileApi fileApi;
|
private final FileUploadApi fileUploadApi;
|
|
/** 扩展名 → DS documentType;未列出的一律拒绝,避免把不支持的格式喂给编辑器 */
|
private static final Map<String, String> DOCUMENT_TYPES = new HashMap<>();
|
|
static {
|
putAll("word", "doc", "docx", "docm", "dot", "dotx", "odt", "ott", "rtf", "txt", "html", "htm");
|
putAll("cell", "xls", "xlsx", "xlsm", "xlt", "xltx", "ods", "ots", "csv");
|
putAll("slide", "ppt", "pptx", "pptm", "pot", "potx", "pps", "ppsx", "odp", "otp");
|
putAll("pdf", "pdf");
|
}
|
|
private static void putAll(String documentType, String... extensions) {
|
for (String extension : extensions) {
|
DOCUMENT_TYPES.put(extension, documentType);
|
}
|
}
|
|
/** 可编辑的格式;其余(如 pdf、txt 之外的只读格式)一律降级为只读 */
|
private static final Set<String> EDITABLE_EXTENSIONS = new HashSet<>(Arrays.asList(
|
"docx", "xlsx", "pptx", "docm", "xlsm", "pptm", "odt", "ods", "odp", "rtf", "txt", "csv"));
|
|
private static final String MODE_VIEW = "view";
|
private static final String MODE_EDIT = "edit";
|
private static final String ELN_TEMPLATE_ANNOTATE_SCENE = "eln.template.annotate";
|
|
@Override
|
public Map<String, Object> buildEditorConfig(OnlyOfficeConfigParam param) {
|
if (ObjectUtils.isEmpty(param.getFileId()) || ObjectUtils.isEmpty(param.getFileName())) {
|
throw new DataException("fileId 与 fileName 不能为空");
|
}
|
requireJwtSecret();
|
|
String extension = extensionOf(param.getFileName());
|
String documentType = DOCUMENT_TYPES.get(extension);
|
if (documentType == null) {
|
throw new DataException("OnlyOffice 不支持的文件格式:" + extension);
|
}
|
|
List<OnlyOfficePluginResolver.ResolvedPlugin> resolvedPlugins = pluginResolver.resolve(param);
|
UserInfo user = UserProvider.getUser();
|
OnlyOfficeSessionEntity session = loadOrCreateSession(param, documentType);
|
String mode = resolveMode(param, extension, session, user);
|
if (MODE_EDIT.equals(mode)) {
|
acquireLock(session, user.getUserId());
|
}
|
|
return assembleConfig(param, session, documentType, extension, mode, user, resolvedPlugins);
|
}
|
|
@Override
|
public Map<String, Object> getSaveStatus(String fileId) {
|
if (ObjectUtils.isEmpty(fileId)) {
|
throw new DataException("fileId 不能为空");
|
}
|
OnlyOfficeSessionEntity session = sessionMapper.selectOne(Wrappers.<OnlyOfficeSessionEntity>lambdaQuery()
|
.eq(OnlyOfficeSessionEntity::getFileId, fileId)
|
.last("limit 1"));
|
Map<String, Object> status = new LinkedHashMap<>();
|
status.put("version", session == null || session.getVersionNo() == null ? 0 : session.getVersionNo());
|
status.put("lastSaveTime", session == null || session.getLastSaveTime() == null
|
? null : session.getLastSaveTime().getTime());
|
return status;
|
}
|
|
// ── 保存回调 ────────────────────────────────────────────────────────────
|
|
@Override
|
public void handleCallback(OnlyOfficeCallbackParam param, String authorization) {
|
verifyCallbackToken(param, authorization);
|
|
if (ObjectUtils.isEmpty(param.getKey()) || param.getStatus() == null) {
|
throw new DataException("回调缺少 key 或 status");
|
}
|
OnlyOfficeSessionEntity session = sessionMapper.selectOne(Wrappers.<OnlyOfficeSessionEntity>lambdaQuery()
|
.eq(OnlyOfficeSessionEntity::getDocKey, param.getKey())
|
.last("limit 1"));
|
if (session == null) {
|
// 会话被清理或 key 对不上。抛错让 Controller 记日志,但仍返回 error:0——
|
// 返回非 0 会让 DS 无限重试一个永远不会成功的回调。
|
throw new DataException("找不到 doc_key 对应的编辑会话:" + param.getKey());
|
}
|
|
switch (param.getStatus()) {
|
case OnlyOfficeCallbackParam.Status.EDITING:
|
refreshLockHeartbeat(session);
|
break;
|
case OnlyOfficeCallbackParam.Status.READY_FOR_SAVING:
|
saveBack(session, param, true);
|
break;
|
case OnlyOfficeCallbackParam.Status.FORCE_SAVING:
|
// 强制保存:落盘但不结束会话,故不轮换 key、不释放锁
|
saveBack(session, param, false);
|
break;
|
case OnlyOfficeCallbackParam.Status.CLOSED_NO_CHANGES:
|
releaseLock(session, OnlyOfficeSessionEntity.SessionStatus.IDLE);
|
break;
|
case OnlyOfficeCallbackParam.Status.SAVE_ERROR:
|
case OnlyOfficeCallbackParam.Status.FORCE_SAVE_ERROR:
|
log.error("OnlyOffice 保存出错,file={} key={} status={}",
|
session.getFileId(), param.getKey(), param.getStatus());
|
releaseLock(session, OnlyOfficeSessionEntity.SessionStatus.SAVE_FAILED);
|
break;
|
default:
|
log.warn("未知的 OnlyOffice 回调 status={},key={}", param.getStatus(), param.getKey());
|
}
|
}
|
|
/**
|
* 验签。回调路径在网关白名单内、匿名可达,这是唯一的安全边界,
|
* 验不过一律抛错,不得降级放过。
|
*/
|
private void verifyCallbackToken(OnlyOfficeCallbackParam param, String authorization) {
|
String token = null;
|
if (!ObjectUtils.isEmpty(authorization)) {
|
token = authorization.startsWith("Bearer ") ? authorization.substring(7).trim() : authorization.trim();
|
}
|
if (ObjectUtils.isEmpty(token)) {
|
// 少数部署把 token 放在 body 而非 Authorization 头
|
token = param.getToken();
|
}
|
if (ObjectUtils.isEmpty(token)) {
|
throw new DataException("回调缺少 JWT,拒绝处理");
|
}
|
if (!JWTUtil.verify(token, requireJwtSecret().getBytes(StandardCharsets.UTF_8))) {
|
throw new DataException("回调 JWT 验签失败,拒绝处理");
|
}
|
}
|
|
/**
|
* 把 DS 产出的新版文档写回文件存储。
|
*
|
* <p>原地覆盖同一个 fileId——这样表单里存的附件字段 JSON 完全不用改写,
|
* 代价只是前端侧的 fileSize 会过期(靠 onSave 回调刷新)。
|
*
|
* @param rotateKey 是否轮换 doc_key 并释放锁。status 2(全员退出)为 true,
|
* status 6(强制保存,仍在编辑)为 false
|
*/
|
private void saveBack(OnlyOfficeSessionEntity session, OnlyOfficeCallbackParam param, boolean rotateKey) {
|
if (ObjectUtils.isEmpty(param.getUrl())) {
|
throw new DataException("回调 status=" + param.getStatus() + " 但缺少下载 url");
|
}
|
String downloadUrl = rewriteToInternalHost(param.getUrl());
|
try (HttpResponse response = HttpUtil.createGet(downloadUrl).timeout(60_000).executeAsync()) {
|
if (!response.isOk()) {
|
throw new DataException("从 DS 下载编辑后文档失败,HTTP " + response.getStatus());
|
}
|
MultipartFile file = new InMemoryMultipartFile(session.getFileId(), response.bodyBytes());
|
String folderPath = fileApi.getPath(session.getFileType());
|
if (ObjectUtils.isEmpty(folderPath)) {
|
throw new DataException("文件服务无法解析文档存储目录");
|
}
|
if (!folderPath.endsWith("/") && !folderPath.endsWith("\\")) {
|
folderPath += "/";
|
}
|
if (!fileUploadApi.replaceFile(file, folderPath, session.getFileId())) {
|
throw new DataException("文件服务写回文档失败");
|
}
|
} catch (DataException e) {
|
throw e;
|
} catch (Exception e) {
|
throw new DataException("写回文档失败:" + e.getMessage());
|
}
|
|
int nextVersion = session.getVersionNo() == null ? 1 : session.getVersionNo() + 1;
|
session.setLastSaveTime(new Date());
|
session.setLastEditorIds(param.getUsers() == null ? null : String.join(",", param.getUsers()));
|
if (rotateKey) {
|
// 内容变了必须换 key,否则下次打开 DS 会命中自身缓存返回旧内容
|
session.setVersionNo(nextVersion);
|
session.setDocKey(buildDocKey(session.getFileId(), nextVersion));
|
session.setLockUserId(null);
|
session.setLockHeartbeat(null);
|
session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE);
|
}
|
sessionMapper.updateById(session);
|
log.info("OnlyOffice 保存回写完成,file={} version={} rotateKey={}",
|
session.getFileId(), session.getVersionNo(), rotateKey);
|
}
|
|
private static final class InMemoryMultipartFile implements MultipartFile {
|
|
private final String fileName;
|
private final byte[] content;
|
|
private InMemoryMultipartFile(String fileName, byte[] content) {
|
this.fileName = fileName;
|
this.content = content;
|
}
|
|
@Override
|
public String getName() {
|
return "multipartFile";
|
}
|
|
@Override
|
public String getOriginalFilename() {
|
return fileName;
|
}
|
|
@Override
|
public String getContentType() {
|
return "application/octet-stream";
|
}
|
|
@Override
|
public boolean isEmpty() {
|
return content.length == 0;
|
}
|
|
@Override
|
public long getSize() {
|
return content.length;
|
}
|
|
@Override
|
public byte[] getBytes() {
|
return content;
|
}
|
|
@Override
|
public InputStream getInputStream() {
|
return new ByteArrayInputStream(content);
|
}
|
|
@Override
|
public void transferTo(File destination) throws IOException {
|
Files.write(destination.toPath(), content);
|
}
|
}
|
|
/**
|
* DS 给的下载 url 用的是它自己视角的主机名,后端不一定能直连。
|
* 配了 ds-internal-url 就只保留 path+query 换到后端可达的主机上。
|
*/
|
private String rewriteToInternalHost(String url) {
|
String base = properties.getDsInternalUrl();
|
if (ObjectUtils.isEmpty(base)) {
|
return url;
|
}
|
try {
|
URI origin = URI.create(url);
|
String pathAndQuery = origin.getRawPath()
|
+ (origin.getRawQuery() == null ? "" : "?" + origin.getRawQuery());
|
String rewritten = base.endsWith("/") ? base.substring(0, base.length() - 1) + pathAndQuery
|
: base + pathAndQuery;
|
if (!rewritten.equals(url)) {
|
log.debug("DS 下载地址重写:{} → {}", url, rewritten);
|
}
|
return rewritten;
|
} catch (IllegalArgumentException e) {
|
log.warn("DS 下载地址无法解析,原样使用:{}", url);
|
return url;
|
}
|
}
|
|
private void refreshLockHeartbeat(OnlyOfficeSessionEntity session) {
|
session.setLockHeartbeat(new Date());
|
session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING);
|
sessionMapper.updateById(session);
|
}
|
|
private void releaseLock(OnlyOfficeSessionEntity session, int status) {
|
session.setLockUserId(null);
|
session.setLockHeartbeat(null);
|
session.setSessionStatus(status);
|
sessionMapper.updateById(session);
|
}
|
|
// ── 会话与编辑锁 ─────────────────────────────────────────────────────────
|
|
/**
|
* 取当前文件的会话;没有则建一条 version_no=1 的新会话。
|
* docKey 由 fileId + versionNo 派生,保证同一版本重复打开拿到相同 key(否则 DS 不认为是同一文档)。
|
*/
|
private OnlyOfficeSessionEntity loadOrCreateSession(OnlyOfficeConfigParam param, String documentType) {
|
OnlyOfficeSessionEntity session = sessionMapper.selectOne(Wrappers.<OnlyOfficeSessionEntity>lambdaQuery()
|
.eq(OnlyOfficeSessionEntity::getFileId, param.getFileId())
|
.eq(OnlyOfficeSessionEntity::getFileType, param.getFileType())
|
.last("limit 1"));
|
if (session != null) {
|
// 业务上下文可能在后续调用里才补全(如先从附件列表打开、后从业务页面打开)
|
if (!ObjectUtils.isEmpty(param.getBizModule())) {
|
session.setBizModule(param.getBizModule());
|
}
|
if (!ObjectUtils.isEmpty(param.getBizDataId())) {
|
session.setBizDataId(param.getBizDataId());
|
}
|
session.setFileName(param.getFileName());
|
session.setDocumentType(documentType);
|
sessionMapper.updateById(session);
|
return session;
|
}
|
|
session = new OnlyOfficeSessionEntity();
|
session.setId(IdUtil.getSnowflakeNextIdStr());
|
session.setFileId(param.getFileId());
|
session.setFileType(param.getFileType());
|
session.setFileName(param.getFileName());
|
session.setDocumentType(documentType);
|
session.setBizModule(param.getBizModule());
|
session.setBizDataId(param.getBizDataId());
|
session.setVersionNo(1);
|
session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE);
|
session.setDocKey(buildDocKey(param.getFileId(), 1));
|
session.setDeleteMark(null);
|
sessionMapper.insert(session);
|
return session;
|
}
|
|
/**
|
* 决定最终模式。前端传的只是期望值,以下四种情况一律降级为只读:
|
* 格式本身不可编辑、当前用户无编辑权、他人正持有编辑锁、DS 上一次保存失败
|
* (避免在错误状态上继续写)。
|
*/
|
private String resolveMode(OnlyOfficeConfigParam param, String extension, OnlyOfficeSessionEntity session, UserInfo user) {
|
if (ObjectUtils.isEmpty(param.getBizScene())) {
|
return MODE_VIEW;
|
}
|
if (MODE_VIEW.equals(param.getMode())) {
|
return MODE_VIEW;
|
}
|
if (!EDITABLE_EXTENSIONS.contains(extension)) {
|
return MODE_VIEW;
|
}
|
if (!hasEditPermission(param.getBizModule(), user)) {
|
return MODE_VIEW;
|
}
|
if (Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.SAVE_FAILED).equals(session.getSessionStatus())) {
|
log.warn("文档 {} 上次保存失败,本次降级只读", session.getFileId());
|
return MODE_VIEW;
|
}
|
if (isLockedByOthers(session, user.getUserId())) {
|
log.info("文档 {} 正被 {} 编辑,{} 降级只读", session.getFileId(), session.getLockUserId(), user.getUserId());
|
return MODE_VIEW;
|
}
|
return MODE_EDIT;
|
}
|
|
/**
|
* 菜单/按钮级编辑权判定。权限码在登录时已灌进 Sa-Token 会话,此处纯本地判断、无远程调用。
|
*
|
* <p>判不了的时候取哪一边由 {@code edit-permission.strict} 决定:默认放行(保持里程碑 3
|
* 之前的行为,那时最简调用并不传 bizModule),登记齐全后应当打开严格模式——否则
|
* 「漏登记」会表现为悄悄放行编辑,而不是能被人发现的报错。
|
*/
|
private boolean hasEditPermission(String bizModule, UserInfo user) {
|
// 超管直通,与 lims 既有判定同一口径(LimsJianceTaskServiceImpl#fetchAuditorOrganizes)
|
if (Boolean.TRUE.equals(user.getIsAdministrator())) {
|
return true;
|
}
|
|
OnlyOfficeProperties.EditPermission rule = properties.getEditPermission();
|
OnlyOfficeProperties.ModuleMapping mapping = ObjectUtils.isEmpty(bizModule)
|
? null
|
: rule.getModules().stream()
|
.filter(item -> bizModule.equals(item.getBizModule()))
|
.findFirst()
|
.orElse(null);
|
|
if (mapping == null || ObjectUtils.isEmpty(mapping.getModuleId())) {
|
if (rule.isStrict()) {
|
log.info("bizModule={} 未登记到 onlyoffice.edit-permission.modules,严格模式下降级只读", bizModule);
|
return false;
|
}
|
log.warn("bizModule={} 未登记编辑权限映射,当前为宽松模式故放行编辑;登记齐全后请开启 edit-permission.strict", bizModule);
|
return true;
|
}
|
|
String moduleId = mapping.getModuleId();
|
// 菜单级:连功能本身都没授权就没有继续判断的必要
|
if (!StpUtil.hasPermission(moduleId)) {
|
log.info("用户 {} 无功能 {} 的菜单权限,文档降级只读", user.getUserId(), moduleId);
|
return false;
|
}
|
// 按钮级:仅当该表单启用了按钮权限时才有意义,否则没人会有这个权限码(见 ModuleMapping 注释)
|
if (mapping.isRequireBtnEdit() && !StpUtil.hasPermission(moduleId + "::" + PermissionConst.BTN_EDIT)) {
|
log.info("用户 {} 无功能 {} 的编辑按钮权限,文档降级只读", user.getUserId(), moduleId);
|
return false;
|
}
|
return true;
|
}
|
|
/**
|
* 锁是否被他人持有。锁正常由 status 2/4 回调释放;浏览器崩溃等异常场景下
|
* 靠心跳超时兜底,否则文档会永久锁死。
|
*/
|
private boolean isLockedByOthers(OnlyOfficeSessionEntity session, String currentUserId) {
|
String holder = session.getLockUserId();
|
if (ObjectUtils.isEmpty(holder) || holder.equals(currentUserId)) {
|
return false;
|
}
|
Date heartbeat = session.getLockHeartbeat();
|
if (heartbeat == null) {
|
return false;
|
}
|
long timeoutMillis = properties.getLockTimeoutMinutes() * 60L * 1000L;
|
return System.currentTimeMillis() - heartbeat.getTime() < timeoutMillis;
|
}
|
|
private void acquireLock(OnlyOfficeSessionEntity session, String userId) {
|
session.setLockUserId(userId);
|
session.setLockHeartbeat(new Date());
|
session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING);
|
sessionMapper.updateById(session);
|
}
|
|
// ── config 组装 ─────────────────────────────────────────────────────────
|
|
private Map<String, Object> assembleConfig(OnlyOfficeConfigParam param, OnlyOfficeSessionEntity session,
|
String documentType, String extension, String mode, UserInfo user,
|
List<OnlyOfficePluginResolver.ResolvedPlugin> resolvedPlugins) {
|
boolean editable = MODE_EDIT.equals(mode);
|
boolean hideComments = !editable || ELN_TEMPLATE_ANNOTATE_SCENE.equals(param.getBizScene());
|
|
Map<String, Object> permissions = new LinkedHashMap<>();
|
permissions.put("edit", editable);
|
// 只读模式关闭协作菜单;ELN 标注场景也隐藏批注,避免批注层与模板字段标注混在一起。
|
// 仅设 permissions.comment=false 关不干净,必须配上 customization.comments=false。
|
// DS 把「能写批注」和「能看批注面板」拆成了两个标志,且后者压根不看 permissions
|
// (web-apps/apps/documenteditor/main/app/controller/Main.js:1725-1727):
|
// canComments = permissions.comment && mode!=='view' && customization.comments!==false
|
// canViewComments = canComments || customization.comments!==false ← 这里没有 permissions
|
// 而左栏那个批注按钮的显隐取的是 canViewComments(controller/LeftMenu.js:206)。
|
// 所以只关 permissions.comment 的效果是「按钮还在、点开只能看不能写」。2026-08-08 实踩。
|
if (hideComments) {
|
permissions.put("comment", false);
|
// 模板标注会用内容控件替换选区;禁用修订,避免原字符被记录为删除、新控件被记录为新增。
|
permissions.put("review", false);
|
}
|
permissions.put("chat", false);
|
// 下载与打印默认关闭:去掉「文件 → 下载为」「文件 → 打印」以及顶栏右侧那两个图标。
|
//
|
// ⚠️ 这两项只是**收掉界面入口**,不等于文件拿不到:/api/file/Image/** 在网关白名单内、
|
// 只靠 securityKey 票据鉴权,知道 URL 的人照样能把原文件拉走。真正的下载管控必须落在
|
// 文件服务那一层,别把这里的开关当安全边界。
|
// 将来要按人区分,用 §6.1 那套 bizModule→moduleId 登记表判 moduleId::btn_download /
|
// btn_batchPrint 即可(PermissionConst 里已有这两个码),机制现成不用新造。
|
permissions.put("download", properties.isAllowDownload());
|
permissions.put("print", properties.isAllowPrint());
|
// 收掉「保护」:同时去掉顶栏的「保护」页签与「文件」左菜单里的「保护」按钮。
|
// 归 permissions.protect 管而不是 customization——customization.layout.toolbar.protect
|
// 属白标参数,社区版镜像不认。DS ≥ 7.5 支持本项(当前 9.4.0.1)。
|
//
|
// 关掉的理由是它与本集成的模型冲突:加密/只读保护写进的是**文档自身**,
|
// 而我们编辑完是原地覆盖回同一个 fileId,一旦被加密,下次打开 DS 要密码、
|
// 后端也无从解,等于把附件锁死。谁能编辑由 §6.1 的权限判定 + 编辑锁决定。
|
permissions.put("protect", false);
|
|
Map<String, Object> document = new LinkedHashMap<>();
|
document.put("fileType", extension);
|
document.put("key", session.getDocKey());
|
document.put("title", param.getFileName());
|
document.put("url", buildFileFetchUrl(param, user));
|
document.put("permissions", permissions);
|
|
Map<String, Object> owner = new LinkedHashMap<>();
|
owner.put("id", user.getUserId());
|
owner.put("name", user.getUserName());
|
|
Map<String, Object> customization = buildCustomization(editable, !resolvedPlugins.isEmpty(), hideComments);
|
|
// 协同不在本期范围,但 DS 没有关闭协同的开关(coEditing 只有 fast/strict)。
|
// strict + change:false 让行为至少可预期:改动要保存后才互相可见,且禁掉界面切换入口
|
// (用户在界面改过模式会写进 localStorage 并覆盖此处传值)。真正的隔离靠编辑锁。
|
Map<String, Object> coEditing = new LinkedHashMap<>();
|
coEditing.put("mode", "strict");
|
coEditing.put("change", false);
|
|
Map<String, Object> editorConfig = new LinkedHashMap<>();
|
editorConfig.put("callbackUrl", properties.getCallbackBaseUrl() + "/api/biz/onlyoffice/callback");
|
editorConfig.put("lang", "zh");
|
editorConfig.put("mode", mode);
|
editorConfig.put("user", owner);
|
editorConfig.put("coEditing", coEditing);
|
editorConfig.put("customization", customization);
|
if (!resolvedPlugins.isEmpty()) {
|
Map<String, Object> plugins = new LinkedHashMap<>();
|
List<String> pluginsData = new ArrayList<>();
|
List<String> autostart = new ArrayList<>();
|
Map<String, Object> options = new LinkedHashMap<>();
|
for (OnlyOfficePluginResolver.ResolvedPlugin resolvedPlugin : resolvedPlugins) {
|
pluginsData.add(resolvedPlugin.getManifestUrl());
|
if (resolvedPlugin.isAutostart()) {
|
autostart.add(resolvedPlugin.getGuid());
|
}
|
Map<String, Object> context = resolvedPlugin.runtimeContext(param.getFileId(), param.getBizDataId());
|
if (OnlyOfficeFillTicketIssuer.PLUGIN_CODE.equals(resolvedPlugin.getPluginCode())) {
|
context.put("canFill", editable);
|
context.put("scope", OnlyOfficeFillTicketIssuer.SCOPE);
|
context.put("ticket", fillTicketIssuer.issue(param, user, resolvedPlugin.getGuid()));
|
} else if (ELN_TEMPLATE_ANNOTATE_SCENE.equals(param.getBizScene())) {
|
context.put("canAnnotate", editable);
|
}
|
options.put(resolvedPlugin.getGuid(), context);
|
}
|
plugins.put("pluginsData", pluginsData);
|
plugins.put("options", options);
|
if (!autostart.isEmpty()) {
|
plugins.put("autostart", autostart);
|
}
|
List<String> disabledPluginGuids = resolvedPlugins.get(0).getDisabledPluginGuids();
|
if (!disabledPluginGuids.isEmpty()) {
|
plugins.put("disable", disabledPluginGuids);
|
}
|
editorConfig.put("plugins", plugins);
|
}
|
|
Map<String, Object> config = new LinkedHashMap<>();
|
config.put("document", document);
|
config.put("documentType", documentType);
|
config.put("editorConfig", editorConfig);
|
|
// token 的 payload 就是上面这份 config 本身;DS 收到后用同一密钥验签,
|
// 防止浏览器篡改 permissions / callbackUrl。必须最后放,不能把自己算进去。
|
config.put("token", JWTUtil.createToken(config, requireJwtSecret().getBytes(StandardCharsets.UTF_8)));
|
return config;
|
}
|
|
/**
|
* 编辑器界面裁剪。**必须在后端拼进 config**:customization 在 JWT 签名范围内,
|
* 前端往 config 里塞字段会让 DS 验签失败(前端只能追加 events,函数不参与序列化)。
|
*
|
* <p>两类考虑分开看:
|
* <ul>
|
* <li><b>安全</b>:{@code macros}/{@code plugins}。附件是用户上传的外来文档,
|
* 可能带宏;插件(含 AI 入口)会把文档内容发往第三方,都不该默认开着。</li>
|
* <li><b>界面噪音</b>:帮助、反馈、顶栏文件名(弹窗标题已经有了)、右侧面板。
|
* 这些纯属观感,改动无风险。</li>
|
* </ul>
|
*
|
* <p>⚠️ logo / about / layout 那一类属**白标定制**,社区版镜像大概率不生效,故此处不设——
|
* 需要的话得先实测再加,别写了以为生效。
|
*
|
* @param editable 是否可编辑;forcesave/autosave 只在编辑态有意义,顶栏展开与否也随它变
|
*/
|
private Map<String, Object> buildCustomization(boolean editable, boolean pluginsEnabled, boolean hideComments) {
|
Map<String, Object> customization = new LinkedHashMap<>();
|
|
// ── 安全 ──
|
// 外来 docx 可能带宏:不自动运行,且不给用户开的入口
|
customization.put("macros", false);
|
customization.put("macrosMode", "disable");
|
// 仅后端白名单场景开启指定插件;空场景仍关闭全部插件入口。
|
customization.put("plugins", pluginsEnabled);
|
|
// ── 界面收敛 ──
|
// 默认关闭拼写检查;用户在界面修改后,localStorage 仍会覆盖这个初始值。
|
Map<String, Object> features = new LinkedHashMap<>();
|
features.put("spellcheck", false);
|
customization.put("features", features);
|
if (hideComments) {
|
// 批注面板的另一半开关,与 permissions.comment=false 必须成对出现。
|
customization.put("comments", false);
|
Map<String, Object> review = new LinkedHashMap<>();
|
review.put("trackChanges", false);
|
review.put("showReviewChanges", false);
|
customization.put("review", review);
|
}
|
customization.put("help", false);
|
customization.put("feedback", false);
|
// 左下角那个「提出功能建议」归 suggestFeature 管,不是 feedback(实测得知)
|
customization.put("suggestFeature", false);
|
// 顶栏不再重复显示文件名——我们的弹窗标题已经有了
|
customization.put("toolbarHideFileName", true);
|
customization.put("hideRightMenu", true);
|
// 顶栏形态按模式分:编辑态展开成完整功能区(工具找得到),只读态收成单行紧凑版
|
// (没有可用的编辑按钮,展开只是白占垂直空间)。这与 DS 自己的默认取向一致,
|
// 但仍显式写出——默认值随版本变过,别指望省略。
|
//
|
// ⚠️ 本项会被浏览器 localStorage 覆盖:用户手动折叠过顶栏,之后打开就一直是折叠的,
|
// 后端传什么都不算数。验证时请换无痕窗口,别以为配置没生效。
|
customization.put("compactToolbar", !editable);
|
|
customization.put("autosave", false);
|
if (editable) {
|
// forcesave:点保存即触发 status 6 回调,不必等全员退出才落盘
|
customization.put("forcesave", true);
|
}
|
return customization;
|
}
|
|
/**
|
* DS 回源拉文档的地址。走 /api/file/Image/**——该路径在 GatewayWhite 硬编码白名单内,
|
* DS 没有登录态也能取,鉴权靠 URL 上的 securityKey 票据。
|
*
|
* <p>🔴 {@code &t=t} 不能省,且它与"时间戳"无关。{@code FileServiceImpl#flushFile} 里
|
* {@code redirect = StringUtil.isEmpty(t)}:**带 securityKey 但不带 t 时,该端点不吐文件流,
|
* 而是 302 跳到 ApiDomain 上的预览地址**。DS 会跟着跳,最终拿到
|
* {@code {"code":400,"msg":"System abnormality"}} 并把这段 JSON 存成 origin.docx,
|
* 表现为编辑器一直转圈(HTTP 200 让 DS 以为下载成功了)。前端的
|
* {@code getAuthMediaUrl(url, isRedirect=false)} 也是靠追加 {@code &t=t} 拿原始流的,此处同源。
|
* 2026-08-08 实踩。
|
*/
|
private String buildFileFetchUrl(OnlyOfficeConfigParam param, UserInfo user) {
|
return properties.getFileFetchBaseUrl()
|
+ "/api/file/Image/" + param.getFileType() + "/" + param.getFileId()
|
+ "?s=" + user.getSecurityKey() + "&t=t";
|
}
|
|
// ── 工具 ────────────────────────────────────────────────────────────────
|
|
/**
|
* 取签名密钥,顺带挡住「占位符没被解析」这种静默失败。
|
*
|
* <p>配置写的是 {@code ${ONLYOFFICE_JWT_SECRET}},而 **Spring Boot 的 Binder 对解析不了的
|
* 占位符是原样放行**(不抛异常)。所以变量没透传进容器时,这里拿到的是字面量
|
* {@code "${ONLYOFFICE_JWT_SECRET}"}——非空、能过空值校验、能签出 token,但 DS 验签必然
|
* 失败,表现为编辑器里「文档安全令牌的格式不正确」,且后端毫无异常可查。
|
* 2026-08-08 实踩过一次(compose 只把该变量喂给了 cx-onlyoffice,漏了 cx-biz-common)。
|
*/
|
private String requireJwtSecret() {
|
String secret = properties.getJwtSecret();
|
if (ObjectUtils.isEmpty(secret)) {
|
throw new DataException("OnlyOffice 未配置 jwt-secret,无法签发/校验令牌");
|
}
|
if (secret.startsWith("${")) {
|
throw new DataException("OnlyOffice 的 jwt-secret 仍是未解析的占位符(" + secret
|
+ "),说明运行环境没有注入 ONLYOFFICE_JWT_SECRET——容器栈检查 docker-compose.yml"
|
+ " 的 cx-biz-common environment,宿主栈检查 .env 是否被 start-all.sh 加载");
|
}
|
return secret;
|
}
|
|
/**
|
* doc_key:DS 用它判断「是不是同一份文档的同一个版本」。
|
* md5 取 32 位十六进制,天然满足 DS 对字符集 [0-9a-zA-Z_-] 与长度 ≤128 的要求。
|
*/
|
private String buildDocKey(String fileId, int versionNo) {
|
return DigestUtil.md5Hex(fileId + "_" + versionNo);
|
}
|
|
private String extensionOf(String fileName) {
|
int dot = fileName.lastIndexOf('.');
|
if (dot < 0 || dot == fileName.length() - 1) {
|
throw new DataException("无法识别文件扩展名:" + fileName);
|
}
|
return fileName.substring(dot + 1).toLowerCase();
|
}
|
}
|