# 文件导入导出规范

> 实现文件导入/导出、对接文件中心时主动加载本规范。

<!-- GENERALIZE:file-import-export — 本骨架正文含 pj-incentive 专有字面量，step-03 生成时必须替换为 {target-root} 真实值或占位符：锁前缀 `pj-incentive:export:` → 本服务前缀；包名 `com.yljr...` → 本服务基础包；前端接口文件名（占位 `{feModule}.js`，本服务无前端则删除前端小节、不留占位）→ 本服务前端文件；专有 Application/表名 → 本服务真实类/表。生成后自检不得残留上述字面量。 -->

## 核心架构原则

**所有文件交互必须通过文件中心（zqyl-file）中转，禁止前端直接将文件发送给后端服务。**

```
前端 ──上传──→ 文件中心 ──返回 fileNo──→ 前端 ──传 fileNo──→ 后端
后端 ──通过 fileNo 下载──→ 文件中心 ──返回文件流──→ 后端解析处理
后端 ──生成新文件上传──→ 文件中心 ──返回新 fileNo──→ 后端拼接下载URL返回前端
```

---

## 文件中心服务

### 服务信息
- 服务名：`zqyl-file`，上下文路径 `/zqyl-file/file`
- 核心概念：每个文件由 `fileNo`（文件编号）唯一标识，数据库存 fileNo 而非文件路径
- 前端上传地址：`/zqyl-manager-api/common/file/uploadFile`（网关代理到文件中心）

### 文件中心 API

| 操作 | 接口 | 说明 |
|------|------|------|
| 上传文件 | `POST /zqyl-file/file/uploadFile` (multipart/form-data) | 返回 `{ "status": "M0200", "data": { "fileNo": "xxx" } }` |
| 下载文件 | `GET /zqyl-file/file/download?fileNo=xxx` | 返回文件流 |
| 创建预留任务 | `POST /zqyl-file/file/createManageReserveTask` | 异步导出专用，返回 requestNo |
| 预留上传 | `POST /zqyl-file/file/reserveUploadFile` | 关联预留任务上传文件 |

### 文件 URL 构造

**配置文件**（`application-base.properties`，生产环境通过 Nacos 覆盖）：
```properties
file.manager.preview.api.url=http://manager-test4.cnyunlian.com:8081/zqyl-manager-api/file/previewFile?fileNo=
file.manager.download.api.url=http://manager-test4.cnyunlian.com:8081/zqyl-manager-api/file/downLoadFile?fileNo=
```

**后端拼接**：
```java
// 通过 FileUrlHelper 静态方法生成完整 URL
String previewUrl = FileUrlHelper.getPreviewUrl(fileNo);   // 前缀 + fileNo
String downloadUrl = FileUrlHelper.getDownloadUrl(fileNo);  // 前缀 + fileNo
```

**前端使用**：后端返回完整 URL，前端直接 `window.open(url)` 打开。

---

## 后端基础设施（已有，直接复用）

| 组件 | 位置 | 职责 |
|------|------|------|
| `FileDownLoadFeign` | `application/feign/FileDownLoadFeign.java` | Feign 客户端：download(fileNo)、createManageReserveTask、reserveUploadFile |
| `FileGateway` | `application/gateway/FileGateway.java` | 防腐层：downloadFile(fileNo) → InputStream |
| `FileUrlConfig` | `infrastructure/config/FileUrlConfig.java` | 配置类：读取 URL 前缀，提供 getPreviewUrl/getDownloadUrl |
| `FileUrlHelper` | `infrastructure/util/FileUrlHelper.java` | 静态工具：FileUrlHelper.getDownloadUrl(fileNo) |
| `FileUploadResultDTO` | `application/feign/dto/FileUploadResultDTO.java` | 上传结果：fileNo, filePath |
| `CreateReserveTaskDTO` | `application/feign/dto/CreateReserveTaskDTO.java` | 预留任务：taskName, taskType, userId |

---

## 后端导入规范

### 完整流程
```
前端上传文件至文件中心 → 获得 fileNo
→ 前端调用后端业务接口传入 fileNo（JSON @RequestBody）
→ 后端通过 FileGateway.downloadFile(fileNo) 从文件中心下载文件流
→ EasyExcel.read() 解析 → 校验记录数上限(300/1000)
→ 逐行 Validator 校验
→ 全部通过则入库 / 有失败则生成错误 Excel 上传文件中心
→ 用 FileUrlHelper.getDownloadUrl(errorFileNo) 拼接错误文件下载 URL
→ 写入导入记录表 → 返回结果（含 errorFileUrl）
```

**禁止使用 `@RequestParam("file") MultipartFile` 直接接收文件。**

### Controller 模式
```java
@PostMapping("/importData")
public ResultData importData(@RequestBody XxxImportDTO dto) {
    if (dto.getFileNo() == null || dto.getFileNo().trim().isEmpty()) {
        return ResultData.failed("文件编号不能为空，请先上传文件");
    }
    return xxxImportApplication.importData(dto);
}
```

### DTO 模式
```java
public class XxxImportDTO extends BaseRequestDTO {
    private String fileNo;      // 文件编号（文件中心返回，必填）
    private String fileName;    // 文件名称
    // 其他业务字段...
    // 手写 getter/setter，禁止 Lombok
}
```

### Application Service 模式
```java
@Service
public class XxxImportApplication {
    @Resource
    private FileGateway fileGateway;

    public ResultData importData(XxxImportDTO dto) {
        // 1. 从文件中心下载文件
        InputStream fileStream = fileGateway.downloadFile(dto.getFileNo());
        // 2. EasyExcel 解析
        // 3. 逐行校验
        // 4. 入库或生成错误文件
        // 5. 错误文件返回下载 URL
    }
}
```

### Excel 数据模型
```java
public class XxxImportExcelModel {
    @ExcelProperty(index = 0)  // 按列索引映射，非列名
    private String field1;
    // 所有字段为 String 类型，由 Validator 负责类型转换
    // 禁止 Lombok，手写 getter/setter
}
```

### 校验器模式
```java
@Component
public class XxxImportValidator {
    public List<String> validate(XxxImportExcelModel model) {
        List<String> errors = new ArrayList<>();
        if (StringUtils.isBlank(model.getField())) {
            errors.add("字段不能为空");
        }
        return errors;
    }
}
```

### 错误文件生成、上传、返回下载 URL
```java
// 1. 生成错误 Excel 字节数组
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
EasyExcel.write(outputStream).head(headers).sheet("校验错误").doWrite(errorData);
byte[] bytes = outputStream.toByteArray();

// 2. 创建文件中心预留任务
CreateReserveTaskDTO taskDTO = new CreateReserveTaskDTO();
taskDTO.setTaskName(fileName);
taskDTO.setTaskType("EXCEL");
ResponseInfo<Object> taskResp = fileDownLoadFeign.createManageReserveTask(taskDTO);
String requestNo = String.valueOf(taskResp.getData());

// 3. 包装为 MultipartFile 上传
AsyncMultipartFile mockFile = new AsyncMultipartFile(fileName, contentType, bytes);
ResponseInfo<FileUploadResultDTO> uploadResp = fileDownLoadFeign.reserveUploadFile(requestNo, mockFile);
String errorFileNo = uploadResp.getData().getFileNo();

// 4. 拼接下载 URL 返回前端
String errorFileUrl = FileUrlHelper.getDownloadUrl(errorFileNo);
result.put("errorFileNo", errorFileNo);
result.put("errorFileUrl", errorFileUrl);
```

### AsyncMultipartFile 工具类
将内存 byte[] 包装为 MultipartFile，避免写临时文件：
```java
public class AsyncMultipartFile implements MultipartFile {
    private final String name;
    private final String contentType;
    private final byte[] content;
    // 实现 getName/getOriginalFilename/getContentType/isEmpty/getSize/getBytes/getInputStream/transferTo
}
```

---

## 后端导出规范

### 两种模式

| 模式 | 适用场景 | 实现方式 |
|------|---------|---------|
| 同步直接下载 | 模板下载、小数据(<5MB) | 写入 HttpServletResponse 流 |
| 异步文件中心 | 大数据量 | Redis锁 + 线程池 + 上传文件中心 → 下载中心取 |

### 同步下载（模板/小数据）
```java
@GetMapping("/downloadTemplate")
public void downloadTemplate(HttpServletResponse response) throws IOException {
    response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
    response.setCharacterEncoding("utf-8");
    String fileName = URLEncoder.encode("模板名", "UTF-8").replaceAll("\\+", "%20");
    response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
    xxxService.generateTemplate(response.getOutputStream());
}
```

### 异步导出（大数据）
```java
public ResultData asyncExport(ExportQueryDTO query) {
    // 1. Redis 分布式锁防重复
    String lockKey = "pj-incentive:export:" + query.getExportType() + ":" + query.getUserId();
    // 2. 文件中心创建预留任务 → requestNo
    // 3. 线程池异步执行：查询数据 → EasyExcel.write() → 上传文件中心
    // 4. 返回"请到下载中心查看"
}
```

### 导出线程池
- 使用 `ThreadPoolExecutor`，禁止 `Executors`
- 线程命名格式：`export-pool-%d`
- 拒绝策略：`CallerRunsPolicy`

---

## 前端导入规范

### 完整流程
```
点击"导入" → 弹窗(yl-dialog) → 下载模板(可选)
→ 选择文件(DefaultUpload，自动上传至文件中心) → @setUploadFile 获得 fileNo
→ 点击"导入" → $axiosJson.post 将 fileNo + 业务参数以 JSON 发送至后端
→ 成功则刷新 / 失败且有 errorFileUrl 则弹窗提供下载
```

**禁止 `autoUpload: false` + `httpRequestCustom` + `$axiosFormataData.post(url, formData)` 旧模式。**

### 组件模式
```vue
<yl-dialog :visible.sync="dialogVisible"
    :attributes="{ width: '520px', title: '数据导入' }">
    <DefaultUpload ref="upload" text="选择文件"
        textTip="仅支持 .xlsx/.xls 格式" authType="excel"
        :limit="1" :showFileList="true"
        @setUploadFile="handleUploadSuccess"
        @removeUploadFile="handleRemoveFile">
    </DefaultUpload>
</yl-dialog>
```
- 使用 `yl-dialog`（禁止 el-dialog）
- 使用 `DefaultUpload`（禁止 el-upload）
- DefaultUpload 默认上传至 `/zqyl-manager-api/common/file/uploadFile`（文件中心）
- 通过 `@setUploadFile` 事件获取 `data.fileNo`

### 上传回调与导入提交
```javascript
handleUploadSuccess(data) {
    if (data && data.fileNo) {
        this.fileNo = data.fileNo;
        this.fileName = data.showName || '';
    }
},
handleRemoveFile() {
    this.fileNo = '';
    this.fileName = '';
},
handleImport() {
    if (!this.fileNo) {
        this.$message.warning('请先上传导入文件');
        return;
    }
    var requestData = {
        fileNo: this.fileNo,
        fileName: this.fileName
        // ... 其他业务参数
    };
    this.$axiosJson.post(INTERFACE.importEndpoint, requestData).then(function(res) {
        if (res.data.status == STATUSCODE.code01) {
            // 导入成功
        } else {
            // 导入失败，检查是否有错误文件下载 URL
            var data = res.data.data;
            if (data && data.errorFileUrl) {
                // 弹窗确认下载
                window.open(data.errorFileUrl, '_blank');
            }
        }
    });
}
```

### DefaultUpload setUploadFile 事件返回的数据结构
```javascript
{
    fileNo: "xxx",          // 文件编号（核心标识）
    showName: "原始文件名",  // 原始文件名
    previewUrl: "...",      // 预览地址
    downLoadUrl: "...",     // 下载地址
    fileMd5: "...",         // MD5
    fileSize: 12345,        // 文件大小
    suffix: "xlsx"          // 文件后缀
}
```

---

## 前端导出规范

### 同步导出
```javascript
window.open(INTERFACE.exportEndpoint + '?' + params);
```

### 异步导出
```javascript
var res = await this.$axiosJson.post(INTERFACE.asyncExport, params);
if (res.data.status == STATUSCODE.code01) {
    this.$message.success('导出任务已提交，请到下载中心查看');
}
```

---

## 前端文件预览与下载

当后端返回 fileNo 时，前端需要构造完整 URL 进行预览或下载：
- **方式一**（推荐）：后端返回完整 URL（通过 FileUrlHelper 拼接），前端直接 `window.open(url)`
- **方式二**：前端使用 DefaultUpload 上传回调中返回的 `previewUrl` / `downLoadUrl` 字段

```javascript
// 下载
window.open(data.errorFileUrl, '_blank');
// 预览（如后端返回了 previewUrl）
window.open(data.previewUrl, '_blank');
```

---

## 文件修改覆盖上传

当需要对已上传的文件进行修改（如标红错误单元格）后重新上传：

```
后端通过 fileNo 下载原文件 → 修改内容（如标红错误单元格）
→ 生成新 Excel 字节数组 → 上传到文件中心获得新 fileNo
→ 用 FileUrlHelper.getDownloadUrl(新fileNo) 拼接下载 URL → 返回前端
```

**注意：不是覆盖原文件，而是生成新文件上传获得新 fileNo。**

---

## 项目已有基础设施

<!-- SCAN:file-infra — step-03 按 extraction-recipes.md#file-import-export 扫描文件基础设施类填充下表（FileGateway/FileUrlHelper/FileUrlConfig/FileDownLoadFeign + 本服务 ImportApplication 清单 + EasyExcel 版本）；严禁照抄 pj-incentive 的 LedgerAdjust/Finance/Endorse 等专有类。 -->
| 组件 | 位置 | 职责 |
|------|------|------|
| （扫码填充真实文件基础设施） | | |

---

## 开发 Checklist

### 新增导入（fileNo 模式）
- [ ] ImportDTO（继承 BaseRequestDTO，含 fileNo/fileName + 业务字段）
- [ ] ExcelModel（@ExcelProperty(index=N) + 全 String）
- [ ] Validator（必填/枚举/格式/条件校验）
- [ ] ImportApplication（FileGateway.downloadFile → 解析 → 校验 → 入库 → 错误文件 + FileUrlHelper）
- [ ] Controller（@PostMapping + @RequestBody ImportDTO）
- [ ] 前端导入弹窗（yl-dialog + DefaultUpload + @setUploadFile）
- [ ] 前端提交用 $axiosJson.post 发送 JSON（含 fileNo）
- [ ] 前端错误处理（errorFileUrl → window.open 下载）
- [ ] 接口文件添加 API 地址
- [ ] 模板下载接口

### 新增导出
- [ ] 判断同步/异步模式
- [ ] 同步：写入 HttpServletResponse
- [ ] 异步：Redis锁 + 线程池 + FileDownLoadFeign
- [ ] 前端触发 + 下载中心

## 关键注意事项
1. fileNo 是核心标识，数据库存 fileNo 不存路径
2. 前端上传到文件中心，后端通过 fileNo 下载，禁止前端直传文件给后端
3. 使用 `@ExcelProperty(index=N)` 而非列名映射
4. 导入上限必须设置（300-1000条），防 OOM
5. 异步导出必须 Redis 分布式锁防重复点击
6. 错误文件上传文件中心后，用 FileUrlHelper.getDownloadUrl(errorFileNo) 拼接完整 URL 返回前端
7. 文件修改不是覆盖原文件，而是生成新文件上传获得新 fileNo
8. 禁止 Lombok，手写 getter/setter
