package jnpf.bizcommon.onlyoffice.service.impl; import cn.hutool.core.net.URLEncodeUtil; import cn.hutool.jwt.JWTUtil; import jnpf.base.UserInfo; import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeCallbackParam; import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeConfigParam; import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeProperties; import jnpf.constant.GlobalConst; import jnpf.dmsEntity.compare.DmsCompareTicket; import jnpf.dmsEntity.preview.vo.DmsFilePreviewTicketVO; import jnpf.exception.DataException; import jnpf.util.TicketUtil; import org.springframework.stereotype.Service; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; import java.util.UUID; @Service public class OnlyOfficeCompareService { private final OnlyOfficeProperties properties; private final DmsDocumentCompareConfigBuilder builder; private final DmsOnlyOfficePreviewTicketValidator validator; private final TicketReader tickets; private final OnlyOfficeComparisonRenderer renderer; @org.springframework.beans.factory.annotation.Autowired public OnlyOfficeCompareService(OnlyOfficeProperties properties, DmsDocumentCompareConfigBuilder builder, DmsOnlyOfficePreviewTicketValidator validator, OnlyOfficeComparisonRenderer renderer) { this(properties, builder, validator, TicketUtil::parseTicket, renderer); } OnlyOfficeCompareService(OnlyOfficeProperties properties, DmsDocumentCompareConfigBuilder builder, DmsOnlyOfficePreviewTicketValidator validator, TicketReader tickets, OnlyOfficeComparisonRenderer renderer) { this.properties = properties; this.builder = builder; this.validator = validator; this.tickets = tickets; this.renderer = renderer; } public Map createConfig(String ticketValue, UserInfo user) { DmsCompareTicket ticket; try { ticket = DmsCompareTicket.fromRaw(tickets.read(ticketValue)); } catch (RuntimeException e) { throw new DataException("比较会话已过期,请重新选择版本"); } String tenant = user == null || user.getTenantId() == null || user.getTenantId().trim().isEmpty() ? GlobalConst.DEFAULT_TENANT_VALUE : user.getTenantId().trim(); if (user == null || !ticket.getTenantId().equals(tenant) || !ticket.getUserId().equals(user.getUserId())) { throw new DataException("比较票据与当前用户或租户不匹配"); } validateSource(ticket.getHistorical(), user); validateSource(ticket.getCurrent(), user); String key = DmsDocumentCompareConfigBuilder.KEY_PREFIX + UUID.randomUUID(); String title = ticket.getCurrent().getFileName() + "|" + ticket.getHistoricalLabel() + " → " + ticket.getCurrentLabel(); secret(); // Fail before invoking DS if signing is unavailable. String resultPath = renderer.generate(sourceUrl(ticket.getHistorical()), sourceUrl(ticket.getCurrent())); if (ticket.getExpiresAt() <= System.currentTimeMillis()) { throw new DataException("比较会话已过期,请重新选择版本"); } Map resultClaims = new LinkedHashMap<>(); resultClaims.put("scope", "dms:onlyoffice:compare-result"); resultClaims.put("key", key); resultClaims.put("path", resultPath); resultClaims.put("expiresAt", ticket.getExpiresAt()); String resultTicket = JWTUtil.createToken(resultClaims, secret()); String resultUrl = properties.getFileFetchBaseUrl() + "/api/biz/onlyoffice/compare/result?ticket=" + URLEncodeUtil.encode(resultTicket, GlobalConst.DEFAULT_CHARSET); Map config = builder.comparisonConfig(key, title, resultUrl, properties.getCallbackBaseUrl() + "/api/biz/onlyoffice/compare/callback", user); config.put("token", JWTUtil.createToken(config, secret())); Map result = new LinkedHashMap<>(); result.put("config", config); result.put("title", title); result.put("expiresAt", ticket.getExpiresAt()); return result; } public byte[] readResult(String ticket) { try { if (ticket == null || !JWTUtil.verify(ticket, secret())) throw new IllegalArgumentException(); cn.hutool.json.JSONObject payload = JWTUtil.parseToken(ticket).getPayloads(); if (!"dms:onlyoffice:compare-result".equals(payload.getStr("scope")) || payload.getLong("expiresAt", 0L) <= System.currentTimeMillis()) { throw new IllegalArgumentException(); } return renderer.readResult(payload.getStr("path")); } catch (Exception e) { throw new DataException("比较结果票据无效或已过期"); } } /** No file/session repository or write-back dependency: DS owns and expires its temporary copy. */ public void acknowledgeCallback(OnlyOfficeCallbackParam body, String authorization) { String token = authorization == null ? body.getToken() : authorization.replaceFirst("^Bearer ", "").trim(); if (token == null || !JWTUtil.verify(token, secret())) throw new DataException("比较回调验签失败"); if (body.getKey() == null || !body.getKey().startsWith(DmsDocumentCompareConfigBuilder.KEY_PREFIX)) { throw new DataException("无效的比较会话"); } // Intentionally never download body.url or call the normal OnlyOffice save handler. } private void validateSource(DmsFilePreviewTicketVO source, UserInfo user) { if (source.getFileName() == null || !source.getFileName().toLowerCase(java.util.Locale.ROOT).endsWith(".docx")) { throw new DataException("版本比较目前仅支持 DOCX 文件"); } OnlyOfficeConfigParam param = new OnlyOfficeConfigParam(); param.setFileId(source.getFileVersionId().toString()); param.setFileName(source.getFileName()); param.setDmsAccessTicket(source.getAccessTicket()); validator.validate(param, user); } private String sourceUrl(DmsFilePreviewTicketVO source) { return properties.getFileFetchBaseUrl() + "/api/file/Dms/OnlyOffice/" + source.getFileVersionId() + "?ticket=" + URLEncodeUtil.encode(source.getAccessTicket(), GlobalConst.DEFAULT_CHARSET); } private byte[] secret() { String value = properties.getJwtSecret(); if (value == null || value.trim().isEmpty() || value.contains("${")) { throw new DataException("OnlyOffice 签名密钥未正确配置"); } return value.getBytes(StandardCharsets.UTF_8); } @FunctionalInterface interface TicketReader { Object read(String ticket); } }