260601-后端各功能接口实现方式与代码规范分析.md 109 KB

CRUD与导入导出代码实现模式分析

分析日期:2026-06-01\ 项目版本:JeecgBoot 3.9.2


一、概述

本文档深入分析湛江人社项目中增删改查(CRUD)数据导入导出下载导入模板等功能的代码实现方式,并提炼出这些功能的最大规范公约数——即所有实现中共享的最核心、最通用的代码模式。


二、增删改查(CRUD)实现模式

2.1 标准单表 CRUD(最大公约数模式)

这是项目中最常见、最规范的实现方式,以 JeecgDemoController 为代表:

@Slf4j
@RestController
@RequestMapping("/test/jeecgDemo")
public class JeecgDemoController extends JeecgController<JeecgDemo, IJeecgDemoService> {

    // ========== 查询(R) ==========
    
    // 分页列表查询
    @GetMapping(value = "/list")
    @PermissionData(pageComponent = "system/examples/demo/index")
    public Result<?> list(JeecgDemo jeecgDemo,
                          @RequestParam(name="pageNo", defaultValue="1") Integer pageNo,
                          @RequestParam(name="pageSize", defaultValue="10") Integer pageSize,
                          HttpServletRequest req) {
        QueryWrapper<JeecgDemo> queryWrapper = QueryGenerator.initQueryWrapper(jeecgDemo, req.getParameterMap());
        Page<JeecgDemo> page = new Page<>(pageNo, pageSize);
        IPage<JeecgDemo> pageList = jeecgDemoService.page(page, queryWrapper);
        return Result.OK(pageList);
    }

    // 通过ID查询
    @GetMapping(value = "/queryById")
    public Result<?> queryById(@RequestParam(name="id", required=true) String id) {
        JeecgDemo obj = jeecgDemoService.getById(id);
        return Result.OK(obj);
    }

    // ========== 新增(C) ==========
    
    @PostMapping(value = "/add")
    @AutoLog(value = "添加测试DEMO")
    public Result<?> add(@RequestBody JeecgDemo jeecgDemo) {
        jeecgDemoService.save(jeecgDemo);
        return Result.OK("添加成功!");
    }

    // ========== 修改(U) ==========
    
    @RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST})
    @AutoLog(value = "编辑DEMO", operateType = CommonConstant.OPERATE_TYPE_3)
    public Result<?> edit(@RequestBody JeecgDemo jeecgDemo) {
        jeecgDemoService.updateById(jeecgDemo);
        return Result.OK("更新成功!");
    }

    // ========== 删除(D) ==========
    
    // 单个删除
    @DeleteMapping(value = "/delete")
    public Result<?> delete(@RequestParam(name="id", required=true) String id) {
        jeecgDemoService.removeById(id);
        return Result.OK("删除成功!");
    }

    // 批量删除
    @DeleteMapping(value = "/deleteBatch")
    public Result<?> deleteBatch(@RequestParam(name="ids", required=true) String ids) {
        this.jeecgDemoService.removeByIds(Arrays.asList(ids.split(",")));
        return Result.OK("批量删除成功!");
    }
}

2.2 CRUD 各操作的实现公约

操作 核心方法 数据访问方式 关键注解
分页查询 QueryGenerator.initQueryWrapper() + service.page() MyBatis-Plus Page + QueryWrapper @PermissionData
ID查询 service.getById() MyBatis-Plus 内置方法 @RequiresPermissions
新增 service.save() MyBatis-Plus 内置方法 @AutoLog
修改 service.updateById() MyBatis-Plus 内置方法 @AutoLog
单个删除 service.removeById() 逻辑删除(del_flag @AutoLog
批量删除 service.removeByIds() 逻辑删除 -

2.3 复杂场景的 CRUD 变体

变体一:多租户隔离查询

// SysUserController 中的多租户隔离
if (MybatisPlusSaasConfig.OPEN_SYSTEM_TENANT_CONTROL) {
    String tenantId = oConvertUtils.getString(TenantContext.getTenant(), "-1");
    List<String> userIds = userTenantService.getUserIdsByTenantId(Integer.valueOf(tenantId));
    if (oConvertUtils.listIsNotEmpty(userIds)) {
        queryWrapper.in("id", userIds);
    }
}

变体二:复杂新增(关联表保存)

// SysUserController 中的用户新增
// 前端传 JSONObject,包含角色、部门等关联信息
SysUser user = JSON.parseObject(jsonObject.toJSONString(), SysUser.class);
String salt = oConvertUtils.randomGen(8);
user.setPassword(PasswordUtil.encrypt(user.getUsername(), user.getPassword(), salt));
sysUserService.saveUser(user, selectedRoles, selectedDeparts, relTenantIds, false);

变体三:安全修改(防空值覆盖)

// SysUserController 中的用户修改
SysUser sysUser = sysUserService.getById(jsonObject.getString("id"));  // 先查旧数据
SysUser user = JSON.parseObject(jsonObject.toJSONString(), SysUser.class);
oConvertUtils.copyNonNullFields(user, sysUser);  // 非空字段覆盖,避免空值覆盖
sysUserService.editUser(sysUser, roles, departs, relTenantIds, updateFromPage);

变体四:缓存清理删除

// SysDictController 中的字典删除
@CacheEvict(value={CacheConstant.SYS_DICT_CACHE, CacheConstant.SYS_ENABLE_DICT_CACHE}, allEntries=true)
public Result<SysDict> delete(@RequestParam(name="id",required=true) String id) {
    sysDictService.removeById(id);
    return Result.ok("删除成功!");
}

变体五:主子表 CRUD

// JeecgOrderMainController 中的一对多新增
@PostMapping(value = "/add")
public Result<?> add(@RequestBody JeecgOrderMainPage vo) {
    JeecgOrderMain main = new JeecgOrderMain();
    BeanUtils.copyProperties(vo, main);
    jeecgOrderMainService.saveMain(main, vo.getJeecgOrderCustomerList(), vo.getJeecgOrderTicketList());
    return Result.ok("添加成功!");
}

三、QueryGenerator 查询条件自动生成

这是整个 CRUD 体系的核心引擎,实现了前端零配置即可完成复杂查询条件自动构建

3.1 核心入口

QueryWrapper<T> queryWrapper = QueryGenerator.initQueryWrapper(entity, request.getParameterMap());

3.2 自动推断查询规则

QueryGenerator 通过分析请求参数的值特征,自动推断查询规则:

值特征 推断规则 示例
>= 值 大于等于 >= 100
<= 值 小于等于 <= 100
> 值 大于 > 100
< 值 小于 < 100
*值* 全模糊 LIKE *张*%张%
*值 左模糊 LIKE *张%张
值* 右模糊 LIKE 张*张%
值1,值2 IN 查询 1,2,3 → IN (1,2,3)
!值 不等于 !admin
普通值 等于 EQ admin

3.3 区间查询

通过 字段_begin字段_end 参数实现:

?createTime_begin=2026-01-01&createTime_end=2026-12-31

3.4 高级查询

通过 superQueryParams 参数传入 JSON 格式的复杂查询条件,支持 AND/OR 组合。

3.5 数据权限注入

通过 @PermissionData 注解 + PermissionDataAspect 切面,自动将数据权限规则注入到查询条件中。

3.6 排序支持

支持三种排序方式(按优先级):

  1. sortInfoString — 多字段排序(优先级最高)
  2. defSortString — 默认排序
  3. column + order — 单字段排序

四、数据导出(Export)实现模式

4.1 模式一:调用父类 exportXls(标准模式 — 最大公约数)

@RequestMapping(value = "/exportXls")
public ModelAndView exportXls(HttpServletRequest request, SysUgroup sysUgroup) {
    return super.exportXls(request, sysUgroup, SysUgroup.class, "用户组表");
}

父类 JeecgController.exportXls 的核心流程

Step 1: QueryGenerator.initQueryWrapper() 组装查询条件
Step 2: selections 参数过滤选中数据
Step 3: service.list(queryWrapper) 获取导出数据
Step 4: 构建 ModelAndView(JeecgEntityExcelView)
        ├── FILE_NAME → 文件名
        ├── CLASS → 实体类(含 @Excel 注解定义列结构)
        ├── PARAMS → ExportParams(标题、Sheet名、格式)
        ├── DATA_LIST → 导出数据列表
        └── EXPORT_FIELDS → 可选,指定导出列

4.2 模式二:分 Sheet 导出

@RequestMapping(value = "/exportXls")
public ModelAndView exportXls(HttpServletRequest request, JeecgDemo jeecgDemo) {
    String exportFields = jeecgDemoService.getExportFields();
    return super.exportXlsSheet(request, jeecgDemo, JeecgDemo.class, "单表模型", exportFields, 500);
}

4.3 模式三:大数据量导出

return super.exportXlsForBigData(request, entity, EntityClass.class, "文件名", 1000);

使用 IExcelExportServer 流式分页查询,避免 OOM。

4.4 模式四:主子表导出(手动实现)

@RequestMapping(value = "/exportXls")
public ModelAndView exportXls(HttpServletRequest request, SysDict sysDict) {
    QueryWrapper<SysDict> queryWrapper = QueryGenerator.initQueryWrapper(sysDict, request.getParameterMap());
    List<SysDictPage> pageList = new ArrayList<>();
    List<SysDict> sysDictList = sysDictService.list(queryWrapper);
    for (SysDict dictMain : sysDictList) {
        SysDictPage vo = new SysDictPage();
        BeanUtils.copyProperties(dictMain, vo);
        // 查询子表数据
        vo.setSysDictItemList(sysDictItemService.selectItemsByMainId(dictMain.getId()));
        pageList.add(vo);
    }
    ModelAndView mv = new ModelAndView(new JeecgEntityExcelView());
    mv.addObject(NormalExcelConstants.FILE_NAME, "数据字典");
    mv.addObject(NormalExcelConstants.CLASS, SysDictPage.class);
    mv.addObject(NormalExcelConstants.PARAMS, new ExportParams("数据字典列表", "导出人:" + user.getRealname(), "数据字典", ExcelType.XSSF));
    mv.addObject(NormalExcelConstants.DATA_LIST, pageList);
    return mv;
}

4.5 导出核心组件关系

@Excel 注解 (实体类/VO)
    ↓ 定义列结构
JeecgEntityExcelView (Spring MVC View)
    ↓ 渲染 Excel
ExportParams (导出参数)
    ↓ 标题/Sheet/格式/样式
NormalExcelConstants (常量)
    ↓ ModelAndView key
AutoPoiDictConfig (字典翻译)
    ↓ 导出时自动翻译字典值
最终输出 .xlsx 文件

五、数据导入(Import)实现模式

5.1 模式一:调用父类 importExcel(标准模式 — 最大公约数)

@RequestMapping(value = "/importExcel", method = RequestMethod.POST)
public Result<?> importExcel(HttpServletRequest request, HttpServletResponse response) {
    return super.importExcel(request, response, JeecgDemo.class);
}

父类 JeecgController.importExcel 的核心流程

Step 1: 从 request 获取上传文件 (MultipartFile)
Step 2: 创建 ImportParams(titleRows=2, headRows=1, needSave=true)
Step 3: ExcelImportUtil.importExcel() 解析 Excel 为 List<T>
Step 4: service.saveBatch(list) 批量保存
Step 5: 返回 Result(成功行数 / 错误信息)

5.2 模式二:手动实现导入(主子表场景)

@RequestMapping(value = "/importExcel", method = RequestMethod.POST)
public Result<?> importExcel(HttpServletRequest request, HttpServletResponse response) {
    MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) request;
    Map<String, MultipartFile> fileMap = multipartRequest.getFileMap();
    for (Map.Entry<String, MultipartFile> entity : fileMap.entrySet()) {
        MultipartFile file = entity.getValue();
        ImportParams params = new ImportParams();
        params.setTitleRows(2);
        params.setHeadRows(2);  // 主子表时表头为2行
        params.setNeedSave(true);
        try {
            List<JeecgOrderMainPage> list = ExcelImportUtil.importExcel(file.getInputStream(), JeecgOrderMainPage.class, params);
            for (JeecgOrderMainPage page : list) {
                JeecgOrderMain po = new JeecgOrderMain();
                BeanUtils.copyProperties(page, po);
                jeecgOrderMainService.saveMain(po, page.getJeecgOrderCustomerList(), page.getJeecgOrderTicketList());
            }
            return Result.ok("文件导入成功!");
        } catch (Exception e) {
            return Result.error("文件导入失败:" + e.getMessage());
        }
    }
    return Result.error("文件导入失败!");
}

5.3 模式三:带校验和错误汇总的导入

// SysDictController 中的导入实现
List<SysDictPage> list = ExcelImportUtil.importExcel(file.getInputStream(), SysDictPage.class, params);
List<String> errorMessage = new ArrayList<>();
int successLines = 0, errorLines = 0;
for (int i = 0; i < list.size(); i++) {
    SysDict po = new SysDict();
    BeanUtils.copyProperties(list.get(i), po);
    try {
        Integer result = sysDictService.saveMain(po, list.get(i).getSysDictItemList());
        if (result > 0) {
            successLines++;
        } else if (result == -1) {
            errorLines++;
            errorMessage.add("字典项值不能为空,忽略导入。");
        } else {
            errorLines++;
            errorMessage.add("字典编码已存在,忽略导入。");
        }
    } catch (Exception e) {
        errorLines++;
        errorMessage.add("第 " + (i+1) + " 行:" + e.getMessage());
    }
}
return ImportExcelUtil.imporReturnRes(errorLines, successLines, errorMessage);

ImportExcelUtil.imporReturnRes 返回格式

{
    "success": true,
    "code": 201,
    "message": "文件导入成功,但有错误。",
    "result": {
        "totalCount": 100,
        "errorCount": 5,
        "successCount": 95,
        "msg": "总上传行数:100,已导入行数:95,错误行数:5",
        "fileUrl": "/sys/common/static/logs/20260601/userImportExcelErrorLogxxx.txt",
        "fileName": "userImportExcelErrorLogxxx.txt"
    }
}

5.4 导入核心组件关系

上传文件 (MultipartFile)
    ↓
ExcelImportUtil.importExcel()
    ↓ 解析为 List<T>
ImportParams (titleRows, headRows)
    ↓ 控制跳过行数
@Excel 注解 (实体类/VO)
    ↓ 列与字段的映射规则
AutoPoiDictConfig (字典翻译)
    ↓ 导入时文本自动转编码值
service.saveBatch() / 逐条 save
    ↓ 批量或逐条保存
ImportExcelUtil.imporReturnRes()
    ↓ 返回导入结果

六、下载导入模板的实现模式

6.1 核心结论:没有独立的"下载模板"接口

JeecgBoot 框架中,下载导入模板和导出数据共用同一个 exportXls 接口。当请求不带查询条件时,查询结果为空列表,AutoPoi 仍生成包含标题行和表头行的 Excel 文件——这就是"导入模板"。

6.2 模板 Excel 结构

┌──────────────────────────────────────────────────┐
│ 第1行: 大标题(如 "单表模型报表")                    │  ← titleRows=2 的第1行
├──────────────────────────────────────────────────┤
│ 第2行: 副标题(如 "导出人:管理员" / 导入规则说明)      │  ← titleRows=2 的第2行
├──────┬──────┬──────┬──────┬──────────────────────┤
│ 姓名 │关键词│打卡时间│ 工资 │ 性别 │ ...          │  ← headRows=1
├──────┼──────┼──────┼──────┼──────────────────────┤
│ (空) │ (空) │ (空) │ (空) │ (空) │ ...          │  ← 数据行(模板时为空)
└──────┴──────┴──────┴──────┴──────────────────────┘

6.3 导入参数与导出结构的对应关系

导出参数 导入参数 说明
ExportParams(title, secondTitle, sheetName) ImportParams.titleRows=2 标题占2行
@Excel 注解定义的列 ImportParams.headRows=1 表头占1行
主子表时 @ExcelCollection 增加的列 ImportParams.headRows=2 主子表表头占2行

6.4 用户管理导出的导入规则说明

SysUserController.exportXls 中,导出参数包含了详细的导入规则作为标题行:

ExportParams exportParams = new ExportParams(
    "导入规则:\n" +
    "1. 用户名为必填项,仅支持新增数据导入;\n" +
    "2. 多个部门、角色或负责部门请用英文分号 ; 分隔;\n" +
    "3. 部门层级请用英文斜杠 / 分隔;\n" +
    "4. 部门类型需与部门层级一致;\n" +
    "5. 部门根据用户名匹配,不存在时自动新增;\n" +
    "6. 负责部门与所属部门导入规则一致;\n" +
    "7. 用户主岗位导入时会在部门下自动创建新岗位。",
    "导出人:" + user.getRealname(),
    "导出信息"
);
exportParams.setTitleHeight((short)70);

七、@Excel 注解使用模式

7.1 注解属性速查表

属性 说明 示例
name 列标题(必填) @Excel(name = "姓名")
width 列宽 @Excel(width = 15)
format 日期格式 @Excel(format = "yyyy-MM-dd HH:mm:ss")
dicCode 字典编码 @Excel(dicCode = "sex")
dictTable 字典表名 @Excel(dictTable = "sys_depart")
dicText 字典文本字段 @Excel(dicText = "depart_name")
type 字段类型 1=文本(默认), 2=图片, 4=数值

7.2 八种使用模式

模式 注解写法 适用场景
基础文本 @Excel(name="名称", width=15) 普通字符串字段
日期格式 @Excel(name="时间", width=20, format="yyyy-MM-dd HH:mm:ss") Date 类型字段
字典翻译 @Excel(name="性别", width=15, dicCode="sex") 字典编码字段
表字典翻译 @Excel(name="部门", dictTable="sys_depart", dicText="depart_name", dicCode="id") 外键关联字段
图片导出 @Excel(name="头像", width=15, type=2) 图片URL字段
数值类型 @Excel(name="排序", width=15, type=4) 数值型字段
一对多 @ExcelCollection(name="子表") + 子实体 @Excel 主子表导出
不导出 不加 @Excel 注解 不需要导入导出的字段

八、最大规范公约数

8.1 定义

最大规范公约数 = 所有 CRUD + 导入导出实现中,最核心、最通用、最不可省略的代码模式和组件依赖。

8.2 公约数清单

第一层:框架基础设施(不可省略)

组件 作用 位置
JeecgController<T, S> Controller 基类,提供 exportXls/importExcel jeecg-boot-base-core
QueryGenerator 查询条件自动生成 jeecg-boot-base-core
Result<T> 统一返回结果封装 jeecg-boot-base-core
MybatisInterceptor 公共字段自动填充(createBy/createTime等) jeecg-boot-base-core
JeecgEntity 实体基类(id/createBy/createTime/updateBy/updateTime) jeecg-boot-base-core

第二层:CRUD 公约数(6 个方法)

// 最简 CRUD Controller 模板
@RestController
@RequestMapping("/模块/实体")
public class XxxController extends JeecgController<Xxx, IXxxService> {

    // 1. 分页列表
    @GetMapping("/list")
    public Result<?> list(Xxx entity, @RequestParam(defaultValue="1") Integer pageNo,
                          @RequestParam(defaultValue="10") Integer pageSize, HttpServletRequest req) {
        QueryWrapper<Xxx> qw = QueryGenerator.initQueryWrapper(entity, req.getParameterMap());
        return Result.OK(service.page(new Page<>(pageNo, pageSize), qw));
    }

    // 2. 新增
    @PostMapping("/add")
    public Result<?> add(@RequestBody Xxx entity) {
        service.save(entity);
        return Result.OK("添加成功!");
    }

    // 3. 修改
    @PutMapping("/edit")
    public Result<?> edit(@RequestBody Xxx entity) {
        service.updateById(entity);
        return Result.OK("更新成功!");
    }

    // 4. 删除
    @DeleteMapping("/delete")
    public Result<?> delete(@RequestParam String id) {
        service.removeById(id);
        return Result.OK("删除成功!");
    }

    // 5. 批量删除
    @DeleteMapping("/deleteBatch")
    public Result<?> deleteBatch(@RequestParam String ids) {
        service.removeByIds(Arrays.asList(ids.split(",")));
        return Result.OK("批量删除成功!");
    }

    // 6. ID查询
    @GetMapping("/queryById")
    public Result<?> queryById(@RequestParam String id) {
        return Result.OK(service.getById(id));
    }
}

第三层:导入导出公约数(2 个方法)

// 导出(同时作为下载模板接口)
@RequestMapping(value = "/exportXls")
public ModelAndView exportXls(HttpServletRequest request, Xxx entity) {
    return super.exportXls(request, entity, Xxx.class, "文件名");
}

// 导入
@RequestMapping(value = "/importExcel", method = RequestMethod.POST)
public Result<?> importExcel(HttpServletRequest request, HttpServletResponse response) {
    return super.importExcel(request, response, Xxx.class);
}

第四层:实体注解公约数

@Data
@TableName("表名")
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class Xxx extends JeecgEntity {
    
    @Excel(name = "字段名", width = 15)          // 需要导出/导入的字段
    private String field;
    
    @Excel(name = "字典字段", width = 15, dicCode = "dict_code")  // 字典翻译字段
    private Integer dictField;
    
    @Excel(name = "日期字段", width = 20, format = "yyyy-MM-dd HH:mm:ss")  // 日期字段
    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date dateField;
    
    // 不需要导出的字段不加 @Excel 注解
    private String internalField;
}

8.3 公约数架构图

┌─────────────────────────────────────────────────────────────┐
│                    最大规范公约数                              │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │              实体层公约数                              │   │
│  │  • 继承 JeecgEntity(公共字段 + @Excel 注解)         │   │
│  │  • @TableName + @TableId(type=ASSIGN_ID)             │   │
│  │  • @Excel(name, width) 定义导出列                     │   │
│  │  • @Excel(dicCode) 字典翻译                           │   │
│  │  • @Excel(format) 日期格式                            │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │              Controller 层公约数                       │   │
│  │  • 继承 JeecgController<T, S>                        │   │
│  │  • list:   QueryGenerator + Page + service.page()    │   │
│  │  • add:    service.save()                            │   │
│  │  • edit:   service.updateById()                      │   │
│  │  • delete: service.removeById()                      │   │
│  │  • exportXls:  super.exportXls()                     │   │
│  │  • importExcel: super.importExcel()                  │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │              Service 层公约数                          │   │
│  │  • 接口: IXxxService extends IService<Xxx>           │   │
│  │  • 实现: XxxServiceImpl extends ServiceImpl<Mapper,  │   │
│  │          Xxx> implements IXxxService                  │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │              Mapper 层公约数                           │   │
│  │  • XxxMapper extends BaseMapper<Xxx>                 │   │
│  │  • XML: resources/xml/XxxMapper.xml(可选)           │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │              框架自动能力公约数                         │   │
│  │  • QueryGenerator: 查询条件自动生成                    │   │
│  │  • MybatisInterceptor: 公共字段自动填充                │   │
│  │  • DictAspect: 字典值自动翻译(@Dict)                 │   │
│  │  • AutoLogAspect: 操作日志自动记录(@AutoLog)         │   │
│  │  • AutoPoiDictConfig: Excel 字典自动翻译              │   │
│  │  • JeecgEntityExcelView: Excel 视图渲染               │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

8.4 开发一个新模块的最小步骤

基于最大公约数,开发一个带完整 CRUD + 导入导出的新模块,只需以下步骤:

  1. 创建实体类:继承 JeecgEntity,加 @TableName@Excel 注解
  2. 创建 Mapper:继承 BaseMapper<T>
  3. 创建 Service 接口:继承 IService<T>
  4. 创建 Service 实现:继承 ServiceImpl<Mapper, T>
  5. 创建 Controller:继承 JeecgController<T, IService<T>>,实现 6 个 CRUD 方法 + 2 个导入导出方法

无需额外开发的

  • 查询条件自动构建 → QueryGenerator 已实现
  • 公共字段自动填充 → MybatisInterceptor 已实现
  • 字典值自动翻译 → DictAspect + AutoPoiDictConfig 已实现
  • 操作日志自动记录 → AutoLogAspect 已实现
  • Excel 导出渲染 → JeecgEntityExcelView 已实现
  • Excel 导入解析 → ExcelImportUtil 已实现
  • 导入模板下载 → 复用 exportXls 接口

九、涉及的文件清单

| 文件 | 操作 | 原因 | | -------------------------------------- | -- | ------ | | AIWork/260601-湛江人社项目CRUD与导入导出模式分析.md | 新增 | 本次分析文档 |


十、关键源码索引

组件 文件路径
Controller 基类 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/base/controller/JeecgController.java
实体基类 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/base/entity/JeecgEntity.java
查询生成器 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/query/QueryGenerator.java
导入工具 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/ImportExcelUtil.java
AutoPoi 配置 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/AutoPoiConfig.java
字典翻译配置 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/AutoPoiDictConfig.java
错误日志工具 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/PmsUtil.java
单表CRUD示例 jeecg-boot/jeecg-boot-module/jeecg-module-demo/src/main/java/org/jeecg/modules/demo/test/controller/JeecgDemoController.java
主子表CRUD示例 jeecg-boot/jeecg-boot-module/jeecg-module-demo/src/main/java/org/jeecg/modules/demo/test/controller/JeecgOrderMainController.java
用户管理Controller jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUserController.java
字典管理Controller jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysDictController.java

十一、文件上传实现模式

追加日期:2026-06-01

JeecgBoot 框架内置了完整的文件上传体系,支持本地存储、MinIO、阿里云 OSS 三种存储方式,通过配置切换。

11.1 核心上传控制器:CommonController

CommonController 是框架的通用文件上传控制器,路径前缀 /sys/common

接口 方法 HTTP方法 功能说明
/sys/common/upload upload() POST 文件上传统一方法,支持 local/minio/alioss 三种存储方式
/sys/common/static/** view() GET 预览图片 & 下载文件(仅本地存储模式生效),Shiro 配置为 anon 免认证
/sys/common/uploadImgByHttp uploadImgByHttp() POST 根据网络图片地址上传到服务器

上传接口核心逻辑:

@PostMapping(value = "/upload")
public Result<?> upload(HttpServletRequest request, HttpServletResponse response) throws Exception {
    String bizPath = request.getParameter("biz");
    MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) request;
    MultipartFile file = multipartRequest.getFile("file");
    SsrfFileTypeFilter.checkUploadFileType(file, bizPath);  // 文件安全校验
    if(CommonConstant.UPLOAD_TYPE_LOCAL.equals(uploadType)){
        savePath = this.uploadLocal(file,bizPath);  // 本地上传
    }else{
        savePath = CommonUtils.upload(file, bizPath, uploadType);  // 云存储上传
    }
}

静态文件服务核心逻辑:

@GetMapping(value = "/static/**")
public void view(HttpServletRequest request, HttpServletResponse response) {
    // 提取路径中 /** 匹配的部分
    String imgPath = extractPathFromPattern(request);
    imgPath = imgPath.replace("..", "");  // 路径遍历防护
    SsrfFileTypeFilter.checkDownloadFileType(imgPath);  // 下载类型校验
    // 读取本地文件 -> 流式输出
}

返回格式:

{
    "success": true,
    "message": "temp/20260601/xxxx.jpg",
    "code": 200
}

message 字段为文件的相对路径,前端据此拼接完整访问 URL。

11.2 其他上传控制器

控制器 路径前缀 功能
SysUploadController /sys/upload MinIO 专用上传,上传后保存文件信息到 oss_file
OssFileController /sys/oss/file OSS 文件管理(上传/列表/删除),需要 system:ossFile:upload 权限

11.3 上传工具类(三层架构)

上传路由层:CommonUtils

CommonUtils 根据 uploadType 自动选择存储方式:

方法签名 功能
upload(MultipartFile, String bizPath, String uploadType) 根据 uploadType 自动选择 MinIO 或阿里云 OSS
uploadLocal(MultipartFile, String bizPath, String uploadpath) 本地文件上传(含路径遍历防护)
getFileName(String fileName) 文件名安全处理(去盘符、特殊字符、空格等)

存储实现层:MinioUtil / OssBootUtil

工具类 文件路径 功能
MinioUtil jeecg-boot-base-core/.../util/MinioUtil.java MinIO 上传/下载/删除/预签名 URL
OssBootUtil jeecg-boot-base-core/.../util/oss/OssBootUtil.java 阿里云 OSS 上传/下载/删除/预签名 URL

11.4 安全校验:SsrfFileTypeFilter

SsrfFileTypeFilter 提供上传/下载安全校验:

方法 功能
checkUploadFileType(MultipartFile, String customPath) 上传文件类型过滤(后缀白名单 + 文件头校验 + 路径安全校验)
checkDownloadFileType(String filePath) 下载文件类型过滤
validatePathSecurity(String customPath) 路径安全校验(防 ..、限制深度5层、限制字符集)

文件类型白名单: jpg, jpeg, png, gif, bmp, svg, ico, heic, txt, doc, docx, pdf, csv, md, mp4, avi, mov, wmv, mp3, wav, xls, xlsx, zip, rar, 7z, tar, apk, wgt, ppt, pptx

文件头黑名单检测: jsp, php, class, sql

11.5 上传配置参数

YAML 配置application-dev.yml):

jeecg:
  uploadType: local          # 本地:local、Minio:minio、阿里云:alioss
  path:
    upload: /opt/upFiles     # 文件上传根目录(本地存储模式)
    webapp: /opt/webapp      # webapp文件路径

spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 10MB

上传类型常量(CommonConstant.java):

String UPLOAD_TYPE_LOCAL = "local";
String UPLOAD_TYPE_MINIO = "minio";
String UPLOAD_TYPE_OSS = "alioss";

11.6 静态资源服务配置

WebMvcConfiguration.java 的 addResourceHandlers() 方法配置了静态资源映射,使上传目录中的文件可通过 Spring 静态资源机制直接访问:

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/**")
        .addResourceLocations("file:" + jeecgBaseConfig.getPath().getUpload() + "//")
        .addResourceLocations("file:" + jeecgBaseConfig.getPath().getWebapp() + "//");
}

11.7 Shiro 认证配置

文件上传接口 /sys/common/upload 需要 Token 认证。文件预览/下载接口 /sys/common/static/** 在 ShiroConfig.java 中配置为 anon(匿名访问):

filterChainDefinitionMap.put("/sys/common/static/**", "anon");  // 图片预览 & 下载文件不限制token

此外,框架还支持 @IgnoreAuth 注解实现方法级别的免认证控制,标注该注解的接口方法会自动免 Token 认证。

11.8 整体上传流程架构

上传请求流程:
  前端 POST /sys/common/upload (file + biz)
      |
      v
  CommonController.upload()
      |
      +-- SsrfFileTypeFilter.checkUploadFileType()  // 安全校验
      |
      +-- uploadType == "local" ?
      |     YES --> CommonController.uploadLocal()   // 本地存储 → /opt/upFiles
      |     NO  --> CommonUtils.upload()             // 云存储路由
      |               |
      |               +-- uploadType == "minio" ?
      |               |     YES --> MinioUtil.upload()
      |               |     NO  --> OssBootUtil.upload()  // 阿里云OSS
      |
      v
  返回 Result(success=true, message=相对路径)

文件访问流程(本地存储模式):
  GET /sys/common/static/{相对路径}
      |
      v
  CommonController.view()
      |
      +-- extractPathFromPattern()   // 提取路径
      +-- 路径遍历防护 (replace "..")
      +-- SsrfFileTypeFilter.checkDownloadFileType()  // 下载类型校验
      +-- 读取本地文件 -> 流式输出

11.9 自定义上传控制器的场景

当业务需要独立的文件存储路径(如 D:\sitefile\zjrs 而非框架默认的 /opt/upFiles)时,需要自定义上传控制器。此时需要注意:

  1. 上传接口:参照 CommonController.upload() 的模式,接收 MultipartFile,保存到自定义目录,返回 Result 格式(message 为相对路径)
  2. 静态文件服务接口:参照 CommonController.view() 的模式,提供文件流输出
  3. 认证配置:使用 @IgnoreAuth 注解让静态文件服务接口免认证,或通过 jeecg.shiro.excludeUrls 配置
  4. 安全防护:必须包含路径遍历防护(过滤 .. 字符)

11.10 关键源码索引(文件上传)

组件 文件路径
通用上传控制器 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/CommonController.java
MinIO上传控制器 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUploadController.java
OSS文件管理控制器 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/oss/controller/OssFileController.java
上传路由工具 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/CommonUtils.java
MinIO工具 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/MinioUtil.java
OSS工具 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/oss/OssBootUtil.java
安全校验过滤器 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/filter/SsrfFileTypeFilter.java
静态资源配置 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/WebMvcConfiguration.java
Shiro配置 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java
免认证注解 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/IgnoreAuth.java
上传类型常量 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/CommonConstant.java
文件上传DTO jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/api/dto/FileUploadDTO.java

十二、主子表(Master-Detail)CRUD 实现模式

追加日期:2026-06-02

12.1 整体架构概览

JeecgBoot 中主子表的实现采用经典的 一对多(OneToMany) 模式,核心思路是:

  • 主表实体子表实体 分别独立定义,子表通过 外键字段 关联主表
  • VO/Page类 将主表字段与子表列表组合在一起,用于前后端数据传输
  • Service层 负责事务管理,统一处理主表和子表的增删改
  • Mapper层 提供通过外键查询/删除子表数据的专用方法

项目中存在两个完整的主子表示例:

  1. Demo模块JeecgOrderMain(订单主表) + JeecgOrderCustomer(客户子表) + JeecgOrderTicket(机票子表)
  2. 系统模块SysDict(字典主表) + SysDictItem(字典项子表)

注意:湛江人社业务模块(jeecg-module-zjrs)当前不存在标准的主子表实现,所有业务均使用单表 CRUD 模式。如需在 zjrs 模块中实现主子表功能,应参照本节所述的标准模式。

12.2 实体类定义 — 主子表关系

主表实体

JeecgOrderMain.java — 主表实体与普通单表实体无异:


@Data
@TableName("jeecg_order_main")
public class JeecgOrderMain implements Serializable {
    @TableId(type = IdType.ASSIGN_ID)
    private String id;
    private String orderCode;
    private String ctype;
    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date orderDate;
    private Double orderMoney;
    private String content;
    private String bpmStatus;
    // ... 审计字段 createBy, createTime, updateBy, updateTime
}

关键点:主表实体不包含子表列表引用,与单表实体结构完全一致。

子表实体

JeecgOrderCustomer.java — 子表通过外键关联主表:


@Data
@TableName("jeecg_order_customer")
public class JeecgOrderCustomer implements Serializable {
    @TableId(type = IdType.ASSIGN_ID)
    private String id;
    @Excel(name = "客户名字", width = 15)
    private String name;
    private String sex;
    @Excel(name = "身份证号码", width = 15)
    private String idcard;
    /**外键*/
    private String orderId;          // <--- 关键:外键字段关联主表
    // ... 审计字段
}

关键点:子表实体通过 orderId 外键字段 关联主表,这是主子表关系的核心纽带。外键字段命名规范为 {主表实体名小写}Id ,如 orderId

VO/Page类 — 主子表数据组合

JeecgOrderMainPage.java — 将主表字段和子表列表组合在一起:


@Data
public class JeecgOrderMainPage {
    private String id;
    @Excel(name = "订单号", width = 15)
    private String orderCode;
    private String ctype;
    @Excel(name = "订单日期", width = 15, format = "yyyy-MM-dd")
    private Date orderDate;
    @Excel(name = "订单金额", width = 15)
    private Double orderMoney;
    private String content;
    private String bpmStatus;

    @ExcelCollection(name = "客户")              // <--- 关键注解:标记子表集合
    private List<JeecgOrderCustomer> jeecgOrderCustomerList;
    @ExcelCollection(name = "机票")              // <--- 关键注解:标记子表集合
    private List<JeecgOrderTicket> jeecgOrderTicketList;
}

关键点

  • @ExcelCollection 注解是 AutoPoi 的注解,用于 Excel 导入导出时识别子表数据
  • VO类将主表字段和多个子表列表组合在一起,是前后端交互的数据传输对象
  • Controller层接收此VO对象,然后通过 BeanUtils.copyProperties 拆分为主表实体

12.3 Controller层 — 主子表增删改查

JeecgOrderMainController.java

新增(主表+子表一起保存)


@PostMapping(value = "/add")
public Result<?> add(@RequestBody JeecgOrderMainPage jeecgOrderMainPage) {
    JeecgOrderMain jeecgOrderMain = new JeecgOrderMain();
    BeanUtils.copyProperties(jeecgOrderMainPage, jeecgOrderMain);  // VO -> 主表实体
    jeecgOrderMainService.saveMain(jeecgOrderMain,
            jeecgOrderMainPage.getJeecgOrderCustomerList(),
            jeecgOrderMainPage.getJeecgOrderTicketList());
    return Result.ok("添加成功!");
}

编辑


@RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST})
public Result<?> eidt(@RequestBody JeecgOrderMainPage jeecgOrderMainPage) {
    JeecgOrderMain jeecgOrderMain = new JeecgOrderMain();
    BeanUtils.copyProperties(jeecgOrderMainPage, jeecgOrderMain);
    jeecgOrderMainService.updateCopyMain(jeecgOrderMain,
            jeecgOrderMainPage.getJeecgOrderCustomerList(),
            jeecgOrderMainPage.getJeecgOrderTicketList());
    return Result.ok("编辑成功!");
}

删除(主表+子表一起删除)


@DeleteMapping(value = "/delete")
public Result<?> delete(@RequestParam(name = "id", required = true) String id) {
    jeecgOrderMainService.delMain(id);
    return Result.ok("删除成功!");
}

@DeleteMapping(value = "/deleteBatch")
public Result<?> deleteBatch(@RequestParam(name = "ids", required = true) String ids) {
    this.jeecgOrderMainService.delBatchMain(Arrays.asList(ids.split(",")));
    return Result.ok("批量删除成功!");
}

查询(主表和子表分开查询)

// 查询主表
@GetMapping(value = "/queryById")
public Result<?> queryById(@RequestParam(name = "id", required = true) String id) {
    JeecgOrderMain jeecgOrderMain = jeecgOrderMainService.getById(id);
    return Result.ok(jeecgOrderMain);
}

// 查询子表 — 客户(每个子表一个独立接口)
@GetMapping(value = "/queryOrderCustomerListByMainId")
public Result<?> queryOrderCustomerListByMainId(@RequestParam(name = "id", required = true) String id) {
    List<JeecgOrderCustomer> list = jeecgOrderCustomerService.selectCustomersByMainId(id);
    return Result.ok(list);
}

// 查询子表 — 机票
@GetMapping(value = "/queryOrderTicketListByMainId")
public Result<?> queryOrderTicketListByMainId(@RequestParam(name = "id", required = true) String id) {
    List<JeecgOrderTicket> list = jeecgOrderTicketService.selectTicketsByMainId(id);
    return Result.ok(list);
}

Excel导出(主子表一起导出)


@RequestMapping(value = "/exportXls")
public ModelAndView exportXls(HttpServletRequest request, JeecgOrderMain jeecgOrderMain) {
    QueryWrapper<JeecgOrderMain> queryWrapper = QueryGenerator.initQueryWrapper(jeecgOrderMain, request.getParameterMap());
    List<JeecgOrderMainPage> pageList = new ArrayList<>();
    List<JeecgOrderMain> jeecgOrderMainList = jeecgOrderMainService.list(queryWrapper);
    for (JeecgOrderMain orderMain : jeecgOrderMainList) {
        JeecgOrderMainPage vo = new JeecgOrderMainPage();
        BeanUtils.copyProperties(orderMain, vo);
        // 查询子表数据并设置到VO
        vo.setJeecgOrderTicketList(jeecgOrderTicketService.selectTicketsByMainId(orderMain.getId()));
        vo.setJeecgOrderCustomerList(jeecgOrderCustomerService.selectCustomersByMainId(orderMain.getId()));
        pageList.add(vo);
    }
    ModelAndView mv = new ModelAndView(new JeecgEntityExcelView());
    mv.addObject(NormalExcelConstants.FILE_NAME, "订单");
    mv.addObject(NormalExcelConstants.CLASS, JeecgOrderMainPage.class);
    mv.addObject(NormalExcelConstants.PARAMS, new ExportParams("订单列表", "导出人:", "订单"));
    mv.addObject(NormalExcelConstants.DATA_LIST, pageList);
    return mv;
}

12.4 Service层 — 事务和业务逻辑

主表Service接口

IJeecgOrderMainService.java — 在标准 IService<T> 基础上扩展主子表操作方法:

public interface IJeecgOrderMainService extends IService<JeecgOrderMain> {
    /** 添加一对多 */
    void saveMain(JeecgOrderMain jeecgOrderMain,
                  List<JeecgOrderCustomer> jeecgOrderCustomerList,
                  List<JeecgOrderTicket> jeecgOrderTicketList);

    /** 修改一对多(先删后插) */
    void updateMain(JeecgOrderMain jeecgOrderMain,
                    List<JeecgOrderCustomer> jeecgOrderCustomerList,
                    List<JeecgOrderTicket> jeecgOrderTicketList);

    /** 修改一对多(增量更新) */
    void updateCopyMain(JeecgOrderMain jeecgOrderMain,
                        List<JeecgOrderCustomer> jeecgOrderCustomerList,
                        List<JeecgOrderTicket> jeecgOrderTicketList);

    /** 删除一对多 */
    void delMain(String id);

    /** 批量删除一对多 */
    void delBatchMain(Collection<? extends Serializable> idList);
}

主表Service实现

JeecgOrderMainServiceImpl.java — 所有主子表操作方法都标注了 @Transactional

新增 — 先插主表,再循环插子表:


@Override
@Transactional(rollbackFor = Exception.class)
public void saveMain(JeecgOrderMain jeecgOrderMain,
                     List<JeecgOrderCustomer> jeecgOrderCustomerList,
                     List<JeecgOrderTicket> jeecgOrderTicketList) {
    jeecgOrderMainMapper.insert(jeecgOrderMain);
    if (jeecgOrderCustomerList != null) {
        for (JeecgOrderCustomer entity : jeecgOrderCustomerList) {
            entity.setOrderId(jeecgOrderMain.getId());  // 设置外键
            jeecgOrderCustomerMapper.insert(entity);
        }
    }
    if (jeecgOrderTicketList != null) {
        for (JeecgOrderTicket entity : jeecgOrderTicketList) {
            entity.setOrderId(jeecgOrderMain.getId());  // 设置外键
            jeecgOrderTicketMapper.insert(entity);
        }
    }
}

修改策略一 — 先删后插(updateMain):


@Override
@Transactional(rollbackFor = Exception.class)
public void updateMain(JeecgOrderMain jeecgOrderMain,
                       List<JeecgOrderCustomer> jeecgOrderCustomerList,
                       List<JeecgOrderTicket> jeecgOrderTicketList) {
    jeecgOrderMainMapper.updateById(jeecgOrderMain);
    // 1.先删除子表数据
    jeecgOrderTicketMapper.deleteTicketsByMainId(jeecgOrderMain.getId());
    jeecgOrderCustomerMapper.deleteCustomersByMainId(jeecgOrderMain.getId());
    // 2.子表数据重新插入
    if (jeecgOrderCustomerList != null) {
        for (JeecgOrderCustomer entity : jeecgOrderCustomerList) {
            entity.setOrderId(jeecgOrderMain.getId());
            jeecgOrderCustomerMapper.insert(entity);
        }
    }
    if (jeecgOrderTicketList != null) {
        for (JeecgOrderTicket entity : jeecgOrderTicketList) {
            entity.setOrderId(jeecgOrderMain.getId());
            jeecgOrderTicketMapper.insert(entity);
        }
    }
}

修改策略二 — 增量更新/差异对比(updateCopyMain):


@Override
@Transactional(rollbackFor = Exception.class)
public void updateCopyMain(JeecgOrderMain jeecgOrderMain,
                           List<JeecgOrderCustomer> jeecgOrderCustomerList,
                           List<JeecgOrderTicket> jeecgOrderTicketList) {
    jeecgOrderMainMapper.updateById(jeecgOrderMain);
    // 循环前台传过来的数据
    for (JeecgOrderTicket ticket : jeecgOrderTicketList) {
        JeecgOrderTicket orderTicket = jeecgOrderTicketMapper.selectById(ticket.getId());
        if (orderTicket == null) {
            // 数据库不存在 -> 新增
            ticket.setOrderId(jeecgOrderMain.getId());
            jeecgOrderTicketMapper.insert(ticket);
            continue;
        }
        if (orderTicket.getId().equals(ticket.getId())) {
            // 数据库存在 -> 更新
            jeecgOrderTicketMapper.updateById(ticket);
        }
    }
    // ... 客户子表同理 ...

    // 取差集:数据库有但前端没传的 -> 删除
    List<JeecgOrderTicket> dbTickets = jeecgOrderTicketMapper.selectTicketsByMainId(jeecgOrderMain.getId());
    List<JeecgOrderTicket> toDelete = dbTickets.stream()
            .filter(item -> !jeecgOrderTicketList.stream()
                    .map(e -> e.getId())
                    .collect(Collectors.toList())
                    .contains(item.getId()))
            .collect(Collectors.toList());
    for (JeecgOrderTicket ticket : toDelete) {
        jeecgOrderTicketMapper.deleteById(ticket.getId());
    }
    // ... 客户子表同理 ...
}

删除 — 先删子表,再删主表:


@Override
@Transactional(rollbackFor = Exception.class)
public void delMain(String id) {
    jeecgOrderMainMapper.deleteById(id);
    jeecgOrderTicketMapper.deleteTicketsByMainId(id);
    jeecgOrderCustomerMapper.deleteCustomersByMainId(id);
}

@Override
@Transactional(rollbackFor = Exception.class)
public void delBatchMain(Collection<? extends Serializable> idList) {
    for (Serializable id : idList) {
        jeecgOrderMainMapper.deleteById(id);
        jeecgOrderTicketMapper.deleteTicketsByMainId(id.toString());
        jeecgOrderCustomerMapper.deleteCustomersByMainId(id.toString());
    }
}

子表Service接口和实现

IJeecgOrderCustomerService.java — 子表Service在标准 IService<T> 基础上增加按外键查询方法:

public interface IJeecgOrderCustomerService extends IService<JeecgOrderCustomer> {
    /** 根据订单id获取订单客户数据 */
    List<JeecgOrderCustomer> selectCustomersByMainId(String mainId);
}

@Service
public class JeecgOrderCustomerServiceImpl extends ServiceImpl<JeecgOrderCustomerMapper, JeecgOrderCustomer>
        implements IJeecgOrderCustomerService {
    @Autowired
    private JeecgOrderCustomerMapper jeecgOrderCustomerMapper;

    @Override
    public List<JeecgOrderCustomer> selectCustomersByMainId(String mainId) {
        return jeecgOrderCustomerMapper.selectCustomersByMainId(mainId);
    }
}

12.5 Mapper层 — 主子表数据操作

主表Mapper(标准MyBatis-Plus)

JeecgOrderMainMapper.java — 主表无需自定义方法:

public interface JeecgOrderMainMapper extends BaseMapper<JeecgOrderMain> {
    // 使用 BaseMapper 提供的 insert/updateById/deleteById/selectById 等
}

子表Mapper(增加外键操作方法)

JeecgOrderCustomerMapper.java — 子表Mapper在 BaseMapper 基础上增加两个核心方法:

public interface JeecgOrderCustomerMapper extends BaseMapper<JeecgOrderCustomer> {
    /** 通过主表外键批量删除客户 */
    @Delete("DELETE FROM JEECG_ORDER_CUSTOMER WHERE ORDER_ID = #{mainId}")
    boolean deleteCustomersByMainId(String mainId);

    /** 通过主表订单外键查询客户 */
    @Select("SELECT * FROM JEECG_ORDER_CUSTOMER WHERE ORDER_ID = #{mainId}")
    List<JeecgOrderCustomer> selectCustomersByMainId(String mainId);
}

关键点:子表Mapper在 BaseMapper 基础上增加了两个核心方法:

  • deleteByMainId — 通过外键批量删除子表数据
  • selectByMainId — 通过外键查询子表数据

Mapper XML模板版

代码生成器模板 [1-n]Mapper.xml 展示了XML方式实现:


<mapper namespace="${bussiPackage}.${entityPackage}.mapper.${subTab.entityName}Mapper">
    <delete id="deleteByMainId" parameterType="java.lang.String">
        DELETE FROM ${subTab.tableName}
        WHERE<#list originalForeignKeys as key>${key} = #{mainId}
    </#list>
</delete>

<select id="selectByMainId" parameterType="java.lang.String"
        resultType="${bussiPackage}.${entityPackage}.entity.${subTab.entityName}">
SELECT * FROM ${subTab.tableName}
WHERE<#list originalForeignKeys as key>${key} = #{mainId}
</#list>
        </select>
        </mapper>

12.6 代码生成器模板

JeecgBoot 提供了多种风格的主子表代码生成模板,位于 jeecg-system-biz/src/main/resources/jeecg/code-template-online/ 下:

模板风格 路径 说明
default default/onetomany/ 默认一对多模板
tab tab/onetomany/ Tab页签风格
jvxe jvxe/onetomany/ JVxe行编辑风格
inner-table inner-table/onetomany/ 内嵌表格风格
erp erp/onetomany/ ERP风格

12.7 主子表CRUD策略对比

操作 策略 说明
新增 先插主表 → 循环插子表 子表设置外键为主表ID
修改(策略一) 更新主表 → 删子表 → 重新插子表 简单粗暴,先删后插
修改(策略二) 更新主表 → 差异对比子表 新增/更新/删除分别处理,保留已有子表ID
删除 删主表 → 删子表 或先删子表再删主表
查询 主表子表分开查询 前端按需加载子表数据,每个子表一个独立接口
导出 遍历主表 → 查子表 → 组装VO 使用 @ExcelCollection 注解
导入 解析Excel → 拆分主子表 → 调用saveMain ImportParams.headRows=2(主子表表头占2行)

12.8 主子表关键注解

注解 用途 位置
@TableName 映射数据库表名 主表/子表实体类
@TableId(type = IdType.ASSIGN_ID) 主键生成策略 主表/子表实体类
@ExcelCollection 标记子表集合,用于Excel导入导出 VO类中的子表列表字段
@Excel Excel字段映射 实体类/VO类字段
@Transactional(rollbackFor = Exception.class) 事务控制 Service层主子表操作方法

12.9 主子表文件结构规范

org.jeecg.modules.<module>.{
    controller/  ${EntityName}Controller.java       -- 主表Controller
    entity/      ${EntityName}.java                 -- 主表实体
                 ${SubEntityName}.java              -- 子表实体(可多个)
    vo/          ${EntityName}Page.java             -- 主子表组合VO
    service/     I${EntityName}Service.java         -- 主表Service接口
                 I${SubEntityName}Service.java      -- 子表Service接口
    service/impl/${EntityName}ServiceImpl.java      -- 主表Service实现(含主子表事务逻辑)
                 ${SubEntityName}ServiceImpl.java   -- 子表Service实现
    mapper/      ${EntityName}Mapper.java           -- 主表Mapper
                 ${SubEntityName}Mapper.java        -- 子表Mapper(含外键操作方法)
    mapper/xml/  ${EntityName}Mapper.xml
                 ${SubEntityName}Mapper.xml
}

12.10 主子表与单表CRUD的差异对比

对比维度 单表 CRUD 主子表 CRUD
Controller入参 @RequestBody Entity @RequestBody EntityPage(VO对象)
新增方法 service.save(entity) service.saveMain(main, subList1, subList2)
修改方法 service.updateById(entity) service.updateMain(main, subList1, subList2)service.updateCopyMain(...)
删除方法 service.removeById(id) service.delMain(id)
查询子表 每个子表一个独立查询接口 queryXxxListByMainId
Service事务 框架默认 显式 @Transactional(rollbackFor = Exception.class)
Mapper方法 BaseMapper 子表增加 deleteByMainId + selectByMainId
Excel导出 super.exportXls() 手动组装VO列表(遍历主表查子表)
Excel导入 super.importExcel() 手动解析(headRows=2)+ 逐条 saveMain
VO类 不需要 需要,包含 @ExcelCollection 子表集合字段

12.11 开发主子表模块的最小步骤

基于以上分析,开发一个带完整 CRUD + 导入导出的主子表新模块,需要以下步骤:

  1. 创建主表实体:继承 JeecgEntity(或 Serializable),加 @TableName@TableId 注解
  2. 创建子表实体:继承 JeecgEntity(或 Serializable),加 @TableName@TableId 注解,必须包含外键字段
  3. 创建VO类:包含主表字段 + @ExcelCollection 标注的子表列表字段
  4. 创建主表Mapper:继承 BaseMapper<T>
  5. 创建子表Mapper:继承 BaseMapper<T>,增加 deleteByMainId + selectByMainId 方法
  6. 创建子表Service接口:继承 IService<T>,增加 selectByMainId 方法
  7. 创建子表Service实现:继承 ServiceImpl<Mapper, T>,实现 selectByMainId
  8. 创建主表Service接口:继承 IService<T>,增加 saveMainupdateMaindelMaindelBatchMain 方法
  9. 创建主表Service实现:继承 ServiceImpl<Mapper, T>,注入所有子表Mapper,实现主子表事务方法
  10. 创建Controller:继承 JeecgController<T, IService<T>>,实现主表CRUD + 子表查询 + 导入导出

12.12 涉及的文件清单

文件 操作 原因
.docs/260601-后端各功能接口实现方式与代码规范分析.md 追加 本次分析文档追加主子表CRUD实现模式

12.13 关键源码索引(主子表)

组件 文件路径
主表Controller示例 jeecg-boot/jeecg-boot-module/jeecg-module-demo/src/main/java/org/jeecg/modules/demo/test/controller/JeecgOrderMainController.java
主表Service接口 jeecg-boot/jeecg-boot-module/jeecg-module-demo/src/main/java/org/jeecg/modules/demo/test/service/IJeecgOrderMainService.java
主表Service实现 jeecg-boot/jeecg-boot-module/jeecg-module-demo/src/main/java/org/jeecg/modules/demo/test/service/impl/JeecgOrderMainServiceImpl.java
主表实体 jeecg-boot/jeecg-boot-module/jeecg-module-demo/src/main/java/org/jeecg/modules/demo/test/entity/JeecgOrderMain.java
子表实体-客户 jeecg-boot/jeecg-boot-module/jeecg-module-demo/src/main/java/org/jeecg/modules/demo/test/entity/JeecgOrderCustomer.java
子表实体-机票 jeecg-boot/jeecg-boot-module/jeecg-module-demo/src/main/java/org/jeecg/modules/demo/test/entity/JeecgOrderTicket.java
主子表VO jeecg-boot/jeecg-boot-module/jeecg-module-demo/src/main/java/org/jeecg/modules/demo/test/vo/JeecgOrderMainPage.java
主表Mapper jeecg-boot/jeecg-boot-module/jeecg-module-demo/src/main/java/org/jeecg/modules/demo/test/mapper/JeecgOrderMainMapper.java
子表Mapper-客户 jeecg-boot/jeecg-boot-module/jeecg-module-demo/src/main/java/org/jeecg/modules/demo/test/mapper/JeecgOrderCustomerMapper.java
子表Mapper-机票 jeecg-boot/jeecg-boot-module/jeecg-module-demo/src/main/java/org/jeecg/modules/demo/test/mapper/JeecgOrderTicketMapper.java
子表Service接口-客户 jeecg-boot/jeecg-boot-module/jeecg-module-demo/src/main/java/org/jeecg/modules/demo/test/service/IJeecgOrderCustomerService.java
子表Service接口-机票 jeecg-boot/jeecg-boot-module/jeecg-module-demo/src/main/java/org/jeecg/modules/demo/test/service/IJeecgOrderTicketService.java
字典VO jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/vo/SysDictPage.java
字典Controller jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysDictController.java
字典Service实现 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysDictServiceImpl.java
字典项Mapper jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysDictItemMapper.java
代码生成器-Controller模板 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/resources/jeecg/code-template-online/default/onetomany/java/${bussiPackage}/${entityPackage}/controller/${entityName}Controller.javai
代码生成器-ServiceImpl模板 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/resources/jeecg/code-template-online/default/onetomany/java/${bussiPackage}/${entityPackage}/service/impl/${entityName}ServiceImpl.javai
代码生成器-子表Mapper模板 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/resources/jeecg/code-template-online/default/onetomany/java/${bussiPackage}/${entityPackage}/mapper/[1-n]Mapper.javai
代码生成器-子表Mapper XML模板 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/resources/jeecg/code-template-online/default/onetomany/java/${bussiPackage}/${entityPackage}/mapper/xml/[1-n]Mapper.xml
代码生成器-VO模板 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/resources/jeecg/code-template-online/default/onetomany/java/${bussiPackage}/${entityPackage}/vo/${entityName}Page.javai

十三、异常处理体系与业务异常抛出方式

追加日期:2026-06-02

13.1 异常类继承体系

项目中定义了 5 个核心自定义异常类,按继承关系整理如下:

RuntimeException
├── JeecgBootException (核心业务异常, errCode=500)
│   └── JeecgBootAssertException (断言异常, 由AssertUtils使用)
├── JeecgBootBizTipException (业务提醒异常, errCode=500)
├── JeecgBoot401Exception (认证/鉴权异常)
└── JeecgSqlInjectionException (SQL注入防护异常)

13.2 各异常类详解

JeecgBootException — 核心业务异常

JeecgBootException.java

继承 RuntimeException,是项目中最主要的自定义异常。用于系统级/不可恢复的业务错误,抛出后会记录系统日志(写入 sys_log 表)。

public class JeecgBootException extends RuntimeException {
    private int errCode = CommonConstant.SC_INTERNAL_SERVER_ERROR_500; // 默认500

    public JeecgBootException(String message) { ...}

    public JeecgBootException(String message, int errCode) { ...}

    public JeecgBootException(Throwable cause) { ...}

    public JeecgBootException(String message, Throwable cause) { ...}
}

JeecgBootBizTipException — 业务提醒异常

JeecgBootBizTipException.java

继承 RuntimeException,结构与 JeecgBootException 几乎相同,但语义不同:用于可预期的业务操作提醒(如" 旧密码输入错误"、"您已是该租户成员")。抛出后不会记录系统日志,只做 log.error 打印。

public class JeecgBootBizTipException extends RuntimeException {
    private int errCode = CommonConstant.SC_INTERNAL_SERVER_ERROR_500; // 默认500

    public JeecgBootBizTipException(String message) { ...}

    public JeecgBootBizTipException(String message, int errCode) { ...}

    public JeecgBootBizTipException(Throwable cause) { ...}

    public JeecgBootBizTipException(String message, Throwable cause) { ...}
}

JeecgBootAssertException — 断言异常

JeecgBootAssertException.java

继承 JeecgBootException ,是断言工具类 AssertUtils 专用的异常。由于继承了 JeecgBootException,会被 @ExceptionHandler(JeecgBootException.class) 统一捕获,行为与 JeecgBootException 一致。

public class JeecgBootAssertException extends JeecgBootException {
    public JeecgBootAssertException(String message) {
        super(message);
    }

    public JeecgBootAssertException(String message, int errCode) {
        super(message, errCode);
    }
}

JeecgBoot401Exception — 认证异常

JeecgBoot401Exception.java

继承 RuntimeException,专门用于认证/鉴权失败场景。处理器中会设置 HTTP 状态码为 401 UNAUTHORIZED

JeecgSqlInjectionException — SQL注入异常

JeecgSqlInjectionException.java

继承 RuntimeException,用于 SQL 注入防护场景。处理器中会对敏感信息(extractvalueupdatexml)进行脱敏处理。

13.3 全局异常处理器

JeecgBootExceptionHandler.java

使用 @RestControllerAdvice 注解,统一拦截所有 Controller 层抛出的异常,转换为 Result<?> 对象返回给前端。

处理的异常类型及返回逻辑

异常类型 返回code 记录系统日志 说明
JeecgBootException 自定义errCode(默认500) 核心业务异常
JeecgBootAssertException 同上(继承JeecgBootException) 断言异常
JeecgBootBizTipException 自定义errCode(默认500) 业务提醒,仅log.error
JeecgBoot401Exception 401 认证失败,HTTP状态码设为401
JeecgSqlInjectionException 500 视情况 SQL注入风险,敏感信息脱敏
MethodArgumentNotValidException 500 参数校验失败
DuplicateKeyException 500 数据库主键重复
DataIntegrityViolationException 500 数据完整性约束违反
UnauthorizedException 510 Shiro权限不足
MaxUploadSizeExceededException 500 文件上传超限
NoHandlerFoundException 404 路径不存在
HttpRequestMethodNotSupportedException 405 请求方法不支持
Exception(兜底) 500 含Sentinel限流判断

核心处理方法源码


@RestControllerAdvice
@Slf4j
public class JeecgBootExceptionHandler {

    @Resource
    BaseCommonService baseCommonService;

    // 核心业务异常 — 记录系统日志
    @ExceptionHandler(JeecgBootException.class)
    public Result<?> handleJeecgBootException(JeecgBootException e) {
        log.error(e.getMessage(), e);
        addSysLog(e);
        return Result.error(e.getErrCode(), e.getMessage());
    }

    // 业务提醒异常 — 不记录系统日志
    @ExceptionHandler(JeecgBootBizTipException.class)
    public Result<?> handleJeecgBootBizTipException(JeecgBootBizTipException e) {
        log.error(e.getMessage());
        return Result.error(e.getErrCode(), e.getMessage());
    }

    // 认证异常 — 设置HTTP状态码401
    @ExceptionHandler(JeecgBoot401Exception.class)
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    public Result<?> handleJeecgBoot401Exception(JeecgBoot401Exception e) {
        log.error(e.getMessage(), e);
        addSysLog(e);
        return new Result(401, e.getMessage());
    }

    // 兜底异常 — 含Sentinel限流判断
    @ExceptionHandler(Exception.class)
    public Result<?> handleException(Exception e) {
        log.error(e.getMessage(), e);
        Throwable throwable = e.getCause();
        SentinelErrorInfoEnum errorInfoEnum = SentinelErrorInfoEnum.getErrorByException(throwable);
        if (ObjectUtil.isNotEmpty(errorInfoEnum)) {
            return Result.error(errorInfoEnum.getError());
        }
        addSysLog(e);
        return Result.error("操作失败," + e.getMessage());
    }
}

13.4 Result 类 — 异常返回给前端的结构

Result.java


@Data
public class Result<T> implements Serializable {
    private boolean success = true;    // 成功标志
    private String message = "";       // 返回处理消息
    private Integer code = 0;          // 返回代码
    private T result;                  // 返回数据对象
    private long timestamp = System.currentTimeMillis(); // 时间戳
}

Result 静态工厂方法

// 成功
Result.OK()                    // success=true, code=200
Result.

OK(String msg)          // success=true, code=200, message=msg, result=msg
Result.

OK(T data)              // success=true, code=200, result=data
Result.

OK(String msg, T data)  // success=true, code=200, message=msg, result=data

// 失败
Result.

error(String msg)                    // success=false, code=500, message=msg
Result.

error(int code, String msg)          // success=false, code=自定义, message=msg
Result.

error(String msg, T data)            // success=false, code=500, message=msg, result=data

// 无权限
Result.

noauth(String msg)                   // success=false, code=510, message=msg

前端接收到的异常响应示例

// JeecgBootException(默认errCode=500)
{
  "success": false,
  "message": "admin用户,不允许删除!",
  "code": 500,
  "result": null,
  "timestamp": 1717000000000
}

// JeecgBootBizTipException(默认errCode=500)
{
  "success": false,
  "message": "旧密码输入错误!",
  "code": 500,
  "result": null,
  "timestamp": 1717000000000
}

// JeecgBoot401Exception
{
  "success": false,
  "message": "token不能为空!",
  "code": 401,
  "result": null,
  "timestamp": 1717000000000
}

// 自定义errCode
{
  "success": false,
  "message": "短信接口请求太多,请稍后再试!",
  "code": 40002,
  "result": null,
  "timestamp": 1717000000000
}

13.5 业务异常抛出方式

方式一:直接抛出 JeecgBootException(最常用)

用于系统级/不可恢复的业务错误,会记录系统日志。这是项目中使用最多的方式。

// 基本用法 — 默认 errCode=500
throw new JeecgBootException("操作数据不存在!");
throw new

JeecgBootException("XX字段不能为空!");

// 自定义错误码
throw new

JeecgBootException("短信接口请求太多,请稍后再试!",CommonConstant.PHONE_SMS_FAIL_CODE);

// 包装原因异常
throw new

JeecgBootException("获取钉钉用户信息失败",cause);

项目中的典型使用场景 (来自 SysUserServiceImpl.java):

throw new JeecgBootException("离职失败,该用户已不存在");
throw new

JeecgBootException("admin用户,不允许删除!");
throw new

JeecgBootException("验证码失效,请重新发送验证码!");
throw new

JeecgBootException("手机号已被注册,请尝试其他手机号!");

方式二:抛出 JeecgBootBizTipException(业务提醒)

用于可预期的业务操作提醒,不会记录系统日志,只做简单的日志打印。适合"提示性"而非"错误性"的场景。

// 密码修改提醒
throw new JeecgBootBizTipException("旧密码输入错误!");
throw new

JeecgBootBizTipException("新密码不允许为空!");

// 租户操作提醒
throw new

JeecgBootBizTipException("管理员已拒绝您加入租户,请联系租户管理员");
throw new

JeecgBootBizTipException("您已是该租户成员");

// 组织架构操作提醒
throw new

JeecgBootBizTipException("当前子公司/部门下存在子级,无法变更为岗位!");

方式三:通过 AssertUtils 断言工具(间接抛出 JeecgBootAssertException)

AssertUtils.java 提供了声明式的断言方法,断言失败时抛出 JeecgBootAssertException(继承自 JeecgBootException),最终被 @ExceptionHandler(JeecgBootException.class) 捕获,行为与 JeecgBootException 一致。

// 确保对象不为空 — 适用于"XX字段不能为空"
AssertUtils.assertNotEmpty("用户ID不能为空",userId);
AssertUtils.

assertNotEmpty("姓名不能为空",name);

// 确保对象为空 — 适用于"该记录已存在"
AssertUtils.

assertEmpty("该记录已存在",existingRecord);

// 确保条件为真 — 适用于"操作数据不存在"
AssertUtils.

assertTrue("操作数据不存在",data !=null);
AssertUtils.

assertTrue("余额不足",balance >=amount);

// 确保相等 — 适用于"验证码不匹配"
AssertUtils.

assertEquals("验证码不匹配",expectedCode, actualCode);

// 数值比较
AssertUtils.

assertGt("数量必须大于0",quantity, 0);
AssertUtils.

assertLt("超出限制",count, maxCount);

// 集合包含
AssertUtils.

assertIn("非法状态",status, "ACTIVE","PENDING");

方式四:直接返回 Result.error()(不抛异常)

在 Controller 层直接返回错误结果,不经过异常处理器。这种方式不会记录系统日志,适用于简单的参数校验场景。


@GetMapping(value = "/queryById")
public Result<?> queryById(@RequestParam(name = "id", required = true) String id) {
    Xxx obj = xxxService.getById(id);
    if (obj == null) {
        return Result.error("未找到对应数据");
    }
    return Result.OK(obj);
}

13.6 JeecgBootException vs JeecgBootBizTipException 选择指南

维度 JeecgBootException JeecgBootBizTipException
语义 系统级错误、不可恢复的业务错误 业务操作提醒、可预期的用户提示
记录系统日志 是(写入 sys_log 表) 否(仅 log.error 打印)
日志打印 log.error(msg, e) 含堆栈 log.error(msg) 不含堆栈
适用场景 数据不存在、管理员保护、验证码失效 密码错误、权限不足提醒、组织架构约束
典型示例 "admin用户不允许删除"、"token非法" "旧密码输入错误"、"您已是该租户成员"

选择原则

  • 如果异常需要被运维人员关注和排查(如数据不一致、关键操作失败),使用 JeecgBootException
  • 如果异常只是给用户的友好提示(如输入校验、操作冲突),使用 JeecgBootBizTipException
  • 在 zjrs 业务模块中,"XX字段不能为空"、"操作数据不存在"这类提示性校验推荐使用 JeecgBootBizTipException,避免大量无意义的系统日志

13.7 异常处理完整流程

Controller / Service 层抛出异常
         │
         ▼
┌──────────────────────────────────────────────────────────────┐
│              JeecgBootExceptionHandler                        │
│              (@RestControllerAdvice)                          │
│                                                              │
│  JeecgBootException ──→ Result.error(errCode, msg)          │
│                          + 记录系统日志(addSysLog)            │
│                                                              │
│  JeecgBootAssertException ──→ 同上(继承JeecgBootException)   │
│                                                              │
│  JeecgBootBizTipException ──→ Result.error(errCode, msg)    │
│                              不记录系统日志(仅log.error)       │
│                                                              │
│  JeecgBoot401Exception ──→ Result(401, msg)                 │
│                            HTTP 401 + 记录系统日志             │
│                                                              │
│  JeecgSqlInjectionException ──→ Result.error(500, msg)      │
│                                敏感信息脱敏                    │
│                                                              │
│  UnauthorizedException ──→ Result.noauth(msg)  code=510      │
│                                                              │
│  DuplicateKeyException ──→ Result.error("数据库中已存在该记录") │
│                                                              │
│  MaxUploadSizeExceededException ──→ Result.error("文件大小超出10MB限制") │
│                                                              │
│  DataIntegrityViolationException ──→ Result.error("执行数据库异常...") │
│                                                              │
│  Exception (兜底) ──→ Result.error("操作失败,"+msg)          │
│                       + Sentinel限流判断                      │
└──────────────────────────────────────────────────────────────┘
         │
         ▼
   JSON 响应返回前端
   {
     "success": false,
     "message": "错误信息",
     "code": 500,
     "result": null,
     "timestamp": 1717000000000
   }

13.8 涉及的文件清单

文件 操作 原因
.docs/260601-后端各功能接口实现方式与代码规范分析.md 追加 本次分析文档追加异常处理体系分析

13.9 关键源码索引(异常处理)

组件 文件路径
核心业务异常 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/exception/JeecgBootException.java
业务提醒异常 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/exception/JeecgBootBizTipException.java
断言异常 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/exception/JeecgBootAssertException.java
认证异常 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/exception/JeecgBoot401Exception.java
SQL注入异常 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/exception/JeecgSqlInjectionException.java
全局异常处理器 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/exception/JeecgBootExceptionHandler.java
断言工具类 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/AssertUtils.java
统一返回结果 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/api/vo/Result.java
常量定义 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/CommonConstant.java

十四、实体类数据校验体系分析

追加日期:2026-06-02

14.1 核心结论:JSR303 声明式校验几乎未使用

虽然框架在 jeecg-boot-base-core/pom.xml 中引入了 spring-boot-starter-validation 依赖:


<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

整个项目中仅有1处测试代码使用了 JSR303 校验注解,zjrs 业务模块和系统模块均未使用任何声明式校验。项目以手动编码校验为主。

14.2 JSR303 校验注解使用情况

唯一使用处:Seata 分布式事务测试模块

PlaceOrderRequest.java — DTO 上使用 @NotNull

public class PlaceOrderRequest {
    @NotNull
    private Long userId;
    @NotNull
    private Long productId;
    @NotNull
    private Integer count;
}

SeataOrderController.java — Controller 上使用 @Validated


@PostMapping("/placeOrder")
public String placeOrder(@Validated @RequestBody PlaceOrderRequest request) {
    orderService.placeOrder(request);
    return "下单成功";
}

zjrs 模块和系统模块 — 零使用

经全量搜索确认:

模块 JSR303 注解使用 @Valid/@Validated 使用 AssertUtils 使用
jeecg-module-zjrs
jeecg-module-system 有(少量)
jeecg-module-airag 有(大量)
jeecg-cloud-test-seata 有(@NotNull 有(@Validated

14.3 项目现有的校验方式

方式一:Controller 层 if-null 判断 + Result.error()(zjrs 模块主流方式)

zjrs 模块所有 Controller 均采用此模式,仅在编辑/查询时做简单的 null 判断:

// PersonalInfoController.java
@PutMapping(value = "/edit")
public Result<?> edit(@RequestBody PersonalInfo personalInfo) {
    PersonalInfo personalInfoEntity = personalInfoService.getById(personalInfo.getId());
    if (personalInfoEntity == null) {
        return Result.error("未找到对应数据");
    }
    personalInfoService.updateById(personalInfo);
    return Result.OK("编辑成功!");
}

局限:只校验了"数据是否存在",没有校验字段非空、数据类型、数据内容、长度等

方式二:Service 层 throw new JeecgBootException / JeecgBootBizTipException(系统模块主流方式)

系统模块在 Service 层进行业务校验,校验失败直接抛异常:

// SysUserServiceImpl.java
throw new JeecgBootException("admin用户,不允许删除!");
throw new

JeecgBootException("验证码失效,请重新发送验证码!");
throw new

JeecgBootBizTipException("旧密码输入错误!");
throw new

JeecgBootBizTipException("新密码不允许为空!");

方式三:AssertUtils 断言工具(airag 模块使用较多)

AssertUtils.java 提供声明式断言,校验失败抛出 JeecgBootAssertException

// AigcWordTemplateController.java
AssertUtils.assertNotEmpty("参数异常",eoaWordTemplate);
AssertUtils.

assertNotEmpty("模版名称不能为空",eoaWordTemplate.getName());
        AssertUtils.

assertFalse("模版编码已存在",isCodeExists);

// AiragKnowledgeDocServiceImpl.java
AssertUtils.

assertNotEmpty("文档不能未空",airagKnowledgeDoc);
AssertUtils.

assertNotEmpty("知识库不能未空",airagKnowledgeDoc.getKnowledgeId());
        AssertUtils.

assertNotEmpty("文档标题不能未空",airagKnowledgeDoc.getTitle());

AssertUtils 完整方法列表

方法 说明 适用场景
assertNotEmpty(msg, obj) 确保对象不为空 字段非空校验
assertEmpty(msg, obj) 确保对象为空 唯一性校验
assertEquals(msg, expected, actual) 验证相等 值匹配校验
assertNotEquals(msg, expected, actual) 验证不相等 值互斥校验
assertTrue(msg, condition) 验证条件为真 通用条件校验
assertFalse(msg, condition) 验证条件为假 通用条件校验
assertIn(msg, obj, objs) 验证存在 枚举值校验
assertNotIn(msg, obj, objs) 验证不存在 排除值校验
assertGt(msg, src, des) 确保大于 数值下限校验
assertGe(msg, src, des) 确保大于等于 数值下限校验
assertLt(msg, src, des) 确保小于 数值上限校验
assertLe(msg, src, des) 确保小于等于 数值上限校验

方式四:throw new RuntimeException(zjrs SSO 模块 — 不推荐)

zjrs 模块的 SSO 相关代码直接抛出 RuntimeException,不符合框架规范:

// LoginSSOServiceImpl.java
throw new RuntimeException("未找到用户信息");
throw new

RuntimeException(checkResult.getMessage());

// VRsUsersServiceImpl.java
        throw new

RuntimeException("用户信息同步失败",e);

应改为throw new JeecgBootException(...)throw new JeecgBootBizTipException(...)

14.4 全局校验异常处理

JeecgBootExceptionHandler.java 中仅有一处校验异常处理:


@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<?> handleValidationExceptions(MethodArgumentNotValidException e) {
    log.error(e.getMessage(), e);
    addSysLog(e);
    return Result.error("校验失败!" + e.getBindingResult().getAllErrors()
            .stream()
            .map(ObjectError::getDefaultMessage)
            .collect(Collectors.joining(",")));
}

注意:该处理器仅处理了 MethodArgumentNotValidException(对应 @Valid/@Validated + @RequestBody 的校验失败),**缺少 **以下两种校验异常的处理:

缺少的异常类型 触发场景 当前后果
BindException @Valid/@Validated + 表单参数绑定校验失败 会被兜底 Exception 处理器捕获,返回不友好的错误信息
ConstraintViolationException @Validated 在方法参数上的校验失败 同上

14.5 自定义校验注解

项目中没有基于 @Constraint + ConstraintValidator 的标准自定义校验注解。

但存在一个签名校验注解,属于 AOP 级别的校验而非字段级校验:

SignatureCheck.java — 方法级签名校验注解:


@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SignatureCheck {
    boolean enabled() default true;

    String errorMessage() default "Sign签名校验失败!";
}

配套切面 SignatureCheckAspect.java ,通过 @Before 拦截标注了 @SignatureCheck 的方法进行签名验证。

14.6 Hibernate Validator 配置

项目中没有任何 Hibernate Validator 的自定义配置:

  • 没有 HibernateValidatorConfiguration 相关配置类
  • 没有 ValidationMessages.propertiesValidationMessages_zh_CN.properties 资源文件
  • 没有 hibernate.validator.* 相关的 YAML 配置
  • 没有自定义的 Validator Bean 注册

完全使用 spring-boot-starter-validation 的默认配置。

14.7 SQL 注入防护体系(安全层面校验)

虽然不属于传统的"数据校验",但项目有一套完善的安全校验体系:

工具类 功能
SqlInjectionUtil SQL注入关键词/正则检测、表名/字段名/排序字段合法性校验
AbstractQueryBlackListHandler 查询表/字段黑名单(如 sys_user.password 禁止查询)
SsrfFileTypeFilter 上传文件类型白名单 + 文件头黑名单检测

14.8 校验体系架构总览

┌──────────────────────────────────────────────────────────────────┐
│                          请求入口                                 │
├──────────────────────────────────────────────────────────────────┤
│  签名校验层   │  @SignatureCheck / SignAuthInterceptor            │
├──────────────────────────────────────────────────────────────────┤
│  Controller层 │  ❌ 几乎没有 @Valid/@Validated(仅1处测试代码)    │
│               │  ✅ 简单 if-null 判断 → Result.error()            │
├──────────────────────────────────────────────────────────────────┤
│  Service层    │  ✅ AssertUtils.assertXxx() → JeecgBootAssertException │
│               │  ✅ throw new JeecgBootException()                │
│               │  ✅ throw new JeecgBootBizTipException()          │
│               │  ❌ throw new RuntimeException()(不推荐)         │
├──────────────────────────────────────────────────────────────────┤
│  实体类       │  ❌ 无任何 JSR303 校验注解                          │
│               │  ✅ @Excel 注解(仅用于导入导出,非校验)            │
├──────────────────────────────────────────────────────────────────┤
│  SQL安全层    │  ✅ SqlInjectionUtil / AbstractQueryBlackListHandler │
├──────────────────────────────────────────────────────────────────┤
│  全局异常处理  │  ✅ MethodArgumentNotValidException               │
│               │  ❌ 缺少 BindException 处理                       │
│               │  ❌ 缺少 ConstraintViolationException 处理         │
│               │  ✅ JeecgBootException / JeecgBootBizTipException │
└──────────────────────────────────────────────────────────────────┘

14.9 zjrs 模块校验现状与改进建议

现状问题

  1. 实体类无任何校验注解 — 字段非空、长度、格式等完全依赖数据库约束或前端校验
  2. Controller 层仅做 null 判断 — 编辑/查询时只校验数据是否存在,不校验字段内容
  3. Service 层无业务校验 — 新增/修改时没有字段级别的业务规则校验
  4. SSO 模块使用 RuntimeException — 不符合框架异常体系规范

改进建议

方案一:引入 JSR303 声明式校验(推荐用于字段级校验)

在实体类上添加校验注解,Controller 层配合 @Validated 使用:

// 实体类
@Data
@TableName("zjrs_personal_info")
public class PersonalInfo extends JeecgEntity {
    @NotBlank(message = "姓名不能为空")
    @Size(max = 50, message = "姓名长度不能超过50")
    @Excel(name = "姓名", width = 15)
    private String name;

    @NotBlank(message = "身份证号不能为空")
    @Pattern(regexp = "^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[0-9Xx]$", message = "身份证号格式不正确")
    @Excel(name = "身份证号", width = 15)
    private String idcard;

    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
    @Excel(name = "手机号", width = 15)
    private String phone;
}

// Controller
@PostMapping(value = "/add")
public Result<?> add(@Validated @RequestBody PersonalInfo personalInfo) {
    personalInfoService.save(personalInfo);
    return Result.OK("添加成功!");
}

方案二:使用 AssertUtils 手动校验(推荐用于业务规则校验)

在 Service 层使用 AssertUtils 进行业务规则校验:


@Override
public void savePersonalInfo(PersonalInfo personalInfo) {
    AssertUtils.assertNotEmpty("姓名不能为空", personalInfo.getName());
    AssertUtils.assertNotEmpty("身份证号不能为空", personalInfo.getIdcard());
    // 业务规则校验
    long count = this.count(new LambdaQueryWrapper<PersonalInfo>()
            .eq(PersonalInfo::getIdcard, personalInfo.getIdcard()));
    AssertUtils.assertTrue("身份证号已存在", count == 0);
    this.save(personalInfo);
}

方案三:混合使用(最佳实践)

  • 字段级校验(非空、长度、格式)→ JSR303 注解 + @Validated,自动触发
  • 业务规则校验(唯一性、关联性、状态约束)→ AssertUtilsJeecgBootBizTipException,在 Service 层手动编码
  • 补充全局异常处理:增加 BindExceptionConstraintViolationException 的处理

14.10 涉及的文件清单

文件 操作 原因
.docs/260601-后端各功能接口实现方式与代码规范分析.md 追加 本次分析文档追加实体类数据校验体系分析

14.11 关键源码索引(数据校验)

组件 文件路径
校验依赖引入 jeecg-boot/jeecg-boot-base-core/pom.xml
全局校验异常处理 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/exception/JeecgBootExceptionHandler.java
断言工具类 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/AssertUtils.java
断言异常 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/exception/JeecgBootAssertException.java
签名校验注解 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/sign/annotation/SignatureCheck.java
签名校验切面 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/sign/aspect/SignatureCheckAspect.java
SQL注入防护 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/SqlInjectionUtil.java
查询黑名单 jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/security/AbstractQueryBlackListHandler.java
JSR303使用示例(DTO) jeecg-boot/jeecg-server-cloud/jeecg-visual/jeecg-cloud-test/jeecg-cloud-test-seata/jeecg-cloud-test-seata-order/src/main/java/org/jeecg/modules/test/seata/order/dto/PlaceOrderRequest.java
JSR303使用示例(Controller) jeecg-boot/jeecg-server-cloud/jeecg-visual/jeecg-cloud-test/jeecg-cloud-test-seata/jeecg-cloud-test-seata-order/src/main/java/org/jeecg/modules/test/seata/order/controller/SeataOrderController.java