Compare commits
19 Commits
014fd8a35c
...
6993950aa5
| Author | SHA1 | Date | |
|---|---|---|---|
| 6993950aa5 | |||
| 9f6a4b0962 | |||
| 656453ea50 | |||
| aa0c49f9b1 | |||
| ebf66ea70b | |||
| 83e2f39a4e | |||
| 332771b009 | |||
| 71d9b5b2d1 | |||
| 85a03a001d | |||
| 10cc8e87a5 | |||
| 1fd40c8ab1 | |||
| 56a2b600bc | |||
| 5205874224 | |||
| 8706a2c1df | |||
| bf4b4e41a2 | |||
| dcba711f90 | |||
| 73c78043ba | |||
| 23e3dece7b | |||
| de45854c0f |
@@ -0,0 +1,48 @@
|
|||||||
|
package com.ruoyi.ccdi.project.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步线程池配置
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
* @date 2026-03-05
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableAsync
|
||||||
|
public class AsyncThreadPoolConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传专用线程池
|
||||||
|
* 容量:100个线程
|
||||||
|
* 拒绝策略:AbortPolicy(直接拒绝,由调度线程捕获并重试)
|
||||||
|
*/
|
||||||
|
@Bean("fileUploadExecutor")
|
||||||
|
public Executor fileUploadExecutor() {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
// 核心线程数
|
||||||
|
executor.setCorePoolSize(100);
|
||||||
|
// 最大线程数
|
||||||
|
executor.setMaxPoolSize(100);
|
||||||
|
// 队列容量(设为0,不使用队列,直接走拒绝策略)
|
||||||
|
executor.setQueueCapacity(0);
|
||||||
|
// 线程名称前缀
|
||||||
|
executor.setThreadNamePrefix("file-upload-");
|
||||||
|
// 拒绝策略:AbortPolicy,抛出 RejectedExecutionException
|
||||||
|
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
|
||||||
|
// 线程空闲时间(秒)
|
||||||
|
executor.setKeepAliveSeconds(60);
|
||||||
|
// 等待所有任务完成后再关闭
|
||||||
|
executor.setWaitForTasksToCompleteOnShutdown(true);
|
||||||
|
// 最长等待时间
|
||||||
|
executor.setAwaitTerminationSeconds(60);
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package com.ruoyi.ccdi.project.controller;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.ruoyi.ccdi.project.domain.dto.CcdiFileUploadQueryDTO;
|
||||||
|
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
|
||||||
|
import com.ruoyi.ccdi.project.domain.vo.CcdiFileUploadStatisticsVO;
|
||||||
|
import com.ruoyi.ccdi.project.service.ICcdiFileUploadService;
|
||||||
|
import com.ruoyi.common.core.controller.BaseController;
|
||||||
|
import com.ruoyi.common.core.domain.AjaxResult;
|
||||||
|
import com.ruoyi.common.core.page.PageDomain;
|
||||||
|
import com.ruoyi.common.core.page.TableDataInfo;
|
||||||
|
import com.ruoyi.common.core.page.TableSupport;
|
||||||
|
import com.ruoyi.common.utils.SecurityUtils;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.util.concurrent.RejectedExecutionException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传 Controller
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
* @date 2026-03-05
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/ccdi/file-upload")
|
||||||
|
@Tag(name = "文件上传管理", description = "项目文件上传相关接口")
|
||||||
|
public class CcdiFileUploadController extends BaseController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ICcdiFileUploadService fileUploadService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量上传文件(异步)
|
||||||
|
*/
|
||||||
|
@PostMapping("/batch")
|
||||||
|
@Operation(summary = "批量上传文件", description = "异步批量上传流水文件")
|
||||||
|
public AjaxResult batchUpload(@RequestParam Long projectId,
|
||||||
|
@RequestParam MultipartFile[] files) {
|
||||||
|
// 参数校验
|
||||||
|
if (projectId == null) {
|
||||||
|
return AjaxResult.error("项目ID不能为空");
|
||||||
|
}
|
||||||
|
if (files == null || files.length == 0) {
|
||||||
|
return AjaxResult.error("请选择要上传的文件");
|
||||||
|
}
|
||||||
|
if (files.length > 100) {
|
||||||
|
return AjaxResult.error("单次最多上传100个文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验文件大小和格式
|
||||||
|
for (MultipartFile file : files) {
|
||||||
|
if (file.isEmpty()) {
|
||||||
|
return AjaxResult.error("文件不能为空");
|
||||||
|
}
|
||||||
|
if (file.getSize() > 50 * 1024 * 1024) {
|
||||||
|
return AjaxResult.error("文件 " + file.getOriginalFilename() + " 超过50MB限制");
|
||||||
|
}
|
||||||
|
String fileName = file.getOriginalFilename();
|
||||||
|
if (!fileName.endsWith(".xlsx") && !fileName.endsWith(".xls")) {
|
||||||
|
return AjaxResult.error("文件 " + fileName + " 格式不支持,仅支持Excel文件");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String username = SecurityUtils.getUsername();
|
||||||
|
String batchId = fileUploadService.batchUploadFiles(projectId, files, username);
|
||||||
|
return AjaxResult.success("上传任务已提交", batchId);
|
||||||
|
} catch (RejectedExecutionException e) {
|
||||||
|
log.warn("线程池已满,拒绝上传请求: projectId={}, fileCount={}", projectId, files.length);
|
||||||
|
return AjaxResult.error("系统繁忙,请稍后再试");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("批量上传失败: projectId={}", projectId, e);
|
||||||
|
return AjaxResult.error("上传失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询上传记录列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
@Operation(summary = "查询上传记录列表", description = "分页查询文件上传记录")
|
||||||
|
public TableDataInfo list(CcdiFileUploadQueryDTO queryDTO) {
|
||||||
|
PageDomain pageDomain = TableSupport.buildPageRequest();
|
||||||
|
Page<CcdiFileUploadRecord> page = new Page<>(pageDomain.getPageNum(), pageDomain.getPageSize());
|
||||||
|
Page<CcdiFileUploadRecord> result = fileUploadService.selectPage(page, queryDTO);
|
||||||
|
return getDataTable(result.getRecords(), result.getTotal());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询上传统计
|
||||||
|
*/
|
||||||
|
@GetMapping("/statistics/{projectId}")
|
||||||
|
@Operation(summary = "查询上传统计", description = "统计各状态的文件数量")
|
||||||
|
public AjaxResult getStatistics(@PathVariable Long projectId) {
|
||||||
|
CcdiFileUploadStatisticsVO statistics = fileUploadService.countByStatus(projectId);
|
||||||
|
return AjaxResult.success(statistics);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询记录详情
|
||||||
|
*/
|
||||||
|
@GetMapping("/detail/{id}")
|
||||||
|
@Operation(summary = "查询记录详情", description = "根据ID查询文件上传记录详情")
|
||||||
|
public AjaxResult getDetail(@PathVariable Long id) {
|
||||||
|
CcdiFileUploadRecord record = fileUploadService.getById(id);
|
||||||
|
return AjaxResult.success(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.ruoyi.ccdi.project.domain.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传记录查询 DTO
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
* @date 2026-03-05
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class CcdiFileUploadQueryDTO implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 项目ID */
|
||||||
|
private Long projectId;
|
||||||
|
|
||||||
|
/** 文件状态 */
|
||||||
|
private String fileStatus;
|
||||||
|
|
||||||
|
/** 文件名称(模糊查询) */
|
||||||
|
private String fileName;
|
||||||
|
|
||||||
|
/** 上传人 */
|
||||||
|
private String uploadUser;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.ruoyi.ccdi.project.domain.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传记录实体
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
* @date 2026-03-05
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("ccdi_file_upload_record")
|
||||||
|
public class CcdiFileUploadRecord implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 主键ID */
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 项目ID */
|
||||||
|
private Long projectId;
|
||||||
|
|
||||||
|
/** 流水分析平台项目ID */
|
||||||
|
private Integer lsfxProjectId;
|
||||||
|
|
||||||
|
/** 流水分析平台返回的logId */
|
||||||
|
private Integer logId;
|
||||||
|
|
||||||
|
/** 文件名称 */
|
||||||
|
private String fileName;
|
||||||
|
|
||||||
|
/** 文件大小(字节) */
|
||||||
|
private Long fileSize;
|
||||||
|
|
||||||
|
/** 文件状态:uploading-上传中,parsing-解析中,parsed_success-解析成功,parsed_failed-解析失败 */
|
||||||
|
private String fileStatus;
|
||||||
|
|
||||||
|
/** 主体名称(多个用逗号分隔) */
|
||||||
|
private String enterpriseNames;
|
||||||
|
|
||||||
|
/** 主体账号(多个用逗号分隔) */
|
||||||
|
private String accountNos;
|
||||||
|
|
||||||
|
/** 错误信息(解析失败时记录) */
|
||||||
|
private String errorMessage;
|
||||||
|
|
||||||
|
/** 上传时间 */
|
||||||
|
private Date uploadTime;
|
||||||
|
|
||||||
|
/** 上传人 */
|
||||||
|
private String uploadUser;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.ruoyi.ccdi.project.domain.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传统计 VO
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
* @date 2026-03-05
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class CcdiFileUploadStatisticsVO implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 上传中数量 */
|
||||||
|
private Long uploading;
|
||||||
|
|
||||||
|
/** 解析中数量 */
|
||||||
|
private Long parsing;
|
||||||
|
|
||||||
|
/** 解析成功数量 */
|
||||||
|
private Long parsedSuccess;
|
||||||
|
|
||||||
|
/** 解析失败数量 */
|
||||||
|
private Long parsedFailed;
|
||||||
|
|
||||||
|
/** 总数量 */
|
||||||
|
private Long total;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.ruoyi.ccdi.project.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传记录 Mapper 接口
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
* @date 2026-03-05
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface CcdiFileUploadRecordMapper extends BaseMapper<CcdiFileUploadRecord> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量插入文件上传记录
|
||||||
|
*
|
||||||
|
* @param records 记录列表
|
||||||
|
* @return 插入条数
|
||||||
|
*/
|
||||||
|
int insertBatch(@Param("list") List<CcdiFileUploadRecord> records);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计各状态文件数量
|
||||||
|
*
|
||||||
|
* @param projectId 项目ID
|
||||||
|
* @return 统计结果(Map形式,key为状态,value为数量)
|
||||||
|
*/
|
||||||
|
List<java.util.Map<String, Object>> countByStatus(@Param("projectId") Long projectId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.ruoyi.ccdi.project.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.ruoyi.ccdi.project.domain.dto.CcdiFileUploadQueryDTO;
|
||||||
|
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
|
||||||
|
import com.ruoyi.ccdi.project.domain.vo.CcdiFileUploadStatisticsVO;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传服务接口
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
* @date 2026-03-05
|
||||||
|
*/
|
||||||
|
public interface ICcdiFileUploadService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量上传文件
|
||||||
|
*
|
||||||
|
* @param projectId 项目ID
|
||||||
|
* @param files 文件数组
|
||||||
|
* @param username 上传人
|
||||||
|
* @return 批次ID
|
||||||
|
*/
|
||||||
|
String batchUploadFiles(Long projectId, MultipartFile[] files, String username);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询上传记录列表
|
||||||
|
*
|
||||||
|
* @param page 分页参数
|
||||||
|
* @param queryDTO 查询条件
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
Page<CcdiFileUploadRecord> selectPage(Page<CcdiFileUploadRecord> page,
|
||||||
|
CcdiFileUploadQueryDTO queryDTO);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计各状态文件数量
|
||||||
|
*
|
||||||
|
* @param projectId 项目ID
|
||||||
|
* @return 统计结果
|
||||||
|
*/
|
||||||
|
CcdiFileUploadStatisticsVO countByStatus(Long projectId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询记录详情
|
||||||
|
*
|
||||||
|
* @param id 记录ID
|
||||||
|
* @return 记录详情
|
||||||
|
*/
|
||||||
|
CcdiFileUploadRecord getById(Long id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
package com.ruoyi.ccdi.project.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.ruoyi.ccdi.project.domain.CcdiProject;
|
||||||
|
import com.ruoyi.ccdi.project.domain.dto.CcdiFileUploadQueryDTO;
|
||||||
|
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
|
||||||
|
import com.ruoyi.ccdi.project.domain.vo.CcdiFileUploadStatisticsVO;
|
||||||
|
import com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper;
|
||||||
|
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
|
||||||
|
import com.ruoyi.ccdi.project.service.ICcdiFileUploadService;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.RejectedExecutionException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传服务实现
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
* @date 2026-03-05
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 临时文件存储目录
|
||||||
|
* TODO: 应该从配置文件中读取
|
||||||
|
*/
|
||||||
|
private static final String TEMP_FILE_DIR = System.getProperty("java.io.tmpdir") + File.separator + "ccdi-upload";
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private CcdiFileUploadRecordMapper recordMapper;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private CcdiProjectMapper projectMapper;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
@Qualifier("fileUploadExecutor")
|
||||||
|
private Executor fileUploadExecutor;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Page<CcdiFileUploadRecord> selectPage(Page<CcdiFileUploadRecord> page,
|
||||||
|
CcdiFileUploadQueryDTO queryDTO) {
|
||||||
|
LambdaQueryWrapper<CcdiFileUploadRecord> queryWrapper = new LambdaQueryWrapper<>();
|
||||||
|
|
||||||
|
// 项目ID
|
||||||
|
if (queryDTO.getProjectId() != null) {
|
||||||
|
queryWrapper.eq(CcdiFileUploadRecord::getProjectId, queryDTO.getProjectId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件状态
|
||||||
|
if (StringUtils.hasText(queryDTO.getFileStatus())) {
|
||||||
|
queryWrapper.eq(CcdiFileUploadRecord::getFileStatus, queryDTO.getFileStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件名称(模糊查询)
|
||||||
|
if (StringUtils.hasText(queryDTO.getFileName())) {
|
||||||
|
queryWrapper.like(CcdiFileUploadRecord::getFileName, queryDTO.getFileName());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传人
|
||||||
|
if (StringUtils.hasText(queryDTO.getUploadUser())) {
|
||||||
|
queryWrapper.eq(CcdiFileUploadRecord::getUploadUser, queryDTO.getUploadUser());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按上传时间倒序
|
||||||
|
queryWrapper.orderByDesc(CcdiFileUploadRecord::getUploadTime);
|
||||||
|
|
||||||
|
return recordMapper.selectPage(page, queryWrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CcdiFileUploadStatisticsVO countByStatus(Long projectId) {
|
||||||
|
// 查询统计数据
|
||||||
|
List<Map<String, Object>> statusCounts = recordMapper.countByStatus(projectId);
|
||||||
|
|
||||||
|
// 组装 VO
|
||||||
|
CcdiFileUploadStatisticsVO vo = new CcdiFileUploadStatisticsVO();
|
||||||
|
vo.setUploading(0L);
|
||||||
|
vo.setParsing(0L);
|
||||||
|
vo.setParsedSuccess(0L);
|
||||||
|
vo.setParsedFailed(0L);
|
||||||
|
|
||||||
|
long total = 0L;
|
||||||
|
for (Map<String, Object> item : statusCounts) {
|
||||||
|
String status = (String) item.get("status");
|
||||||
|
Long count = ((Number) item.get("count")).longValue();
|
||||||
|
total += count;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case "uploading" -> vo.setUploading(count);
|
||||||
|
case "parsing" -> vo.setParsing(count);
|
||||||
|
case "parsed_success" -> vo.setParsedSuccess(count);
|
||||||
|
case "parsed_failed" -> vo.setParsedFailed(count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vo.setTotal(total);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CcdiFileUploadRecord getById(Long id) {
|
||||||
|
return recordMapper.selectById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public String batchUploadFiles(Long projectId, MultipartFile[] files, String username) {
|
||||||
|
log.info("【文件上传】开始批量上传: projectId={}, 文件数量={}, username={}",
|
||||||
|
projectId, files.length, username);
|
||||||
|
|
||||||
|
// 1. 生成批次ID
|
||||||
|
String batchId = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
|
||||||
|
// 2. 查询项目信息并获取 lsfxProjectId
|
||||||
|
CcdiProject project = projectMapper.selectById(projectId);
|
||||||
|
if (project == null) {
|
||||||
|
throw new IllegalArgumentException("项目不存在: projectId=" + projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer lsfxProjectId = project.getLsfxProjectId();
|
||||||
|
if (lsfxProjectId == null) {
|
||||||
|
throw new IllegalStateException("项目未关联流水分析平台: projectId=" + projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("【文件上传】项目信息验证通过: projectId={}, lsfxProjectId={}", projectId, lsfxProjectId);
|
||||||
|
|
||||||
|
// Critical Fix #2: 保存MultipartFile到临时存储,避免异步处理时文件已被清理
|
||||||
|
List<String> tempFilePaths = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
// 确保临时目录存在
|
||||||
|
Path tempDir = Paths.get(TEMP_FILE_DIR);
|
||||||
|
if (!Files.exists(tempDir)) {
|
||||||
|
Files.createDirectories(tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存所有文件到临时目录
|
||||||
|
for (MultipartFile file : files) {
|
||||||
|
String originalFilename = file.getOriginalFilename();
|
||||||
|
String tempFileName = batchId + "_" + System.currentTimeMillis() + "_" + originalFilename;
|
||||||
|
Path tempFilePath = tempDir.resolve(tempFileName);
|
||||||
|
|
||||||
|
// 将MultipartFile内容复制到临时文件
|
||||||
|
Files.copy(file.getInputStream(), tempFilePath, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
tempFilePaths.add(tempFilePath.toString());
|
||||||
|
|
||||||
|
log.debug("【文件上传】保存临时文件: originalName={}, tempPath={}",
|
||||||
|
originalFilename, tempFilePath);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("【文件上传】保存临时文件失败", e);
|
||||||
|
throw new RuntimeException("保存临时文件失败: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 批量插入文件记录(status=uploading)
|
||||||
|
List<CcdiFileUploadRecord> records = new ArrayList<>();
|
||||||
|
Date now = new Date();
|
||||||
|
|
||||||
|
for (int i = 0; i < files.length; i++) {
|
||||||
|
MultipartFile file = files[i];
|
||||||
|
CcdiFileUploadRecord record = new CcdiFileUploadRecord();
|
||||||
|
record.setProjectId(projectId);
|
||||||
|
record.setLsfxProjectId(lsfxProjectId);
|
||||||
|
record.setFileName(file.getOriginalFilename());
|
||||||
|
record.setFileSize(file.getSize());
|
||||||
|
record.setFileStatus("uploading");
|
||||||
|
record.setUploadTime(now);
|
||||||
|
record.setUploadUser(username);
|
||||||
|
records.add(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordMapper.insertBatch(records);
|
||||||
|
log.info("【文件上传】批量插入记录成功: 数量={}", records.size());
|
||||||
|
|
||||||
|
// Critical Fix #3: 验证ID已生成
|
||||||
|
for (CcdiFileUploadRecord record : records) {
|
||||||
|
if (record.getId() == null) {
|
||||||
|
throw new RuntimeException("批量插入失败: 未生成记录ID,请检查Mapper配置useGeneratedKeys=true");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.debug("【文件上传】ID验证通过: 所有记录ID已生成");
|
||||||
|
|
||||||
|
// Critical Fix #1: 使用TransactionSynchronization确保异步任务在事务提交后启动
|
||||||
|
final Integer finalLsfxProjectId = lsfxProjectId;
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
log.info("【文件上传】事务已提交,启动异步任务");
|
||||||
|
CompletableFuture.runAsync(() -> {
|
||||||
|
submitTasksAsync(projectId, finalLsfxProjectId, tempFilePaths, records, batchId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info("【文件上传】批量上传任务已提交: batchId={}", batchId);
|
||||||
|
return batchId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调度线程:循环提交任务到线程池
|
||||||
|
* 支持等待30秒重试机制
|
||||||
|
*
|
||||||
|
* @param projectId 项目ID
|
||||||
|
* @param lsfxProjectId 流水分析项目ID
|
||||||
|
* @param tempFilePaths 临时文件路径列表
|
||||||
|
* @param records 文件上传记录列表
|
||||||
|
* @param batchId 批次ID
|
||||||
|
*/
|
||||||
|
private void submitTasksAsync(Long projectId, Integer lsfxProjectId,
|
||||||
|
List<String> tempFilePaths,
|
||||||
|
List<CcdiFileUploadRecord> records,
|
||||||
|
String batchId) {
|
||||||
|
log.info("【文件上传】调度线程启动: projectId={}, batchId={}", projectId, batchId);
|
||||||
|
|
||||||
|
// 循环提交任务
|
||||||
|
for (int i = 0; i < tempFilePaths.size(); i++) {
|
||||||
|
// Critical Fix #6: 检查线程中断状态
|
||||||
|
if (Thread.currentThread().isInterrupted()) {
|
||||||
|
log.warn("【文件上传】调度线程被中断,停止提交剩余任务");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
String tempFilePath = tempFilePaths.get(i);
|
||||||
|
CcdiFileUploadRecord record = records.get(i);
|
||||||
|
|
||||||
|
boolean submitted = false;
|
||||||
|
int retryCount = 0;
|
||||||
|
|
||||||
|
while (!submitted && retryCount < 2) {
|
||||||
|
try {
|
||||||
|
// 尝试提交异步任务
|
||||||
|
CompletableFuture.runAsync(
|
||||||
|
() -> processFileAsync(projectId, lsfxProjectId, tempFilePath,
|
||||||
|
record.getId(), batchId, record),
|
||||||
|
fileUploadExecutor
|
||||||
|
);
|
||||||
|
submitted = true;
|
||||||
|
log.info("【文件上传】任务提交成功: fileName={}, recordId={}",
|
||||||
|
record.getFileName(), record.getId());
|
||||||
|
} catch (RejectedExecutionException e) {
|
||||||
|
retryCount++;
|
||||||
|
if (retryCount == 1) {
|
||||||
|
log.warn("【文件上传】线程池已满,等待30秒后重试: fileName={}",
|
||||||
|
record.getFileName());
|
||||||
|
try {
|
||||||
|
Thread.sleep(30000);
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
log.error("【文件上传】等待被中断: fileName={}", record.getFileName());
|
||||||
|
updateRecordStatus(record.getId(), "parsed_failed", "任务提交被中断");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.error("【文件上传】重试失败,放弃任务: fileName={}", record.getFileName());
|
||||||
|
updateRecordStatus(record.getId(), "parsed_failed", "系统繁忙,请稍后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("【文件上传】调度线程完成: projectId={}, batchId={}", projectId, batchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新记录状态(辅助方法)
|
||||||
|
*/
|
||||||
|
private void updateRecordStatus(Long recordId, String status, String errorMessage) {
|
||||||
|
CcdiFileUploadRecord record = new CcdiFileUploadRecord();
|
||||||
|
record.setId(recordId);
|
||||||
|
record.setFileStatus(status);
|
||||||
|
record.setErrorMessage(errorMessage);
|
||||||
|
recordMapper.updateById(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步处理单个文件的完整流程
|
||||||
|
* 包含:上传 → 轮询解析状态 → 获取结果 → 保存流水数据
|
||||||
|
*
|
||||||
|
* @param projectId 项目ID
|
||||||
|
* @param lsfxProjectId 流水分析项目ID
|
||||||
|
* @param tempFilePath 临时文件路径
|
||||||
|
* @param recordId 记录ID
|
||||||
|
* @param batchId 批次ID
|
||||||
|
* @param record 文件上传记录
|
||||||
|
*/
|
||||||
|
@Async("fileUploadExecutor")
|
||||||
|
public void processFileAsync(Long projectId, Integer lsfxProjectId, String tempFilePath,
|
||||||
|
Long recordId, String batchId, CcdiFileUploadRecord record) {
|
||||||
|
log.info("【文件上传】开始处理文件: fileName={}, recordId={}, tempPath={}",
|
||||||
|
record.getFileName(), recordId, tempFilePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 步骤1:状态已是uploading,记录已存在
|
||||||
|
|
||||||
|
// 从临时文件路径读取文件
|
||||||
|
Path filePath = Paths.get(tempFilePath);
|
||||||
|
if (!Files.exists(filePath)) {
|
||||||
|
throw new RuntimeException("临时文件不存在: " + tempFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤2:上传文件到流水分析平台
|
||||||
|
log.info("【文件上传】步骤2: 上传文件到流水分析平台");
|
||||||
|
// TODO: 调用 lsfxClient.uploadFile()
|
||||||
|
// 需要将临时文件转换为MultipartFile或直接使用文件路径
|
||||||
|
// UploadFileResponse uploadResponse = lsfxClient.uploadFile(lsfxProjectId, filePath.toFile());
|
||||||
|
// Integer logId = uploadResponse.getData().getLogId();
|
||||||
|
|
||||||
|
// 临时模拟 logId
|
||||||
|
Integer logId = (int) (System.currentTimeMillis() % 1000000);
|
||||||
|
|
||||||
|
// 步骤3:更新状态为 parsing
|
||||||
|
log.info("【文件上传】步骤3: 更新状态为解析中, logId={}", logId);
|
||||||
|
record.setLogId(logId);
|
||||||
|
record.setFileStatus("parsing");
|
||||||
|
recordMapper.updateById(record);
|
||||||
|
|
||||||
|
// 步骤4:轮询解析状态(最多300次,间隔2秒)
|
||||||
|
log.info("【文件上传】步骤4: 开始轮询解析状态");
|
||||||
|
// TODO: 实现真实的轮询逻辑
|
||||||
|
// boolean parsingComplete = waitForParsingComplete(lsfxProjectId, logId.toString());
|
||||||
|
boolean parsingComplete = true; // 临时模拟
|
||||||
|
|
||||||
|
if (!parsingComplete) {
|
||||||
|
throw new RuntimeException("解析超时(超过10分钟),请检查文件格式是否正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤5:获取文件上传状态
|
||||||
|
log.info("【文件上传】步骤5: 获取文件上传状态");
|
||||||
|
// TODO: 调用 lsfxClient.getFileUploadStatus()
|
||||||
|
// GetFileUploadStatusResponse statusResponse = lsfxClient.getFileUploadStatus(...);
|
||||||
|
|
||||||
|
// 步骤6:判断解析结果
|
||||||
|
// TODO: 实现真实的判断逻辑
|
||||||
|
boolean parseSuccess = true; // 临时模拟
|
||||||
|
|
||||||
|
if (parseSuccess) {
|
||||||
|
// 解析成功
|
||||||
|
log.info("【文件上传】步骤6: 解析成功,保存主体信息");
|
||||||
|
record.setFileStatus("parsed_success");
|
||||||
|
// TODO: 从实际的解析结果中获取
|
||||||
|
record.setEnterpriseNames("测试主体1,测试主体2");
|
||||||
|
record.setAccountNos("622xxx,623xxx");
|
||||||
|
recordMapper.updateById(record);
|
||||||
|
|
||||||
|
// 步骤7:获取流水数据并保存
|
||||||
|
log.info("【文件上传】步骤7: 获取流水数据");
|
||||||
|
// TODO: 实现 fetchAndSaveBankStatements
|
||||||
|
// fetchAndSaveBankStatements(projectId, lsfxProjectId, logId, totalCount);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// 解析失败
|
||||||
|
log.warn("【文件上传】步骤6: 解析失败");
|
||||||
|
record.setFileStatus("parsed_failed");
|
||||||
|
record.setErrorMessage("解析失败:文件格式错误");
|
||||||
|
recordMapper.updateById(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("【文件上传】处理完成: fileName={}", record.getFileName());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("【文件上传】处理失败: fileName={}", record.getFileName(), e);
|
||||||
|
updateRecordStatus(recordId, "parsed_failed", e.getMessage());
|
||||||
|
} finally {
|
||||||
|
// 清理临时文件
|
||||||
|
try {
|
||||||
|
Path filePath = Paths.get(tempFilePath);
|
||||||
|
if (Files.exists(filePath)) {
|
||||||
|
Files.delete(filePath);
|
||||||
|
log.debug("【文件上传】清理临时文件: {}", tempFilePath);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("【文件上传】清理临时文件失败: {}", tempFilePath, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询解析状态
|
||||||
|
* TODO: 实现真实逻辑
|
||||||
|
*/
|
||||||
|
private boolean waitForParsingComplete(Integer groupId, String logId) {
|
||||||
|
// TODO: 调用 lsfxClient.checkParseStatus() 轮询
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取并保存流水数据
|
||||||
|
* TODO: 实现真实逻辑
|
||||||
|
*/
|
||||||
|
private void fetchAndSaveBankStatements(Long projectId, Integer groupId,
|
||||||
|
Integer logId, int totalCount) {
|
||||||
|
// TODO: 调用 lsfxClient.getBankStatement() 获取流水
|
||||||
|
// TODO: 批量插入到 ccdi_bank_statement
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper
|
||||||
|
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper">
|
||||||
|
|
||||||
|
<resultMap type="com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord" id="CcdiFileUploadRecordResult">
|
||||||
|
<id property="id" column="id" />
|
||||||
|
<result property="projectId" column="project_id" />
|
||||||
|
<result property="lsfxProjectId" column="lsfx_project_id" />
|
||||||
|
<result property="logId" column="log_id" />
|
||||||
|
<result property="fileName" column="file_name" />
|
||||||
|
<result property="fileSize" column="file_size" />
|
||||||
|
<result property="fileStatus" column="file_status" />
|
||||||
|
<result property="enterpriseNames" column="enterprise_names" />
|
||||||
|
<result property="accountNos" column="account_nos" />
|
||||||
|
<result property="errorMessage" column="error_message" />
|
||||||
|
<result property="uploadTime" column="upload_time" />
|
||||||
|
<result property="uploadUser" column="upload_user" />
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<sql id="selectCcdiFileUploadRecordVo">
|
||||||
|
select id, project_id, lsfx_project_id, log_id, file_name, file_size,
|
||||||
|
file_status, enterprise_names, account_nos, error_message,
|
||||||
|
upload_time, upload_user
|
||||||
|
from ccdi_file_upload_record
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<!-- 批量插入 -->
|
||||||
|
<insert id="insertBatch" parameterType="java.util.List" useGeneratedKeys="true" keyProperty="id">
|
||||||
|
insert into ccdi_file_upload_record (
|
||||||
|
project_id, lsfx_project_id, file_name, file_size, file_status,
|
||||||
|
upload_time, upload_user
|
||||||
|
) values
|
||||||
|
<foreach collection="list" item="item" separator=",">
|
||||||
|
(
|
||||||
|
#{item.projectId}, #{item.lsfxProjectId}, #{item.fileName},
|
||||||
|
#{item.fileSize}, #{item.fileStatus}, #{item.uploadTime},
|
||||||
|
#{item.uploadUser}
|
||||||
|
)
|
||||||
|
</foreach>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 统计各状态文件数量 -->
|
||||||
|
<select id="countByStatus" resultType="java.util.Map">
|
||||||
|
select file_status as `status`, count(*) as count
|
||||||
|
from ccdi_file_upload_record
|
||||||
|
where project_id = #{projectId}
|
||||||
|
group by file_status
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
227
doc/api-docs/ccdi-file-upload-api.md
Normal file
227
doc/api-docs/ccdi-file-upload-api.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# 文件上传 API 文档
|
||||||
|
|
||||||
|
## 1. 批量上传文件
|
||||||
|
|
||||||
|
### 接口地址
|
||||||
|
POST /ccdi/file-upload/batch
|
||||||
|
|
||||||
|
### 请求参数
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| projectId | Long | 是 | 项目ID |
|
||||||
|
| files | File[] | 是 | 文件数组(最多100个,单个最大50MB) |
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8080/ccdi/file-upload/batch" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-F "projectId=1" \
|
||||||
|
-F "files=@/path/to/file1.xlsx" \
|
||||||
|
-F "files=@/path/to/file2.xlsx"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 返回示例
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "上传任务已提交",
|
||||||
|
"data": "a1b2c3d4e5f6g7h8"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 返回字段说明
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| code | Integer | 状态码,200表示成功 |
|
||||||
|
| msg | String | 提示信息 |
|
||||||
|
| data | String | 批次ID,用于追踪上传任务 |
|
||||||
|
|
||||||
|
### 错误码说明
|
||||||
|
| code | msg | 说明 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 500 | 项目ID不能为空 | 缺少必填参数 |
|
||||||
|
| 500 | 请选择要上传的文件 | 文件数组为空 |
|
||||||
|
| 500 | 单次最多上传100个文件 | 文件数量超限 |
|
||||||
|
| 500 | 文件 xxx 超过50MB限制 | 文件大小超限 |
|
||||||
|
| 500 | 文件 xxx 格式不支持,仅支持Excel文件 | 文件格式错误 |
|
||||||
|
| 500 | 系统繁忙,请稍后再试 | 线程池已满 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 查询上传记录列表
|
||||||
|
|
||||||
|
### 接口地址
|
||||||
|
GET /ccdi/file-upload/list
|
||||||
|
|
||||||
|
### 请求参数
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| projectId | Long | 否 | 项目ID |
|
||||||
|
| fileStatus | String | 否 | 文件状态:uploading/parsing/parsed_success/parsed_failed |
|
||||||
|
| fileName | String | 否 | 文件名称(模糊查询) |
|
||||||
|
| uploadUser | String | 否 | 上传人 |
|
||||||
|
| pageNum | Integer | 否 | 页码,默认1 |
|
||||||
|
| pageSize | Integer | 否 | 每页数量,默认10 |
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8080/ccdi/file-upload/list?projectId=1&fileStatus=parsed_success&pageNum=1&pageSize=10" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 返回示例
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "查询成功",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"projectId": 1,
|
||||||
|
"lsfxProjectId": 100,
|
||||||
|
"logId": 123456,
|
||||||
|
"fileName": "流水1.xlsx",
|
||||||
|
"fileSize": 2621440,
|
||||||
|
"fileStatus": "parsed_success",
|
||||||
|
"enterpriseNames": "张三,李四",
|
||||||
|
"accountNos": "622xxx,623xxx",
|
||||||
|
"uploadTime": "2026-03-05 10:30:00",
|
||||||
|
"uploadUser": "admin"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 返回字段说明
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| rows | Array | 记录列表 |
|
||||||
|
| total | Long | 总记录数 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 查询上传统计
|
||||||
|
|
||||||
|
### 接口地址
|
||||||
|
GET /ccdi/file-upload/statistics/{projectId}
|
||||||
|
|
||||||
|
### 路径参数
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| projectId | Long | 是 | 项目ID |
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8080/ccdi/file-upload/statistics/1" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 返回示例
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "查询成功",
|
||||||
|
"data": {
|
||||||
|
"uploading": 2,
|
||||||
|
"parsing": 3,
|
||||||
|
"parsedSuccess": 15,
|
||||||
|
"parsedFailed": 1,
|
||||||
|
"total": 21
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 返回字段说明
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| uploading | Long | 上传中数量 |
|
||||||
|
| parsing | Long | 解析中数量 |
|
||||||
|
| parsedSuccess | Long | 解析成功数量 |
|
||||||
|
| parsedFailed | Long | 解析失败数量 |
|
||||||
|
| total | Long | 总数量 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 查询记录详情
|
||||||
|
|
||||||
|
### 接口地址
|
||||||
|
GET /ccdi/file-upload/detail/{id}
|
||||||
|
|
||||||
|
### 路径参数
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | Long | 是 | 记录ID |
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8080/ccdi/file-upload/detail/1" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 返回示例
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "查询成功",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"projectId": 1,
|
||||||
|
"lsfxProjectId": 100,
|
||||||
|
"logId": 123456,
|
||||||
|
"fileName": "流水1.xlsx",
|
||||||
|
"fileSize": 2621440,
|
||||||
|
"fileStatus": "parsed_success",
|
||||||
|
"enterpriseNames": "张三,李四",
|
||||||
|
"accountNos": "622xxx,623xxx",
|
||||||
|
"errorMessage": null,
|
||||||
|
"uploadTime": "2026-03-05 10:30:00",
|
||||||
|
"uploadUser": "admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 文件状态说明
|
||||||
|
|
||||||
|
| 状态 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| uploading | 文件上传中 |
|
||||||
|
| parsing | 文件解析中 |
|
||||||
|
| parsed_success | 文件解析成功 |
|
||||||
|
| parsed_failed | 文件解析失败 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 通用说明
|
||||||
|
|
||||||
|
### 认证方式
|
||||||
|
所有接口需要在请求头中携带 Token:
|
||||||
|
```
|
||||||
|
Authorization: Bearer YOUR_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取 Token
|
||||||
|
```bash
|
||||||
|
POST /login/test?username=admin&password=admin123
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应格式
|
||||||
|
所有接口统一返回格式:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "操作成功",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误处理
|
||||||
|
当发生错误时,返回格式:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 500,
|
||||||
|
"msg": "错误信息"
|
||||||
|
}
|
||||||
|
```
|
||||||
560
doc/design/2026-03-05-async-file-upload-design.md
Normal file
560
doc/design/2026-03-05-async-file-upload-design.md
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
# 项目异步文件上传功能 - 设计文档
|
||||||
|
|
||||||
|
## 文档信息
|
||||||
|
- **创建日期**: 2026-03-05
|
||||||
|
- **版本**: v1.0
|
||||||
|
- **作者**: Claude
|
||||||
|
- **状态**: 已批准
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
### 1.1 功能描述
|
||||||
|
实现项目流水文件的异步批量上传功能,支持文件上传到流水分析平台、轮询解析状态、获取解析结果、保存流水数据到本地数据库的完整流程。
|
||||||
|
|
||||||
|
### 1.2 核心需求
|
||||||
|
- 批量上传流水文件(最多100个文件)
|
||||||
|
- 异步处理每个文件的上传→解析→存储流程
|
||||||
|
- 线程池容量100,超载时等待30秒重试
|
||||||
|
- 实时跟踪文件处理状态
|
||||||
|
- 生成独立的批次日志文件便于维护
|
||||||
|
|
||||||
|
### 1.3 技术栈
|
||||||
|
- Spring @Async 异步处理
|
||||||
|
- ThreadPoolTaskExecutor 线程池
|
||||||
|
- MyBatis Plus 批量操作
|
||||||
|
- Logback 自定义日志
|
||||||
|
- Vue + Element UI 前端
|
||||||
|
|
||||||
|
## 2. 数据库设计
|
||||||
|
|
||||||
|
### 2.1 文件上传记录表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE `ccdi_file_upload_record` (
|
||||||
|
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
`project_id` bigint(20) NOT NULL COMMENT '项目ID',
|
||||||
|
`lsfx_project_id` int(11) DEFAULT NULL COMMENT '流水分析平台项目ID',
|
||||||
|
`log_id` int(11) DEFAULT NULL COMMENT '流水分析平台返回的logId',
|
||||||
|
`file_name` varchar(255) NOT NULL COMMENT '文件名称',
|
||||||
|
`file_size` bigint(20) DEFAULT NULL COMMENT '文件大小(字节)',
|
||||||
|
`file_status` varchar(20) NOT NULL COMMENT '文件状态:uploading-上传中,parsing-解析中,parsed_success-解析成功,parsed_failed-解析失败',
|
||||||
|
`enterprise_names` text COMMENT '主体名称(多个用逗号分隔)',
|
||||||
|
`account_nos` text COMMENT '主体账号(多个用逗号分隔)',
|
||||||
|
`error_message` text COMMENT '错误信息(解析失败时记录)',
|
||||||
|
`upload_time` datetime NOT NULL COMMENT '上传时间',
|
||||||
|
`upload_user` varchar(64) NOT NULL COMMENT '上传人',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_project_id` (`project_id`),
|
||||||
|
KEY `idx_log_id` (`log_id`),
|
||||||
|
KEY `idx_file_status` (`file_status`),
|
||||||
|
KEY `idx_upload_time` (`upload_time`),
|
||||||
|
KEY `idx_project_status` (`project_id`, `file_status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目文件上传记录表';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 | 备注 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | bigint | 主键ID | 自增 |
|
||||||
|
| project_id | bigint | 项目ID | 外键关联 ccdi_project |
|
||||||
|
| lsfx_project_id | int | 流水分析平台项目ID | 用于调用流水分析接口 |
|
||||||
|
| log_id | int | 流水分析平台返回的logId | 关键字段,用于查询解析状态和流水数据 |
|
||||||
|
| file_name | varchar(255) | 文件名称 | 原始文件名 |
|
||||||
|
| file_size | bigint | 文件大小 | 字节数 |
|
||||||
|
| file_status | varchar(20) | 文件状态 | uploading/parsing/parsed_success/parsed_failed |
|
||||||
|
| enterprise_names | text | 主体名称 | 解析成功后存储,多个用逗号分隔 |
|
||||||
|
| account_nos | text | 主体账号 | 解析成功后存储,多个用逗号分隔 |
|
||||||
|
| error_message | text | 错误信息 | 解析失败时记录原因 |
|
||||||
|
| upload_time | datetime | 上传时间 | 记录创建时间 |
|
||||||
|
| upload_user | varchar(64) | 上传人 | 操作用户 |
|
||||||
|
|
||||||
|
## 3. 后端架构设计
|
||||||
|
|
||||||
|
### 3.1 模块结构
|
||||||
|
|
||||||
|
```
|
||||||
|
ccdi-project/src/main/java/com/ruoyi/ccdi/project/
|
||||||
|
├── controller/
|
||||||
|
│ └── CcdiFileUploadController.java # 文件上传接口
|
||||||
|
├── service/
|
||||||
|
│ ├── ICcdiFileUploadService.java # 文件上传服务接口
|
||||||
|
│ └── impl/
|
||||||
|
│ └── CcdiFileUploadServiceImpl.java # 文件上传服务实现
|
||||||
|
├── mapper/
|
||||||
|
│ └── CcdiFileUploadRecordMapper.java # 文件上传记录Mapper
|
||||||
|
├── domain/
|
||||||
|
│ ├── entity/
|
||||||
|
│ │ └── CcdiFileUploadRecord.java # 文件上传记录实体
|
||||||
|
│ ├── dto/
|
||||||
|
│ │ └── CcdiFileUploadQueryDTO.java # 查询DTO
|
||||||
|
│ └── vo/
|
||||||
|
│ ├── CcdiFileUploadVO.java # 文件上传响应VO
|
||||||
|
│ └── CcdiFileUploadStatisticsVO.java # 统计VO
|
||||||
|
├── config/
|
||||||
|
│ └── AsyncThreadPoolConfig.java # 异步线程池配置
|
||||||
|
└── log/
|
||||||
|
└── FileUploadLogAppender.java # 自定义日志Appender
|
||||||
|
|
||||||
|
ccdi-project/src/main/resources/
|
||||||
|
└── mapper/ccdi/project/
|
||||||
|
└── CcdiFileUploadRecordMapper.xml # Mapper XML映射文件
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Controller 接口设计
|
||||||
|
|
||||||
|
| 接口路径 | 方法 | 功能 | 参数 | 返回值 |
|
||||||
|
|---------|------|------|------|--------|
|
||||||
|
| `/ccdi/file-upload/batch` | POST | 批量上传文件 | projectId, files[] | batchId |
|
||||||
|
| `/ccdi/file-upload/list` | GET | 查询上传记录列表 | projectId, fileStatus, pageNum, pageSize | 分页列表 |
|
||||||
|
| `/ccdi/file-upload/statistics/{projectId}` | GET | 查询上传统计 | projectId | 各状态数量 |
|
||||||
|
| `/ccdi/file-upload/detail/{id}` | GET | 查询记录详情 | id | 完整信息 |
|
||||||
|
| `/ccdi/file-upload/thread-pool/status` | GET | 查询线程池状态 | - | 线程池状态信息 |
|
||||||
|
|
||||||
|
### 3.3 Service 核心方法
|
||||||
|
|
||||||
|
#### ICcdiFileUploadService 接口
|
||||||
|
|
||||||
|
```java
|
||||||
|
public interface ICcdiFileUploadService {
|
||||||
|
/**
|
||||||
|
* 批量上传文件
|
||||||
|
* @param projectId 项目ID
|
||||||
|
* @param files 文件数组
|
||||||
|
* @param username 上传人
|
||||||
|
* @return 批次ID
|
||||||
|
*/
|
||||||
|
String batchUploadFiles(Long projectId, MultipartFile[] files, String username);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步处理单个文件
|
||||||
|
* @Async("fileUploadExecutor")
|
||||||
|
*/
|
||||||
|
void processFileAsync(Long projectId, Integer lsfxProjectId, MultipartFile file,
|
||||||
|
Long recordId, String batchId, CcdiFileUploadRecord record);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询上传记录列表
|
||||||
|
*/
|
||||||
|
Page<CcdiFileUploadRecord> selectPage(Page<CcdiFileUploadRecord> page,
|
||||||
|
CcdiFileUploadQueryDTO queryDTO);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计各状态文件数量
|
||||||
|
*/
|
||||||
|
Map<String, Long> countByStatus(Long projectId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 核心处理流程
|
||||||
|
|
||||||
|
```java
|
||||||
|
// 1. batchUploadFiles - 主入口
|
||||||
|
String batchUploadFiles(Long projectId, MultipartFile[] files, String username) {
|
||||||
|
// 1.1 生成批次ID
|
||||||
|
String batchId = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
|
||||||
|
// 1.2 获取项目的 lsfxProjectId
|
||||||
|
Integer lsfxProjectId = project.getLsfxProjectId();
|
||||||
|
|
||||||
|
// 1.3 批量插入文件记录(status=uploading)
|
||||||
|
List<CcdiFileUploadRecord> records = createRecords(projectId, lsfxProjectId, files, username);
|
||||||
|
recordMapper.insertBatch(records);
|
||||||
|
|
||||||
|
// 1.4 异步启动调度线程提交任务
|
||||||
|
CompletableFuture.runAsync(() -> {
|
||||||
|
submitTasksAsync(projectId, lsfxProjectId, files, records, batchId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1.5 立即返回 batchId
|
||||||
|
return batchId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. submitTasksAsync - 调度线程
|
||||||
|
void submitTasksAsync(Long projectId, Integer lsfxProjectId, MultipartFile[] files,
|
||||||
|
List<CcdiFileUploadRecord> records, String batchId) {
|
||||||
|
// 2.1 创建批次日志文件
|
||||||
|
FileUploadLogAppender.createBatchLogFile(projectId, batchId);
|
||||||
|
|
||||||
|
// 2.2 循环提交任务,支持重试
|
||||||
|
for (int i = 0; i < files.length; i++) {
|
||||||
|
boolean submitted = false;
|
||||||
|
int retryCount = 0;
|
||||||
|
|
||||||
|
while (!submitted && retryCount < 2) {
|
||||||
|
try {
|
||||||
|
// 提交异步任务到线程池
|
||||||
|
CompletableFuture.runAsync(
|
||||||
|
() -> processFileAsync(projectId, lsfxProjectId, files[i],
|
||||||
|
records.get(i).getId(), batchId, records.get(i)),
|
||||||
|
fileUploadExecutor
|
||||||
|
);
|
||||||
|
submitted = true;
|
||||||
|
} catch (RejectedExecutionException e) {
|
||||||
|
retryCount++;
|
||||||
|
if (retryCount == 1) {
|
||||||
|
Thread.sleep(30000); // 等待30秒
|
||||||
|
} else {
|
||||||
|
// 重试失败,更新记录状态
|
||||||
|
updateRecordStatus(records.get(i).getId(), "parsed_failed", "系统繁忙");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. processFileAsync - 文件处理线程
|
||||||
|
@Async("fileUploadExecutor")
|
||||||
|
void processFileAsync(Long projectId, Integer lsfxProjectId, MultipartFile file,
|
||||||
|
Long recordId, String batchId, CcdiFileUploadRecord record) {
|
||||||
|
try {
|
||||||
|
// 3.1 上传文件到流水分析平台
|
||||||
|
UploadFileResponse uploadResponse = lsfxClient.uploadFile(lsfxProjectId, file);
|
||||||
|
Integer logId = uploadResponse.getData().getLogId();
|
||||||
|
|
||||||
|
// 3.2 更新状态为 parsing
|
||||||
|
record.setLogId(logId);
|
||||||
|
record.setFileStatus("parsing");
|
||||||
|
recordMapper.updateById(record);
|
||||||
|
|
||||||
|
// 3.3 轮询解析状态(最多300次,间隔2秒)
|
||||||
|
boolean parsingComplete = waitForParsingComplete(lsfxProjectId, logId.toString());
|
||||||
|
|
||||||
|
// 3.4 获取文件上传状态
|
||||||
|
GetFileUploadStatusResponse statusResponse = lsfxClient.getFileUploadStatus(...);
|
||||||
|
|
||||||
|
// 3.5 判断解析结果
|
||||||
|
if (status == -5 && desc == "data.wait.confirm.newaccount") {
|
||||||
|
// 解析成功
|
||||||
|
record.setFileStatus("parsed_success");
|
||||||
|
record.setEnterpriseNames(...);
|
||||||
|
record.setAccountNos(...);
|
||||||
|
recordMapper.updateById(record);
|
||||||
|
|
||||||
|
// 3.6 获取流水数据并批量保存
|
||||||
|
fetchAndSaveBankStatements(projectId, lsfxProjectId, logId, totalCount);
|
||||||
|
} else {
|
||||||
|
// 解析失败
|
||||||
|
record.setFileStatus("parsed_failed");
|
||||||
|
record.setErrorMessage(...);
|
||||||
|
recordMapper.updateById(record);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
updateRecordStatus(recordId, "parsed_failed", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 线程池配置
|
||||||
|
|
||||||
|
### 4.1 配置类
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Configuration
|
||||||
|
@EnableAsync
|
||||||
|
public class AsyncThreadPoolConfig {
|
||||||
|
|
||||||
|
@Bean("fileUploadExecutor")
|
||||||
|
public Executor fileUploadExecutor() {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
executor.setCorePoolSize(100); // 核心线程数
|
||||||
|
executor.setMaxPoolSize(100); // 最大线程数
|
||||||
|
executor.setQueueCapacity(0); // 队列容量(0表示不使用队列)
|
||||||
|
executor.setThreadNamePrefix("file-upload-"); // 线程名称前缀
|
||||||
|
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); // 拒绝策略
|
||||||
|
executor.setKeepAliveSeconds(60); // 空闲线程存活时间
|
||||||
|
executor.setWaitForTasksToCompleteOnShutdown(true); // 等待任务完成再关闭
|
||||||
|
executor.setAwaitTerminationSeconds(60); // 最长等待时间
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 拒绝策略
|
||||||
|
|
||||||
|
- **策略**: AbortPolicy
|
||||||
|
- **行为**: 抛出 RejectedExecutionException
|
||||||
|
- **处理**: 调度线程捕获异常,等待30秒后重试1次
|
||||||
|
- **重试失败**: 更新记录状态为 `parsed_failed`,错误信息"系统繁忙"
|
||||||
|
|
||||||
|
## 5. 日志管理
|
||||||
|
|
||||||
|
### 5.1 日志文件组织
|
||||||
|
|
||||||
|
- **路径格式**: `logs/file-upload/{projectId}/{timestamp}.log`
|
||||||
|
- **示例**: `logs/file-upload/123/20260305-103025.log`
|
||||||
|
- **特点**: 每个批次生成独立的日志文件
|
||||||
|
|
||||||
|
### 5.2 Logback 配置
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- logback-fileupload.xml -->
|
||||||
|
<appender name="FILE_UPLOAD" class="com.ruoyi.ccdi.project.log.FileUploadLogAppender">
|
||||||
|
<layout class="ch.qos.logback.classic.PatternLayout">
|
||||||
|
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
|
||||||
|
</layout>
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||||
|
<fileNamePattern>logs/file-upload/%d{yyyy-MM-dd}/%d{HH}.log</fileNamePattern>
|
||||||
|
<maxHistory>30</maxHistory>
|
||||||
|
<maxFileSize>100MB</maxFileSize>
|
||||||
|
</rollingPolicy>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<logger name="com.ruoyi.ccdi.project.service.impl.CcdiFileUploadServiceImpl"
|
||||||
|
level="INFO" additivity="false">
|
||||||
|
<appender-ref ref="FILE_UPLOAD"/>
|
||||||
|
</logger>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 自定义 Appender
|
||||||
|
|
||||||
|
```java
|
||||||
|
public class FileUploadLogAppender extends UnsynchronizedAppenderBase<ILoggingEvent> {
|
||||||
|
|
||||||
|
private static final ThreadLocal<FileAppender<ILoggingEvent>> currentAppender =
|
||||||
|
new ThreadLocal<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为指定批次创建独立的日志文件
|
||||||
|
*/
|
||||||
|
public static void createBatchLogFile(Long projectId, String batchId) {
|
||||||
|
String timestamp = new SimpleDateFormat("yyyyMMdd-HHmmss").format(new Date());
|
||||||
|
String logPath = String.format("logs/file-upload/%d/%s.log", projectId, timestamp);
|
||||||
|
|
||||||
|
FileAppender<ILoggingEvent> appender = new FileAppender<>();
|
||||||
|
appender.setFile(logPath);
|
||||||
|
appender.setLayout(...);
|
||||||
|
appender.start();
|
||||||
|
|
||||||
|
currentAppender.set(appender);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void append(ILoggingEvent event) {
|
||||||
|
FileAppender<ILoggingEvent> appender = currentAppender.get();
|
||||||
|
if (appender != null) {
|
||||||
|
appender.doAppend(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 前端交互设计
|
||||||
|
|
||||||
|
### 6.1 上传流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户选择文件 → 确认上传 → 显示loading
|
||||||
|
↓
|
||||||
|
调用 batchUploadFiles() API
|
||||||
|
↓
|
||||||
|
后端立即返回 batchId
|
||||||
|
↓
|
||||||
|
前端提示"上传任务已提交"
|
||||||
|
↓
|
||||||
|
跳转到上传记录列表页
|
||||||
|
↓
|
||||||
|
每5秒自动刷新列表(可关闭)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 列表页展示
|
||||||
|
|
||||||
|
**统计卡片:**
|
||||||
|
- 上传中: 2
|
||||||
|
- 解析中: 3
|
||||||
|
- 解析成功: 15
|
||||||
|
- 解析失败: 1
|
||||||
|
|
||||||
|
**文件列表:**
|
||||||
|
|
||||||
|
| 文件名 | 大小 | 状态 | 主体名称 | 上传时间 | 操作 |
|
||||||
|
|--------|------|------|----------|----------|------|
|
||||||
|
| 流水1.xlsx | 2.5MB | 🔄 解析中 | - | 10:30:25 | - |
|
||||||
|
| 流水2.xlsx | 1.8MB | ✅ 解析成功 | 张三,李四 | 10:28:15 | 查看流水 |
|
||||||
|
| 流水3.xlsx | 3.2MB | ❌ 解析失败 | - | 10:25:30 | 查看错误 |
|
||||||
|
|
||||||
|
### 6.3 API 接口
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 批量上传文件
|
||||||
|
POST /ccdi/file-upload/batch
|
||||||
|
参数: FormData(projectId, files[])
|
||||||
|
返回: { code: 200, msg: "上传任务已提交", data: batchId }
|
||||||
|
|
||||||
|
// 查询上传记录列表
|
||||||
|
GET /ccdi/file-upload/list
|
||||||
|
参数: { projectId, fileStatus, pageNum, pageSize }
|
||||||
|
返回: { rows: [], total: 100 }
|
||||||
|
|
||||||
|
// 查询上传统计
|
||||||
|
GET /ccdi/file-upload/statistics/{projectId}
|
||||||
|
返回: { uploading: 2, parsing: 3, parsed_success: 15, parsed_failed: 1 }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 异常处理
|
||||||
|
|
||||||
|
### 7.1 Controller 层异常
|
||||||
|
|
||||||
|
| 异常类型 | 处理方式 | 返回信息 |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| 参数为空 | 参数校验 | "项目ID不能为空" |
|
||||||
|
| 文件数量超限 | 参数校验 | "单次最多上传100个文件" |
|
||||||
|
| 文件大小超限 | 参数校验 | "文件超过50MB限制" |
|
||||||
|
| 文件格式错误 | 参数校验 | "仅支持Excel文件" |
|
||||||
|
| 项目不存在 | 业务校验 | "项目不存在" |
|
||||||
|
|
||||||
|
### 7.2 Service 层异常
|
||||||
|
|
||||||
|
| 异常类型 | 处理方式 | 记录状态 |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| 流水分析平台接口异常 | 捕获并记录 | parsed_failed |
|
||||||
|
| 轮询超时(>300次) | 捕获并记录 | parsed_failed |
|
||||||
|
| 文件解析失败 | 捕获并记录 | parsed_failed |
|
||||||
|
| 线程池满且重试失败 | 捕获并记录 | parsed_failed |
|
||||||
|
| 其他未知异常 | 捕获并记录 | parsed_failed |
|
||||||
|
|
||||||
|
### 7.3 异常处理代码示例
|
||||||
|
|
||||||
|
```java
|
||||||
|
try {
|
||||||
|
// 处理文件
|
||||||
|
processFileInternal(projectId, lsfxProjectId, file, record);
|
||||||
|
} catch (LsfxApiException e) {
|
||||||
|
log.error("流水分析平台接口异常", e);
|
||||||
|
updateRecordStatus(recordId, "parsed_failed", "流水分析平台接口异常:" + e.getMessage());
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
log.error("处理被中断", e);
|
||||||
|
updateRecordStatus(recordId, "parsed_failed", "处理被中断");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("处理失败(未知异常)", e);
|
||||||
|
updateRecordStatus(recordId, "parsed_failed", "处理失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 性能优化
|
||||||
|
|
||||||
|
### 8.1 数据库优化
|
||||||
|
|
||||||
|
**索引建议:**
|
||||||
|
```sql
|
||||||
|
-- 组合索引提升查询性能
|
||||||
|
ALTER TABLE ccdi_file_upload_record
|
||||||
|
ADD INDEX idx_project_status (project_id, file_status);
|
||||||
|
|
||||||
|
ALTER TABLE ccdi_bank_statement
|
||||||
|
ADD INDEX idx_project_log (project_id, batch_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
**批量插入:**
|
||||||
|
- 使用 MyBatis Plus 的 `saveBatch(statements, 500)`
|
||||||
|
- 每批500条,避免单次插入过多数据
|
||||||
|
|
||||||
|
### 8.2 轮询优化
|
||||||
|
|
||||||
|
**动态间隔策略:**
|
||||||
|
- 前10次:1秒间隔
|
||||||
|
- 11-50次:2秒间隔
|
||||||
|
- 51次后:5秒间隔
|
||||||
|
|
||||||
|
### 8.3 线程池监控
|
||||||
|
|
||||||
|
```java
|
||||||
|
@GetMapping("/thread-pool/status")
|
||||||
|
public AjaxResult getThreadPoolStatus() {
|
||||||
|
ThreadPoolExecutor pool = fileUploadExecutor.getThreadPoolExecutor();
|
||||||
|
|
||||||
|
Map<String, Object> status = new HashMap<>();
|
||||||
|
status.put("activeCount", pool.getActiveCount());
|
||||||
|
status.put("corePoolSize", pool.getCorePoolSize());
|
||||||
|
status.put("queueSize", pool.getQueue().size());
|
||||||
|
status.put("completedTaskCount", pool.getCompletedTaskCount());
|
||||||
|
|
||||||
|
return AjaxResult.success(status);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 测试场景
|
||||||
|
|
||||||
|
### 9.1 功能测试
|
||||||
|
|
||||||
|
| 场景 | 输入 | 预期结果 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 正常上传 | 10个Excel文件,每个5MB | 所有文件处理成功 |
|
||||||
|
| 大文件上传 | 1个50MB文件 | 处理成功 |
|
||||||
|
| 文件数量超限 | 101个文件 | 返回错误提示 |
|
||||||
|
| 文件格式错误 | 上传PDF文件 | 返回错误提示 |
|
||||||
|
| 解析失败 | 格式错误的Excel | 状态更新为parsed_failed |
|
||||||
|
|
||||||
|
### 9.2 压力测试
|
||||||
|
|
||||||
|
| 场景 | 并发数 | 预期结果 |
|
||||||
|
|------|--------|---------|
|
||||||
|
| 正常并发 | 100个线程同时上传 | 所有任务正常处理 |
|
||||||
|
| 超载测试 | 150个文件同时上传 | 超过100的文件等待30秒重试 |
|
||||||
|
| 持续运行 | 1000次循环上传 | 无内存泄漏,无线程死锁 |
|
||||||
|
|
||||||
|
### 9.3 边界测试
|
||||||
|
|
||||||
|
| 场景 | 操作 | 预期结果 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 项目被删除 | 上传中删除项目 | 任务取消,状态更新为失败 |
|
||||||
|
| 重复上传 | 同一文件上传2次 | 生成2条独立记录和logId |
|
||||||
|
| 网络中断 | 轮询时网络断开 | 捕获异常,状态更新为失败 |
|
||||||
|
|
||||||
|
## 10. 部署注意事项
|
||||||
|
|
||||||
|
### 10.1 配置检查清单
|
||||||
|
|
||||||
|
- [ ] 线程池容量配置(默认100)
|
||||||
|
- [ ] 文件上传大小限制(默认50MB)
|
||||||
|
- [ ] 日志文件路径权限
|
||||||
|
- [ ] 数据库索引创建
|
||||||
|
- [ ] 流水分析平台地址配置
|
||||||
|
- [ ] 应用认证信息配置
|
||||||
|
|
||||||
|
### 10.2 监控指标
|
||||||
|
|
||||||
|
- 线程池活跃线程数
|
||||||
|
- 文件上传成功率(parsed_success / total)
|
||||||
|
- 平均处理时长
|
||||||
|
- 线程池拒绝次数
|
||||||
|
- 日志文件大小和数量
|
||||||
|
|
||||||
|
### 10.3 运维建议
|
||||||
|
|
||||||
|
- 定期清理30天前的日志文件
|
||||||
|
- 监控线程池状态,必要时调整容量
|
||||||
|
- 关注数据库连接池使用情况
|
||||||
|
- 流水分析平台接口调用成功率监控
|
||||||
|
|
||||||
|
## 11. 附录
|
||||||
|
|
||||||
|
### 11.1 状态机转换
|
||||||
|
|
||||||
|
```
|
||||||
|
uploading (初始状态)
|
||||||
|
↓
|
||||||
|
parsing (上传成功,轮询中)
|
||||||
|
↓
|
||||||
|
parsed_success (解析成功) 或 parsed_failed (解析失败)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 关键时序
|
||||||
|
|
||||||
|
- 文件上传:2-5秒(取决于文件大小)
|
||||||
|
- 轮询解析:最多10分钟(300次 × 2秒)
|
||||||
|
- 获取流水数据:1-3分钟(取决于流水数量)
|
||||||
|
- 总处理时长:约3-15分钟/文件
|
||||||
|
|
||||||
|
### 11.3 数据量估算
|
||||||
|
|
||||||
|
- 单个Excel文件:平均5000条流水
|
||||||
|
- 100个文件:约50万条流水
|
||||||
|
- 数据库存储:约200MB
|
||||||
|
- 日志文件:约5-10MB/批次
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档结束**
|
||||||
149
doc/design/2026-03-05-async-file-upload-frontend-design.md
Normal file
149
doc/design/2026-03-05-async-file-upload-frontend-design.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# 项目异步文件上传功能 - 前端设计文档(轮询版本)
|
||||||
|
|
||||||
|
## 文档信息
|
||||||
|
- **创建日期**: 2026-03-05
|
||||||
|
- **版本**: v1.1
|
||||||
|
- **作者**: Claude
|
||||||
|
- **状态**: 已批准
|
||||||
|
- **关联文档**: [后端设计文档](./2026-03-05-async-file-upload-design.md)
|
||||||
|
- **变更说明**: 移除WebSocket,改为页面轮询机制
|
||||||
|
|
||||||
|
## 1. 设计概述
|
||||||
|
|
||||||
|
### 1.1 功能描述
|
||||||
|
基于现有项目管理模块的上传数据组件(UploadData.vue),扩展实现流水文件的异步批量上传功能。
|
||||||
|
|
||||||
|
### 1.2 技术栈
|
||||||
|
- Vue.js 2.6.12
|
||||||
|
- Element UI 2.15.14
|
||||||
|
- Axios(HTTP 请求)
|
||||||
|
- 页面轮询(定时刷新)
|
||||||
|
|
||||||
|
## 2. 核心变更
|
||||||
|
|
||||||
|
### 2.1 移除WebSocket
|
||||||
|
- 不再使用WebSocket实时推送
|
||||||
|
- 改用HTTP轮询机制定时刷新
|
||||||
|
|
||||||
|
### 2.2 轮询机制
|
||||||
|
|
||||||
|
**启动条件**:
|
||||||
|
- 上传文件后立即启动
|
||||||
|
- 检测到有uploading或parsing状态文件时自动启动
|
||||||
|
|
||||||
|
**停止条件**:
|
||||||
|
- 所有文件处理完成(无uploading和parsing状态)
|
||||||
|
- 组件销毁时
|
||||||
|
- 用户手动停止
|
||||||
|
|
||||||
|
**轮询间隔**:
|
||||||
|
- 默认5秒
|
||||||
|
- 可根据活跃任务数量动态调整
|
||||||
|
|
||||||
|
## 3. 轮询实现
|
||||||
|
|
||||||
|
### 3.1 数据结构
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
// 轮询相关
|
||||||
|
pollingTimer: null,
|
||||||
|
pollingEnabled: false,
|
||||||
|
pollingInterval: 5000 // 5秒
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 核心方法
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
methods: {
|
||||||
|
// 启动轮询
|
||||||
|
startPolling() {
|
||||||
|
if (this.pollingEnabled) return
|
||||||
|
|
||||||
|
this.pollingEnabled = true
|
||||||
|
|
||||||
|
const poll = () => {
|
||||||
|
if (!this.pollingEnabled) return
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
this.loadStatistics(),
|
||||||
|
this.loadFileList()
|
||||||
|
]).then(() => {
|
||||||
|
// 检查是否需要继续轮询
|
||||||
|
if (this.statistics.uploading === 0 &&
|
||||||
|
this.statistics.parsing === 0) {
|
||||||
|
this.stopPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pollingTimer = setTimeout(poll, this.pollingInterval)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
poll()
|
||||||
|
},
|
||||||
|
|
||||||
|
// 停止轮询
|
||||||
|
stopPolling() {
|
||||||
|
this.pollingEnabled = false
|
||||||
|
if (this.pollingTimer) {
|
||||||
|
clearTimeout(this.pollingTimer)
|
||||||
|
this.pollingTimer = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 上传成功后启动轮询
|
||||||
|
async handleBatchUpload() {
|
||||||
|
// ... 上传逻辑 ...
|
||||||
|
|
||||||
|
// 刷新数据并启动轮询
|
||||||
|
await Promise.all([
|
||||||
|
this.loadStatistics(),
|
||||||
|
this.loadFileList()
|
||||||
|
])
|
||||||
|
|
||||||
|
this.startPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 生命周期管理
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
mounted() {
|
||||||
|
this.loadStatistics()
|
||||||
|
this.loadFileList()
|
||||||
|
|
||||||
|
// 检查是否需要启动轮询
|
||||||
|
if (this.statistics.uploading > 0 || this.statistics.parsing > 0) {
|
||||||
|
this.startPolling()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeDestroy() {
|
||||||
|
this.stopPolling()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 其他功能
|
||||||
|
|
||||||
|
批量上传弹窗、统计卡片、文件列表等功能保持不变,详见原设计文档。
|
||||||
|
|
||||||
|
## 5. 开发计划
|
||||||
|
|
||||||
|
1. **API 接口封装**(0.5天)
|
||||||
|
2. **批量上传弹窗**(1天)
|
||||||
|
3. **统计卡片组件**(0.5天)
|
||||||
|
4. **文件列表组件**(1天)
|
||||||
|
5. **轮询机制**(0.5天)
|
||||||
|
6. **联调测试**(1天)
|
||||||
|
|
||||||
|
**总计**:4.5个工作日
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档结束**
|
||||||
|
```
|
||||||
483
doc/plans/2026-03-05-async-file-upload-part1-database.md
Normal file
483
doc/plans/2026-03-05-async-file-upload-part1-database.md
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
# 项目异步文件上传功能 - 子计划1:数据库和基础组件
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** 创建文件上传功能的数据库表、实体类、Mapper接口和基础配置
|
||||||
|
|
||||||
|
**Architecture:** 使用 MyBatis Plus 进行数据持久化,配置容量100的异步线程池
|
||||||
|
|
||||||
|
**Tech Stack:** MySQL 8.0, MyBatis Plus 3.5.10, Spring Boot 3.5.8
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 数据库表创建
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `sql/ccdi_file_upload_record.sql`
|
||||||
|
|
||||||
|
**Step 1: 创建SQL脚本文件**
|
||||||
|
|
||||||
|
创建文件 `sql/ccdi_file_upload_record.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 项目文件上传记录表
|
||||||
|
-- 用途:记录项目下所有文件的上传记录和处理状态
|
||||||
|
-- 作者:系统
|
||||||
|
-- 日期:2026-03-05
|
||||||
|
|
||||||
|
USE ccdi;
|
||||||
|
|
||||||
|
-- 创建文件上传记录表
|
||||||
|
CREATE TABLE `ccdi_file_upload_record` (
|
||||||
|
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
`project_id` bigint(20) NOT NULL COMMENT '项目ID',
|
||||||
|
`lsfx_project_id` int(11) DEFAULT NULL COMMENT '流水分析平台项目ID',
|
||||||
|
`log_id` int(11) DEFAULT NULL COMMENT '流水分析平台返回的logId',
|
||||||
|
`file_name` varchar(255) NOT NULL COMMENT '文件名称',
|
||||||
|
`file_size` bigint(20) DEFAULT NULL COMMENT '文件大小(字节)',
|
||||||
|
`file_status` varchar(20) NOT NULL COMMENT '文件状态:uploading-上传中,parsing-解析中,parsed_success-解析成功,parsed_failed-解析失败',
|
||||||
|
`enterprise_names` text COMMENT '主体名称(多个用逗号分隔)',
|
||||||
|
`account_nos` text COMMENT '主体账号(多个用逗号分隔)',
|
||||||
|
`error_message` text COMMENT '错误信息(解析失败时记录)',
|
||||||
|
`upload_time` datetime NOT NULL COMMENT '上传时间',
|
||||||
|
`upload_user` varchar(64) NOT NULL COMMENT '上传人',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_project_id` (`project_id`),
|
||||||
|
KEY `idx_log_id` (`log_id`),
|
||||||
|
KEY `idx_file_status` (`file_status`),
|
||||||
|
KEY `idx_upload_time` (`upload_time`),
|
||||||
|
KEY `idx_project_status` (`project_id`, `file_status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目文件上传记录表';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 执行SQL脚本**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysql -h 116.62.17.81 -u root -pKfcx@1234 ccdi < sql/ccdi_file_upload_record.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 验证表创建成功**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysql -h 116.62.17.81 -u root -pKfcx@1234 ccdi -e "SHOW CREATE TABLE ccdi_file_upload_record\G"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 输出表结构,包含所有字段和索引
|
||||||
|
|
||||||
|
**Step 4: 提交SQL脚本**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add sql/ccdi_file_upload_record.sql
|
||||||
|
git commit -m "feat: 添加文件上传记录表SQL脚本"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: 实体类创建
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiFileUploadRecord.java`
|
||||||
|
|
||||||
|
**Step 1: 创建实体类**
|
||||||
|
|
||||||
|
创建文件 `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiFileUploadRecord.java`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.ruoyi.ccdi.project.domain.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传记录实体
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
* @date 2026-03-05
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("ccdi_file_upload_record")
|
||||||
|
public class CcdiFileUploadRecord implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 主键ID */
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 项目ID */
|
||||||
|
private Long projectId;
|
||||||
|
|
||||||
|
/** 流水分析平台项目ID */
|
||||||
|
private Integer lsfxProjectId;
|
||||||
|
|
||||||
|
/** 流水分析平台返回的logId */
|
||||||
|
private Integer logId;
|
||||||
|
|
||||||
|
/** 文件名称 */
|
||||||
|
private String fileName;
|
||||||
|
|
||||||
|
/** 文件大小(字节) */
|
||||||
|
private Long fileSize;
|
||||||
|
|
||||||
|
/** 文件状态:uploading-上传中,parsing-解析中,parsed_success-解析成功,parsed_failed-解析失败 */
|
||||||
|
private String fileStatus;
|
||||||
|
|
||||||
|
/** 主体名称(多个用逗号分隔) */
|
||||||
|
private String enterpriseNames;
|
||||||
|
|
||||||
|
/** 主体账号(多个用逗号分隔) */
|
||||||
|
private String accountNos;
|
||||||
|
|
||||||
|
/** 错误信息(解析失败时记录) */
|
||||||
|
private String errorMessage;
|
||||||
|
|
||||||
|
/** 上传时间 */
|
||||||
|
private Date uploadTime;
|
||||||
|
|
||||||
|
/** 上传人 */
|
||||||
|
private String uploadUser;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 编译验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ccdi-project
|
||||||
|
mvn clean compile
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: BUILD SUCCESS
|
||||||
|
|
||||||
|
**Step 3: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiFileUploadRecord.java
|
||||||
|
git commit -m "feat: 添加文件上传记录实体类"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Mapper 接口和 XML
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiFileUploadRecordMapper.java`
|
||||||
|
- Create: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiFileUploadRecordMapper.xml`
|
||||||
|
|
||||||
|
**Step 1: 创建 Mapper 接口**
|
||||||
|
|
||||||
|
创建文件 `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiFileUploadRecordMapper.java`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.ruoyi.ccdi.project.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传记录 Mapper 接口
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
* @date 2026-03-05
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface CcdiFileUploadRecordMapper extends BaseMapper<CcdiFileUploadRecord> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量插入文件上传记录
|
||||||
|
*
|
||||||
|
* @param records 记录列表
|
||||||
|
* @return 插入条数
|
||||||
|
*/
|
||||||
|
int insertBatch(@Param("list") List<CcdiFileUploadRecord> records);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计各状态文件数量
|
||||||
|
*
|
||||||
|
* @param projectId 项目ID
|
||||||
|
* @return 统计结果(Map形式,key为状态,value为数量)
|
||||||
|
*/
|
||||||
|
List<java.util.Map<String, Object>> countByStatus(@Param("projectId") Long projectId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 创建 Mapper XML**
|
||||||
|
|
||||||
|
创建文件 `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiFileUploadRecordMapper.xml`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper
|
||||||
|
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper">
|
||||||
|
|
||||||
|
<resultMap type="com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord" id="CcdiFileUploadRecordResult">
|
||||||
|
<id property="id" column="id" />
|
||||||
|
<result property="projectId" column="project_id" />
|
||||||
|
<result property="lsfxProjectId" column="lsfx_project_id" />
|
||||||
|
<result property="logId" column="log_id" />
|
||||||
|
<result property="fileName" column="file_name" />
|
||||||
|
<result property="fileSize" column="file_size" />
|
||||||
|
<result property="fileStatus" column="file_status" />
|
||||||
|
<result property="enterpriseNames" column="enterprise_names" />
|
||||||
|
<result property="accountNos" column="account_nos" />
|
||||||
|
<result property="errorMessage" column="error_message" />
|
||||||
|
<result property="uploadTime" column="upload_time" />
|
||||||
|
<result property="uploadUser" column="upload_user" />
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<sql id="selectCcdiFileUploadRecordVo">
|
||||||
|
select id, project_id, lsfx_project_id, log_id, file_name, file_size,
|
||||||
|
file_status, enterprise_names, account_nos, error_message,
|
||||||
|
upload_time, upload_user
|
||||||
|
from ccdi_file_upload_record
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<!-- 批量插入 -->
|
||||||
|
<insert id="insertBatch" parameterType="java.util.List">
|
||||||
|
insert into ccdi_file_upload_record (
|
||||||
|
project_id, lsfx_project_id, file_name, file_size, file_status,
|
||||||
|
upload_time, upload_user
|
||||||
|
) values
|
||||||
|
<foreach collection="list" item="item" separator=",">
|
||||||
|
(
|
||||||
|
#{item.projectId}, #{item.lsfxProjectId}, #{item.fileName},
|
||||||
|
#{item.fileSize}, #{item.fileStatus}, #{item.uploadTime},
|
||||||
|
#{item.uploadUser}
|
||||||
|
)
|
||||||
|
</foreach>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 统计各状态文件数量 -->
|
||||||
|
<select id="countByStatus" resultType="java.util.Map">
|
||||||
|
select file_status as `status`, count(*) as count
|
||||||
|
from ccdi_file_upload_record
|
||||||
|
where project_id = #{projectId}
|
||||||
|
group by file_status
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 编译验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ccdi-project
|
||||||
|
mvn clean compile
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: BUILD SUCCESS
|
||||||
|
|
||||||
|
**Step 4: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiFileUploadRecordMapper.java
|
||||||
|
git add ccdi-project/src/main/resources/mapper/ccdi/project/CcdiFileUploadRecordMapper.xml
|
||||||
|
git commit -m "feat: 添加文件上传记录Mapper接口和XML映射"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: DTO 和 VO 类
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiFileUploadQueryDTO.java`
|
||||||
|
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFileUploadStatisticsVO.java`
|
||||||
|
|
||||||
|
**Step 1: 创建查询 DTO**
|
||||||
|
|
||||||
|
创建文件 `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiFileUploadQueryDTO.java`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.ruoyi.ccdi.project.domain.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传记录查询 DTO
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
* @date 2026-03-05
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class CcdiFileUploadQueryDTO implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 项目ID */
|
||||||
|
private Long projectId;
|
||||||
|
|
||||||
|
/** 文件状态 */
|
||||||
|
private String fileStatus;
|
||||||
|
|
||||||
|
/** 文件名称(模糊查询) */
|
||||||
|
private String fileName;
|
||||||
|
|
||||||
|
/** 上传人 */
|
||||||
|
private String uploadUser;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 创建统计 VO**
|
||||||
|
|
||||||
|
创建文件 `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFileUploadStatisticsVO.java`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.ruoyi.ccdi.project.domain.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传统计 VO
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
* @date 2026-03-05
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class CcdiFileUploadStatisticsVO implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 上传中数量 */
|
||||||
|
private Long uploading;
|
||||||
|
|
||||||
|
/** 解析中数量 */
|
||||||
|
private Long parsing;
|
||||||
|
|
||||||
|
/** 解析成功数量 */
|
||||||
|
private Long parsedSuccess;
|
||||||
|
|
||||||
|
/** 解析失败数量 */
|
||||||
|
private Long parsedFailed;
|
||||||
|
|
||||||
|
/** 总数量 */
|
||||||
|
private Long total;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 编译验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ccdi-project
|
||||||
|
mvn clean compile
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: BUILD SUCCESS
|
||||||
|
|
||||||
|
**Step 4: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiFileUploadQueryDTO.java
|
||||||
|
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFileUploadStatisticsVO.java
|
||||||
|
git commit -m "feat: 添加文件上传查询DTO和统计VO"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: 线程池配置
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/config/AsyncThreadPoolConfig.java`
|
||||||
|
|
||||||
|
**Step 1: 创建线程池配置类**
|
||||||
|
|
||||||
|
创建文件 `ccdi-project/src/main/java/com/ruoyi/ccdi/project/config/AsyncThreadPoolConfig.java`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.ruoyi.ccdi.project.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步线程池配置
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
* @date 2026-03-05
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableAsync
|
||||||
|
public class AsyncThreadPoolConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传专用线程池
|
||||||
|
* 容量:100个线程
|
||||||
|
* 拒绝策略:AbortPolicy(直接拒绝,由调度线程捕获并重试)
|
||||||
|
*/
|
||||||
|
@Bean("fileUploadExecutor")
|
||||||
|
public Executor fileUploadExecutor() {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
// 核心线程数
|
||||||
|
executor.setCorePoolSize(100);
|
||||||
|
// 最大线程数
|
||||||
|
executor.setMaxPoolSize(100);
|
||||||
|
// 队列容量(设为0,不使用队列,直接走拒绝策略)
|
||||||
|
executor.setQueueCapacity(0);
|
||||||
|
// 线程名称前缀
|
||||||
|
executor.setThreadNamePrefix("file-upload-");
|
||||||
|
// 拒绝策略:AbortPolicy,抛出 RejectedExecutionException
|
||||||
|
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
|
||||||
|
// 线程空闲时间(秒)
|
||||||
|
executor.setKeepAliveSeconds(60);
|
||||||
|
// 等待所有任务完成后再关闭
|
||||||
|
executor.setWaitForTasksToCompleteOnShutdown(true);
|
||||||
|
// 最长等待时间
|
||||||
|
executor.setAwaitTerminationSeconds(60);
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 编译验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ccdi-project
|
||||||
|
mvn clean compile
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: BUILD SUCCESS
|
||||||
|
|
||||||
|
**Step 3: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/config/AsyncThreadPoolConfig.java
|
||||||
|
git commit -m "feat: 添加异步线程池配置"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 子计划1完成检查清单
|
||||||
|
|
||||||
|
- [ ] 数据库表创建成功
|
||||||
|
- [ ] 实体类编译通过
|
||||||
|
- [ ] Mapper接口和XML映射正确
|
||||||
|
- [ ] DTO和VO类创建完成
|
||||||
|
- [ ] 线程池配置完成
|
||||||
|
- [ ] 所有代码已提交到git
|
||||||
|
|
||||||
|
**下一步:** 执行子计划2 - Service层核心实现
|
||||||
510
doc/plans/2026-03-05-async-file-upload-part2-service.md
Normal file
510
doc/plans/2026-03-05-async-file-upload-part2-service.md
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
# 项目异步文件上传功能 - 子计划2:Service层核心实现
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** 实现文件上传的核心业务逻辑,包括批量上传、异步处理、状态更新
|
||||||
|
|
||||||
|
**Architecture:** 双层异步架构(调度线程 + 文件处理线程池),先插入记录后异步处理
|
||||||
|
|
||||||
|
**Tech Stack:** Spring @Async, CompletableFuture, MyBatis Plus
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Service 接口
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java`
|
||||||
|
|
||||||
|
**Step 1: 创建 Service 接口**
|
||||||
|
|
||||||
|
创建文件 `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.ruoyi.ccdi.project.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.ruoyi.ccdi.project.domain.dto.CcdiFileUploadQueryDTO;
|
||||||
|
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
|
||||||
|
import com.ruoyi.ccdi.project.domain.vo.CcdiFileUploadStatisticsVO;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传服务接口
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
* @date 2026-03-05
|
||||||
|
*/
|
||||||
|
public interface ICcdiFileUploadService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量上传文件
|
||||||
|
*
|
||||||
|
* @param projectId 项目ID
|
||||||
|
* @param files 文件数组
|
||||||
|
* @param username 上传人
|
||||||
|
* @return 批次ID
|
||||||
|
*/
|
||||||
|
String batchUploadFiles(Long projectId, MultipartFile[] files, String username);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询上传记录列表
|
||||||
|
*
|
||||||
|
* @param page 分页参数
|
||||||
|
* @param queryDTO 查询条件
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
Page<CcdiFileUploadRecord> selectPage(Page<CcdiFileUploadRecord> page,
|
||||||
|
CcdiFileUploadQueryDTO queryDTO);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计各状态文件数量
|
||||||
|
*
|
||||||
|
* @param projectId 项目ID
|
||||||
|
* @return 统计结果
|
||||||
|
*/
|
||||||
|
CcdiFileUploadStatisticsVO countByStatus(Long projectId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询记录详情
|
||||||
|
*
|
||||||
|
* @param id 记录ID
|
||||||
|
* @return 记录详情
|
||||||
|
*/
|
||||||
|
CcdiFileUploadRecord getById(Long id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 编译验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ccdi-project
|
||||||
|
mvn clean compile
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: BUILD SUCCESS
|
||||||
|
|
||||||
|
**Step 3: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java
|
||||||
|
git commit -m "feat: 添加文件上传服务接口"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Service 实现 - Part 1: 基础CRUD方法
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
|
||||||
|
|
||||||
|
**Step 1: 创建 Service 实现类**
|
||||||
|
|
||||||
|
创建文件 `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.ruoyi.ccdi.project.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.ruoyi.ccdi.project.domain.dto.CcdiFileUploadQueryDTO;
|
||||||
|
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
|
||||||
|
import com.ruoyi.ccdi.project.domain.vo.CcdiFileUploadStatisticsVO;
|
||||||
|
import com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper;
|
||||||
|
import com.ruoyi.ccdi.project.service.ICcdiFileUploadService;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传服务实现
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
* @date 2026-03-05
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private CcdiFileUploadRecordMapper recordMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Page<CcdiFileUploadRecord> selectPage(Page<CcdiFileUploadRecord> page,
|
||||||
|
CcdiFileUploadQueryDTO queryDTO) {
|
||||||
|
LambdaQueryWrapper<CcdiFileUploadRecord> queryWrapper = new LambdaQueryWrapper<>();
|
||||||
|
|
||||||
|
// 项目ID
|
||||||
|
if (queryDTO.getProjectId() != null) {
|
||||||
|
queryWrapper.eq(CcdiFileUploadRecord::getProjectId, queryDTO.getProjectId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件状态
|
||||||
|
if (StringUtils.hasText(queryDTO.getFileStatus())) {
|
||||||
|
queryWrapper.eq(CcdiFileUploadRecord::getFileStatus, queryDTO.getFileStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件名称(模糊查询)
|
||||||
|
if (StringUtils.hasText(queryDTO.getFileName())) {
|
||||||
|
queryWrapper.like(CcdiFileUploadRecord::getFileName, queryDTO.getFileName());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传人
|
||||||
|
if (StringUtils.hasText(queryDTO.getUploadUser())) {
|
||||||
|
queryWrapper.eq(CcdiFileUploadRecord::getUploadUser, queryDTO.getUploadUser());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按上传时间倒序
|
||||||
|
queryWrapper.orderByDesc(CcdiFileUploadRecord::getUploadTime);
|
||||||
|
|
||||||
|
return recordMapper.selectPage(page, queryWrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CcdiFileUploadStatisticsVO countByStatus(Long projectId) {
|
||||||
|
// 查询统计数据
|
||||||
|
List<Map<String, Object>> statusCounts = recordMapper.countByStatus(projectId);
|
||||||
|
|
||||||
|
// 组装 VO
|
||||||
|
CcdiFileUploadStatisticsVO vo = new CcdiFileUploadStatisticsVO();
|
||||||
|
vo.setUploading(0L);
|
||||||
|
vo.setParsing(0L);
|
||||||
|
vo.setParsedSuccess(0L);
|
||||||
|
vo.setParsedFailed(0L);
|
||||||
|
|
||||||
|
long total = 0L;
|
||||||
|
for (Map<String, Object> item : statusCounts) {
|
||||||
|
String status = (String) item.get("status");
|
||||||
|
Long count = ((Number) item.get("count")).longValue();
|
||||||
|
total += count;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case "uploading" -> vo.setUploading(count);
|
||||||
|
case "parsing" -> vo.setParsing(count);
|
||||||
|
case "parsed_success" -> vo.setParsedSuccess(count);
|
||||||
|
case "parsed_failed" -> vo.setParsedFailed(count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vo.setTotal(total);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CcdiFileUploadRecord getById(Long id) {
|
||||||
|
return recordMapper.selectById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchUploadFiles 方法将在下一步实现
|
||||||
|
@Override
|
||||||
|
public String batchUploadFiles(Long projectId, MultipartFile[] files, String username) {
|
||||||
|
// TODO: 将在下一步实现
|
||||||
|
throw new UnsupportedOperationException("Method not implemented yet");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 编译验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ccdi-project
|
||||||
|
mvn clean compile
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: BUILD SUCCESS
|
||||||
|
|
||||||
|
**Step 3: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java
|
||||||
|
git commit -m "feat: 添加文件上传服务实现(基础CRUD方法)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Service 实现 - Part 2: 批量上传主方法
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
|
||||||
|
|
||||||
|
**Step 1: 实现批量上传主方法**
|
||||||
|
|
||||||
|
在 `CcdiFileUploadServiceImpl.java` 中添加以下代码(替换原来的 TODO):
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Resource
|
||||||
|
@org.springframework.beans.factory.annotation.Qualifier("fileUploadExecutor")
|
||||||
|
private java.util.concurrent.Executor fileUploadExecutor;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String batchUploadFiles(Long projectId, MultipartFile[] files, String username) {
|
||||||
|
log.info("【文件上传】开始批量上传: projectId={}, 文件数量={}, username={}",
|
||||||
|
projectId, files.length, username);
|
||||||
|
|
||||||
|
// 1. 生成批次ID
|
||||||
|
String batchId = java.util.UUID.randomUUID().toString().replace("-", "");
|
||||||
|
|
||||||
|
// 2. 获取项目的 lsfxProjectId
|
||||||
|
// TODO: 需要注入 CcdiProjectMapper 并查询项目信息
|
||||||
|
// Integer lsfxProjectId = project.getLsfxProjectId();
|
||||||
|
Integer lsfxProjectId = 1; // 临时硬编码,稍后修复
|
||||||
|
|
||||||
|
// 3. 批量插入文件记录(status=uploading)
|
||||||
|
List<CcdiFileUploadRecord> records = new java.util.ArrayList<>();
|
||||||
|
java.util.Date now = new java.util.Date();
|
||||||
|
|
||||||
|
for (MultipartFile file : files) {
|
||||||
|
CcdiFileUploadRecord record = new CcdiFileUploadRecord();
|
||||||
|
record.setProjectId(projectId);
|
||||||
|
record.setLsfxProjectId(lsfxProjectId);
|
||||||
|
record.setFileName(file.getOriginalFilename());
|
||||||
|
record.setFileSize(file.getSize());
|
||||||
|
record.setFileStatus("uploading");
|
||||||
|
record.setUploadTime(now);
|
||||||
|
record.setUploadUser(username);
|
||||||
|
records.add(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordMapper.insertBatch(records);
|
||||||
|
log.info("【文件上传】批量插入记录成功: 数量={}", records.size());
|
||||||
|
|
||||||
|
// 4. 异步启动调度线程提交任务
|
||||||
|
final Integer finalLsfxProjectId = lsfxProjectId;
|
||||||
|
java.util.concurrent.CompletableFuture.runAsync(() -> {
|
||||||
|
submitTasksAsync(projectId, finalLsfxProjectId, files, records, batchId);
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info("【文件上传】批量上传任务已提交: batchId={}", batchId);
|
||||||
|
return batchId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调度线程:循环提交任务到线程池
|
||||||
|
* 支持等待30秒重试机制
|
||||||
|
*/
|
||||||
|
private void submitTasksAsync(Long projectId, Integer lsfxProjectId,
|
||||||
|
MultipartFile[] files,
|
||||||
|
List<CcdiFileUploadRecord> records,
|
||||||
|
String batchId) {
|
||||||
|
log.info("【文件上传】调度线程启动: projectId={}, batchId={}", projectId, batchId);
|
||||||
|
|
||||||
|
// 循环提交任务
|
||||||
|
for (int i = 0; i < files.length; i++) {
|
||||||
|
MultipartFile file = files[i];
|
||||||
|
CcdiFileUploadRecord record = records.get(i);
|
||||||
|
|
||||||
|
boolean submitted = false;
|
||||||
|
int retryCount = 0;
|
||||||
|
|
||||||
|
while (!submitted && retryCount < 2) {
|
||||||
|
try {
|
||||||
|
// 尝试提交异步任务
|
||||||
|
java.util.concurrent.CompletableFuture.runAsync(
|
||||||
|
() -> processFileAsync(projectId, lsfxProjectId, file,
|
||||||
|
record.getId(), batchId, record),
|
||||||
|
fileUploadExecutor
|
||||||
|
);
|
||||||
|
submitted = true;
|
||||||
|
log.info("【文件上传】任务提交成功: fileName={}, recordId={}",
|
||||||
|
file.getOriginalFilename(), record.getId());
|
||||||
|
} catch (java.util.concurrent.RejectedExecutionException e) {
|
||||||
|
retryCount++;
|
||||||
|
if (retryCount == 1) {
|
||||||
|
log.warn("【文件上传】线程池已满,等待30秒后重试: fileName={}",
|
||||||
|
file.getOriginalFilename());
|
||||||
|
try {
|
||||||
|
Thread.sleep(30000);
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
log.error("【文件上传】等待被中断: fileName={}", file.getOriginalFilename());
|
||||||
|
updateRecordStatus(record.getId(), "parsed_failed", "任务提交被中断");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.error("【文件上传】重试失败,放弃任务: fileName={}", file.getOriginalFilename());
|
||||||
|
updateRecordStatus(record.getId(), "parsed_failed", "系统繁忙,请稍后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("【文件上传】调度线程完成: projectId={}, batchId={}", projectId, batchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新记录状态(辅助方法)
|
||||||
|
*/
|
||||||
|
private void updateRecordStatus(Long recordId, String status, String errorMessage) {
|
||||||
|
CcdiFileUploadRecord record = new CcdiFileUploadRecord();
|
||||||
|
record.setId(recordId);
|
||||||
|
record.setFileStatus(status);
|
||||||
|
record.setErrorMessage(errorMessage);
|
||||||
|
recordMapper.updateById(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步处理单个文件的完整流程
|
||||||
|
* TODO: 下一步实现完整逻辑
|
||||||
|
*/
|
||||||
|
private void processFileAsync(Long projectId, Integer lsfxProjectId, MultipartFile file,
|
||||||
|
Long recordId, String batchId, CcdiFileUploadRecord record) {
|
||||||
|
// TODO: 将在下一步实现
|
||||||
|
log.info("【文件上传】开始处理文件: fileName={}", file.getOriginalFilename());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 编译验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ccdi-project
|
||||||
|
mvn clean compile
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: BUILD SUCCESS
|
||||||
|
|
||||||
|
**Step 3: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java
|
||||||
|
git commit -m "feat: 实现批量上传主方法和调度线程"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Service 实现 - Part 3: 异步处理单个文件
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
|
||||||
|
|
||||||
|
**Step 1: 实现异步处理单个文件的完整流程**
|
||||||
|
|
||||||
|
在 `CcdiFileUploadServiceImpl.java` 中,替换 `processFileAsync` 方法:
|
||||||
|
|
||||||
|
```java
|
||||||
|
/**
|
||||||
|
* 异步处理单个文件的完整流程
|
||||||
|
* 包含:上传 → 轮询解析状态 → 获取结果 → 保存流水数据
|
||||||
|
*/
|
||||||
|
@org.springframework.scheduling.annotation.Async("fileUploadExecutor")
|
||||||
|
public void processFileAsync(Long projectId, Integer lsfxProjectId, MultipartFile file,
|
||||||
|
Long recordId, String batchId, CcdiFileUploadRecord record) {
|
||||||
|
log.info("【文件上传】开始处理文件: fileName={}, recordId={}",
|
||||||
|
file.getOriginalFilename(), recordId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 步骤1:状态已是uploading,记录已存在
|
||||||
|
|
||||||
|
// 步骤2:上传文件到流水分析平台
|
||||||
|
log.info("【文件上传】步骤2: 上传文件到流水分析平台");
|
||||||
|
// TODO: 调用 lsfxClient.uploadFile()
|
||||||
|
// UploadFileResponse uploadResponse = lsfxClient.uploadFile(lsfxProjectId, file);
|
||||||
|
// Integer logId = uploadResponse.getData().getLogId();
|
||||||
|
|
||||||
|
// 临时模拟 logId
|
||||||
|
Integer logId = (int) (System.currentTimeMillis() % 1000000);
|
||||||
|
|
||||||
|
// 步骤3:更新状态为 parsing
|
||||||
|
log.info("【文件上传】步骤3: 更新状态为解析中, logId={}", logId);
|
||||||
|
record.setLogId(logId);
|
||||||
|
record.setFileStatus("parsing");
|
||||||
|
recordMapper.updateById(record);
|
||||||
|
|
||||||
|
// 步骤4:轮询解析状态(最多300次,间隔2秒)
|
||||||
|
log.info("【文件上传】步骤4: 开始轮询解析状态");
|
||||||
|
// TODO: 实现真实的轮询逻辑
|
||||||
|
// boolean parsingComplete = waitForParsingComplete(lsfxProjectId, logId.toString());
|
||||||
|
boolean parsingComplete = true; // 临时模拟
|
||||||
|
|
||||||
|
if (!parsingComplete) {
|
||||||
|
throw new RuntimeException("解析超时(超过10分钟),请检查文件格式是否正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤5:获取文件上传状态
|
||||||
|
log.info("【文件上传】步骤5: 获取文件上传状态");
|
||||||
|
// TODO: 调用 lsfxClient.getFileUploadStatus()
|
||||||
|
// GetFileUploadStatusResponse statusResponse = lsfxClient.getFileUploadStatus(...);
|
||||||
|
|
||||||
|
// 步骤6:判断解析结果
|
||||||
|
// TODO: 实现真实的判断逻辑
|
||||||
|
boolean parseSuccess = true; // 临时模拟
|
||||||
|
|
||||||
|
if (parseSuccess) {
|
||||||
|
// 解析成功
|
||||||
|
log.info("【文件上传】步骤6: 解析成功,保存主体信息");
|
||||||
|
record.setFileStatus("parsed_success");
|
||||||
|
record.setEnterpriseNames("测试主体1,测试主体2");
|
||||||
|
record.setAccountNos("622xxx,623xxx");
|
||||||
|
recordMapper.updateById(record);
|
||||||
|
|
||||||
|
// 步骤7:获取流水数据并保存
|
||||||
|
log.info("【文件上传】步骤7: 获取流水数据");
|
||||||
|
// TODO: 实现 fetchAndSaveBankStatements
|
||||||
|
// fetchAndSaveBankStatements(projectId, lsfxProjectId, logId, totalCount);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// 解析失败
|
||||||
|
log.warn("【文件上传】步骤6: 解析失败");
|
||||||
|
record.setFileStatus("parsed_failed");
|
||||||
|
record.setErrorMessage("解析失败:文件格式错误");
|
||||||
|
recordMapper.updateById(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("【文件上传】处理完成: fileName={}", file.getOriginalFilename());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("【文件上传】处理失败: fileName={}", file.getOriginalFilename(), e);
|
||||||
|
updateRecordStatus(recordId, "parsed_failed", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询解析状态
|
||||||
|
* TODO: 实现真实逻辑
|
||||||
|
*/
|
||||||
|
private boolean waitForParsingComplete(Integer groupId, String logId) {
|
||||||
|
// TODO: 调用 lsfxClient.checkParseStatus() 轮询
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取并保存流水数据
|
||||||
|
* TODO: 实现真实逻辑
|
||||||
|
*/
|
||||||
|
private void fetchAndSaveBankStatements(Long projectId, Integer groupId,
|
||||||
|
Integer logId, int totalCount) {
|
||||||
|
// TODO: 调用 lsfxClient.getBankStatement() 获取流水
|
||||||
|
// TODO: 批量插入到 ccdi_bank_statement
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 编译验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ccdi-project
|
||||||
|
mvn clean compile
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: BUILD SUCCESS
|
||||||
|
|
||||||
|
**Step 3: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java
|
||||||
|
git commit -m "feat: 实现异步处理单个文件的完整流程"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 子计划2完成检查清单
|
||||||
|
|
||||||
|
- [ ] Service接口创建完成
|
||||||
|
- [ ] 基础CRUD方法实现并测试通过
|
||||||
|
- [ ] 批量上传主方法实现完成
|
||||||
|
- [ ] 调度线程和重试机制实现
|
||||||
|
- [ ] 异步处理单个文件流程实现
|
||||||
|
- [ ] 所有代码已提交到git
|
||||||
|
|
||||||
|
**下一步:** 执行子计划3 - Controller和API文档
|
||||||
477
doc/plans/2026-03-05-async-file-upload-part3-controller.md
Normal file
477
doc/plans/2026-03-05-async-file-upload-part3-controller.md
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
# 项目异步文件上传功能 - 子计划3:Controller和文档
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** 实现文件上传的 REST API 接口,提供批量上传、查询、统计等功能
|
||||||
|
|
||||||
|
**Architecture:** RESTful API 设计,参数校验,异常处理,Swagger 文档
|
||||||
|
|
||||||
|
**Tech Stack:** Spring MVC, Swagger/OpenAPI 3.0, Jackson
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Controller 实现
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java`
|
||||||
|
|
||||||
|
**Step 1: 创建 Controller**
|
||||||
|
|
||||||
|
创建文件 `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.ruoyi.ccdi.project.controller;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.ruoyi.ccdi.project.domain.dto.CcdiFileUploadQueryDTO;
|
||||||
|
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
|
||||||
|
import com.ruoyi.ccdi.project.domain.vo.CcdiFileUploadStatisticsVO;
|
||||||
|
import com.ruoyi.ccdi.project.service.ICcdiFileUploadService;
|
||||||
|
import com.ruoyi.common.core.controller.BaseController;
|
||||||
|
import com.ruoyi.common.core.domain.AjaxResult;
|
||||||
|
import com.ruoyi.common.core.page.TableDataInfo;
|
||||||
|
import com.ruoyi.common.utils.SecurityUtils;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.util.concurrent.RejectedExecutionException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传 Controller
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
* @date 2026-03-05
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/ccdi/file-upload")
|
||||||
|
@Tag(name = "文件上传管理", description = "项目文件上传相关接口")
|
||||||
|
public class CcdiFileUploadController extends BaseController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ICcdiFileUploadService fileUploadService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量上传文件(异步)
|
||||||
|
*/
|
||||||
|
@PostMapping("/batch")
|
||||||
|
@Operation(summary = "批量上传文件", description = "异步批量上传流水文件")
|
||||||
|
public AjaxResult batchUpload(@RequestParam Long projectId,
|
||||||
|
@RequestParam MultipartFile[] files) {
|
||||||
|
// 参数校验
|
||||||
|
if (projectId == null) {
|
||||||
|
return AjaxResult.error("项目ID不能为空");
|
||||||
|
}
|
||||||
|
if (files == null || files.length == 0) {
|
||||||
|
return AjaxResult.error("请选择要上传的文件");
|
||||||
|
}
|
||||||
|
if (files.length > 100) {
|
||||||
|
return AjaxResult.error("单次最多上传100个文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验文件大小和格式
|
||||||
|
for (MultipartFile file : files) {
|
||||||
|
if (file.isEmpty()) {
|
||||||
|
return AjaxResult.error("文件不能为空");
|
||||||
|
}
|
||||||
|
if (file.getSize() > 50 * 1024 * 1024) {
|
||||||
|
return AjaxResult.error("文件 " + file.getOriginalFilename() + " 超过50MB限制");
|
||||||
|
}
|
||||||
|
String fileName = file.getOriginalFilename();
|
||||||
|
if (!fileName.endsWith(".xlsx") && !fileName.endsWith(".xls")) {
|
||||||
|
return AjaxResult.error("文件 " + fileName + " 格式不支持,仅支持Excel文件");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String username = SecurityUtils.getUsername();
|
||||||
|
String batchId = fileUploadService.batchUploadFiles(projectId, files, username);
|
||||||
|
return AjaxResult.success("上传任务已提交", batchId);
|
||||||
|
} catch (RejectedExecutionException e) {
|
||||||
|
log.warn("线程池已满,拒绝上传请求: projectId={}, fileCount={}", projectId, files.length);
|
||||||
|
return AjaxResult.error("系统繁忙,请稍后再试");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("批量上传失败: projectId={}", projectId, e);
|
||||||
|
return AjaxResult.error("上传失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询上传记录列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
@Operation(summary = "查询上传记录列表", description = "分页查询文件上传记录")
|
||||||
|
public TableDataInfo list(CcdiFileUploadQueryDTO queryDTO) {
|
||||||
|
Page<CcdiFileUploadRecord> page = new Page<>(getPageNum(), getPageSize());
|
||||||
|
Page<CcdiFileUploadRecord> result = fileUploadService.selectPage(page, queryDTO);
|
||||||
|
return getDataTable(result.getRecords(), result.getTotal());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询上传统计
|
||||||
|
*/
|
||||||
|
@GetMapping("/statistics/{projectId}")
|
||||||
|
@Operation(summary = "查询上传统计", description = "统计各状态的文件数量")
|
||||||
|
public AjaxResult getStatistics(@PathVariable Long projectId) {
|
||||||
|
CcdiFileUploadStatisticsVO statistics = fileUploadService.countByStatus(projectId);
|
||||||
|
return AjaxResult.success(statistics);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询记录详情
|
||||||
|
*/
|
||||||
|
@GetMapping("/detail/{id}")
|
||||||
|
@Operation(summary = "查询记录详情", description = "根据ID查询文件上传记录详情")
|
||||||
|
public AjaxResult getDetail(@PathVariable Long id) {
|
||||||
|
CcdiFileUploadRecord record = fileUploadService.getById(id);
|
||||||
|
return AjaxResult.success(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 编译验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ccdi-project
|
||||||
|
mvn clean compile
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: BUILD SUCCESS
|
||||||
|
|
||||||
|
**Step 3: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java
|
||||||
|
git commit -m "feat: 添加文件上传Controller"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: API 文档
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `doc/api-docs/ccdi-file-upload-api.md`
|
||||||
|
|
||||||
|
**Step 1: 创建 API 文档**
|
||||||
|
|
||||||
|
创建文件 `doc/api-docs/ccdi-file-upload-api.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 文件上传 API 文档
|
||||||
|
|
||||||
|
## 1. 批量上传文件
|
||||||
|
|
||||||
|
### 接口地址
|
||||||
|
POST /ccdi/file-upload/batch
|
||||||
|
|
||||||
|
### 请求参数
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| projectId | Long | 是 | 项目ID |
|
||||||
|
| files | File[] | 是 | 文件数组(最多100个,单个最大50MB) |
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8080/ccdi/file-upload/batch" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-F "projectId=1" \
|
||||||
|
-F "files=@/path/to/file1.xlsx" \
|
||||||
|
-F "files=@/path/to/file2.xlsx"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 返回示例
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "上传任务已提交",
|
||||||
|
"data": "a1b2c3d4e5f6g7h8"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 返回字段说明
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| code | Integer | 状态码,200表示成功 |
|
||||||
|
| msg | String | 提示信息 |
|
||||||
|
| data | String | 批次ID,用于追踪上传任务 |
|
||||||
|
|
||||||
|
### 错误码说明
|
||||||
|
| code | msg | 说明 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 500 | 项目ID不能为空 | 缺少必填参数 |
|
||||||
|
| 500 | 请选择要上传的文件 | 文件数组为空 |
|
||||||
|
| 500 | 单次最多上传100个文件 | 文件数量超限 |
|
||||||
|
| 500 | 文件 xxx 超过50MB限制 | 文件大小超限 |
|
||||||
|
| 500 | 文件 xxx 格式不支持,仅支持Excel文件 | 文件格式错误 |
|
||||||
|
| 500 | 系统繁忙,请稍后再试 | 线程池已满 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 查询上传记录列表
|
||||||
|
|
||||||
|
### 接口地址
|
||||||
|
GET /ccdi/file-upload/list
|
||||||
|
|
||||||
|
### 请求参数
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| projectId | Long | 否 | 项目ID |
|
||||||
|
| fileStatus | String | 否 | 文件状态:uploading/parsing/parsed_success/parsed_failed |
|
||||||
|
| fileName | String | 否 | 文件名称(模糊查询) |
|
||||||
|
| uploadUser | String | 否 | 上传人 |
|
||||||
|
| pageNum | Integer | 否 | 页码,默认1 |
|
||||||
|
| pageSize | Integer | 否 | 每页数量,默认10 |
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8080/ccdi/file-upload/list?projectId=1&fileStatus=parsed_success&pageNum=1&pageSize=10" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 返回示例
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "查询成功",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"projectId": 1,
|
||||||
|
"lsfxProjectId": 100,
|
||||||
|
"logId": 123456,
|
||||||
|
"fileName": "流水1.xlsx",
|
||||||
|
"fileSize": 2621440,
|
||||||
|
"fileStatus": "parsed_success",
|
||||||
|
"enterpriseNames": "张三,李四",
|
||||||
|
"accountNos": "622xxx,623xxx",
|
||||||
|
"uploadTime": "2026-03-05 10:30:00",
|
||||||
|
"uploadUser": "admin"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 返回字段说明
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| rows | Array | 记录列表 |
|
||||||
|
| total | Long | 总记录数 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 查询上传统计
|
||||||
|
|
||||||
|
### 接口地址
|
||||||
|
GET /ccdi/file-upload/statistics/{projectId}
|
||||||
|
|
||||||
|
### 路径参数
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| projectId | Long | 是 | 项目ID |
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8080/ccdi/file-upload/statistics/1" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 返回示例
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "查询成功",
|
||||||
|
"data": {
|
||||||
|
"uploading": 2,
|
||||||
|
"parsing": 3,
|
||||||
|
"parsedSuccess": 15,
|
||||||
|
"parsedFailed": 1,
|
||||||
|
"total": 21
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 返回字段说明
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| uploading | Long | 上传中数量 |
|
||||||
|
| parsing | Long | 解析中数量 |
|
||||||
|
| parsedSuccess | Long | 解析成功数量 |
|
||||||
|
| parsedFailed | Long | 解析失败数量 |
|
||||||
|
| total | Long | 总数量 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 查询记录详情
|
||||||
|
|
||||||
|
### 接口地址
|
||||||
|
GET /ccdi/file-upload/detail/{id}
|
||||||
|
|
||||||
|
### 路径参数
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | Long | 是 | 记录ID |
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8080/ccdi/file-upload/detail/1" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 返回示例
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "查询成功",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"projectId": 1,
|
||||||
|
"lsfxProjectId": 100,
|
||||||
|
"logId": 123456,
|
||||||
|
"fileName": "流水1.xlsx",
|
||||||
|
"fileSize": 2621440,
|
||||||
|
"fileStatus": "parsed_success",
|
||||||
|
"enterpriseNames": "张三,李四",
|
||||||
|
"accountNos": "622xxx,623xxx",
|
||||||
|
"errorMessage": null,
|
||||||
|
"uploadTime": "2026-03-05 10:30:00",
|
||||||
|
"uploadUser": "admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 文件状态说明
|
||||||
|
|
||||||
|
| 状态 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| uploading | 文件上传中 |
|
||||||
|
| parsing | 文件解析中 |
|
||||||
|
| parsed_success | 文件解析成功 |
|
||||||
|
| parsed_failed | 文件解析失败 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 通用说明
|
||||||
|
|
||||||
|
### 认证方式
|
||||||
|
所有接口需要在请求头中携带 Token:
|
||||||
|
```
|
||||||
|
Authorization: Bearer YOUR_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取 Token
|
||||||
|
```bash
|
||||||
|
POST /login/test?username=admin&password=admin123
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应格式
|
||||||
|
所有接口统一返回格式:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "操作成功",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误处理
|
||||||
|
当发生错误时,返回格式:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 500,
|
||||||
|
"msg": "错误信息"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 提交文档**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add doc/api-docs/ccdi-file-upload-api.md
|
||||||
|
git commit -m "docs: 添加文件上传API文档"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: 最终提交和推送
|
||||||
|
|
||||||
|
**Step 1: 查看所有修改**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
git log --oneline -10
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 推送到远程仓库**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 推送成功
|
||||||
|
|
||||||
|
**Step 3: 验证 Swagger 文档**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动应用后访问
|
||||||
|
# http://localhost:8080/swagger-ui/index.html
|
||||||
|
# 查找 "文件上传管理" 分组
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 子计划3完成检查清单
|
||||||
|
|
||||||
|
- [ ] Controller实现完成
|
||||||
|
- [ ] 参数校验正确
|
||||||
|
- [ ] 异常处理完善
|
||||||
|
- [ ] API文档创建完成
|
||||||
|
- [ ] Swagger注解正确
|
||||||
|
- [ ] 所有代码已提交并推送到远程仓库
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能总结
|
||||||
|
|
||||||
|
**已完成的完整功能:**
|
||||||
|
- ✅ 数据库表创建和索引
|
||||||
|
- ✅ 实体类、DTO、VO 创建
|
||||||
|
- ✅ Mapper 接口和 XML 映射(支持批量插入和统计)
|
||||||
|
- ✅ 线程池配置(容量100,AbortPolicy拒绝策略)
|
||||||
|
- ✅ Service 接口和实现(核心异步处理逻辑)
|
||||||
|
- ✅ Controller 接口(批量上传、查询、统计、详情)
|
||||||
|
- ✅ API 文档
|
||||||
|
|
||||||
|
**核心特性:**
|
||||||
|
- ✅ 双层异步架构(调度线程 + 文件处理线程池)
|
||||||
|
- ✅ 智能重试机制(线程池满时等待30秒重试1次)
|
||||||
|
- ✅ 完整的状态追踪(4种状态)
|
||||||
|
- ✅ 批量插入优化(使用自定义XML)
|
||||||
|
- ✅ 完善的参数校验和异常处理
|
||||||
|
- ✅ Swagger API 文档
|
||||||
|
|
||||||
|
**后续优化方向:**
|
||||||
|
- ⏳ 完善流水分析平台接口调用(当前为模拟逻辑)
|
||||||
|
- ⏳ 实现自定义日志 Appender(独立批次日志文件)
|
||||||
|
- ⏳ 前端页面开发
|
||||||
|
- ⏳ 更完善的轮询和重试机制
|
||||||
|
- ⏳ 性能监控和告警
|
||||||
|
|
||||||
|
**部署检查清单:**
|
||||||
|
- [ ] 数据库表已创建
|
||||||
|
- [ ] 线程池配置正确(容量100)
|
||||||
|
- [ ] 文件上传大小限制配置(50MB)
|
||||||
|
- [ ] 流水分析平台地址配置正确
|
||||||
|
- [ ] 日志目录权限正确
|
||||||
|
- [ ] 应用启动成功
|
||||||
|
- [ ] Swagger 文档可访问
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**所有子计划执行完成!**
|
||||||
355
doc/plans/2026-03-05-async-file-upload-part4-frontend.md
Normal file
355
doc/plans/2026-03-05-async-file-upload-part4-frontend.md
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
# 异步文件上传功能实施计划 - Part 4: 前端开发
|
||||||
|
|
||||||
|
## 文档信息
|
||||||
|
- **创建日期**: 2026-03-05
|
||||||
|
- **版本**: v1.1
|
||||||
|
- **作者**: Claude
|
||||||
|
- **关联设计**: [前端设计文档](../design/2026-03-05-async-file-upload-frontend-design.md)
|
||||||
|
- **变更说明**: 移除WebSocket,改为页面轮询机制
|
||||||
|
|
||||||
|
## 任务概述
|
||||||
|
|
||||||
|
根据前端设计文档,扩展UploadData.vue组件实现异步批量上传功能。
|
||||||
|
|
||||||
|
**预计工时**: 4.5个工作日
|
||||||
|
|
||||||
|
## 任务清单
|
||||||
|
|
||||||
|
### 任务 1: API接口封装(0.5天)
|
||||||
|
|
||||||
|
**文件**: `ruoyi-ui/src/api/ccdiProjectUpload.js`
|
||||||
|
|
||||||
|
**工作内容**:
|
||||||
|
```javascript
|
||||||
|
// 批量上传文件
|
||||||
|
export function batchUploadFiles(projectId, files) {
|
||||||
|
const formData = new FormData()
|
||||||
|
files.forEach(file => formData.append('files', file))
|
||||||
|
formData.append('projectId', projectId)
|
||||||
|
|
||||||
|
return request({
|
||||||
|
url: '/ccdi/file-upload/batch',
|
||||||
|
method: 'post',
|
||||||
|
data: formData,
|
||||||
|
timeout: 300000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询文件上传记录列表
|
||||||
|
export function getFileUploadList(params) {
|
||||||
|
return request({
|
||||||
|
url: '/ccdi/file-upload/list',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询文件上传统计
|
||||||
|
export function getFileUploadStatistics(projectId) {
|
||||||
|
return request({
|
||||||
|
url: `/ccdi/file-upload/statistics/${projectId}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 任务 2: 批量上传弹窗(1天)
|
||||||
|
|
||||||
|
**文件**: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
|
||||||
|
|
||||||
|
**主要修改**:
|
||||||
|
1. 添加批量上传弹窗状态
|
||||||
|
2. 修改`handleUploadClick`方法
|
||||||
|
3. 实现文件选择和校验逻辑
|
||||||
|
4. 实现批量上传功能
|
||||||
|
|
||||||
|
**关键代码**:
|
||||||
|
```javascript
|
||||||
|
// 批量上传
|
||||||
|
async handleBatchUpload() {
|
||||||
|
if (this.selectedFiles.length === 0) {
|
||||||
|
this.$message.warning('请选择要上传的文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.uploadLoading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await batchUploadFiles(
|
||||||
|
this.projectId,
|
||||||
|
this.selectedFiles.map(f => f.raw)
|
||||||
|
)
|
||||||
|
|
||||||
|
this.uploadLoading = false
|
||||||
|
this.batchUploadDialogVisible = false
|
||||||
|
|
||||||
|
this.$message.success('上传任务已提交,请查看处理进度')
|
||||||
|
|
||||||
|
// 刷新数据并启动轮询
|
||||||
|
await Promise.all([
|
||||||
|
this.loadStatistics(),
|
||||||
|
this.loadFileList()
|
||||||
|
])
|
||||||
|
|
||||||
|
this.startPolling()
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.uploadLoading = false
|
||||||
|
this.$message.error('上传失败:' + (error.msg || '未知错误'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 任务 3: 统计卡片(0.5天)
|
||||||
|
|
||||||
|
**工作内容**:
|
||||||
|
1. 添加统计数据状态
|
||||||
|
2. 实现统计卡片组件
|
||||||
|
3. 实现点击筛选功能
|
||||||
|
|
||||||
|
**模板代码**:
|
||||||
|
```vue
|
||||||
|
<div class="statistics-section">
|
||||||
|
<div class="stat-card" @click="handleStatusFilter('uploading')">
|
||||||
|
<div class="stat-icon uploading">
|
||||||
|
<i class="el-icon-upload"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-label">上传中</div>
|
||||||
|
<div class="stat-value">{{ statistics.uploading }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 其他3个统计卡片 -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 任务 4: 文件列表(1天)
|
||||||
|
|
||||||
|
**工作内容**:
|
||||||
|
1. 添加文件列表状态
|
||||||
|
2. 实现文件列表组件
|
||||||
|
3. 实现分页和筛选
|
||||||
|
4. 实现操作按钮
|
||||||
|
|
||||||
|
**关键方法**:
|
||||||
|
```javascript
|
||||||
|
// 加载文件列表
|
||||||
|
async loadFileList() {
|
||||||
|
this.listLoading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getFileUploadList({
|
||||||
|
projectId: this.projectId,
|
||||||
|
fileStatus: this.queryParams.fileStatus,
|
||||||
|
pageNum: this.queryParams.pageNum,
|
||||||
|
pageSize: this.queryParams.pageSize
|
||||||
|
})
|
||||||
|
|
||||||
|
this.fileList = res.rows || []
|
||||||
|
this.total = res.total || 0
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
this.listLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 任务 5: 轮询机制(0.5天)
|
||||||
|
|
||||||
|
**优先级**: P0
|
||||||
|
**依赖**: 任务2、任务3、任务4完成
|
||||||
|
|
||||||
|
**工作内容**:
|
||||||
|
|
||||||
|
1. **添加轮询状态**:
|
||||||
|
```javascript
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
// 轮询相关
|
||||||
|
pollingTimer: null,
|
||||||
|
pollingEnabled: false,
|
||||||
|
pollingInterval: 5000 // 5秒轮询间隔
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **生命周期钩子**:
|
||||||
|
```javascript
|
||||||
|
mounted() {
|
||||||
|
this.loadStatistics()
|
||||||
|
this.loadFileList()
|
||||||
|
|
||||||
|
// 检查是否需要启动轮询
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.statistics.uploading > 0 || this.statistics.parsing > 0) {
|
||||||
|
this.startPolling()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeDestroy() {
|
||||||
|
this.stopPolling()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **轮询方法**:
|
||||||
|
```javascript
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* 启动轮询
|
||||||
|
*/
|
||||||
|
startPolling() {
|
||||||
|
if (this.pollingEnabled) {
|
||||||
|
return // 已经在轮询中
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pollingEnabled = true
|
||||||
|
console.log('启动轮询')
|
||||||
|
|
||||||
|
const poll = () => {
|
||||||
|
if (!this.pollingEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新统计数据和列表
|
||||||
|
Promise.all([
|
||||||
|
this.loadStatistics(),
|
||||||
|
this.loadFileList()
|
||||||
|
]).then(() => {
|
||||||
|
// 检查是否需要继续轮询
|
||||||
|
if (this.statistics.uploading === 0 &&
|
||||||
|
this.statistics.parsing === 0) {
|
||||||
|
this.stopPolling()
|
||||||
|
console.log('所有任务已完成,停止轮询')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 继续下一次轮询
|
||||||
|
this.pollingTimer = setTimeout(poll, this.pollingInterval)
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('轮询失败:', error)
|
||||||
|
// 发生错误时继续轮询
|
||||||
|
this.pollingTimer = setTimeout(poll, this.pollingInterval)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即执行一次
|
||||||
|
poll()
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止轮询
|
||||||
|
*/
|
||||||
|
stopPolling() {
|
||||||
|
this.pollingEnabled = false
|
||||||
|
|
||||||
|
if (this.pollingTimer) {
|
||||||
|
clearTimeout(this.pollingTimer)
|
||||||
|
this.pollingTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('停止轮询')
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动刷新
|
||||||
|
*/
|
||||||
|
async handleManualRefresh() {
|
||||||
|
await Promise.all([
|
||||||
|
this.loadStatistics(),
|
||||||
|
this.loadFileList()
|
||||||
|
])
|
||||||
|
|
||||||
|
this.$message.success('刷新成功')
|
||||||
|
|
||||||
|
// 如果有进行中的任务,启动轮询
|
||||||
|
if (this.statistics.uploading > 0 || this.statistics.parsing > 0) {
|
||||||
|
this.startPolling()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态筛选
|
||||||
|
*/
|
||||||
|
handleStatusFilter(status) {
|
||||||
|
this.queryParams.fileStatus = status
|
||||||
|
this.queryParams.pageNum = 1
|
||||||
|
this.loadFileList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **在模板中添加刷新按钮**:
|
||||||
|
```vue
|
||||||
|
<el-button
|
||||||
|
icon="el-icon-refresh"
|
||||||
|
@click="handleManualRefresh"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2 验证方式
|
||||||
|
|
||||||
|
1. **启动轮询测试**:
|
||||||
|
- 上传文件后,检查控制台输出"启动轮询"
|
||||||
|
- 观察5秒后数据是否自动刷新
|
||||||
|
|
||||||
|
2. **停止轮询测试**:
|
||||||
|
- 等待所有文件处理完成
|
||||||
|
- 检查控制台输出"停止轮询"
|
||||||
|
|
||||||
|
3. **手动刷新测试**:
|
||||||
|
- 点击刷新按钮
|
||||||
|
- 验证数据立即更新
|
||||||
|
- 验证提示消息显示
|
||||||
|
|
||||||
|
4. **页面销毁测试**:
|
||||||
|
- 切换到其他页面
|
||||||
|
- 检查控制台输出"停止轮询"
|
||||||
|
- 确认定时器被清除
|
||||||
|
|
||||||
|
### 任务 6: 联调测试(1天)
|
||||||
|
|
||||||
|
**测试项**:
|
||||||
|
1. 批量上传功能
|
||||||
|
2. 统计卡片展示和筛选
|
||||||
|
3. 文件列表展示和分页
|
||||||
|
4. 轮询机制(启动、停止、手动刷新)
|
||||||
|
5. 操作按钮(查看流水、查看错误)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- [ ] 所有API接口正常调用
|
||||||
|
- [ ] 批量上传弹窗正常工作
|
||||||
|
- [ ] 统计卡片正常显示和筛选
|
||||||
|
- [ ] 文件列表正常展示和操作
|
||||||
|
- [ ] 轮询机制正常(自动启动/停止/手动刷新)
|
||||||
|
- [ ] 所有测试项通过
|
||||||
|
|
||||||
|
## 轮询优化建议(可选)
|
||||||
|
|
||||||
|
**智能轮询间隔**:
|
||||||
|
```javascript
|
||||||
|
// 根据活跃任务数动态调整轮询间隔
|
||||||
|
getPollingInterval() {
|
||||||
|
const { uploading, parsing } = this.statistics
|
||||||
|
const activeCount = uploading + parsing
|
||||||
|
|
||||||
|
if (activeCount > 50) {
|
||||||
|
return 3000 // 大量任务时,3秒轮询
|
||||||
|
} else if (activeCount > 10) {
|
||||||
|
return 5000 // 正常情况,5秒轮询
|
||||||
|
} else {
|
||||||
|
return 10000 // 少量任务时,10秒轮询
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**用户体验优化**:
|
||||||
|
- 在页面顶部显示"自动刷新中..."状态提示
|
||||||
|
- 支持用户手动开关轮询开关
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档结束**
|
||||||
28
sql/ccdi_file_upload_record.sql
Normal file
28
sql/ccdi_file_upload_record.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- 项目文件上传记录表
|
||||||
|
-- 用途:记录项目下所有文件的上传记录和处理状态
|
||||||
|
-- 作者:系统
|
||||||
|
-- 日期:2026-03-05
|
||||||
|
|
||||||
|
USE ccdi;
|
||||||
|
|
||||||
|
-- 创建文件上传记录表
|
||||||
|
CREATE TABLE `ccdi_file_upload_record` (
|
||||||
|
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
`project_id` bigint(20) NOT NULL COMMENT '项目ID',
|
||||||
|
`lsfx_project_id` int(11) DEFAULT NULL COMMENT '流水分析平台项目ID',
|
||||||
|
`log_id` int(11) DEFAULT NULL COMMENT '流水分析平台返回的logId',
|
||||||
|
`file_name` varchar(255) NOT NULL COMMENT '文件名称',
|
||||||
|
`file_size` bigint(20) DEFAULT NULL COMMENT '文件大小(字节)',
|
||||||
|
`file_status` varchar(20) NOT NULL COMMENT '文件状态:uploading-上传中,parsing-解析中,parsed_success-解析成功,parsed_failed-解析失败',
|
||||||
|
`enterprise_names` text COMMENT '主体名称(多个用逗号分隔)',
|
||||||
|
`account_nos` text COMMENT '主体账号(多个用逗号分隔)',
|
||||||
|
`error_message` text COMMENT '错误信息(解析失败时记录)',
|
||||||
|
`upload_time` datetime NOT NULL COMMENT '上传时间',
|
||||||
|
`upload_user` varchar(64) NOT NULL COMMENT '上传人',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_project_id` (`project_id`),
|
||||||
|
KEY `idx_log_id` (`log_id`),
|
||||||
|
KEY `idx_file_status` (`file_status`),
|
||||||
|
KEY `idx_upload_time` (`upload_time`),
|
||||||
|
KEY `idx_project_status` (`project_id`, `file_status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目文件上传记录表';
|
||||||
Reference in New Issue
Block a user