吴羡
2026-10-24 3d397976d08f0359b8cfeb4e4f22403ae5d0c04f
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
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("无法读取报告版本"); }
    }
}