|
|
@@ -0,0 +1,430 @@
|
|
|
+/*
|
|
|
+ * Copyright (c) 2018-2999 广州亚米信息科技有限公司 All rights reserved.
|
|
|
+ *
|
|
|
+ * https://www.gz-yami.com/
|
|
|
+ *
|
|
|
+ * 未经允许,不可做商业用途!
|
|
|
+ *
|
|
|
+ * 版权所有,侵权必究!
|
|
|
+ */
|
|
|
+
|
|
|
+package com.yami.shop.service.impl;
|
|
|
+
|
|
|
+import cn.hutool.core.collection.CollectionUtil;
|
|
|
+import cn.hutool.core.date.DateTime;
|
|
|
+import cn.hutool.core.date.DateUtil;
|
|
|
+import cn.hutool.core.io.IORuntimeException;
|
|
|
+import cn.hutool.core.lang.Snowflake;
|
|
|
+import cn.hutool.core.util.ObjectUtil;
|
|
|
+import cn.hutool.core.util.RandomUtil;
|
|
|
+import cn.hutool.core.util.StrUtil;
|
|
|
+import cn.hutool.poi.excel.ExcelUtil;
|
|
|
+import cn.hutool.poi.excel.ExcelWriter;
|
|
|
+import com.alibaba.fastjson.JSON;
|
|
|
+import com.alibaba.fastjson.JSONObject;
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.baomidou.mybatisplus.core.metadata.IPage;
|
|
|
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
|
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
|
|
+import com.google.common.collect.Lists;
|
|
|
+import com.google.common.collect.Maps;
|
|
|
+import com.google.gson.JsonObject;
|
|
|
+import com.google.gson.internal.LinkedTreeMap;
|
|
|
+import com.yami.shop.bean.app.dto.OrderCountData;
|
|
|
+import com.yami.shop.bean.app.dto.ShopCartOrderMergerDto;
|
|
|
+import com.yami.shop.bean.dto.hb.HBBaseReq;
|
|
|
+import com.yami.shop.bean.enums.*;
|
|
|
+import com.yami.shop.bean.event.CancelOrderEvent;
|
|
|
+import com.yami.shop.bean.event.ReceiptOrderEvent;
|
|
|
+import com.yami.shop.bean.event.SubmitOrderEvent;
|
|
|
+import com.yami.shop.bean.event.SubmitScoreOrderEvent;
|
|
|
+import com.yami.shop.bean.model.*;
|
|
|
+import com.yami.shop.bean.param.*;
|
|
|
+import com.yami.shop.bean.vo.ExportContext;
|
|
|
+import com.yami.shop.bean.vo.ExportTaskVo;
|
|
|
+import com.yami.shop.bean.vo.OrderCountVo;
|
|
|
+import com.yami.shop.common.config.Constant;
|
|
|
+import com.yami.shop.common.exception.GlobalException;
|
|
|
+import com.yami.shop.common.util.Arith;
|
|
|
+import com.yami.shop.common.util.PageAdapter;
|
|
|
+import com.yami.shop.common.util.PageParam;
|
|
|
+import com.yami.shop.common.util.R;
|
|
|
+import com.yami.shop.dao.*;
|
|
|
+import com.yami.shop.service.ExportTaskService;
|
|
|
+import com.yami.shop.service.OrderItemService;
|
|
|
+import com.yami.shop.service.OrderService;
|
|
|
+import com.yami.shop.service.OrderSettlementService;
|
|
|
+import com.yami.shop.utils.CullenUtils;
|
|
|
+import com.yami.shop.utils.ExportUtils;
|
|
|
+import com.yami.shop.utils.HBSignUtil;
|
|
|
+import com.yami.shop.wx.po.RefundInfoPo;
|
|
|
+import com.yami.shop.wx.service.WxProviderService;
|
|
|
+import lombok.AllArgsConstructor;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.apache.commons.lang3.ObjectUtils;
|
|
|
+import org.apache.commons.lang3.StringUtils;
|
|
|
+import org.apache.poi.ss.usermodel.CellStyle;
|
|
|
+import org.apache.poi.ss.usermodel.Row;
|
|
|
+import org.apache.poi.ss.usermodel.Sheet;
|
|
|
+import org.apache.poi.ss.usermodel.Workbook;
|
|
|
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
|
|
+import org.springframework.beans.BeanUtils;
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.cache.annotation.CacheEvict;
|
|
|
+import org.springframework.cache.annotation.CachePut;
|
|
|
+import org.springframework.cache.annotation.Cacheable;
|
|
|
+import org.springframework.context.ApplicationEventPublisher;
|
|
|
+import org.springframework.http.HttpStatus;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.transaction.annotation.Transactional;
|
|
|
+import org.springframework.transaction.support.TransactionTemplate;
|
|
|
+
|
|
|
+import javax.servlet.ServletOutputStream;
|
|
|
+import javax.servlet.http.HttpServletRequest;
|
|
|
+import javax.servlet.http.HttpServletResponse;
|
|
|
+import java.io.*;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.math.RoundingMode;
|
|
|
+import java.net.URLEncoder;
|
|
|
+import java.nio.ByteBuffer;
|
|
|
+import java.nio.channels.Channels;
|
|
|
+import java.nio.channels.ClosedChannelException;
|
|
|
+import java.nio.channels.FileChannel;
|
|
|
+import java.nio.channels.WritableByteChannel;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.nio.file.Path;
|
|
|
+import java.nio.file.Paths;
|
|
|
+import java.text.SimpleDateFormat;
|
|
|
+import java.time.Instant;
|
|
|
+import java.time.LocalDateTime;
|
|
|
+import java.util.*;
|
|
|
+import java.util.concurrent.ConcurrentHashMap;
|
|
|
+import java.util.concurrent.Semaphore;
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
+import java.util.concurrent.TimeoutException;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+import static com.yami.shop.common.util.HttpUtil.post;
|
|
|
+
|
|
|
+/**
|
|
|
+ * @author lgh on 2018/09/15.
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+@AllArgsConstructor
|
|
|
+public class ExportTaskServiceImpl extends ServiceImpl<ExportTaskMapper, ExportTask> implements ExportTaskService {
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ ExportTaskMapper exportTaskMapper;
|
|
|
+ @Autowired
|
|
|
+ private TransactionTemplate transactionTemplate;
|
|
|
+ // 下载信号量,控制并发
|
|
|
+ private static final Semaphore downloadSemaphore = new Semaphore(3);
|
|
|
+
|
|
|
+
|
|
|
+ // 存储正在执行的任务
|
|
|
+ private final Map<String, ExportContext> exportTasks = new ConcurrentHashMap<>();
|
|
|
+ @Override
|
|
|
+ public ExportTask findByUserIdAndStatusAndType(Long userId, int exportStatus, int exportType) {
|
|
|
+ return baseMapper.findByUserIdAndStatusAndType(userId,exportStatus,exportType);
|
|
|
+ }
|
|
|
+ @Override
|
|
|
+ public void downloadFile(HttpServletRequest request, HttpServletResponse response, String fileId, Long userId) throws IOException {
|
|
|
+
|
|
|
+ ExportTask exportTask = baseMapper.findByUserIdAndId(fileId,userId);
|
|
|
+
|
|
|
+ if (exportTask==null){
|
|
|
+ throw new GlobalException("导出任务不存在或不属于该用户,文件id:"+fileId);
|
|
|
+ }
|
|
|
+ if (!(exportTask.getExportStatus()==1||exportTask.getExportStatus()==3)){
|
|
|
+ throw new GlobalException("导出任务未生成不允许下载");
|
|
|
+ }
|
|
|
+ String clientIp = getClientIp(request);
|
|
|
+ String filePath = null;
|
|
|
+ try {
|
|
|
+ // 2. 参数验证
|
|
|
+ filePath = exportTask.getFileUrl();
|
|
|
+ // 3. 路径安全验证
|
|
|
+ String safeFilePath = ExportUtils.sanitize(exportTask.getFileUrl());
|
|
|
+ Path safePath = Paths.get(safeFilePath).normalize();
|
|
|
+ // 4. 文件存在性检查
|
|
|
+ File file = safePath.toFile();
|
|
|
+ if (!file.exists()) {
|
|
|
+ throw new FileNotFoundException("文件不存在: " + filePath);
|
|
|
+ }
|
|
|
+ if (!file.isFile()) {
|
|
|
+ throw new IllegalArgumentException("不是有效的文件: " + filePath);
|
|
|
+ }
|
|
|
+ // 7. 设置响应头
|
|
|
+ setResponseHeaders(response, file, request, exportTask);
|
|
|
+ // 8. 执行文件下载
|
|
|
+ transferFile(file, response);
|
|
|
+ exportTask.setExportMsg("已下载");
|
|
|
+ exportTask.setExportStatus(3);
|
|
|
+ exportTaskMapper.updateById(exportTask);
|
|
|
+ log.info("文件下载成功-文件: {},IP: {}, 时间: {}ms",
|
|
|
+ file.getName(),
|
|
|
+ clientIp,
|
|
|
+ System.currentTimeMillis());
|
|
|
+ } catch (FileNotFoundException e) {
|
|
|
+ handleException(e, e.getMessage(), response,
|
|
|
+ HttpStatus.NOT_FOUND.value());
|
|
|
+ } catch (IllegalArgumentException e) {
|
|
|
+ handleException(e, e.getMessage(), response,
|
|
|
+ HttpStatus.BAD_REQUEST.value());
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("文件下载异常: {}", filePath, e);
|
|
|
+ handleException(e, "服务器内部错误", response,
|
|
|
+ HttpStatus.INTERNAL_SERVER_ERROR.value());
|
|
|
+ } finally {
|
|
|
+ downloadSemaphore.release();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void putTaskId(String taskId, ExportContext context) {
|
|
|
+ exportTasks.put(taskId, context);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void removeTaskId(String taskId) {
|
|
|
+ exportTasks.remove(taskId);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public R<String> cancelExport(String fileId, Long userId) {
|
|
|
+ log.info("");
|
|
|
+ // 1. 使用事务确保数据一致性
|
|
|
+ return transactionTemplate.execute(status -> {
|
|
|
+ try {
|
|
|
+ ExportTask exportTask = exportTaskMapper.findByUserIdAndId(fileId, userId); // 加锁
|
|
|
+ if (exportTask == null) {
|
|
|
+ return R.FAIL("中断失败-未查询到任务");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查任务是否已结束
|
|
|
+ if (exportTask.getExportStatus()!=0) {
|
|
|
+ return R.FAIL("任务不在进行中,无法取消");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 安全取消执行
|
|
|
+ ExportContext context = exportTasks.get(exportTask.getTaskId());
|
|
|
+ if (context != null) {
|
|
|
+ return safeCancelExportTask(context, exportTask);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 上下文已不存在,直接返回成功
|
|
|
+ return R.SUCCESS("任务已不存在,标记为取消");
|
|
|
+
|
|
|
+ } catch (Exception e) {
|
|
|
+ status.setRollbackOnly();
|
|
|
+ log.error("取消导出任务失败, fileId: {}", fileId, e);
|
|
|
+ return R.FAIL("系统错误,取消失败");
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public IPage<ExportTaskVo> findPage(PageParam<ExportTaskVo> page, Long userId,Integer exportType) {
|
|
|
+ return exportTaskMapper.findPage(page,userId,exportType);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 传输文件到响应流
|
|
|
+ */
|
|
|
+ private long transferFile(File file, HttpServletResponse response) throws IOException {
|
|
|
+
|
|
|
+ long bytesTransferred = 0;
|
|
|
+ long fileSize = file.length();
|
|
|
+
|
|
|
+ try (RandomAccessFile raf = new RandomAccessFile(file, "r");
|
|
|
+ FileChannel fileChannel = raf.getChannel();
|
|
|
+ OutputStream outputStream = response.getOutputStream()) {
|
|
|
+
|
|
|
+ WritableByteChannel outputChannel = Channels.newChannel(outputStream);
|
|
|
+
|
|
|
+ // 使用直接缓冲区提高性能
|
|
|
+ ByteBuffer buffer = ByteBuffer.allocateDirect(64 * 1024); // 64KB
|
|
|
+ while (bytesTransferred < fileSize) {
|
|
|
+ buffer.clear();
|
|
|
+ int bytesRead = fileChannel.read(buffer);
|
|
|
+
|
|
|
+ if (bytesRead == -1) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ buffer.flip();
|
|
|
+ while (buffer.hasRemaining()) {
|
|
|
+ outputChannel.write(buffer);
|
|
|
+ }
|
|
|
+ bytesTransferred += bytesRead;
|
|
|
+ }
|
|
|
+
|
|
|
+ outputStream.flush();
|
|
|
+
|
|
|
+ } catch (ClosedChannelException e) {
|
|
|
+ log.info("客户端中断了下载: {}", file.getName());
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+ return bytesTransferred;
|
|
|
+ }
|
|
|
+ /**
|
|
|
+ * 设置响应头
|
|
|
+ */
|
|
|
+ private void setResponseHeaders(HttpServletResponse response,
|
|
|
+ File file,
|
|
|
+ HttpServletRequest request,
|
|
|
+ ExportTask exportTask) {
|
|
|
+
|
|
|
+ // 1. 文件名处理
|
|
|
+ String fileName = exportTask.getFileName() != null
|
|
|
+ ? exportTask.getFileName()
|
|
|
+ : file.getName();
|
|
|
+
|
|
|
+ String safeFileName = sanitizeFileName(fileName);
|
|
|
+ String encodedFileName = encodeFileNameForBrowser(safeFileName, request);
|
|
|
+
|
|
|
+ // 2. 内容类型
|
|
|
+ String contentType = ExportUtils.getContentType(file);
|
|
|
+ response.setContentType(contentType);
|
|
|
+
|
|
|
+ // 3. 下载头
|
|
|
+ response.setHeader("Content-Disposition",
|
|
|
+ "attachment; filename=\"" + encodedFileName + "\"");
|
|
|
+
|
|
|
+ // 4. 文件信息
|
|
|
+ response.setContentLengthLong(file.length());
|
|
|
+ response.setHeader("Accept-Ranges", "none"); // 明确不支持断点续传
|
|
|
+ response.setHeader("X-File-Size", String.valueOf(file.length()));
|
|
|
+ response.setHeader("X-File-Name", safeFileName);
|
|
|
+ response.setHeader("X-File-Modified",
|
|
|
+ Instant.ofEpochMilli(file.lastModified()).toString());
|
|
|
+
|
|
|
+ // 5. 安全头
|
|
|
+ response.setHeader("X-Content-Type-Options", "nosniff");
|
|
|
+ response.setHeader("X-Frame-Options", "DENY");
|
|
|
+ response.setHeader("X-XSS-Protection", "1; mode=block");
|
|
|
+ response.setHeader("Content-Security-Policy", "default-src 'none'");
|
|
|
+ response.setHeader("Referrer-Policy", "no-referrer");
|
|
|
+
|
|
|
+ // 6. 缓存控制
|
|
|
+ response.setHeader("Cache-Control",
|
|
|
+ "no-store, no-cache, must-revalidate, max-age=0");
|
|
|
+ response.setHeader("Pragma", "no-cache");
|
|
|
+ response.setHeader("Expires", "0");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 文件名编码
|
|
|
+ */
|
|
|
+ private String encodeFileNameForBrowser(String fileName, HttpServletRequest request) {
|
|
|
+ String userAgent = request.getHeader("User-Agent");
|
|
|
+
|
|
|
+ try {
|
|
|
+ if (userAgent == null) {
|
|
|
+ return URLEncoder.encode(fileName, StandardCharsets.UTF_8.name())
|
|
|
+ .replace("+", "%20");
|
|
|
+ }
|
|
|
+
|
|
|
+ if (userAgent.contains("MSIE") || userAgent.contains("Trident")) {
|
|
|
+ // IE浏览器
|
|
|
+ return URLEncoder.encode(fileName, "UTF-8").replace("+", "%20");
|
|
|
+ } else if (userAgent.contains("Firefox")) {
|
|
|
+ // Firefox
|
|
|
+ return "=?UTF-8?B?" + Base64.getEncoder()
|
|
|
+ .encodeToString(fileName.getBytes(StandardCharsets.UTF_8)) + "?=";
|
|
|
+ } else {
|
|
|
+ // Chrome, Safari, Edge等
|
|
|
+ return new String(fileName.getBytes(StandardCharsets.UTF_8),
|
|
|
+ StandardCharsets.ISO_8859_1);
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("文件名编码异常,使用原始文件名: {}", fileName, e);
|
|
|
+ return fileName;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ /**
|
|
|
+ * 处理异常
|
|
|
+ */
|
|
|
+ private void handleException(Exception e, String message,
|
|
|
+ HttpServletResponse response, int status) {
|
|
|
+ response.setStatus(status);
|
|
|
+ response.setContentType("application/json");
|
|
|
+
|
|
|
+ try {
|
|
|
+ JsonObject error = new JsonObject();
|
|
|
+ error.addProperty("code", status);
|
|
|
+ error.addProperty("message", message);
|
|
|
+ error.addProperty("timestamp", Instant.now().toString());
|
|
|
+
|
|
|
+ if (status >= 500) {
|
|
|
+ error.addProperty("errorId", UUID.randomUUID().toString());
|
|
|
+ }
|
|
|
+
|
|
|
+ response.getWriter().write(error.toString());
|
|
|
+ } catch (IOException ex) {
|
|
|
+ log.error("写入错误响应失败", ex);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ /**
|
|
|
+ * 清理文件名
|
|
|
+ */
|
|
|
+ private String sanitizeFileName(String fileName) {
|
|
|
+ if (StringUtils.isBlank(fileName)) {
|
|
|
+ return "download_" + System.currentTimeMillis();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 移除路径分隔符和危险字符
|
|
|
+ String sanitized = fileName
|
|
|
+ .replaceAll("[\\\\/:*?\"<>|]", "_")
|
|
|
+ .replaceAll("\\s+", " ")
|
|
|
+ .trim();
|
|
|
+
|
|
|
+ // 限制文件名长度
|
|
|
+ int maxLength = 255;
|
|
|
+ if (sanitized.length() > maxLength) {
|
|
|
+ int lastDot = sanitized.lastIndexOf('.');
|
|
|
+ if (lastDot > 0 && lastDot < maxLength - 10) {
|
|
|
+ // 保留扩展名
|
|
|
+ String ext = sanitized.substring(lastDot);
|
|
|
+ String name = sanitized.substring(0, maxLength - ext.length() - 1);
|
|
|
+ sanitized = name + "_" + ext;
|
|
|
+ } else {
|
|
|
+ sanitized = sanitized.substring(0, maxLength);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return sanitized;
|
|
|
+ }
|
|
|
+ /**
|
|
|
+ * 获取客户端IP
|
|
|
+ */
|
|
|
+ private String getClientIp(HttpServletRequest request) {
|
|
|
+ String[] headers = {
|
|
|
+ "X-Forwarded-For",
|
|
|
+ "Proxy-Client-IP",
|
|
|
+ "WL-Proxy-Client-IP",
|
|
|
+ "HTTP_CLIENT_IP",
|
|
|
+ "HTTP_X_FORWARDED_FOR"
|
|
|
+ };
|
|
|
+
|
|
|
+ for (String header : headers) {
|
|
|
+ String ip = request.getHeader(header);
|
|
|
+ if (StringUtils.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) {
|
|
|
+ return ip.split(",")[0].trim();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return request.getRemoteAddr();
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 安全取消导出任务
|
|
|
+ */
|
|
|
+ private R<String> safeCancelExportTask(ExportContext context, ExportTask exportTask) {
|
|
|
+ // 方案2: 设置取消标志,等待任务自己结束
|
|
|
+ context.setCancelled(true);
|
|
|
+ return R.SUCCESS("任务正在结束,请稍后查看");
|
|
|
+ }
|
|
|
+}
|