目录
-
- 模板先确定下来
- 上传文件,为什么要先保存?
- 解析文件,再保存住户
- 导出查哪些数据?
-
- [小区名称用 Map 转,还是在 SQL 里查?](#小区名称用 Map 转,还是在 SQL 里查?)
- [`PersonVO` 和 `personVoMap`](#
PersonVO和personVoMap)
- 创建并保存导出文件
-
- 表头和住户行
- [`write()` 写到哪里?](#
write()写到哪里?)
- 前端怎样下载这个文件?
这次整理智慧社区项目里的 Excel 导入和导出。导入沿用老师的写法,分成上传文件、解析入库两个接口;导出按接口文档实现,先生成文件,再由前端下载。
模板先确定下来
项目用的是 .xls。前端限制了文件后缀,后端用 HSSFWorkbook 读取,所以文件本身也要是这个格式,不能把 .xlsx 改个名字就拿来用。POI 中 HSSFWorkbook 对应 .xls,XSSFWorkbook 对应 .xlsx,见 POI 官方指南。
模板第 1 行是标题,第 2 行是列名,第 3 行开始放住户数据,共 9 列:

| 列 | 下标 | 内容 | 对应字段 |
|---|---|---|---|
| A | 0 | ID | 导入时不使用这一列的值 |
| B | 1 | 小区名称 | 先查小区,再取得 communityId |
| C | 2 | 所属楼栋 | termName |
| D | 3 | 房号 | houseNo |
| E | 4 | 姓名 | userName |
| F | 5 | 性别 | sex |
| G | 6 | 手机号码 | mobile |
| H | 7 | 居住性质 | personType |
| I | 8 | 备注 | remark |
老师代码中的 stitle 数组有 10 项,多了一项"状态",但它没有参与读取。实际用的模板只有上面这 9 列,因此下标 8 读到的仍然是备注。列的含义要对照模板和读取代码看,不能只看这个数组。
上传文件,为什么要先保存?
我一开始没明白为什么要先保存表格,它又不像图片一样需要预览。看了解析接口才发现,它只接收文件名,没有再接收文件内容。所以要先把文件存下来,第二次请求才能找到它。
text
POST /sys/person/excelUpload
接收文件 → 保存到磁盘 → 返回新文件名
↓
POST /sys/person/parsefile/{fileName}
按文件名读取 → 解析表格 → 保存住户
这和表格本身需不需要预览没有关系。如果上传后就在同一个请求里解析,也可以直接读上传文件的输入流;这里按现有前端和接口分工实现。
Controller 类上有公共路径,文件目录从配置中取:
java
@RestController
@RequestMapping("/sys/person")
public class PersonController {
@Value("${upload.excel}")
private String excel;
}
yaml
upload:
excel: D:/community/excel/
上传方法如下,放在这个 Controller 里面:
java
@PostMapping({"/excelUpload", "/excelUpload/"})
public Result excelUpload(@RequestParam(value = "uploadExcel", required = false) MultipartFile file) {
// 1. 检查有没有选择文件,以及文件内容是否为空。
if (file == null || file.isEmpty()) {
return Result.error("没有选中要上传的文件");
}
String oldFilename = file.getOriginalFilename();
if (oldFilename == null || !oldFilename.toLowerCase(Locale.ROOT).endsWith(".xls")) {
return Result.error("请上传xls文件");
}
if (file.getSize() > 10L * 1024 * 1024) {
return Result.error("文件大小不能超过10MB");
}
// 2. 用UUID生成新名字,保留原文件的.xls后缀,避免同名覆盖。
String newfilename = UUID.randomUUID().toString();
String ext = oldFilename.substring(oldFilename.lastIndexOf(".")).toLowerCase(Locale.ROOT);
String newFilename1 = newfilename + ext;
// 3. 确保配置的保存目录存在;new File只是表示路径,不会保存上传内容。
File directory = new File(excel).getAbsoluteFile();
if (!directory.isDirectory() && !directory.mkdirs() && !directory.isDirectory()) {
return Result.error("Excel保存目录创建失败");
}
File file1 = new File(directory, newFilename1);
// 4. 将上传文件真正保存到硬盘,并返回文件名给前端。
try {
file.transferTo(file1);
return Result.ok().put("data", newFilename1);
} catch (IOException | IllegalStateException e) {
e.printStackTrace();
return Result.error("Excel文件保存失败");
}
}
接收文件和保存文件是两件事
MultipartFile file 表示请求中上传的文件。前端通过 multipart/form-data 提交,字段名是 uploadExcel:
html
<el-upload name="uploadExcel" ...>
所以后端写 @RequestParam("uploadExcel")。这不是提交 JSON 的接口,不用 @RequestBody。required = false 允许方法自行判断没收到文件的情况,isEmpty() 则检查文件内容是否为空。
getOriginalFilename() 取得原文件名。代码检查后缀、大小后,生成 UUID 作为新名字,保留 .xls 后缀,避免直接沿用原名造成覆盖。检查后缀不等于已经确认内容有效,文件能否打开要等解析时才能知道。
我之前问过这一句是什么:
java
file.transferTo(file1);
它把上传内容保存到 file1 指向的位置。前面的 new File(directory, newFilename1) 只是组合路径,没有把文件内容写进去。transferTo(File) 的具体说明可查 Spring MultipartFile 文档。
上传成功后返回的是 JSON:
json
{
"code": 200,
"msg": "操作成功",
"data": "一个UUID.xls"
}
此时文件已经保存,住户还没有入库。前端在上传成功的回调 handleAvatarSuccess 中拿到 data.data,再调用:
javascript
parsefile(data.data).then(res => {
// 根据 res.status 显示解析结果
})
当前页面是上传成功后自动解析。弹窗里的"确定"负责通知列表刷新、关闭弹窗,不是等点了确定才开始保存住户。
解析文件,再保存住户
解析接口接收上传接口返回的文件名。老师先把单元格读进二维数组,再逐行组装 Person,最后调用 Service 保存。下面保留这套逻辑,注释集中在文件读取、行列对应和字段转换上。
java
@PostMapping("/parsefile/{fileName}")
public Result parsefile(@PathVariable("fileName") String fileName,HttpSession session){
// 创建人从登录会话中取。
User user = (User) session.getAttribute("user");
POIFSFileSystem fs = null;
HSSFWorkbook wb = null;
try {
// 根据上传接口返回的文件名,打开服务器上的.xls文件。
String basePath = excel + fileName;
fs = new POIFSFileSystem(new FileInputStream(basePath));
wb = new HSSFWorkbook(fs);
} catch (Exception e) {
e.printStackTrace();
}
// 取第一个工作表,确定行列范围。
HSSFSheet sheet = wb.getSheetAt(0);
Object[][] data = null;
int r = sheet.getLastRowNum()+1;
int c = sheet.getRow(0).getLastCellNum();
// 跳过两行表头,数组只保存住户数据。
int headRow = 2;
data = new Object[r - headRow][c];
for (int i = headRow; i < r; i++) {
HSSFRow row = sheet.getRow(i);
for (int j = 0; j < c; j++) {
HSSFCell cell = null;
try {
cell = row.getCell(j);
try {
cell = row.getCell(j);
// 按单元格格式取得字符串,放入二维数组。
DataFormatter dataFormater = new DataFormatter();
String a = dataFormater.formatCellValue(cell);
data[i - headRow][j] = a;
} catch (Exception e) {
data[i-headRow][j] = "";
if(j==0){
try {
double d = cell.getNumericCellValue();
data[i - headRow][j] = (int)d + "";
}catch(Exception ex){
data[i-headRow][j] = "";
}
}
}
} catch (Exception e) {
System.out.println("i="+i+";j="+j+":"+e.getMessage());
}
}
}
int row = data.length;
int col = 0;
String errinfo = "";
// 后面用数组下标加3,给出Excel中的实际行号。
headRow = 3;
// 此数组未参与读取;实际模板为九列,最后一列是备注。
String[] stitle={"ID","小区名称","所属楼栋","房号","姓名","性别","手机号码","居住性质","状态","备注"};
errinfo = "";
for (int i = 0; i < row; i++) {
// 每一行对应一个住户,状态和图片地址由代码设置。
Person single = new Person();
single.setPersonId(0);
single.setState(1);
single.setFaceUrl("");
try {
// 跳过ID列,按模板顺序取值。
col=1;
String communityName = data[i][col++].toString();
// 表格中是小区名称,入库前先查出小区ID。
QueryWrapper<Community> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("community_name", communityName);
Community community = this.communityService.getOne(queryWrapper);
if( community == null){
errinfo += "Excel文件第" + (i + headRow) + "行小区名称不存在!";
return Result.ok().put("status", "fail").put("data", errinfo);
}
single.setCommunityId(community.getCommunityId());
single.setTermName(data[i][col++].toString());
single.setHouseNo(data[i][col++].toString());
single.setUserName(data[i][col++].toString());
single.setSex(data[i][col++].toString());
single.setMobile(data[i][col++].toString());
single.setPersonType(data[i][col++].toString());
// 此时col为8,对应实际模板的备注列。
single.setRemark(data[i][col++].toString());
single.setCreater(user.getUsername());
// 当前行在这里保存到数据库。
this.personService.save(single);
} catch (Exception e) {
e.printStackTrace();
}
}
return Result.ok().put("status", "success").put("data","数据导入完成!");
}
从文件到单元格
java
String basePath = excel + fileName;
fs = new POIFSFileSystem(new FileInputStream(basePath));
wb = new HSSFWorkbook(fs);
HSSFSheet sheet = wb.getSheetAt(0);
这里的文件已经在磁盘上了。FileInputStream 读字节,POIFSFileSystem 解析旧版 .xls 的文件结构,再交给 HSSFWorkbook 打开。getSheetAt(0) 取第一个工作表。
POI 对象与表格的关系是:
text
HSSFWorkbook:工作簿
HSSFSheet:工作表
HSSFRow:一行
HSSFCell:单元格
表格行号和数组下标
java
int r = sheet.getLastRowNum() + 1;
int c = sheet.getRow(0).getLastCellNum();
int headRow = 2;
data = new Object[r - headRow][c];
getLastRowNum() 返回最后一行的下标,所以加 1。getLastCellNum() 已经是该行最后一个单元格下标加 1,不用再加。这些值确定读取范围,不是在统计有多少条非空住户记录。
前两行不放住户,所以 headRow 为 2,数组只分配 r - headRow 行。例如 Excel 第 3 行的下标是 2,进入数组后应该是第 0 行:
java
data[i - headRow][j] = a;
后面 headRow 又被赋成 3,用途变了:它用来计算提示中的 Excel 行号。数组下标 0 加 3,正好对应 Excel 第 3 行。这两个地方分别处理"读数据"和"报行号",并没有重新读一遍表格。
单元格转成字符串
java
DataFormatter dataFormater = new DataFormatter();
String a = dataFormater.formatCellValue(cell);
表格里可能有文本、数字等不同类型,DataFormatter 按单元格格式取得字符串,见 POI DataFormatter 文档。但它不能恢复 Excel 已经丢掉的前导零或精度。房号、手机号在表格里按文本填写更合适,例如房号 0101。
老师还在格式化异常后尝试将第一列按数字读取。不过后面并没有使用 Excel 的 ID,而是另行调用 single.setPersonId(0),所以这段数字转换不决定新增住户的主键。
从小区名称取得小区 ID
数组转 Person 时,col = 1 跳过了 ID 列,接下来各列按表头顺序赋值。小区名称需要多一步处理:
java
QueryWrapper<Community> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("community_name", communityName);
Community community = communityService.getOne(queryWrapper);
Excel 写的是名称,住户表存的是 community_id。先按名称查到 Community,再把它的 ID 放入住户对象。community_name 是数据库列名,不是 Java 属性名。
没有对应小区时,代码返回具体行号;查出多条时,getOne() 默认可能报错。因此表格中填写的名称要能对应到数据库里的小区。
其余字段赋值后,这一句才真正调用入库逻辑:
java
this.personService.save(single);
状态、图片地址和创建人不从表格读取。代码把状态设为 1、图片地址设为空字符串,创建人从登录会话里取。导入住户资料本身没有完成人脸采集。
这里的 ID 赋 0 是老师原写法,能否按预期自增与数据库配置有关。项目的普通新增接口则将 ID 设为 null。
导出查哪些数据?
接口文档规定请求地址为:
http
GET /sys/person/exportExcel
参数包括 page、limit、userName、communityId、mobile,成功时返回:
json
{
"msg": "操作成功",
"code": 200,
"data": "20230719185659.xls"
}
看到 page 和 limit,再结合后面几个搜索字段,我推测这里应当带上分页和筛选条件。文档没有直接写明导出范围,这次就按"当前搜索条件的当前页"实现。例如搜索姓名包含"张"的住户,第 2 页、每页 10 条,就导出这组条件下第 2 页的数据。
返回的 data 是文件名,生成接口先返回 JSON。前端拿到名称后再请求文件,所以不能在这里直接返回 Excel 文件流。
小区名称用 Map 转,还是在 SQL 里查?
导出时遇到与导入相反的问题:住户对象里有 communityId,表格需要显示小区名称。
可以一次查出住户、一次查出小区,建一个 ID 到名称的 Map;也可以用 SQL 关联查询。我选了后者,因为项目的住户列表已经有这套联查,可以直接复用。Map 也能实现,而且"一次查全部小区"不等于"每导出一个住户就查一次小区",不能只靠查询次数就认定它很慢。
关联部分是:
sql
FROM person p
LEFT JOIN community cm
ON p.community_id = cm.community_id
LEFT JOIN 会保留住户记录,即使找不到关联小区也不把住户过滤掉,只是名称为空。
PersonVO 和 personVoMap
项目已经有 PersonVO,在住户字段之外增加小区名称:
java
@Data
@EqualsAndHashCode(callSuper = true)
public class PersonVO extends Person {
private String communityName;
}
我当时问过,没写 personVoMap 怎么用。后来核对了文件,它已经在 Mapper XML 顶部,是一段结果映射的名字:
xml
<resultMap id="personVoMap"
type="com.qcby.springbootCommunity.VO.PersonVO">
<id property="personId" column="person_id"/>
<result property="communityId" column="community_id"/>
<result property="termName" column="term_name"/>
<result property="houseNo" column="house_no"/>
<result property="userName" column="user_name"/>
<result property="sex" column="sex"/>
<result property="mobile" column="mobile"/>
<result property="personType" column="person_type"/>
<result property="remark" column="remark"/>
<result property="communityName" column="community_name"/>
</resultMap>
这里摘录了与导出有关的字段。最后一项就是把查询结果中的 community_name 赋给 PersonVO.communityName。查询标签写 resultMap="personVoMap",便会使用这套规则。
原有 Mapper 方法是:
java
IPage<PersonVO> selectPersonPage(
Page<PersonVO> page,
@Param("query") PersonQuery query
);
Service 的 getPersonPage(page, query) 调用它。XML 按姓名、手机号模糊查询,按小区 ID 精确查询,结果按住户 ID 倒序排列;分页由项目已经配置的 MyBatis-Plus 分页插件处理。
既然要导出当前页,就没必要另外加一个查询全部住户的方法。继续调用 getPersonPage(),用 getRecords() 取出该页记录即可。
创建并保存导出文件
下面是 PersonController 中的完整方法,类上的公共路径仍是 /sys/person:
java
@GetMapping("/exportExcel")
public Result exportExcel(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer limit,
PersonQuery query) {
if (page < 1 || limit < 1) {
return Result.error("页码和每页条数必须大于0");
}
// .xls 最多65536行,标题、表头各占一行。
if (limit > 65534) {
return Result.error("每页最多导出65534条住户");
}
// 1. 复用已有联表分页查询,筛选条件、排序与住户列表一致。
Page<PersonVO> pageParam = new Page<>(page, limit);
IPage<PersonVO> personPage = personService.getPersonPage(pageParam, query);
List<PersonVO> personList = personPage.getRecords();
// 2. 准备目录及不重复的文件名;File负责组合目录和文件名。
File directory = new File(excel);
if (!directory.isDirectory() && !directory.mkdirs() && !directory.isDirectory()) {
return Result.error("创建导出目录失败");
}
String fileName = UUID.randomUUID().toString() + ".xls";
File targetFile = new File(directory, fileName);
// 3. 工作簿在内存中生成;退出try时自动关闭,包括异常退出。
try (HSSFWorkbook workbook = new HSSFWorkbook()) {
HSSFSheet sheet = workbook.createSheet("住户信息");
// 第1行:标题,合并A1到I1。与导入模板保持两行表头、九列结构。
HSSFRow titleRow = sheet.createRow(0);
titleRow.createCell(0).setCellValue("住户信息明细表");
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 8));
// 第2行:列名。索引从0开始,列顺序须与下面的数据对应。
String[] headers = {
"ID", "小区名称", "所属楼栋", "房号", "姓名",
"性别", "手机号码", "居住性质", "备注"
};
HSSFRow headerRow = sheet.createRow(1);
for (int i = 0; i < headers.length; i++) {
headerRow.createCell(i).setCellValue(headers[i]);
sheet.setColumnWidth(i, 18 * 256);
}
sheet.setColumnWidth(8, 30 * 256);
// 第3行开始:一条住户对应一行;房号和手机号按字符串写入。
int rowIndex = 2;
for (PersonVO person : personList) {
HSSFRow row = sheet.createRow(rowIndex++);
row.createCell(0).setCellValue(person.getPersonId());
row.createCell(1).setCellValue(person.getCommunityName());
row.createCell(2).setCellValue(person.getTermName());
row.createCell(3).setCellValue(person.getHouseNo());
row.createCell(4).setCellValue(person.getUserName());
row.createCell(5).setCellValue(person.getSex());
row.createCell(6).setCellValue(person.getMobile());
row.createCell(7).setCellValue(person.getPersonType());
row.createCell(8).setCellValue(person.getRemark());
}
// 4. 输出流写向磁盘文件;内层try先关闭流,外层再关闭工作簿。
try (FileOutputStream outputStream = new FileOutputStream(targetFile)) {
workbook.write(outputStream);
}
} catch (IOException e) {
e.printStackTrace();
return Result.error("导出文件失败");
}
// 5. 成功响应只返回文件名,前端拼接下载地址后发起第二次请求。
return Result.ok().put("data", fileName);
}
这次新增的导入是:
java
import java.io.FileOutputStream;
import org.apache.poi.ss.util.CellRangeAddress;
page 和 limit 用 @RequestParam 接收,PersonQuery 接收三个搜索字段。前端通过 GET 查询参数传入,不是 JSON 请求体,因此这里也不使用 @RequestBody。
表头和住户行
导入时从输入流打开现有工作簿,导出则创建一个新的 HSSFWorkbook。标题、表头仍然占两行,住户数据从下标 2 开始,列顺序与导入模板相同。
java
new CellRangeAddress(0, 0, 0, 8)
四个参数是开始行、结束行、开始列、结束列,所以上面合并的是 A1 到 I1。18 * 256 是设置列宽时使用的单位,并不是 18 个像素。
每条 PersonVO 写一行。getCommunityName() 取得联查的小区名称,房号、手机号按字符串写入。没有查询结果时,仍然可以生成文件,只包含标题和表头。
write() 写到哪里?
java
try (FileOutputStream outputStream = new FileOutputStream(targetFile)) {
workbook.write(outputStream);
}
前面的 createRow() 和 setCellValue() 都在构造内存中的表格,write() 才把内容写出。这里传的是文件输出流,所以保存到服务器磁盘。如果传 response.getOutputStream(),则直接发给浏览器。
直接返回文件流也能做导出,但要改成与之配套的前端下载方式。本项目已有前端期待"JSON 里的文件名",所以按原接口约定保存文件,再返回名称。
外层 try-with-resources 管理工作簿,内层管理输出流。离开代码块时先关闭流,再关闭工作簿;发生异常也会关闭已经创建的资源。close() 是释放资源,不能代替 write()。
上传和导出的几处操作放在一起看:
| 操作 | 用途 |
|---|---|
new File(directory, fileName) |
组合目标路径 |
file.transferTo(targetFile) |
保存上传内容 |
new HSSFWorkbook(input) |
读取已有工作簿 |
workbook.write(outputStream) |
写出工作簿内容 |
前端怎样下载这个文件?
导出接口返回文件名后,前端执行:
javascript
exportExcel(this.listQuery).then(res => {
location.href = process.env.VUE_APP_BASE_API
+ '/community/upload/excel/' + res.data
})
exportExcel() 的请求配置是:
javascript
export function exportExcel(data) {
return request({
url: '/sys/person/exportExcel',
method: 'get',
params: data
})
}
这就是两次请求:Axios 先取 JSON,location.href 再让浏览器访问文件。当前实现没有设置 responseType: 'blob',也不需要按 Blob 处理第一次响应。
服务器文件保存在 D:/community/excel/,前端访问的却是 /community/upload/excel/。这两个路径不必一致,中间通过资源映射对应起来:
java
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
@Value("${upload.face}")
String face;
@Value("${upload.excel}")
String excel;
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/community/face/**")
.addResourceLocations("file:/" + face);
registry.addResourceHandler(
"/community/upload/excel/**", "/community/excel/**")
.addResourceLocations("file:/" + excel);
}
}
第一条是原来的人脸图片映射,Excel 用后面这一条。保留了两个 Excel URL,它们指向同一目录,不会因此产生两份文件。file: 表示磁盘资源,配置中的目录末尾保留 /。相关配置见 Spring 静态资源文档。
例如文件名是 demo.xls,开发环境的访问过程是:
text
/dev-api/community/upload/excel/demo.xls
↓ 前端代理去掉 /dev-api,转发到 localhost:8090
/community/upload/excel/demo.xls
↓ 后端资源映射
D:/community/excel/demo.xls
生成接口返回的是文件名,实际文件内容由这第二次请求取得。