leniu-java-export

SKILL.md

leniu-tengyun-core 数据导出规范

项目特征

特征 说明
包名 net.xnzn.*
异常类 LeException
导出工具 ExportApi.startExcelExportTaskByPage()
国际化 I18n.getMessage()
工具类 Hutool(CollUtil、BeanUtil 等)
请求包装 LeRequest<T>

核心组件

  • ExportApi: 导出API接口
  • EasyExcelUtil: 同步导出工具(报表 Controller 直接使用)
  • I18n: 国际化工具
  • PageDTO: 分页参数
  • ReportConstant: 报表常量(工作表名称等)

两种导出模式

模式 工具 适用场景
同步导出 EasyExcelUtil.writeExcelByDownLoadIncludeWrite() 报表 Controller 直接返回文件流,数据量不大
异步分页导出 exportApi.startExcelExportTaskByPage() 大数据量,任务队列方式
异步分页导出(Feign) orderClients.export().startExcelExportTaskByPage() 跨模块导出,通过 Feign 客户端

同步导出(EasyExcelUtil)

@ApiOperation(value = "流水汇总-同步导出")
@PostMapping("/export")
@SneakyThrows
public void export(@RequestBody LeRequest<ReportAnalysisTurnoverParam> request,
                   HttpServletResponse response) {
    ReportAnalysisTurnoverParam param = request.getContent();

    // 1. 查询数据
    ReportBaseTotalVO<TurnoverVO> result = reportService.pageSummary(param);

    // 2. 将列表 + 合计行合并(合计行追加到列表末尾)
    List<TurnoverVO> records = result.getResultPage().getRecords();
    CollUtil.addAll(records, result.getTotalLine());  // 合计行是单个 VO 或 List

    // 3. 直接写出文件流
    EasyExcelUtil.writeExcelByDownLoadIncludeWrite(
        response,
        I18n.getMessage("report.turnover.title"),  // 文件名(国际化)
        TurnoverVO.class,                           // VO 类型
        I18n.getMessage(ReportConstant.REPORT_TITLE_DETAILS), // 工作表名
        records,                                    // 数据列表(含合计行)
        param.getExportCols()                       // 导出列
    );
}

注意事项

  • 方法签名必须加 @SneakyThrowsEasyExcelUtil 抛出受检异常)
  • HttpServletResponse response 作为方法参数接收
  • 合计行用 CollUtil.addAll(records, totalLine) 追加到列表末尾
  • 若合计行是单个 VO:records.add(totalLine) 即可

异步分页导出

基础导出模板

@ApiOperation(value = "xxx导出")
@PostMapping("/export")
public void export(@RequestBody LeRequest<XxxPageParam> request) {
    XxxPageParam param = request.getContent();

    // 获取合计行(可选)
    XxxVO totalLine = xxxService.getSummaryTotal(param);

    // 启动异步导出任务
    exportApi.startExcelExportTaskByPage(
        I18n.getMessage("report.xxx.title"),                          // 文件名(国际化)
        I18n.getMessage(ReportConstant.REPORT_TITLE_DETAILS),         // 工作表名
        XxxVO.class,                                                   // 数据类型
        param.getExportCols(),                                        // 导出列
        param.getPage(),                                              // 分页参数
        totalLine,                                                    // 合计行(可为null)
        () -> xxxService.pageList(param).getResultPage()              // 数据提供者
    );
}

实际项目示例

@PostMapping("/purchase/order/export")
@ApiOperation(value = "采购-采购订单汇总-导出")
public void exportPurchaseOrder(@RequestBody LeRequest<MonitorPageParam> param) {
    MonitorPageParam content = param.getContent();

    // 1. 获取合计行
    PurchaseOrderSummaryVO totalLine = monitorSafetyPurchaseService.getPurchaseOrderSummaryTotal(content);

    // 2. 启动导出任务
    exportApi.startExcelExportTaskByPage(
        I18n.getMessage("school.purchase-order-summary"),              // 文件名(国际化)
        I18n.getMessage(ReportConstant.REPORT_TITLE_DETAILS),         // 工作表名
        PurchaseOrderSummaryVO.class,                                  // VO类型
        content.getExportCols(),                                      // 导出列
        content.getPage(),                                            // 分页参数
        totalLine,                                                    // 合计行
        () -> monitorSafetyPurchaseService.getPurchaseOrderSummary(content).getResultPage()
    );
}

异步分页导出(Feign 客户端模式)

跨模块导出时,通过 Feign 客户端调用目标模块的导出接口。订单模块导出是典型示例:

/**
 * 订单导出 Controller(独立拆分,避免与主 Controller 耦合)
 */
@Slf4j
@Api(tags = "订单导出")
@RestController
@RequestMapping("/web/order/export")
public class OrderInfoExportWebController {

    // ✅ 跨模块依赖:通过 Feign 客户端调用,@Lazy 避免循环依赖
    @Autowired
    @Lazy
    private OrderClients orderClients;

    @ApiOperation(value = "订单导出")
    @PostMapping("/start")
    public void export(@RequestBody LeRequest<OrderDetailWebDTO> request) {
        OrderDetailWebDTO dto = request.getContent();

        // 转换为内部查询参数
        OrderSearchParam searchParam = dto.convertToOrderSearchParam();

        // 通过 Feign 客户端启动异步导出任务
        orderClients.export().startExcelExportTaskByPage(
            I18n.getMessage("order.export.title"),                      // 文件名
            I18n.getMessage(ReportConstant.REPORT_TITLE_DETAILS),       // 工作表名
            OrderDetailVO.class,                                        // VO 类型
            dto.getExportCols(),                                        // 导出列
            dto.getPage(),                                              // 分页参数
            null,                                                       // 无合计行
            () -> orderClients.order().pageOrder(searchParam)           // Lambda 提供数据
        );
    }
}

独立 Export Controller 的优点

  • 将导出逻辑与查询逻辑解耦
  • 避免单个 Controller 过于庞大
  • @Autowired @Lazy 防止 Spring 循环依赖

VO类导出注解

使用@ExcelProperty

⚠️ 金额字段必须使用 converter = CustomNumberConverter.class,禁止用 @NumberFormat

import net.xnzn.core.common.export.converter.CustomNumberConverter;

@Data
@ApiModel("xxx导出VO")
public class XxxVO {

    @ExcelProperty(value = "ID", index = 0)
    @ApiModelProperty("ID")
    private Long id;

    @ExcelProperty(value = "名称", index = 1)
    @ApiModelProperty("名称")
    private String name;

    @ExcelProperty(value = "状态", index = 2)
    @ApiModelProperty("状态")
    private String statusDesc;

    // ✅ 金额字段:必须用 CustomNumberConverter,框架自动完成分→元转换
    @ExcelProperty(value = "金额(元)", index = 3, converter = CustomNumberConverter.class)
    @ApiModelProperty("金额(分)")
    private BigDecimal amount;

    @ExcelProperty(value = "创建时间", index = 4)
    @ApiModelProperty("创建时间")
    @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;
}

导出参数类

PageParam包含导出列

@Data
@ApiModel("xxx分页查询参数")
public class XxxPageParam extends ReportBaseParam {

    @ApiModelProperty(value = "查询条件")
    private String keyword;

    // 其他查询条件...
    // 导出列 exportCols 和分页 page 已在 ReportBaseParam 基类中定义
}

Service层导出逻辑

导出时不分页

public PageVO<XxxVO> pageList(XxxPageParam param) {
    // 导出时不分页,查询全部数据
    if (CollUtil.isNotEmpty(param.getExportCols())) {
        // 不调用 PageMethod.startPage()
        List<XxxEntity> records = mapper.selectList(param);
        List<XxxVO> voList = BeanUtil.copyToList(records, XxxVO.class);
        return PageVO.of(voList);
    }

    // 正常分页查询
    PageMethod.startPage(param);
    List<XxxEntity> records = mapper.pageList(param);
    List<XxxVO> voList = BeanUtil.copyToList(records, XxxVO.class);
    return PageVO.of(voList);
}

带合计行的导出

// ⚠️ 系统默认在商户库执行,业务查询无需 Executors.readInSystem()
// Executors.readInSystem() 仅用于需要访问系统库的场景(如全局配置、商户管理)

public ReportBaseTotalVO<XxxVO> pageWithTotal(XxxPageParam param) {
    ReportBaseTotalVO<XxxVO> result = new ReportBaseTotalVO<>();

    // 1. 导出时不查询合计行(避免不必要的性能开销)
    if (CollUtil.isEmpty(param.getExportCols())) {
        XxxVO totalLine = mapper.getSummaryTotal(param);
        result.setTotalLine(totalLine);
    }

    // 2. 导出时不分页
    if (CollUtil.isNotEmpty(param.getExportCols())) {
        List<XxxVO> list = mapper.getSummaryList(param);
        result.setResultPage(PageVO.of(list));
    } else {
        // 正常分页查询
        PageMethod.startPage(param);
        List<XxxVO> list = mapper.getSummaryList(param);
        result.setResultPage(PageVO.of(list));
    }

    return result;
}

导出文件名国际化

使用I18n

// 在 resources/message_zh.properties 中定义
report.order.title=订单报表
report.order.sheet=订单明细

// 在 resources/message_en.properties 中定义
report.order.title=Order Report
report.order.sheet=Order Details

// 在代码中使用
exportApi.startExcelExportTaskByPage(
    I18n.getMessage("report.order.title"),   // 订单报表
    I18n.getMessage("report.order.sheet"),   // 订单明细
    OrderVO.class,
    param.getExportCols(),
    param.getPage(),
    totalLine,
    () -> orderService.pageList(param).getResultPage()
);

导出列控制

前端传递导出列

{
  "page": {
    "current": 1,
    "size": 10
  },
  "exportCols": ["id", "name", "status", "amount", "createTime"],
  "keyword": "test"
}

后端处理导出列

@PostMapping("/export")
public void export(@RequestBody LeRequest<XxxPageParam> request) {
    XxxPageParam param = request.getContent();

    // 校验导出列
    if (CollUtil.isEmpty(param.getExportCols())) {
        throw new LeException("导出列不能为空");
    }

    // 启动导出任务
    exportApi.startExcelExportTaskByPage(
        I18n.getMessage("report.xxx.title"),
        I18n.getMessage("report.xxx.sheet"),
        XxxVO.class,
        param.getExportCols(),   // 传递导出列
        param.getPage(),
        null,
        () -> xxxService.pageList(param).getResultPage()
    );
}

导出数据转换

状态码转换为描述(使用BeanUtil)

public PageVO<XxxVO> pageList(XxxPageParam param) {
    PageMethod.startPage(param);
    List<XxxEntity> records = mapper.pageList(param);

    // 转换为VO并处理状态描述(leniu 使用 BeanUtil,不用 MapstructUtils)
    List<XxxVO> voList = records.stream()
            .map(entity -> {
                XxxVO vo = new XxxVO();
                BeanUtil.copyProperties(entity, vo);

                // 状态码转换为描述
                vo.setStatusDesc(StatusEnum.getByCode(entity.getStatus()).getDesc());

                return vo;
            })
            .collect(Collectors.toList());

    return PageVO.of(voList);
}

常见场景

场景1:订单导出

@ApiOperation(value = "订单导出")
@PostMapping("/export")
public void export(@RequestBody LeRequest<OrderPageParam> request) {
    OrderPageParam param = request.getContent();

    log.info("【导出】订单导出,条件:{}", param);

    // 获取合计行
    OrderVO totalLine = orderService.getSummaryTotal(param);

    // 启动导出任务
    exportApi.startExcelExportTaskByPage(
        I18n.getMessage("report.order.title"),
        I18n.getMessage("report.order.sheet"),
        OrderVO.class,
        param.getExportCols(),
        param.getPage(),
        totalLine,
        () -> orderService.pageList(param).getResultPage()
    );
}

场景2:报表导出

@ApiOperation(value = "销售报表导出")
@PostMapping("/export")
public void export(@RequestBody LeRequest<SalesReportParam> request) {
    SalesReportParam param = request.getContent();

    log.info("【导出】销售报表导出,日期范围:{} - {}",
             param.getStartDate(), param.getEndDate());

    // 获取合计行
    SalesReportVO totalLine = reportService.getSummaryTotal(param);

    // 启动导出任务
    exportApi.startExcelExportTaskByPage(
        I18n.getMessage("report.sales.title"),
        I18n.getMessage("report.sales.sheet"),
        SalesReportVO.class,
        param.getExportCols(),
        param.getPage(),
        totalLine,
        () -> reportService.getSummary(param).getResultPage()
    );
}

场景3:带权限过滤的导出

@ApiOperation(value = "数据导出")
@PostMapping("/export")
public void export(@RequestBody LeRequest<DataPageParam> request) {
    DataPageParam param = request.getContent();

    // 获取用户权限
    MgrUserAuthPO authPO = mgrAuthApi.getUserAuthPO();
    ReportDataPermissionParam dataPermission =
        reportDataPermissionService.getDataPermission(authPO);

    log.info("【导出】数据导出,用户:{}, 权限范围:{}",
             authPO.getUserId(), dataPermission.getCanteenIds());

    // 启动导出任务(权限过滤在Service层处理)
    exportApi.startExcelExportTaskByPage(
        I18n.getMessage("report.data.title"),
        I18n.getMessage("report.data.sheet"),
        DataVO.class,
        param.getExportCols(),
        param.getPage(),
        null,
        () -> dataService.pageList(param).getResultPage()
    );
}

导出性能优化

1. 限制导出数量

@PostMapping("/export")
public void export(@RequestBody LeRequest<XxxPageParam> request) {
    XxxPageParam param = request.getContent();

    // 查询总数
    long total = xxxService.count(param);

    // 限制导出数量
    if (total > 100000) {
        throw new LeException("导出数据量过大,请缩小查询范围");
    }

    // 启动导出任务
    exportApi.startExcelExportTaskByPage(
        I18n.getMessage("report.xxx.title"),
        I18n.getMessage("report.xxx.sheet"),
        XxxVO.class,
        param.getExportCols(),
        param.getPage(),
        null,
        () -> xxxService.pageList(param).getResultPage()
    );
}

2. 导出数据脱敏

public PageVO<UserVO> pageList(UserPageParam param) {
    PageMethod.startPage(param);
    List<User> records = mapper.pageList(param);

    List<UserVO> voList = records.stream()
            .map(user -> {
                UserVO vo = new UserVO();
                BeanUtil.copyProperties(user, vo);

                // 导出时脱敏
                if (CollUtil.isNotEmpty(param.getExportCols())) {
                    vo.setMobile(maskMobile(user.getMobile()));
                    vo.setIdCard(maskIdCard(user.getIdCard()));
                }

                return vo;
            })
            .collect(Collectors.toList());

    return PageVO.of(voList);
}

最佳实践

1. 导出日志

@PostMapping("/export")
public void export(@RequestBody LeRequest<XxxPageParam> request) {
    XxxPageParam param = request.getContent();

    log.info("【导出】开始导出,文件名:{}, 条件:{}",
             I18n.getMessage("report.xxx.title"), param);

    exportApi.startExcelExportTaskByPage(
        I18n.getMessage("report.xxx.title"),
        I18n.getMessage("report.xxx.sheet"),
        XxxVO.class,
        param.getExportCols(),
        param.getPage(),
        null,
        () -> xxxService.pageList(param).getResultPage()
    );

    log.info("【导出】导出任务已启动");
}

2. 导出权限校验

@PostMapping("/export")
public void export(@RequestBody LeRequest<XxxPageParam> request) {
    // 校验导出权限
    if (!hasExportPermission()) {
        throw new LeException("无导出权限");
    }

    // 启动导出任务
    exportApi.startExcelExportTaskByPage(...);
}

常见错误

错误写法 正确写法 说明
throw new ServiceException("msg") throw new LeException("msg") leniu 项目异常类
MapstructUtils.convert(a, B.class) BeanUtil.copyProperties(a, b) leniu 使用 Hutool
@RequestParam 接收请求 @RequestBody LeRequest<T> leniu 接口使用 LeRequest 包装
import javax.validation.* import jakarta.validation.* JDK 21 + Spring Boot 3.x
不写导出日志 写 log.info 记录导出参数 便于排查导出问题
@NumberFormat("#,##0.00") 用于金额字段 @ExcelProperty(converter = CustomNumberConverter.class) @NumberFormat 无法完成分→元转换,框架的 CustomNumberConverter 才能自动处理
mapFunc 参数手动做分→元转换 ✅ VO 字段加 converter = CustomNumberConverter.class 金额转换在 VO 注解层处理,不在 startExcelExportTaskByPage 的 mapFunc 里做
⛔ 手写 EasyExcel.write() + List<List<Object>> ✅ 使用 exportApi.startExcelExportTaskByPage() 禁止手动拼接行列数据
⛔ Service 里手动创建临时 File + exportApi.createRecord() ✅ 使用 exportApi.startExcelExportTaskByPage() 统一使用标准异步分页导出接口
⛔ VO 无 @ExcelProperty 注解 ✅ 每个导出字段必须加 @ExcelProperty("列头") 无注解字段不会被导出
Weekly Installs
2
GitHub Stars
9
First Seen
9 days ago
Installed on
amp2
cline2
opencode2
cursor2
kimi-cli2
codex2