# 项目异步文件上传功能 - 设计文档

## 文档信息
- **创建日期**: 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/批次

---

**文档结束**
