人人都会AI编程

27.2 后台管理系统:通用 CRUD、分页查询、数据导出

更新时间:2026-07-10

后台管理系统的开发中,通用 CRUD、分页查询、数据导出 是最基础也是最高频的三个功能模块。将它们设计得足够复用、足够简洁,能大幅缩短后续业务模块的开发周期。本节以 Spring Boot + MyBatis-Plus + EasyExcel 的组合为例,展示一套经过实战检验的实现方式。

27.2.1 通用 CRUD:让每个实体都有一致的操作

在实际项目中,90% 的实体都只需要增、删、改、查、列表分页等标准操作。逐个编写相似的 Controller 和 Service 是在浪费生命。我们可以通过 MyBatis-Plus 的 BaseMapper + 通用 Service 封装,将核心的 CRUD 抽象到基类中。

1. 通用 Service 接口

定义 BaseService 接口,它继承了 MyBatis-Plus 的 IService,并扩展几个常用方法:

public interface BaseService<T> extends IService<T> {
    /**
     * 分页查询(通用)
     * @param page 分页对象
     * @param wrapper 查询条件构造器
     * @return 分页结果
     */
    IPage<T> pageQuery(Page<T> page, Wrapper<T> wrapper);
}

实现类 BaseServiceImpl 继承 ServiceImpl<M, T> 并实现该接口:

public class BaseServiceImpl<M extends BaseMapper<T>, T> 
        extends ServiceImpl<M, T> implements BaseService<T> {

    @Override
    public IPage<T> pageQuery(Page<T> page, Wrapper<T> wrapper) {
        return baseMapper.selectPage(page, wrapper);
    }
}

2. 通用 Controller 封装

对于标准的 CRUD,我们无法在 Java 中写出一个完全通用的 Controller 基类(因为不同实体的字段、校验规则不同),但可以将请求路径、参数模式固定下来,通过泛型约束。一个典型的基类如下:

public abstract class BaseController<S extends BaseService<T>, T> {

    @Autowired
    protected S baseService;

    @GetMapping("/{id}")
    public R<T> getById(@PathVariable Long id) {
        T entity = baseService.getById(id);
        return R.ok(entity);
    }

    @PostMapping
    public R<Void> save(@RequestBody @Validated T entity) {
        baseService.save(entity);
        return R.ok();
    }

    @PutMapping
    public R<Void> update(@RequestBody @Validated T entity) {
        baseService.updateById(entity);
        return R.ok();
    }

    @DeleteMapping("/{id}")
    public R<Void> delete(@PathVariable Long id) {
        baseService.removeById(id);
        return R.ok();
    }

    @PostMapping("/page")
    public R<IPage<T>> page(@RequestBody PageQueryDTO query) {
        Page<T> page = new Page<>(query.getPageNum(), query.getPageSize());
        // 子类需实现条件构造方法
        Wrapper<T> wrapper = buildQueryWrapper(query);
        IPage<T> result = baseService.pageQuery(page, wrapper);
        return R.ok(result);
    }

    /**
     * 由子类提供查询条件构造逻辑
     */
    protected abstract Wrapper<T> buildQueryWrapper(PageQueryDTO query);
}

子类 Controller 只需继承即可,例如用户管理:

@RestController
@RequestMapping("/api/admin/users")
public class UserController extends BaseController<UserService, User> {

    @Override
    protected Wrapper<User> buildQueryWrapper(PageQueryDTO query) {
        // 从 query 中提取实际的查询参数(如 keyword)
        String keyword = query.getKeyword();
        return new LambdaQueryWrapper<User>()
                .like(StringUtils.hasText(keyword), User::getUsername, keyword)
                .or()
                .like(StringUtils.hasText(keyword), User::getNickname, keyword)
                .orderByDesc(User::getCreateTime);
    }
}

如此,每新增一个实体管理,只需要编写 Entity、Mapper、Service 接口(空继承即可)和 Controller 子类,焦点只放在特定的查询条件和校验规则上。通用 CRUD 的复用程度立刻得到质的提升

27.2.2 分页查询:前端友好、后端稳健

分页查询是后台管理中出现频率最高的接口。通常前端会传递 pageNumpageSize 和若干查询条件,后端返回总记录数、当前页数据等。我们采用统一的请求体和响应体来规范化交互。

1. 通用分页请求 DTO

@Data
public class PageQueryDTO {
    @NotNull(message = "页码不能为空")
    private Integer pageNum = 1;

    @NotNull(message = "每页条数不能为空")
    @Max(value = 100, message = "每页最多100条")
    private Integer pageSize = 10;

    // 通用的搜索关键词,按需扩展具体条件
    private String keyword;
}

2. 通用分页响应封装

MyBatis-Plus 的 IPage 已经包含了 recordstotalpages 等字段,前端通常需要如下结构:

{
    "code": 200,
    "data": {
        "records": [...],
        "total": 50,
        "pageNum": 1,
        "pageSize": 10
    },
    "message": "success"
}

我们的 R 类可以直接包装 IPage 对象,序列化即可。

3. 联表查询与复杂条件

对于简单的单表模糊查询,LambdaQueryWrapper 足够;但对于联表或更复杂的动态条件,建议在 Mapper 中自定义 SQL,并在 Service 层封装 Page<T> 对象,手动设置 totalrecords。例如:

@Service
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements OrderService {
    public IPage<OrderVO> pageOrders(Page<Order> page, OrderQueryDTO query) {
        // 自定义 Mapper 方法返回 IPage<OrderVO>
        return baseMapper.selectOrderPage(page, query);
    }
}

对应的 Mapper XML 使用 MyBatis-Plus 的分页插件拦截器自动生成 count 查询,只需写好查询数据的 SQL 即可。

27.2.3 数据导出:安全可靠的海量数据下载

后台管理系统中,运营人员常常需要将查询结果导出为 Excel。处理导出时有几个雷区:OOM(内存溢出)、响应超时、数据权限泄露。通过 EasyExcel 的流式写入和同步下载模式,我们可以高效且安全地解决。

1. 导出工具类

封装一个通用的 ExcelUtil,利用 EasyExcel 的 write 方法,直接将数据写入 HttpServletResponse 的输出流:

public class ExcelUtil {
    /**
     * 导出 Excel(适用于数据量中等、一次性加载到内存的场景)
     * @param fileName 文件名(不含后缀)
     * @param sheetName sheet名
     * @param headClass 表头类(注解 @ExcelProperty 标记)
     * @param dataList 数据列表
     * @param response HttpServletResponse
     */
    public static <T> void export(String fileName, String sheetName, 
                                  Class<T> headClass, List<T> dataList,
                                  HttpServletResponse response) throws IOException {
        // 设置响应头
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setCharacterEncoding("utf-8");
        String encodedFileName = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20");
        response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + encodedFileName + ".xlsx");

        EasyExcel.write(response.getOutputStream(), headClass)
                .sheet(sheetName)
                .doWrite(dataList);
    }
}

2. 大数据量导出:分页查询 + 流式写入

当数据量可能达到数十万行时,一次性加载全部数据会撑爆内存。EasyExcel 支持多次 doWrite 追加写入,我们结合分页查询分批获取数据:

public static <T> void exportLarge(String fileName, String sheetName,
                                   Class<T> headClass,
                                   Function<PageQueryDTO, IPage<T>> pageQueryFunc,
                                   HttpServletResponse response) throws IOException {
    // 响应头设置同上
    response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
    response.setCharacterEncoding("utf-8");
    String encodedFileName = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20");
    response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + encodedFileName + ".xlsx");

    ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), headClass).build();
    WriteSheet writeSheet = EasyExcel.writerSheet(sheetName).build();

    int pageNum = 1;
    int pageSize = 5000; // 每批5000条
    Long total;
    do {
        PageQueryDTO query = new PageQueryDTO();
        query.setPageNum(pageNum);
        query.setPageSize(pageSize);
        IPage<T> page = pageQueryFunc.apply(query);
        List<T> records = page.getRecords();
        if (pageNum == 1) {
            total = page.getTotal();
        }
        excelWriter.write(records, writeSheet);
        pageNum++;
    } while ((pageNum - 1) * pageSize < total);
    
    excelWriter.finish();
}

Controller 中的调用非常简洁:

@GetMapping("/export")
public void exportOrders(OrderQueryDTO query, HttpServletResponse response) throws IOException {
    ExcelUtil.exportLarge("订单列表", "订单数据", OrderExportVO.class,
            pageQuery -> orderService.pageOrders(new Page<>(pageQuery.getPageNum(), 
                                         pageQuery.getPageSize()), query),
            response);
}

3. 安全与规范要点

  • 权限控制:导出接口应加上 @PreAuthorize("hasAuthority('order:export')") 或自定义注解。
  • 数据脱敏:如手机号、身份证等字段在导出 VO 中使用 @ExcelProperty 标注,并单独处理脱敏逻辑。
  • 异步导出与文件缓存:对于超大文件(超过百万行),建议采用异步任务,生成临时下载链接,避免 HTTP 连接长时间挂起。
  • 文件名防乱码:务必使用 URLEncoder 处理文件名,兼容各浏览器。

27.2.4 小结

通过 通用 CRUD 基类 + 分页查询统一封装 + EasyExcel 流式导出 这三板斧,后台管理系统中 80% 的常规功能都可以快速搭建起来。开发人员只需关注业务实体的字段定义和特殊查询逻辑,对产品提出的“加个列表页、加个导出”需求,通常十分钟内即可完成。

在真实项目中,你还可以进一步将这三种能力整合成代码生成器(配合模板引擎),通过读取数据库表结构自动生成 Controller、Service、Mapper 和前端页面,从而到达后台开发效率的极致。但即使不依赖生成器,基类封装带来的效率提升也足以应对绝大多数中小型系统的迭代节奏。