liu
5 小时以前 6c0fd2cb801554f7999d14e45a94ef0f0d0dfb8d
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
220
221
222
223
package cn.iocoder.yudao.module.mes.service.pro.qrexport;
 
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.qrexport.MesProQrExportTaskDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.qrexport.MesProQrExportTaskMapper;
import cn.iocoder.yudao.module.mes.enums.pro.MesProQrExportTaskStatusEnum;
import cn.iocoder.yudao.module.mes.framework.qrcode.QrCodeImageUtils;
import cn.iocoder.yudao.module.mes.framework.qrcode.QrLabelInfo;
import cn.iocoder.yudao.module.mes.framework.qrexport.MesProQrExportProperties;
import cn.iocoder.yudao.module.mes.framework.qrexport.MesProQrExportTaskRegistry;
import cn.iocoder.yudao.module.mes.framework.qrexport.SystemCpuLoadMonitor;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
 
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.zip.ZipOutputStream;
 
/**
 * 二维码导出任务执行器
 * <p>
 * 在后台线程内把标签逐张写入临时 ZIP,并做三件事:回写进度、响应取消、在 CPU 高水位时让出 CPU。
 * <p>
 * 这里刻意不一次性装配全部标签,而是按页取、逐张写:单批次可达成千上万张,一次性装配会让堆占用
 * 随导出量线性增长,量级再往上就会 OOM。
 */
@Slf4j
@Component
public class MesProQrExportRunner {
 
    /**
     * 每次向 ZIP 取数的页大小
     */
    private static final int PAGE_SIZE = 500;
 
    @Resource
    private MesProQrExportTaskMapper taskMapper;
    @Resource
    private MesProQrExportProperties properties;
    @Resource
    private SystemCpuLoadMonitor cpuMonitor;
    @Resource
    private MesProQrExportTaskRegistry registry;
 
    /**
     * 执行导出任务(由线程池调用,勿在请求线程内直接调用)
     *
     * @param taskId  任务编号
     * @param source  标签数据源
     */
    public void run(Long taskId, MesProQrLabelSource source) {
        // 取消标记在提交时已注册,这里只取用;取不到说明注册环节被绕过,补一个以保证取消可用
        AtomicBoolean cancelFlag = registry.get(taskId);
        if (cancelFlag == null) {
            cancelFlag = registry.register(taskId);
        }
        File target = null;
        try {
            taskMapper.updateStatus(taskId, MesProQrExportTaskStatusEnum.RUNNING.getStatus(),
                    null, null, null, null);
            target = resolveTargetFile(taskId);
            int written = writeZip(target, source, taskId, cancelFlag);
            if (cancelFlag.get()) {
                deleteQuietly(target);
                taskMapper.updateProgress(taskId, written);
                taskMapper.updateStatus(taskId, MesProQrExportTaskStatusEnum.CANCELED.getStatus(),
                        null, null, null, LocalDateTime.now());
                log.info("[run][导出任务 {} 已取消,已生成 {} 张]", taskId, written);
                return;
            }
            // 进度是每若干张回写一次,收尾时必须补一次真实的已生成张数,
            // 否则任务已完成却停在 83% 这类中间值上,界面与记录都会误导使用者
            taskMapper.updateProgress(taskId, written);
            taskMapper.updateStatus(taskId, MesProQrExportTaskStatusEnum.FINISHED.getStatus(),
                    target.getAbsolutePath(), target.length(), null, LocalDateTime.now());
            log.info("[run][导出任务 {} 生成完成,共 {} 张,文件 {} 字节]",
                    taskId, written, target.length());
        } catch (QrExportCancelledException e) {
            deleteQuietly(target);
            taskMapper.updateStatus(taskId, MesProQrExportTaskStatusEnum.CANCELED.getStatus(),
                    null, null, null, LocalDateTime.now());
            log.info("[run][导出任务 {} 已取消]", taskId);
        } catch (Exception e) {
            deleteQuietly(target);
            taskMapper.updateStatus(taskId, MesProQrExportTaskStatusEnum.FAILED.getStatus(),
                    null, null, truncateError(e), LocalDateTime.now());
            log.error("[run][导出任务 {} 生成失败]", taskId, e);
        } finally {
            registry.unregister(taskId);
            registry.release();
        }
    }
 
    /**
     * 逐页取标签、逐张写入 ZIP
     *
     * @return 实际写入的标签张数
     */
    private int writeZip(File target, MesProQrLabelSource source, Long taskId,
                         AtomicBoolean cancelFlag) throws IOException {
        long deadline = System.currentTimeMillis()
                + Duration.ofMinutes(Math.max(1, properties.getMaxRuntimeMinutes())).toMillis();
        int written = 0;
        int pageNo = 1;
        try (OutputStream fileOut = new FileOutputStream(target);
             ZipOutputStream zip = new ZipOutputStream(new BufferedOutputStream(fileOut))) {
            while (true) {
                List<QrLabelInfo> page = source.fetchPage(pageNo, PAGE_SIZE);
                if (page.isEmpty()) {
                    break;
                }
                for (QrLabelInfo label : page) {
                    // 取消检查放在每张之前:原子读开销可忽略,换来取消的及时响应
                    if (cancelFlag.get()) {
                        throw new QrExportCancelledException();
                    }
                    QrCodeImageUtils.writeZipEntry(zip, label);
                    written++;
                    if (written % Math.max(1, properties.getProgressCheckInterval()) == 0) {
                        afterEachInterval(taskId, written, deadline);
                    }
                }
                pageNo++;
            }
            zip.finish();
        }
        return written;
    }
 
    /**
     * 每生成若干张后的例行动作:回写进度、检查运行超时、按 CPU 水位让出 CPU
     */
    private void afterEachInterval(Long taskId, int written, long deadline) throws IOException {
        taskMapper.updateProgress(taskId, written);
        if (System.currentTimeMillis() > deadline) {
            throw new IOException("导出任务超过最长运行时间(" + properties.getMaxRuntimeMinutes() + " 分钟)");
        }
        throttleIfCpuBusy();
    }
 
    /**
     * CPU 高于高水位时让出 CPU
     * <p>
     * 任务一旦开始就必须跑完,只靠提交时拒绝挡不住「提交时 CPU 低、跑起来把同机业务拖卡」,
     * 因此生成过程中持续让路:导出变慢是可以接受的,业务页面卡顿不可接受。
     */
    private void throttleIfCpuBusy() {
        if (!cpuMonitor.isAvailable()) {
            return;
        }
        double load = cpuMonitor.getCpuLoad();
        if (load < properties.getCpuThrottleThreshold()) {
            return;
        }
        try {
            Thread.sleep(Math.max(1L, properties.getCpuThrottleSleepMillis()));
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new QrExportCancelledException();
        }
    }
 
    /**
     * 解析临时文件路径,并确保目录存在
     */
    private File resolveTargetFile(Long taskId) throws IOException {
        File dir = new File(properties.resolveTempDir());
        if (!dir.exists() && !dir.mkdirs() && !dir.exists()) {
            throw new IOException("无法创建二维码导出临时目录:" + dir.getAbsolutePath());
        }
        // 文件名只用任务编号,避免业务码值中的特殊字符进入文件系统
        return new File(dir, "qr-export-" + taskId + ".zip");
    }
 
    private void deleteQuietly(File file) {
        if (file == null || !file.exists()) {
            return;
        }
        try {
            Files.deleteIfExists(file.toPath());
        } catch (IOException e) {
            log.warn("[deleteQuietly][删除临时文件失败 {}]", file.getAbsolutePath(), e);
        }
    }
 
    private String truncateError(Exception e) {
        String message = e.getMessage();
        if (message == null || message.isBlank()) {
            message = e.getClass().getSimpleName();
        }
        return message.length() > 500 ? message.substring(0, 500) : message;
    }
 
    /**
     * 取消信号:生成过程中检测到取消时抛出,由 {@link #run} 统一收尾
     */
    static class QrExportCancelledException extends RuntimeException {
 
        QrExportCancelledException() {
            super("导出任务已取消");
        }
 
    }
 
    /**
     * 供清理任务复用:删除任务遗留的临时文件
     */
    void deleteTaskFile(MesProQrExportTaskDO task) {
        if (task.getFilePath() == null || task.getFilePath().isBlank()) {
            return;
        }
        deleteQuietly(new File(task.getFilePath()));
    }
 
}