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 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.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 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.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 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.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.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 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.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.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.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("无法读取报告版本"); } } }