package jnpf.elnService.impl; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import feign.Response; import jnpf.base.ActionResult; import jnpf.base.ActionResultCode; import jnpf.elnEntity.runtime.ElnRecordEntity; import jnpf.elnEntity.runtime.ElnRecordFieldValueEntity; import jnpf.elnEntity.runtime.ElnRecordRevisionEntity; import jnpf.elnEntity.template.ElnTemplateBindingEntity; import jnpf.elnMapper.ElnTemplateBindingMapper; import jnpf.elnService.template.docx.DocxContentControlWriter; import jnpf.exception.DataException; import jnpf.file.FileApi; import jnpf.file.FileUploadApi; import jnpf.model.upload.UploadFileModel; import jnpf.onlyoffice.RecordRevisionFenceApi; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; /** 多批次共享填写的 DOCX、引用控件和 OnlyOffice 会话一致性边界。 */ @Slf4j @Service public class ElnRecordSharedFillDocumentService { private static final String STORAGE_TYPE = "annex"; private static final long MAX_DOCX_BYTES = 50L * 1024L * 1024L; private final ElnTemplateBindingMapper bindingMapper; private final FileApi fileApi; private final FileUploadApi fileUploadApi; private final RecordRevisionFenceApi fenceApi; private final DocxContentControlWriter writer; @Autowired public ElnRecordSharedFillDocumentService( ElnTemplateBindingMapper bindingMapper, FileApi fileApi, FileUploadApi fileUploadApi, RecordRevisionFenceApi fenceApi) { this(bindingMapper, fileApi, fileUploadApi, fenceApi, new DocxContentControlWriter()); } ElnRecordSharedFillDocumentService( ElnTemplateBindingMapper bindingMapper, FileApi fileApi, FileUploadApi fileUploadApi, RecordRevisionFenceApi fenceApi, DocxContentControlWriter writer) { this.bindingMapper = bindingMapper; this.fileApi = fileApi; this.fileUploadApi = fileUploadApi; this.fenceApi = fenceApi; this.writer = writer; } public void synchronize(String tenantId, ElnRecordEntity record, ElnRecordRevisionEntity revision, List changedValues, String operationCode) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { throw new DataException("共享填写 DOCX 同步必须在独立事务中执行"); } if (!StringUtils.hasText(tenantId) || record == null || revision == null || !StringUtils.hasText(revision.getJiluWendang())) { throw new DataException("共享填写记录文档身份无效"); } Map valuesByTag = valuesByTag( tenantId, record.getMubanId(), changedValues); String fileId = revision.getJiluWendang().trim(); String token = acquire(tenantId, record.getId(), fileId); byte[] original = null; String folder = null; boolean candidateWritten = false; boolean completionRegistered = false; try { original = download(fileId); byte[] candidate = writer.write(original, valuesByTag); if (MessageDigest.isEqual(sha256(original), sha256(candidate))) { release(tenantId, record.getId(), fileId, token, false); return; } folder = fileApi.getPath(STORAGE_TYPE); if (!StringUtils.hasText(folder)) { throw new DataException("电子记录文件目录不可用"); } replace(folder, fileId, candidate, "电子记录 DOCX 写入失败"); candidateWritten = true; byte[] observed = download(fileId); if (candidate.length != observed.length || !MessageDigest.isEqual(sha256(candidate), sha256(observed))) { throw new DataException("电子记录 DOCX 回读校验失败"); } registerCompletion(tenantId, record.getId(), fileId, token, folder, original, operationCode); completionRegistered = true; } catch (RuntimeException failure) { boolean safeToRelease = true; if (candidateWritten && folder != null && original != null) { try { restore(folder, fileId, original); } catch (RuntimeException restoreFailure) { safeToRelease = false; failure.addSuppressed(restoreFailure); } } if (!completionRegistered && safeToRelease) { try { release(tenantId, record.getId(), fileId, token, false); } catch (RuntimeException releaseFailure) { failure.addSuppressed(releaseFailure); } } throw failure; } } private Map valuesByTag( String tenantId, String templateId, List changedValues) { if (!StringUtils.hasText(templateId) || changedValues == null || changedValues.isEmpty()) { throw new DataException("共享填写 DOCX 字段不能为空"); } List bindings = bindingMapper.selectList( Wrappers.lambdaQuery() .eq(ElnTemplateBindingEntity::getFTenantId, tenantId) .eq(ElnTemplateBindingEntity::getMubanId, templateId) .eq(ElnTemplateBindingEntity::getBangdingZhuangtai, "ACTIVE") .and(q -> q.isNull(ElnTemplateBindingEntity::getFDeleteMark) .or().eq(ElnTemplateBindingEntity::getFDeleteMark, 0))); Map byId = new LinkedHashMap<>(); for (ElnTemplateBindingEntity binding : bindings) { if (byId.put(binding.getId(), binding) != null) { throw new DataException("电子记录模板活动绑定重复"); } } Map result = new LinkedHashMap<>(); for (ElnRecordFieldValueEntity value : changedValues) { if (value == null || !"PRIMARY".equals(value.getBangdingJuese()) || !"ACTIVE".equals(value.getBangdingZhuangtai())) { throw new DataException("共享填写 DOCX 仅支持活动主字段"); } ElnTemplateBindingEntity primary = byId.get(value.getMubanBangdingId()); requireSameField(value, primary); putTag(result, primary.getKongjianBiaoqian(), value.getZhiWenben()); for (ElnTemplateBindingEntity reference : bindings) { if (!"REFERENCE".equals(reference.getKongjianJuese()) || !Objects.equals(primary.getId(), reference.getYinyongShiliId())) { continue; } requireSameIdentity(primary, reference); putTag(result, reference.getKongjianBiaoqian(), value.getZhiWenben()); } } return result; } private static void requireSameField( ElnRecordFieldValueEntity value, ElnTemplateBindingEntity binding) { if (binding == null || !"PRIMARY".equals(binding.getKongjianJuese()) || !Objects.equals(value.getZiduanDingyiId(), binding.getZiduanDingyiId()) || !Objects.equals(value.getZiduanBianma(), binding.getZiduanBianma()) || !Objects.equals(value.getZiduanBanbenId(), binding.getZiduanBanbenId())) { throw new DataException("共享填写字段与活动模板绑定不一致"); } } private static void requireSameIdentity( ElnTemplateBindingEntity primary, ElnTemplateBindingEntity reference) { if (!Objects.equals(primary.getZiduanDingyiId(), reference.getZiduanDingyiId()) || !Objects.equals(primary.getZiduanBianma(), reference.getZiduanBianma()) || !Objects.equals(primary.getZiduanBanbenId(), reference.getZiduanBanbenId())) { throw new DataException("共享填写引用与主字段固定版本不一致"); } } private static void putTag(Map target, String tag, String value) { if (!StringUtils.hasText(tag)) { throw new DataException("共享填写 DOCX 目标控件无效"); } String text = value == null ? "" : value; String previous = target.put(tag.trim(), text); if (previous != null && !Objects.equals(previous, text)) { throw new DataException("共享填写 DOCX 目标控件值冲突"); } } private String acquire(String tenantId, String recordId, String fileId) { ActionResult result = fenceApi.acquire( tenantId, recordId, fileId, STORAGE_TYPE); if (result == null || !ActionResultCode.SUCCESS.getCode().equals(result.getCode()) || !StringUtils.hasText(result.getData())) { throw new DataException(result != null && StringUtils.hasText(result.getMsg()) ? result.getMsg() : "电子记录文档正在编辑或保存,请稍后重试"); } return result.getData(); } private void registerCompletion( String tenantId, String recordId, String fileId, String token, String folder, byte[] original, String operationCode) { TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCompletion(int status) { boolean committed = status == TransactionSynchronization.STATUS_COMMITTED; boolean safeToRelease = true; try { if (!committed) restore(folder, fileId, original); } catch (RuntimeException restoreFailure) { safeToRelease = false; log.error("共享填写事务回滚后 DOCX 恢复失败,operationCode={}, recordId={}, fileId={}", operationCode, recordId, fileId, restoreFailure); } if (!safeToRelease) return; try { release(tenantId, recordId, fileId, token, committed); } catch (RuntimeException releaseFailure) { log.error("共享填写 DOCX fence 收尾失败,operationCode={}, recordId={}, fileId={}, committed={}", operationCode, recordId, fileId, committed, releaseFailure); } } }); } private void restore(String folder, String fileId, byte[] original) { replace(folder, fileId, original, "电子记录 DOCX 恢复失败,需人工处理"); byte[] observed = download(fileId); if (original.length != observed.length || !MessageDigest.isEqual(sha256(original), sha256(observed))) { throw new DataException("电子记录 DOCX 恢复校验失败,需人工处理"); } } private void release(String tenantId, String recordId, String fileId, String token, boolean documentUpdated) { ActionResult result = documentUpdated ? fenceApi.releaseAfterDocumentUpdate( tenantId, recordId, fileId, STORAGE_TYPE, token) : fenceApi.release(tenantId, recordId, fileId, STORAGE_TYPE, token); if (result == null || !ActionResultCode.SUCCESS.getCode().equals(result.getCode()) || !Boolean.TRUE.equals(result.getData())) { throw new DataException(result != null && StringUtils.hasText(result.getMsg()) ? result.getMsg() : "电子记录文档锁释放失败,需人工处理"); } } private void replace(String folder, String fileId, byte[] content, String message) { if (!fileUploadApi.replaceFile( new InMemoryMultipartFile(fileId, content), folder, fileId)) { throw new DataException(message); } } private byte[] download(String fileId) { try (Response response = fileUploadApi.downFile( new UploadFileModel(STORAGE_TYPE, fileId))) { if (response == null || response.status() < 200 || response.status() >= 300 || response.body() == null) { throw new DataException("电子记录 DOCX 下载失败"); } Integer length = response.body().length(); if (length != null && (length == 0 || length > MAX_DOCX_BYTES)) { throw new DataException("电子记录 DOCX 大小无效"); } try (InputStream input = response.body().asInputStream(); ByteArrayOutputStream output = new ByteArrayOutputStream()) { byte[] buffer = new byte[8192]; int total = 0; int read; while ((read = input.read(buffer)) != -1) { total += read; if (total > MAX_DOCX_BYTES) { throw new DataException("电子记录 DOCX 超过 50MB 限制"); } output.write(buffer, 0, read); } if (total == 0) throw new DataException("电子记录 DOCX 不能为空"); return output.toByteArray(); } } catch (DataException e) { throw e; } catch (IOException | RuntimeException e) { throw new DataException("电子记录 DOCX 下载失败"); } } private static byte[] sha256(byte[] bytes) { try { return MessageDigest.getInstance("SHA-256").digest(bytes); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("JVM 不支持 SHA-256", e); } } 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/vnd.openxmlformats-officedocument.wordprocessingml.document"; } @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); } } }