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<ElnRecordFieldValueEntity> 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<String, String> 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<String, String> valuesByTag(
|
String tenantId, String templateId,
|
List<ElnRecordFieldValueEntity> changedValues) {
|
if (!StringUtils.hasText(templateId)
|
|| changedValues == null || changedValues.isEmpty()) {
|
throw new DataException("共享填写 DOCX 字段不能为空");
|
}
|
List<ElnTemplateBindingEntity> bindings = bindingMapper.selectList(
|
Wrappers.<ElnTemplateBindingEntity>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<String, ElnTemplateBindingEntity> byId = new LinkedHashMap<>();
|
for (ElnTemplateBindingEntity binding : bindings) {
|
if (byId.put(binding.getId(), binding) != null) {
|
throw new DataException("电子记录模板活动绑定重复");
|
}
|
}
|
Map<String, String> 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<String, String> 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<String> 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<Boolean> 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);
|
}
|
}
|
}
|