liu
10 小时以前 66517edf1de22c8203daa256abffddae43bae704
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
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
package cn.iocoder.yudao.module.mes.service.pro.qrexport;
 
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
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.MesProQrExportBizTypeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProQrExportTaskStatusEnum;
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 jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.util.StreamUtils;
import org.springframework.validation.annotation.Validated;
 
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.RejectedExecutionException;
 
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_BAG_CODE_NOT_EXISTS;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_BATCH_NOT_EXISTS;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_PALLET_NOT_EXISTS;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_SERVER_BUSY;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_TASK_BUSY;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_TASK_FILE_MISSING;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_TASK_NOT_EXISTS;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_TASK_STATUS_INVALID;
 
/**
 * MES 二维码导出任务 Service 实现
 */
@Slf4j
@Service
@Validated
public class MesProQrExportTaskServiceImpl implements MesProQrExportTaskService {
 
    /**
     * 非终态状态集合
     */
    private static final List<Integer> UNFINISHED_STATUSES = List.of(
            MesProQrExportTaskStatusEnum.PENDING.getStatus(),
            MesProQrExportTaskStatusEnum.RUNNING.getStatus());
 
    /**
     * 终态状态集合
     */
    private static final List<Integer> FINAL_STATUSES = List.of(
            MesProQrExportTaskStatusEnum.FINISHED.getStatus(),
            MesProQrExportTaskStatusEnum.CANCELED.getStatus(),
            MesProQrExportTaskStatusEnum.FAILED.getStatus(),
            MesProQrExportTaskStatusEnum.DOWNLOADED.getStatus());
 
    @Resource
    private MesProQrExportTaskMapper taskMapper;
    @Resource
    private MesProQrLabelSourceFactory sourceFactory;
    @Resource
    private MesProQrExportRunner runner;
    @Resource
    private MesProQrExportProperties properties;
    @Resource
    private SystemCpuLoadMonitor cpuMonitor;
    @Resource
    private MesProQrExportTaskRegistry registry;
 
    @Resource(name = "mesQrExportExecutor")
    private ThreadPoolTaskExecutor executor;
 
    @Override
    public long countLabels(MesProQrExportBizTypeEnum bizType, String uuid, Long batchId) {
        return sourceFactory.count(bizType, uuid, batchId);
    }
 
    @Override
    public MesProQrExportTaskDO submit(MesProQrExportBizTypeEnum bizType, String uuid, Long batchId) {
        // 1. 解析数据源并统计数量:参数不合法或业务数据不存在时,在此直接抛出而不落任务
        MesProQrLabelSource source = sourceFactory.create(bizType, uuid, batchId);
        long total = source.count();
        if (total <= 0) {
            throw exception(resolveEmptyErrorCode(bizType));
        }
        // 2. 并发闸门:同一时间只跑一个大批量任务;小批量同步导出不走这里,因此不会被挡住
        if (!registry.tryAcquire()) {
            throw exception(PRO_QR_EXPORT_TASK_BUSY, resolveRunningProgress());
        }
        // 一旦任务被交给执行器,并发许可就转由执行线程在收尾时归还;此前的任何失败都必须在这里归还
        boolean handedOver = false;
        try {
            // 3. CPU 闸门:服务器已经很忙时不再叠加导出负载
            assertCpuAllowSubmit();
            // 4. 落任务
            MesProQrExportTaskDO task = MesProQrExportTaskDO.builder()
                    .bizType(bizType.getType())
                    .uuid(uuid)
                    .batchId(batchId)
                    .taskName(source.zipName())
                    .status(MesProQrExportTaskStatusEnum.PENDING.getStatus())
                    .totalCount((int) total)
                    .finishedCount(0)
                    .build();
            taskMapper.insert(task);
            // 5. 先注册运行态再投递:避免「已落库但未注册」的窗口被清理任务误回收
            registry.register(task.getId());
            handedOver = true;
            try {
                executor.execute(() -> runner.run(task.getId(), source));
            } catch (RejectedExecutionException e) {
                registry.unregister(task.getId());
                handedOver = false;
                taskMapper.updateStatus(task.getId(), MesProQrExportTaskStatusEnum.FAILED.getStatus(),
                        null, null, "导出线程繁忙,请稍后重试", LocalDateTime.now());
                throw exception(PRO_QR_EXPORT_TASK_BUSY, 0);
            }
            return task;
        } finally {
            if (!handedOver) {
                registry.release();
            }
        }
    }
 
    @Override
    public MesProQrExportTaskDO getTask(Long id) {
        MesProQrExportTaskDO task = taskMapper.selectById(id);
        if (task == null) {
            throw exception(PRO_QR_EXPORT_TASK_NOT_EXISTS);
        }
        return task;
    }
 
    @Override
    public void cancel(Long id) {
        MesProQrExportTaskDO task = getTask(id);
        if (isFinalStatus(task.getStatus())) {
            throw exception(PRO_QR_EXPORT_TASK_STATUS_INVALID);
        }
        if (registry.requestCancel(id)) {
            // 生成线程会自行收尾:删除半成品文件并置为已取消
            return;
        }
        // 不在本进程运行(进程重启后的残留任务):直接收尾
        runner.deleteTaskFile(task);
        taskMapper.updateStatus(id, MesProQrExportTaskStatusEnum.CANCELED.getStatus(),
                null, null, null, LocalDateTime.now());
    }
 
    @Override
    public void download(Long id, HttpServletResponse response) throws IOException {
        MesProQrExportTaskDO task = getTask(id);
        if (!MesProQrExportTaskStatusEnum.FINISHED.getStatus().equals(task.getStatus())) {
            throw exception(PRO_QR_EXPORT_TASK_STATUS_INVALID);
        }
        File file = resolveTaskFile(task);
        response.setContentType("application/zip");
        response.setHeader("Content-Disposition",
                "attachment;filename=" + HttpUtils.encodeUtf8(task.getTaskName() + ".zip"));
        boolean success = false;
        try (InputStream in = new BufferedInputStream(new FileInputStream(file))) {
            StreamUtils.copy(in, response.getOutputStream());
            response.getOutputStream().flush();
            success = true;
        } finally {
            // 仅在完整传输后删除;中断时保留,交由清理任务在保留期后删除,便于短时间内重试
            if (success) {
                deleteAfterDownload(task, file);
            }
        }
    }
 
    @Override
    public int cleanupExpiredFiles() {
        LocalDateTime deadline = LocalDateTime.now()
                .minusMinutes(Math.max(1, properties.getTempFileRetentionMinutes()));
        List<MesProQrExportTaskDO> list = taskMapper.selectExpiredFinalList(FINAL_STATUSES, deadline);
        int count = 0;
        for (MesProQrExportTaskDO task : list) {
            if (StrUtil.isBlank(task.getFilePath())) {
                continue;
            }
            runner.deleteTaskFile(task);
            taskMapper.clearFilePath(task.getId());
            count++;
        }
        return count;
    }
 
    @Override
    public int recycleStaleTasks() {
        List<MesProQrExportTaskDO> list = taskMapper.selectUnfinishedList(UNFINISHED_STATUSES);
        int count = 0;
        for (MesProQrExportTaskDO task : list) {
            if (registry.isRunning(task.getId())) {
                continue;
            }
            runner.deleteTaskFile(task);
            taskMapper.clearFilePath(task.getId());
            taskMapper.updateStatus(task.getId(), MesProQrExportTaskStatusEnum.FAILED.getStatus(),
                    null, null, "任务因服务重启或异常中断而终止,请重新导出", LocalDateTime.now());
            count++;
        }
        return count;
    }
 
    // ==================== 私有方法 ====================
 
    /**
     * 校验系统 CPU 是否允许提交大批量导出
     * <p>
     * 取不到 CPU 指标时按放行处理:不能因为读不到监控值就阻断业务。
     */
    private void assertCpuAllowSubmit() {
        if (!cpuMonitor.isAvailable()) {
            return;
        }
        double load = cpuMonitor.getCpuLoad();
        double threshold = properties.getCpuRejectThreshold();
        if (load >= threshold) {
            throw exception(PRO_QR_EXPORT_SERVER_BUSY,
                    Math.round(load * 100), Math.round(threshold * 100));
        }
    }
 
    /**
     * 数据为空时的错误码:按业务类型给出更准确的提示
     */
    private cn.iocoder.yudao.framework.common.exception.ErrorCode resolveEmptyErrorCode(
            MesProQrExportBizTypeEnum bizType) {
        return switch (bizType) {
            case PALLET_LABEL, PALLET_BUSINESS -> PRO_PALLET_NOT_EXISTS;
            case BATCH_LABEL, BATCH_BUSINESS -> PRO_BATCH_NOT_EXISTS;
            default -> PRO_BAG_CODE_NOT_EXISTS;
        };
    }
 
    /**
     * 正在运行任务的进度百分比,用于「已有任务进行中」的提示
     */
    private int resolveRunningProgress() {
        List<MesProQrExportTaskDO> tasks = taskMapper.selectUnfinishedList(UNFINISHED_STATUSES);
        return tasks.stream().findFirst().map(this::calcProgress).orElse(0);
    }
 
    private int calcProgress(MesProQrExportTaskDO task) {
        Integer total = task.getTotalCount();
        if (total == null || total <= 0) {
            return 0;
        }
        int finished = task.getFinishedCount() == null ? 0 : task.getFinishedCount();
        return (int) Math.min(100L, (long) finished * 100 / total);
    }
 
    private boolean isFinalStatus(Integer status) {
        return status != null && FINAL_STATUSES.contains(status);
    }
 
    /**
     * 定位任务的临时 ZIP,文件缺失时顺手清空路径避免后续反复失败
     */
    private File resolveTaskFile(MesProQrExportTaskDO task) {
        String filePath = task.getFilePath();
        if (StrUtil.isBlank(filePath)) {
            throw exception(PRO_QR_EXPORT_TASK_FILE_MISSING);
        }
        File file = new File(filePath);
        if (!file.isFile()) {
            taskMapper.clearFilePath(task.getId());
            throw exception(PRO_QR_EXPORT_TASK_FILE_MISSING);
        }
        return file;
    }
 
    /**
     * 下载完成即删除临时文件:单次导出可达上 GB,留着会迅速把磁盘吃满
     */
    private void deleteAfterDownload(MesProQrExportTaskDO task, File file) {
        try {
            Files.deleteIfExists(file.toPath());
        } catch (IOException e) {
            log.warn("[deleteAfterDownload][删除临时文件失败 {}]", file.getAbsolutePath(), e);
        }
        taskMapper.clearFilePath(task.getId());
        taskMapper.updateStatus(task.getId(), MesProQrExportTaskStatusEnum.DOWNLOADED.getStatus(),
                null, null, null, null);
    }
 
}