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 的签发。 * *
本类只负责「打开文档」这一段。三段链路的凭据各不相同:打开走用户登录 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 原地覆盖同一个 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. 判不了的时候取哪一边由 {@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 两类考虑分开看:
* ⚠️ logo / about / layout 那一类属**白标定制**,社区版镜像大概率不生效,故此处不设——
* 需要的话得先实测再加,别写了以为生效。
*
* @param editable 是否可编辑;forcesave/autosave 只在编辑态有意义,顶栏展开与否也随它变
*/
private Map 🔴 {@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";
}
// ── 工具 ────────────────────────────────────────────────────────────────
/**
* 取签名密钥,顺带挡住「占位符没被解析」这种静默失败。
*
* 配置写的是 {@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();
}
}
*
*
*