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() // 导出列
);
}
注意事项:
- 方法签名必须加
@SneakyThrows(EasyExcelUtil抛出受检异常) 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
Repository
xu-cell/ai-engi…ing-initGitHub Stars
9
First Seen
9 days ago
Security Audits
Installed on
amp2
cline2
opencode2
cursor2
kimi-cli2
codex2