分析日期:2026-06-01\ 项目版本:JeecgBoot 3.9.2
本文档深入分析湛江人社项目中增删改查(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("批量删除成功!");
}
}
| 操作 | 核心方法 | 数据访问方式 | 关键注解 |
|---|---|---|---|
| 分页查询 | 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() |
逻辑删除 | - |
// 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("删除成功!");
}
// 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("添加成功!");
}
这是整个 CRUD 体系的核心引擎,实现了前端零配置即可完成复杂查询条件自动构建。
QueryWrapper<T> queryWrapper = QueryGenerator.initQueryWrapper(entity, request.getParameterMap());
QueryGenerator 通过分析请求参数的值特征,自动推断查询规则:
| 值特征 | 推断规则 | 示例 |
|---|---|---|
>= 值 |
大于等于 | >= 100 |
<= 值 |
小于等于 | <= 100 |
> 值 |
大于 | > 100 |
< 值 |
小于 | < 100 |
*值* |
全模糊 LIKE | *张* → %张% |
*值 |
左模糊 LIKE | *张 → %张 |
值* |
右模糊 LIKE | 张* → 张% |
值1,值2 |
IN 查询 | 1,2,3 → IN (1,2,3) |
!值 |
不等于 | !admin |
| 普通值 | 等于 EQ | admin |
通过 字段_begin 和 字段_end 参数实现:
?createTime_begin=2026-01-01&createTime_end=2026-12-31
通过 superQueryParams 参数传入 JSON 格式的复杂查询条件,支持 AND/OR 组合。
通过 @PermissionData 注解 + PermissionDataAspect 切面,自动将数据权限规则注入到查询条件中。
支持三种排序方式(按优先级):
sortInfoString — 多字段排序(优先级最高)defSortString — 默认排序column + order — 单字段排序@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 → 可选,指定导出列
@RequestMapping(value = "/exportXls")
public ModelAndView exportXls(HttpServletRequest request, JeecgDemo jeecgDemo) {
String exportFields = jeecgDemoService.getExportFields();
return super.exportXlsSheet(request, jeecgDemo, JeecgDemo.class, "单表模型", exportFields, 500);
}
return super.exportXlsForBigData(request, entity, EntityClass.class, "文件名", 1000);
使用 IExcelExportServer 流式分页查询,避免 OOM。
@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;
}
@Excel 注解 (实体类/VO)
↓ 定义列结构
JeecgEntityExcelView (Spring MVC View)
↓ 渲染 Excel
ExportParams (导出参数)
↓ 标题/Sheet/格式/样式
NormalExcelConstants (常量)
↓ ModelAndView key
AutoPoiDictConfig (字典翻译)
↓ 导出时自动翻译字典值
最终输出 .xlsx 文件
@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(成功行数 / 错误信息)
@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("文件导入失败!");
}
// 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"
}
}
上传文件 (MultipartFile)
↓
ExcelImportUtil.importExcel()
↓ 解析为 List<T>
ImportParams (titleRows, headRows)
↓ 控制跳过行数
@Excel 注解 (实体类/VO)
↓ 列与字段的映射规则
AutoPoiDictConfig (字典翻译)
↓ 导入时文本自动转编码值
service.saveBatch() / 逐条 save
↓ 批量或逐条保存
ImportExcelUtil.imporReturnRes()
↓ 返回导入结果
JeecgBoot 框架中,下载导入模板和导出数据共用同一个 exportXls 接口。当请求不带查询条件时,查询结果为空列表,AutoPoi 仍生成包含标题行和表头行的 Excel 文件——这就是"导入模板"。
┌──────────────────────────────────────────────────┐
│ 第1行: 大标题(如 "单表模型报表") │ ← titleRows=2 的第1行
├──────────────────────────────────────────────────┤
│ 第2行: 副标题(如 "导出人:管理员" / 导入规则说明) │ ← titleRows=2 的第2行
├──────┬──────┬──────┬──────┬──────────────────────┤
│ 姓名 │关键词│打卡时间│ 工资 │ 性别 │ ... │ ← headRows=1
├──────┼──────┼──────┼──────┼──────────────────────┤
│ (空) │ (空) │ (空) │ (空) │ (空) │ ... │ ← 数据行(模板时为空)
└──────┴──────┴──────┴──────┴──────────────────────┘
| 导出参数 | 导入参数 | 说明 |
|---|---|---|
ExportParams(title, secondTitle, sheetName) |
ImportParams.titleRows=2 |
标题占2行 |
@Excel 注解定义的列 |
ImportParams.headRows=1 |
表头占1行 |
主子表时 @ExcelCollection 增加的列 |
ImportParams.headRows=2 |
主子表表头占2行 |
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);
| 属性 | 说明 | 示例 |
|---|---|---|
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=数值 |
| 模式 | 注解写法 | 适用场景 |
|---|---|---|
| 基础文本 | @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 注解 |
不需要导入导出的字段 |
最大规范公约数 = 所有 CRUD + 导入导出实现中,最核心、最通用、最不可省略的代码模式和组件依赖。
| 组件 | 作用 | 位置 |
|---|---|---|
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 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));
}
}
// 导出(同时作为下载模板接口)
@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;
}
┌─────────────────────────────────────────────────────────────┐
│ 最大规范公约数 │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 实体层公约数 │ │
│ │ • 继承 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 视图渲染 │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
基于最大公约数,开发一个带完整 CRUD + 导入导出的新模块,只需以下步骤:
JeecgEntity,加 @TableName、@Excel 注解BaseMapper<T>IService<T>ServiceImpl<Mapper, T>JeecgController<T, IService<T>>,实现 6 个 CRUD 方法 + 2 个导入导出方法无需额外开发的:
QueryGenerator 已实现MybatisInterceptor 已实现DictAspect + AutoPoiDictConfig 已实现AutoLogAspect 已实现JeecgEntityExcelView 已实现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 三种存储方式,通过配置切换。
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。
| 控制器 | 路径前缀 | 功能 |
|---|---|---|
| SysUploadController | /sys/upload |
MinIO 专用上传,上传后保存文件信息到 oss_file 表 |
| OssFileController | /sys/oss/file |
OSS 文件管理(上传/列表/删除),需要 system:ossFile:upload 权限 |
CommonUtils 根据 uploadType 自动选择存储方式:
| 方法签名 | 功能 |
|---|---|
upload(MultipartFile, String bizPath, String uploadType) |
根据 uploadType 自动选择 MinIO 或阿里云 OSS |
uploadLocal(MultipartFile, String bizPath, String uploadpath) |
本地文件上传(含路径遍历防护) |
getFileName(String fileName) |
文件名安全处理(去盘符、特殊字符、空格等) |
| 工具类 | 文件路径 | 功能 |
|---|---|---|
| MinioUtil | jeecg-boot-base-core/.../util/MinioUtil.java |
MinIO 上传/下载/删除/预签名 URL |
| OssBootUtil | jeecg-boot-base-core/.../util/oss/OssBootUtil.java |
阿里云 OSS 上传/下载/删除/预签名 URL |
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
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";
WebMvcConfiguration.java 的 addResourceHandlers() 方法配置了静态资源映射,使上传目录中的文件可通过 Spring 静态资源机制直接访问:
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations("file:" + jeecgBaseConfig.getPath().getUpload() + "//")
.addResourceLocations("file:" + jeecgBaseConfig.getPath().getWebapp() + "//");
}
文件上传接口 /sys/common/upload 需要 Token 认证。文件预览/下载接口 /sys/common/static/** 在 ShiroConfig.java 中配置为 anon(匿名访问):
filterChainDefinitionMap.put("/sys/common/static/**", "anon"); // 图片预览 & 下载文件不限制token
此外,框架还支持 @IgnoreAuth 注解实现方法级别的免认证控制,标注该注解的接口方法会自动免 Token 认证。
上传请求流程:
前端 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() // 下载类型校验
+-- 读取本地文件 -> 流式输出
当业务需要独立的文件存储路径(如 D:\sitefile\zjrs 而非框架默认的 /opt/upFiles)时,需要自定义上传控制器。此时需要注意:
CommonController.upload() 的模式,接收 MultipartFile,保存到自定义目录,返回 Result 格式(message 为相对路径)CommonController.view() 的模式,提供文件流输出@IgnoreAuth 注解让静态文件服务接口免认证,或通过 jeecg.shiro.excludeUrls 配置.. 字符)| 组件 | 文件路径 |
|---|---|
| 通用上传控制器 | 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 |
追加日期:2026-06-02
JeecgBoot 中主子表的实现采用经典的 一对多(OneToMany) 模式,核心思路是:
项目中存在两个完整的主子表示例:
JeecgOrderMain(订单主表) + JeecgOrderCustomer(客户子表) + JeecgOrderTicket(机票子表)SysDict(字典主表) + SysDictItem(字典项子表)注意:湛江人社业务模块(
jeecg-module-zjrs)当前不存在标准的主子表实现,所有业务均使用单表 CRUD 模式。如需在 zjrs 模块中实现主子表功能,应参照本节所述的标准模式。
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。
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 导入导出时识别子表数据BeanUtils.copyProperties 拆分为主表实体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);
}
@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;
}
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);
}
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());
}
}
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);
}
}
JeecgOrderMainMapper.java — 主表无需自定义方法:
public interface JeecgOrderMainMapper extends BaseMapper<JeecgOrderMain> {
// 使用 BaseMapper 提供的 insert/updateById/deleteById/selectById 等
}
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 — 通过外键查询子表数据代码生成器模板 [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>
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风格 |
| 操作 | 策略 | 说明 |
|---|---|---|
| 新增 | 先插主表 → 循环插子表 | 子表设置外键为主表ID |
| 修改(策略一) | 更新主表 → 删子表 → 重新插子表 | 简单粗暴,先删后插 |
| 修改(策略二) | 更新主表 → 差异对比子表 | 新增/更新/删除分别处理,保留已有子表ID |
| 删除 | 删主表 → 删子表 | 或先删子表再删主表 |
| 查询 | 主表子表分开查询 | 前端按需加载子表数据,每个子表一个独立接口 |
| 导出 | 遍历主表 → 查子表 → 组装VO | 使用 @ExcelCollection 注解 |
| 导入 | 解析Excel → 拆分主子表 → 调用saveMain | ImportParams.headRows=2(主子表表头占2行) |
| 注解 | 用途 | 位置 |
|---|---|---|
@TableName |
映射数据库表名 | 主表/子表实体类 |
@TableId(type = IdType.ASSIGN_ID) |
主键生成策略 | 主表/子表实体类 |
@ExcelCollection |
标记子表集合,用于Excel导入导出 | VO类中的子表列表字段 |
@Excel |
Excel字段映射 | 实体类/VO类字段 |
@Transactional(rollbackFor = Exception.class) |
事务控制 | Service层主子表操作方法 |
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
}
| 对比维度 | 单表 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 子表集合字段 |
基于以上分析,开发一个带完整 CRUD + 导入导出的主子表新模块,需要以下步骤:
JeecgEntity(或 Serializable),加 @TableName、@TableId 注解JeecgEntity(或 Serializable),加 @TableName、@TableId 注解,必须包含外键字段@ExcelCollection 标注的子表列表字段BaseMapper<T>BaseMapper<T>,增加 deleteByMainId + selectByMainId 方法IService<T>,增加 selectByMainId 方法ServiceImpl<Mapper, T>,实现 selectByMainIdIService<T>,增加 saveMain、updateMain、delMain、delBatchMain 方法ServiceImpl<Mapper, T>,注入所有子表Mapper,实现主子表事务方法JeecgController<T, IService<T>>,实现主表CRUD + 子表查询 + 导入导出| 文件 | 操作 | 原因 |
|---|---|---|
.docs/260601-后端各功能接口实现方式与代码规范分析.md |
追加 | 本次分析文档追加主子表CRUD实现模式 |
| 组件 | 文件路径 |
|---|---|
| 主表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
项目中定义了 5 个核心自定义异常类,按继承关系整理如下:
RuntimeException
├── JeecgBootException (核心业务异常, errCode=500)
│ └── JeecgBootAssertException (断言异常, 由AssertUtils使用)
├── JeecgBootBizTipException (业务提醒异常, errCode=500)
├── JeecgBoot401Exception (认证/鉴权异常)
└── JeecgSqlInjectionException (SQL注入防护异常)
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.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.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.java
继承 RuntimeException,专门用于认证/鉴权失败场景。处理器中会设置 HTTP 状态码为 401 UNAUTHORIZED。
JeecgSqlInjectionException.java
继承 RuntimeException,用于 SQL 注入防护场景。处理器中会对敏感信息(extractvalue、updatexml)进行脱敏处理。
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());
}
}
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
}
用于系统级/不可恢复的业务错误,会记录系统日志。这是项目中使用最多的方式。
// 基本用法 — 默认 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("手机号已被注册,请尝试其他手机号!");
用于可预期的业务操作提醒,不会记录系统日志,只做简单的日志打印。适合"提示性"而非"错误性"的场景。
// 密码修改提醒
throw new JeecgBootBizTipException("旧密码输入错误!");
throw new
JeecgBootBizTipException("新密码不允许为空!");
// 租户操作提醒
throw new
JeecgBootBizTipException("管理员已拒绝您加入租户,请联系租户管理员");
throw new
JeecgBootBizTipException("您已是该租户成员");
// 组织架构操作提醒
throw new
JeecgBootBizTipException("当前子公司/部门下存在子级,无法变更为岗位!");
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");
在 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);
}
| 维度 | JeecgBootException | JeecgBootBizTipException |
|---|---|---|
| 语义 | 系统级错误、不可恢复的业务错误 | 业务操作提醒、可预期的用户提示 |
| 记录系统日志 | 是(写入 sys_log 表) |
否(仅 log.error 打印) |
| 日志打印 | log.error(msg, e) 含堆栈 |
log.error(msg) 不含堆栈 |
| 适用场景 | 数据不存在、管理员保护、验证码失效 | 密码错误、权限不足提醒、组织架构约束 |
| 典型示例 | "admin用户不允许删除"、"token非法" | "旧密码输入错误"、"您已是该租户成员" |
选择原则:
JeecgBootExceptionJeecgBootBizTipExceptionJeecgBootBizTipException,避免大量无意义的系统日志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
}
| 文件 | 操作 | 原因 |
|---|---|---|
.docs/260601-后端各功能接口实现方式与代码规范分析.md |
追加 | 本次分析文档追加异常处理体系分析 |
| 组件 | 文件路径 |
|---|---|
| 核心业务异常 | 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
虽然框架在 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 业务模块和系统模块均未使用任何声明式校验。项目以手动编码校验为主。
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 "下单成功";
}
经全量搜索确认:
| 模块 | JSR303 注解使用 | @Valid/@Validated 使用 | AssertUtils 使用 |
|---|---|---|---|
| jeecg-module-zjrs | 无 | 无 | 无 |
| jeecg-module-system | 无 | 无 | 有(少量) |
| jeecg-module-airag | 无 | 无 | 有(大量) |
| jeecg-cloud-test-seata | 有(@NotNull) | 有(@Validated) | 无 |
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 层进行业务校验,校验失败直接抛异常:
// SysUserServiceImpl.java
throw new JeecgBootException("admin用户,不允许删除!");
throw new
JeecgBootException("验证码失效,请重新发送验证码!");
throw new
JeecgBootBizTipException("旧密码输入错误!");
throw new
JeecgBootBizTipException("新密码不允许为空!");
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) |
确保小于等于 | 数值上限校验 |
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(...)。
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 在方法参数上的校验失败 |
同上 |
项目中没有基于 @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 的方法进行签名验证。
项目中没有任何 Hibernate Validator 的自定义配置:
HibernateValidatorConfiguration 相关配置类ValidationMessages.properties 或 ValidationMessages_zh_CN.properties 资源文件hibernate.validator.* 相关的 YAML 配置Validator Bean 注册完全使用 spring-boot-starter-validation 的默认配置。
虽然不属于传统的"数据校验",但项目有一套完善的安全校验体系:
| 工具类 | 功能 |
|---|---|
| SqlInjectionUtil | SQL注入关键词/正则检测、表名/字段名/排序字段合法性校验 |
| AbstractQueryBlackListHandler | 查询表/字段黑名单(如 sys_user.password 禁止查询) |
| SsrfFileTypeFilter | 上传文件类型白名单 + 文件头黑名单检测 |
┌──────────────────────────────────────────────────────────────────┐
│ 请求入口 │
├──────────────────────────────────────────────────────────────────┤
│ 签名校验层 │ @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 │
└──────────────────────────────────────────────────────────────────┘
方案一:引入 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);
}
方案三:混合使用(最佳实践)
@Validated,自动触发AssertUtils 或 JeecgBootBizTipException,在 Service 层手动编码BindException 和 ConstraintViolationException 的处理| 文件 | 操作 | 原因 |
|---|---|---|
.docs/260601-后端各功能接口实现方式与代码规范分析.md |
追加 | 本次分析文档追加实体类数据校验体系分析 |
| 组件 | 文件路径 |
|---|---|
| 校验依赖引入 | 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 |