# 异步导出到下载中心规范

> 实现按搜索条件异步导出到下载中心时主动加载本规范。

<!-- GENERALIZE:async-export — 本骨架正文（代码块/常量/包名）含 pj-incentive 专有字面量，step-03 生成时必须全部替换为 {target-root} 真实值或占位符：
  · 锁前缀 `pj-incentive:export:xxx:` → 本服务前缀（如 `{服务名}:export:`）
  · `MODULE_NAME = "票据对接费用"` → 本服务模块中文名
  · `MODULE_CODE = "pj-incentive"` / `APP_ID = "zqyl-pj-incentive"` → 本服务编码 / 服务名
  · `T_PJ_FEE_PLAN` 等表名 → 本服务真实表（无则删除该示例）
  · `com.yljr.interfaces.vo.XxxVO` → 本服务 `{基础包}.interfaces.vo.XxxVO`
  · 前端接口文件占位 `{feModule}.js` → 替换为本服务前端接口文件名（正文已用中性占位）；**若本服务无前端（未命中前端维度），「前端实现模式」整节连同 `{feModule}.js` 删除或标注「本服务无前端，前端部分略」，不留残留占位**
  生成后自检：本文件不得残留上述任一字面量。 -->

本规范定义按搜索条件异步导出列表数据到文件中心下载中心的完整实现模式（前后端）。

---

## 整体流程

```
前端 handleExport()
  → 取 lastSearchParams 深拷贝 → 删除分页参数(page/pageRow/localRandom)
  → POST /xxx/export（JSON）
后端 Controller
  → ExportApplication.exportXxx(req)
  → Redis 锁防重复导出（60秒过期，value="1"）
  → exportThreadPool.submit(异步任务) → 立即返回 ResultData.success("导出成功，请到下载中心查看任务")
异步线程（全部在异步线程内完成）
  → Mapper.listForExportVO(query) 查询数据
  → EasyExcel 内存生成 Excel byte[]
  → 生成 requestNo（UUID）
  → FileDownLoadFeign.createManageReserveTask(taskDTO) 创建文件中心预约任务
  → InMemoryMultipartFile 包装 Excel
  → FileDownLoadFeign.reserveUploadFile(file, requestNo) 上传文件
用户
  → 在下载中心查看并下载文件
```

**关键设计**：`createManageReserveTask` 和 `reserveUploadFile` 都在**异步线程**内执行，同步阶段只做 Redis 锁 + 立即返回。

---

## 文件中心 API 规格

### createManageReserveTask — 创建后台预约任务
- 地址：`POST /zqyl-file/file/createManageReserveTask`（Feign 内部调用）
- 请求参数（**全部必填**）：

| 字段 | 类型 | 说明 |
|------|------|------|
| `requestNo` | String | 请求流水号，**调用方自行生成**（UUID），后续 reserveUploadFile 用此关联 |
| `bizType` | String | 业务类型（如 `"EXPORT"`） |
| `modulName` | String | 模块名称，显示在下载中心"模块名称"列 |
| `modulCode` | String | 模块编码 |
| `taskType` | Integer | 任务类型：**1=预约上传**，2=预约下载 |
| `userId` | **BigDecimal** | 创建人ID（网关注入的用户ID，**不是 String**） |
| `companyId` | **BigDecimal** | 企业ID，后台导出传 `BigDecimal.ZERO` |
| `manageMark` | String | 后台标识，后台导出必须传 `"1"` |
| `appId` | String | 服务标识（如 `"zqyl-pj-incentive"`） |

- 响应：`{ "status": "M0200", "msg": "..." }` — 仅 status 和 msg，**无 data 字段**
- 判断成功：`"M0200".equals(createResult.getStatus())`

### reserveUploadFile — 预约文件上传
- 地址：`POST /zqyl-file/file/reserveUploadFile`（multipart/form-data）
- 请求参数：

| 字段 | 注解 | 类型 | 说明 |
|------|------|------|------|
| `file` | **@RequestPart("file")** | MultipartFile | Excel 文件 |
| `requestNo` | **@RequestParam("requestNo")** | String | 与 createManageReserveTask 传入的相同 |

- 注意：`requestNo` 用 `@RequestParam`，**不是** `@RequestPart`
- 参数顺序：**file 在前，requestNo 在后**

---

## 前端实现模式

### 1. 接口定义（`{feModule}.js`，本服务前端接口文件，占位替换为实际文件名）

```javascript
xxxExport: API_PREFIX + '/xxx/export',
```

### 2. 页面组件关键要素

#### data 中声明

```javascript
data() {
    return {
        lastSearchParams: null,  // 保存最近一次查询参数（必须深拷贝）
        // searchFormConfig、ylTableData 等...
    };
}
```

#### handleGetFullData 捕获查询参数

```javascript
handleGetFullData(resData, params) {
    this.lastSearchParams = JSON.parse(JSON.stringify(params));  // 必须深拷贝
    // 可选：发起汇总查询等附加逻辑
}
```

yl-search-form 绑定事件：
```html
<yl-search-form @getFullData="handleGetFullData" ...>
```

#### handleExport 导出方法

```javascript
handleExport() {
    if (!this.lastSearchParams) {
        this.$message.warning('请先查询数据');
        return;
    }
    var params = JSON.parse(JSON.stringify(this.lastSearchParams));
    delete params.page;
    delete params.pageRow;
    delete params.localRandom;
    var self = this;
    self.$axiosJson.post(INTERFACE.xxxExport, params).then(function(res) {
        if (res.data && res.data.status == STATUSCODE.code01) {
            self.$message.success(res.data.msg || '导出成功，请到下载中心查看任务');
        } else if (res.data && res.data.msg === '暂无数据') {
            self.$message.warning('暂无数据');
        } else {
            self.$message.error(res.data.msg || '导出失败');
        }
    }).catch(function() {
        self.$message.error('导出失败，请稍后重试');
    });
}
```

#### 导出按钮

```html
<el-button type="info" size="small" icon="el-icon-download"
    @click="handleExport">导出</el-button>
```

按钮放在 `<ylManagerTitle>` 插槽中，与其他操作按钮并排。

### 3. 前端关键点

- `lastSearchParams` 通过 `@getFullData` 事件在每次查询后自动更新，**必须深拷贝**（`JSON.parse(JSON.stringify(params))`）
- 导出前必须检查 `lastSearchParams` 是否存在（用户未查询时禁止导出）
- 删除 `page`、`pageRow`、`localRandom` 三个分页相关参数后发送
- 使用 `$axiosJson.post` 发送 JSON 请求体
- 状态码判断使用 `STATUSCODE.code01`，禁止硬编码

---

## 后端实现模式

### 1. ExportDTO（继承查询 DTO）

```java
public class XxxExportDTO extends XxxQueryDTO {
    // 无需额外字段，复用查询条件
    // 继承链：XxxExportDTO → XxxQueryDTO → PageRequestDTO → BaseRequestDTO
    // BaseRequestDTO 包含 userId、companyId（网关注入）
}
```

### 2. Controller

```java
@PostMapping("/export")
public ResultData<Object> export(@RequestBody XxxExportDTO req) {
    return exportApplication.exportXxx(req);
}
```

### 3. ExportApplication 编排方法（同步部分）

统一在 `ExportApplication.java` 中添加导出方法，遵循以下模板：

```java
/** Redis锁key前缀 */
private static final String XXX_EXPORT_LOCK_PREFIX = "pj-incentive:export:xxx:";

public ResultData<Object> exportXxx(XxxExportDTO req) {
    String userId = req.getUserId();

    // Redis锁防重复导出（60秒过期，value 使用常量 "1" 避免 null NPE）
    String lockKey = XXX_EXPORT_LOCK_PREFIX + userId;
    Boolean locked = stringRedisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", EXPORT_LOCK_EXPIRE_SECONDS, TimeUnit.SECONDS);
    if (locked == null || !locked) {
        return ResultData.failed("有正在导出的任务，请一分钟之后尝试");
    }

    // 提交异步任务（createTask + 查数据 + Excel + upload 全部在异步线程内）
    exportThreadPool.submit(() -> {
        try {
            doExportXxx(req, userId);
        } catch (Exception e) {
            logger.error("导出异常, userId: {}", userId, e);
        }
    });

    return ResultData.success("导出成功，请到下载中心查看任务");
}
```

**关键点**：
- 同步阶段**只做 Redis 锁 + 提交异步 + 立即返回**，不做 countByCondition、不做 createManageReserveTask
- Redis 锁 value 使用常量 `"1"`，**禁止**用 userId（userId 可能为 null 导致 NPE）
- 返回 `ResultData.success("msg")`，消息会放在 data 字段

### 4. 异步执行方法（异步线程内完成全部工作）

```java
private void doExportXxx(XxxExportDTO query, String userId) {
    String dateStr = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
    String fileName = "导出名称" + dateStr + ".xlsx";
    String requestNo = UUID.randomUUID().toString().replace("-", "");

    try {
        // 1. 查询数据
        List<XxxVO> dataList = xxxMapper.listForExportVO(query);
        if (dataList == null || dataList.isEmpty()) {
            logger.warn("导出数据为空");
            return;
        }
        logger.info("导出数据, 数据条数: {}", dataList.size());

        // 2. 生成Excel byte[]
        byte[] excelBytes = buildXxxExcelBytes(dataList);

        // 3. 创建文件中心预约任务（requestNo 由调用方生成，不是响应返回的）
        CreateReserveTaskDTO taskDTO = buildReserveTask(requestNo, userId);
        ResponseInfo<Object> createResult = fileDownLoadFeign.createManageReserveTask(taskDTO);
        if (createResult == null || !"M0200".equals(createResult.getStatus())) {
            logger.error("创建导出任务失败, requestNo: {}, result: {}", requestNo, createResult);
            return;
        }

        // 4. 上传Excel至文件中心（file 在前，requestNo 在后）
        MultipartFile multipartFile = new InMemoryMultipartFile(
            "file", fileName,
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            excelBytes);
        fileDownLoadFeign.reserveUploadFile(multipartFile, requestNo);

        logger.info("导出上传成功, requestNo: {}", requestNo);
    } catch (Exception e) {
        logger.error("异步导出异常, requestNo: {}, msg: {}", requestNo, e.getMessage(), e);
    }
}
```

### 5. 构建文件中心预约任务（通用辅助方法）

```java
/** 模块名称（显示在下载中心） */
private static final String MODULE_NAME = "票据对接费用";
private static final String MODULE_CODE = "pj-incentive";
private static final String BIZ_TYPE = "EXPORT";
private static final String APP_ID = "zqyl-pj-incentive";

private CreateReserveTaskDTO buildReserveTask(String requestNo, String userId) {
    CreateReserveTaskDTO taskDTO = new CreateReserveTaskDTO();
    taskDTO.setRequestNo(requestNo);                                          // 调用方自行生成
    taskDTO.setBizType(BIZ_TYPE);
    taskDTO.setModulName(MODULE_NAME);
    taskDTO.setModulCode(MODULE_CODE);
    taskDTO.setTaskType(1);                                                   // 1=预约上传
    taskDTO.setUserId(userId != null ? new BigDecimal(userId) : BigDecimal.ZERO);  // BigDecimal 类型！
    taskDTO.setManageMark("1");                                               // 后台标识
    taskDTO.setCompanyId(BigDecimal.ZERO);                                    // 后台导出传 0
    taskDTO.setAppId(APP_ID);
    taskDTO.setUploadFileNum("1");
    return taskDTO;
}
```

### 6. Excel 生成方法

```java
/** 表头配置（static final，类加载时初始化） */
private static final List<List<String>> XXX_EXPORT_HEADERS = buildXxxExportHeaders();

private static List<List<String>> buildXxxExportHeaders() {
    String[] headers = { "列名1", "列名2", "列名3" /* ... */ };
    List<List<String>> headerList = new ArrayList<>(headers.length);
    for (String header : headers) {
        headerList.add(Arrays.asList(header));
    }
    return headerList;
}

private byte[] buildXxxExcelBytes(List<XxxVO> dataList) {
    ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
    List<List<Object>> rows = convertXxxToRows(dataList);
    EasyExcel.write(outputStream)
        .head(XXX_EXPORT_HEADERS)
        .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
        .sheet("Sheet名称")
        .doWrite(rows);
    return outputStream.toByteArray();
}
```

### 7. Mapper 层

```java
// Mapper 接口（注意 @Param("query") 注解）
int countByCondition(@Param("query") XxxQueryDTO query);
List<XxxVO> listForExportVO(@Param("query") XxxQueryDTO query);
```

Mapper XML 中 `listForExportVO`：
- 复用 `listByCondition` 的 SELECT 字段和 JOIN（含 T_PJ_FEE_PLAN 关联）
- 复用查询条件 `<include refid="xxxQueryCondition"/>`
- **去掉分页**（不用 ROWNUM 分页包装），仅用 `WHERE ROWNUM <= 50000` 限制最大条数
- `resultType` 指向 VO 类（如 `com.yljr.interfaces.vo.XxxVO`），**不用** resultMap

---

## Feign 客户端定义

```java
@FeignClient(name = "zqyl-file", path = "/zqyl-file")
public interface FileDownLoadFeign {

    @RequestMapping(value = "/file/createManageReserveTask",
        method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE)
    ResponseInfo<Object> createManageReserveTask(@RequestBody CreateReserveTaskDTO request);

    @RequestMapping(value = "/file/reserveUploadFile",
        method = RequestMethod.POST, consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    ResponseInfo<FileUploadResultDTO> reserveUploadFile(
        @RequestPart("file") MultipartFile file,        // file 在前
        @RequestParam("requestNo") String requestNo);   // requestNo 用 @RequestParam，在后
}
```

---

## CreateReserveTaskDTO 完整字段

```java
public class CreateReserveTaskDTO {
    private String requestNo;           // 请求流水号（调用方生成，UUID）
    private String bizType;             // 业务类型
    private String modulName;           // 模块名称（下载中心显示）
    private String modulCode;           // 模块编码
    private Integer taskType;           // 任务类型（1=预约上传，2=预约下载）
    private BigDecimal userId;          // 创建人ID（BigDecimal，不是 String！）
    private BigDecimal companyId;       // 企业ID（后台导出传 BigDecimal.ZERO）
    private String manageMark;          // 后台标识（"1"=是）
    private String appId;               // 服务标识
    private String uploadFileNum;       // 预约上传文件数量
    private List<BatchDownloadInfo> data; // 下载文件信息（预约上传时可不传）
    private String zipName;             // 压缩包名称（可不传）
}
```

---

## 已有基础设施（直接复用，无需新建）

<!-- SCAN:async-export-infra — step-03 按 extraction-recipes.md#async-export 扫描导出基础设施填充下表（ExportApplication 方法、线程池 bean 名与参数、Redis 锁前缀、CreateReserveTaskDTO 字段）；类名/参数以本服务实测为准，严禁照抄 pj-incentive。 -->
| 组件 | 位置 | 说明 |
|------|------|------|
| （扫码填充真实导出基础设施） | | |

---

## 新增导出功能 Checklist

### 后端
- [ ] 创建 `XxxExportDTO`（继承 `XxxQueryDTO`，无需额外字段）
- [ ] Controller 添加 `@PostMapping("/export")` 方法
- [ ] `ExportApplication` 添加 `exportXxx()` 同步方法（Redis锁 → 提交异步 → 立即返回）
- [ ] `ExportApplication` 添加 `doExportXxx()` 异步方法（查数据 → Excel → createTask → upload）
- [ ] `ExportApplication` 添加 `buildXxxExportHeaders()`、`buildXxxExcelBytes()`、`convertXxxToRows()`
- [ ] Mapper 接口添加 `listForExportVO(@Param("query") XxxQueryDTO query)` 方法
- [ ] Mapper XML 添加 `listForExportVO` SQL（JOIN 费用方案表，复用查询条件，限制5万条，resultType=VO）
- [ ] 新增 Redis 锁前缀常量 `XXX_EXPORT_LOCK_PREFIX`
- [ ] 配置网关为导出接口注入 userId/companyId

### 前端
- [ ] `{feModule}.js`（本服务前端接口文件）添加导出接口地址 `xxxExport`
- [ ] 页面 data 中声明 `lastSearchParams: null`
- [ ] `handleGetFullData` 中深拷贝保存 `lastSearchParams`
- [ ] 实现 `handleExport()` 方法（深拷贝参数 → 删分页 → POST → 提示下载中心）
- [ ] 添加导出按钮到 `<ylManagerTitle>` 插槽

---

## 关键注意事项

1. **导出基于搜索条件**：发送的是查询参数（去掉分页），不是选中行 ID
2. **文件中心调用在异步线程内**：同步阶段只做 Redis 锁 + 立即返回，createManageReserveTask 和 reserveUploadFile 都在异步线程中
3. **requestNo 由调用方生成**：使用 `UUID.randomUUID().toString().replace("-", "")`，**不是**从文件中心响应获取
4. **userId 必须是 BigDecimal**：文件中心 API 要求 Number 类型，传 String 会导致反序列化失败
5. **companyId 传 BigDecimal.ZERO**：后台导出固定传 0
6. **manageMark 传 "1"**：标识为后台导出
7. **reserveUploadFile 参数注解**：file 用 `@RequestPart`，requestNo 用 `@RequestParam`（不是 @RequestPart）
8. **reserveUploadFile 参数顺序**：`(MultipartFile file, String requestNo)` — file 在前
9. **Redis锁 value 使用常量 "1"**：禁止用 userId（可能为 null 导致 NPE）
10. **响应判断**：`"M0200".equals(createResult.getStatus())`，禁止用 `isSuccess()`（编译时可能找不到）或 `getData() != null`（响应无 data）
11. **listForExportVO 用 resultType 不用 resultMap**：返回 VO 类型需要别名映射，用 `resultType="com.yljr.interfaces.vo.XxxVO"`

## 常见错误模式

- ❌ `CreateReserveTaskDTO` 只传 taskName/taskType/userId → ✅ 必须传 requestNo/bizType/modulName/modulCode/taskType/userId/companyId/manageMark/appId
- ❌ userId 用 String 类型 → ✅ 必须用 `BigDecimal`
- ❌ requestNo 从响应的 `getData()` 获取 → ✅ 调用方自行生成 UUID
- ❌ reserveUploadFile 的 requestNo 用 `@RequestPart` → ✅ 必须用 `@RequestParam`
- ❌ 同步阶段调用 createManageReserveTask → ✅ 文件中心调用放在异步线程内
- ❌ Redis 锁 value 用 userId → ✅ 用常量 `"1"` 防 NPE
- ❌ `createResult.isSuccess()` 判断 → ✅ 用 `"M0200".equals(createResult.getStatus())`
- ❌ `createResult.getData() == null` 判断失败 → ✅ 文件中心创建任务响应无 data 字段，用 status 判断
- ❌ 前端 `lastSearchParams` 不深拷贝 → ✅ 必须 `JSON.parse(JSON.stringify(params))`
