package com.ruoyi.inspect.service;
|
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
import com.ruoyi.common.utils.SecurityUtils;
|
import com.ruoyi.framework.exception.ErrorException;
|
import com.ruoyi.inspect.mapper.InsReportEditSessionMapper;
|
import com.ruoyi.inspect.mapper.InsReportMapper;
|
import com.ruoyi.inspect.pojo.InsReport;
|
import com.ruoyi.inspect.pojo.InsReportEditSession;
|
import jakarta.annotation.Resource;
|
import org.apache.commons.lang3.StringUtils;
|
import org.apache.poi.xwpf.usermodel.XWPFDocument;
|
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.stereotype.Service;
|
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.web.multipart.MultipartFile;
|
import java.io.InputStream;
|
import java.nio.file.*;
|
import java.security.MessageDigest;
|
import java.time.LocalDateTime;
|
import java.util.*;
|
|
/** 浏览器内 DOCX 编辑:登录鉴权、版本校验,无匿名回调及文档服务器依赖。 */
|
@Service
|
public class InsReportEditingService {
|
private static final long MAX_BYTES = 100L * 1024 * 1024;
|
@Resource private InsReportMapper reportMapper;
|
@Resource private InsReportEditSessionMapper sessionMapper;
|
@Resource private InsReportService reportService;
|
@Value("${wordUrl}") private String wordUrl;
|
|
@Transactional(rollbackFor = Exception.class)
|
public Map<String, Object> createEditConfig(Integer id, Integer type) {
|
if (!Objects.equals(type, 0) && !Objects.equals(type, 1)) throw new ErrorException("报告类型不正确");
|
InsReport report = lockEditable(id);
|
Path file = resolveFile(report, type);
|
LocalDateTime now = LocalDateTime.now();
|
sessionMapper.update(null, Wrappers.<InsReportEditSession>lambdaUpdate()
|
.eq(InsReportEditSession::getReportId, id).eq(InsReportEditSession::getReportType, type)
|
.in(InsReportEditSession::getStatus, "EDITING", "SAVED", "FAILED", "SAVE_REQUESTED")
|
.set(InsReportEditSession::getStatus, "EXPIRED"));
|
InsReportEditSession session = new InsReportEditSession();
|
session.setReportId(id);
|
session.setReportType(type);
|
session.setToken(UUID.randomUUID().toString());
|
session.setDocumentKey(version(file));
|
session.setUserId(SecurityUtils.getUserId().intValue());
|
// 未打开编辑的第二份报告确认使用当前持久化版本;当前编辑器由前端保存屏障提交。
|
session.setStatus("SAVED");
|
session.setSavedAt(now);
|
session.setExpiresAt(now.plusHours(8));
|
session.setCreateTime(now);
|
session.setUpdateTime(now);
|
sessionMapper.insert(session);
|
return Map.of("sessionToken", session.getToken(), "version", session.getDocumentKey(),
|
"title", file.getFileName().toString(), "reportType", type);
|
}
|
|
@Transactional(rollbackFor = Exception.class)
|
public byte[] readDocument(String token) {
|
InsReportEditSession session = findSession(token);
|
InsReport report = lockEditable(session.getReportId());
|
requireUsable(session);
|
Path file = resolveFile(report, session.getReportType());
|
requireVersion(session, file);
|
try { return Files.readAllBytes(file); }
|
catch (Exception e) { throw new ErrorException("读取报告失败"); }
|
}
|
|
@Transactional(rollbackFor = Exception.class)
|
public Map<String, Object> saveDocument(String token, String expectedVersion, MultipartFile file) {
|
InsReportEditSession session = findSession(token);
|
InsReport report = lockEditable(session.getReportId());
|
requireUsable(session);
|
if (!Objects.equals(expectedVersion, session.getDocumentKey())) {
|
throw new ErrorException("报告版本已变化,请重新打开后编辑");
|
}
|
requireVersion(session, resolveFile(report, session.getReportType()));
|
if (file == null || file.isEmpty() || file.getSize() > MAX_BYTES) {
|
throw new ErrorException("报告不能为空,且不能超过 100MB");
|
}
|
try {
|
// 校验 OOXML 结构,拒绝 ZIP、HTML 或错误响应冒充 DOCX。
|
try (InputStream input = file.getInputStream(); XWPFDocument doc = new XWPFDocument(input)) {
|
if (doc.getDocument().getBody() == null) throw new IllegalArgumentException();
|
}
|
Path root = Paths.get(wordUrl).toAbsolutePath().normalize();
|
Files.createDirectories(root);
|
Path staged = root.resolve("edited-" + UUID.randomUUID() + ".docx");
|
file.transferTo(staged);
|
// 新 Word、PDF 全部生成成功后发布数据库指针;任何失败都不覆盖旧文件。
|
String pdfUrl = reportService.prepareEditedReportPreview(staged.toString());
|
String documentUrl = "/word/" + staged.getFileName();
|
var update = Wrappers.<InsReport>lambdaUpdate().eq(InsReport::getId, report.getId());
|
if (session.getReportType() == 1) {
|
update.set(InsReport::getInspectionUrl, documentUrl).set(InsReport::getInspectionTempUrlPdf, pdfUrl);
|
} else {
|
update.set(InsReport::getUrlS, documentUrl).set(InsReport::getTempUrlPdf, pdfUrl);
|
}
|
reportMapper.update(null, update);
|
session.setDocumentKey(version(staged));
|
session.setStatus("SAVED");
|
session.setSavedAt(LocalDateTime.now());
|
session.setUpdateTime(LocalDateTime.now());
|
session.setMessage("保存成功");
|
sessionMapper.updateById(session);
|
return Map.of("version", session.getDocumentKey(), "completed", true, "pdfUrl", pdfUrl);
|
} catch (Exception e) {
|
throw e instanceof ErrorException error ? error : new ErrorException("保存 Word 失败,请重试:" + e.getMessage());
|
}
|
}
|
|
/** 保存版本核对、审核签名及会话失效使用同一个报告行锁与数据库事务。 */
|
@Transactional(rollbackFor = Exception.class)
|
public int examineReport(Integer id, Integer passed, String reason, Integer approver, Map<String, String> tokens) {
|
InsReport report = lockEditable(id);
|
if (!Objects.equals(passed, 0) && !Objects.equals(passed, 1)) throw new ErrorException("审核结果不正确");
|
if (Objects.equals(passed, 1)) {
|
checkSaved(report, 0, tokens);
|
if (StringUtils.isNotBlank(report.getInspectionUrl())) checkSaved(report, 1, tokens);
|
// 签名流程会改写文件;先复制再在事务中切换指针,失败时旧的已保存稿不受影响。
|
stageForSignature(report);
|
}
|
int result = reportService.examineReport(id, passed, reason, approver);
|
sessionMapper.update(null, Wrappers.<InsReportEditSession>lambdaUpdate()
|
.eq(InsReportEditSession::getReportId, id)
|
.in(InsReportEditSession::getStatus, "EDITING", "SAVED", "FAILED", "SAVE_REQUESTED")
|
.set(InsReportEditSession::getStatus, "FINALIZED")
|
.set(InsReportEditSession::getUpdateTime, LocalDateTime.now()));
|
return result;
|
}
|
|
private void stageForSignature(InsReport report) {
|
try {
|
Path root = Paths.get(wordUrl).toAbsolutePath().normalize();
|
var update = Wrappers.<InsReport>lambdaUpdate().eq(InsReport::getId, report.getId());
|
for (int type : StringUtils.isNotBlank(report.getInspectionUrl()) ? new int[]{0, 1} : new int[]{0}) {
|
Path copy = root.resolve("reviewed-" + UUID.randomUUID() + ".docx");
|
Files.copy(resolveFile(report, type), copy);
|
String url = "/word/" + copy.getFileName();
|
if (type == 0) update.set(InsReport::getUrlS, url);
|
else update.set(InsReport::getInspectionUrl, url);
|
}
|
reportMapper.update(null, update);
|
} catch (Exception e) { throw new ErrorException("准备审核签名文件失败,请重试"); }
|
}
|
|
private void checkSaved(InsReport report, int type, Map<String, String> tokens) {
|
if (tokens == null || StringUtils.isBlank(tokens.get(String.valueOf(type)))) {
|
throw new ErrorException("请先打开审核编辑页面并保存报告");
|
}
|
InsReportEditSession session = findSession(tokens.get(String.valueOf(type)));
|
requireUsable(session);
|
if (!Objects.equals(session.getReportId(), report.getId()) || session.getReportType() != type) {
|
throw new ErrorException("报告编辑会话不匹配");
|
}
|
requireVersion(session, resolveFile(report, type));
|
}
|
|
private InsReport lockEditable(Integer id) {
|
InsReport report = reportMapper.selectOne(Wrappers.<InsReport>lambdaQuery()
|
.eq(InsReport::getId, id).last("FOR UPDATE"));
|
if (report == null) throw new ErrorException("报告不存在");
|
if (!Objects.equals(report.getState(), 1) || Objects.equals(report.getIsExamine(), 1)
|
|| Objects.equals(report.getIsRatify(), 1)) throw new ErrorException("报告已不在待审核状态");
|
if (!Objects.equals(report.getExamineUserId(), SecurityUtils.getUserId().intValue())) {
|
throw new ErrorException("只有指定审核人可以编辑和审核报告");
|
}
|
return report;
|
}
|
|
private InsReportEditSession findSession(String token) {
|
if (StringUtils.isBlank(token)) throw new ErrorException("缺少编辑会话");
|
InsReportEditSession session = sessionMapper.selectOne(Wrappers.<InsReportEditSession>lambdaQuery()
|
.eq(InsReportEditSession::getToken, token));
|
if (session == null || !Objects.equals(session.getUserId(), SecurityUtils.getUserId().intValue())) {
|
throw new ErrorException("编辑会话不存在或无权访问");
|
}
|
return session;
|
}
|
|
private void requireUsable(InsReportEditSession session) {
|
// 等待报告锁期间可能已被另一窗口失效,故此处进行锁定读。
|
InsReportEditSession current = sessionMapper.selectOne(Wrappers.<InsReportEditSession>lambdaQuery()
|
.eq(InsReportEditSession::getId, session.getId()).last("FOR UPDATE"));
|
if (current.getExpiresAt().isBefore(LocalDateTime.now()) || !"SAVED".equals(current.getStatus())) {
|
throw new ErrorException("编辑会话已失效,请重新打开报告");
|
}
|
session.setDocumentKey(current.getDocumentKey());
|
session.setStatus(current.getStatus());
|
}
|
|
private Path resolveFile(InsReport report, int type) {
|
String url = type == 1 ? report.getInspectionUrl() : StringUtils.defaultIfBlank(report.getUrlS(), report.getUrl());
|
if (StringUtils.isBlank(url) || !url.startsWith("/word/") || !url.toLowerCase().endsWith(".docx")) {
|
throw new ErrorException("对应的 DOCX 报告不存在");
|
}
|
try {
|
Path root = Paths.get(wordUrl).toRealPath();
|
Path file = root.resolve(url.substring(6)).normalize().toRealPath();
|
if (!file.startsWith(root) || !Files.isRegularFile(file) || Files.size(file) > MAX_BYTES) throw new IllegalArgumentException();
|
return file;
|
} catch (Exception e) { throw new ErrorException("报告文件不存在或路径不合法"); }
|
}
|
|
private void requireVersion(InsReportEditSession session, Path file) {
|
if (!Objects.equals(session.getDocumentKey(), version(file))) throw new ErrorException("报告已被替换,请重新打开后编辑");
|
}
|
|
private String version(Path file) {
|
try (InputStream input = Files.newInputStream(file)) {
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
byte[] buffer = new byte[8192];
|
int length;
|
while ((length = input.read(buffer)) != -1) digest.update(buffer, 0, length);
|
return HexFormat.of().formatHex(digest.digest());
|
} catch (Exception e) { throw new ErrorException("无法读取报告版本"); }
|
}
|
}
|