做幼儿园管理系统这个项目,最初是朋友介绍的一个实际需求:一家连锁幼儿园还在用纸质表格管理幼儿档案、考勤和收费,月底算考勤、算餐费、统计退费,全靠行政老师手动整理,不仅慢,还经常出错。需求梳理下来其实很清晰——幼儿信息管理、教职工管理、班级管理、入离园考勤、收费管理、食谱管理、通知公告,再加上后台的菜单权限。技术选型没有太多犹豫,直接锁定Spring Boot。这类中小型管理系统,Spring Boot就是最务实的答案:自动配置省掉大量XML配置,起步依赖让项目搭建变得很轻,内嵌Tomcat让部署也简单,再加上社区生态足够成熟,遇到问题基本都能快速找到解决方案。
这篇文章会把整个项目的设计思路、数据库表结构、核心模块实现、常见坑点完整拆开来讲。无论你是刚学完Spring Boot想做项目练手,还是在学校做毕业设计,亦或是接手了一个类似的幼儿园/学校管理类系统需求,这篇实战记录应该都能给你一些直接可抄的参考。
1. 项目整体设计与思路拆解
1.1 幼儿园管理的核心痛点,系统到底要解决什么
先说需求侧。幼儿园和中小学的管理有很大差异,它有几个独有的业务特征。
第一是幼儿信息维度多。除了基本的姓名、性别、出生日期,还需要关注过敏史、既往病史、监护人信息、接送人信息。这在系统设计时意味着幼儿表不能只做一个简单的信息登记表,这些字段在后面体检记录、食谱管理、接送管理中都会被关联使用。
第二是考勤方式特殊。幼儿园的考勤不是刷脸打卡这么简单,它关联着“孩子几点到园”“几点被谁接走”“有没有按时吃药”这类细节。家长关心的是孩子安全,园长关心的是统计报表,这决定了考勤模块既要支持快速登记,也要有完善的记录追溯。
第三是收费计算复杂。保教费、餐费、延时服务费、材料费、校车费,有的按月收,有的按学期收,中途入园退园还要按天折算。如果靠Excel硬算,很容易出纠纷。所以收费模块的核心不是简单的增删改查,而是“费用项配置 + 自动计算 + 缴费记录”的闭环。
系统功能上,最终拆成了这些模块:
- 系统管理:用户、角色、菜单、操作日志
- 基础档案:幼儿档案、教职工档案、班级管理
- 日常业务:入离园考勤、食谱管理、体检记录、通知公告
- 财务管理:收费项目、收费记录、退费记录
- 家长端(可选):微信小程序或H5,查看通知、考勤、缴费信息
1.2 技术选型:Spring Boot + MyBatis-Plus + MySQL是性价比最高的组合
技术选型这块,我是基于“现有团队维护成本”和“项目规模”两个维度来考虑的。
后端框架用Spring Boot 2.7.x,不要盲目追新。老实说,Spring Boot 3.x已经出来很久了,但如果你用JDK 8,就不要碰Spring Boot 3,因为它强制要求JDK 17。这个项目服务端我用的是JDK 8 + Spring Boot 2.7.18,稳定性非常好,各种第三方库的兼容性问题也最少。后面我也会专门讲版本坑。
ORM层选了MyBatis-Plus。做这类管理系统,大部分操作都是单表CRUD和简单的多表关联查询,MyBatis-Plus的BaseMapper几乎覆盖了80%的数据库操作,不用手写XML。剩下的复杂报表查询,自定义Mapper XML也完全够用。
数据库用MySQL 5.7+(实际生产用的8.0),存储引擎InnoDB,字符集utf8mb4。有的字段需要存表情符号(比如家长备注里可能有emoji),utf8mb4必须从一开始就定好。
权限认证用的是Spring Security + JWT。为什么不直接用Shiro?因为Spring Security和Spring Boot集成更顺滑,虽然配置繁琐一点,但一旦封装好,后面扩展OAuth2、微信登录都比较方便。如果项目赶工期,也可以自己写拦截器+Redis session,但我觉得做管理系统还是把Security用起来更规范。
前端这块,这个项目实际采用的是前后端分离,Vue 3 + Element Plus。不过如果你是一个人开发,或者工期很紧,我更推荐用Thymeleaf做服务端渲染。原因很简单:少维护一套前端工程,少了跨域问题,少了联调成本。后面我会把两种方案怎么取舍讲透。
1.3 单体架构还是微服务?别被概念带偏
这是很多新手容易纠结的问题。我想说,一个幼儿园管理系统,用户量撑死几百人,并发量极低,用微服务完全是自己给自己上强度。
我做的是标准的单体分层架构:
text复制controller / service / mapper / entity
再加一个common模块放统一返回结果、异常处理、工具类,一个config包放配置类。代码层面不搞多模块Maven工程,就一个spring-boot工程,内部用包结构区分。为什么?因为多模块在IDE里调试、打包时Maven依赖关系处理起来更繁琐,一个中小型项目根本不需要这种复杂度。等业务真的复杂到需要拆服务时,再按业务边界拆也不迟。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 数据库设计:整个系统最核心的部分
数据库设计是这类管理系统最重要的环节。表设计得不好,后期写代码全是泪。我把核心表结构列出来,直接给你可以参考的DDL思路。
系统用户表 t_user
sql复制CREATE TABLE `t_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL COMMENT '登录账号',
`password` varchar(100) NOT NULL COMMENT '密码(BCrypt加密)',
`real_name` varchar(50) DEFAULT NULL COMMENT '真实姓名',
`type` tinyint(4) NOT NULL DEFAULT '2' COMMENT '1=管理员 2=教职工 3=家长',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1=启用 0=禁用',
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';
这里有个关键点:家长是否要拥有登录账号?如果需要,家长账号和幼儿档案之间要建立关联表,即家长账号和幼儿是多对多关系,因为一个家里可能有两个孩子在同一所幼儿园。
幼儿信息表 t_child
sql复制CREATE TABLE `t_child` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL,
`gender` tinyint(4) NOT NULL COMMENT '1=男 2=女',
`birth_date` date DEFAULT NULL,
`class_id` bigint(20) DEFAULT NULL COMMENT '所属班级ID',
`avatar` varchar(255) DEFAULT NULL COMMENT '头像URL',
`allergy_info` varchar(255) DEFAULT NULL COMMENT '过敏史',
`medical_history` varchar(500) DEFAULT NULL COMMENT '既往病史',
`father_name` varchar(50) DEFAULT NULL,
`father_phone` varchar(20) DEFAULT NULL,
`mother_name` varchar(50) DEFAULT NULL,
`mother_phone` varchar(20) DEFAULT NULL,
`address` varchar(200) DEFAULT NULL,
`enroll_date` date DEFAULT NULL COMMENT '入园日期',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1=在读 2=离园',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='幼儿信息表';
这块我想多说一句:接送人信息不要单独用两个字段存。现实中一个孩子可能爷爷、奶奶、外公、外婆、爸爸、妈妈都来接,所以最好单独建一张 t_child_guardian 表,一个孩子对应多条接送人记录。这在我后面做考勤接送登记时会用到。
班级表 t_class 比较简单:id、班级名称、年级、班主任ID、保育员ID、容量。
考勤记录表 t_attendance
sql复制CREATE TABLE `t_attendance` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`child_id` bigint(20) NOT NULL,
`class_id` bigint(20) NOT NULL,
`attendance_date` date NOT NULL COMMENT '考勤日期',
`in_time` datetime DEFAULT NULL COMMENT '入园时间',
`out_time` datetime DEFAULT NULL COMMENT '离园时间',
`in_operator` bigint(20) DEFAULT NULL COMMENT '登记入园操作人',
`out_operator` bigint(20) DEFAULT NULL COMMENT '登记离园操作人',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1=正常 2=迟到 3=请假 4=缺勤',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_child_date` (`child_id`,`attendance_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考勤记录表';
注意这个唯一索引:child_id + attendance_date,保证了每个孩子每天只有一条考勤记录。这是整个考勤模块的核心约束。
收费记录表 t_fee_record
sql复制CREATE TABLE `t_fee_record` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`child_id` bigint(20) NOT NULL,
`fee_item_id` bigint(20) NOT NULL COMMENT '费用项ID',
`amount` decimal(10,2) NOT NULL COMMENT '应收金额',
`paid_amount` decimal(10,2) DEFAULT '0.00' COMMENT '实收金额',
`due_date` date DEFAULT NULL COMMENT '应缴日期',
`pay_date` date DEFAULT NULL COMMENT '缴费日期',
`status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0=待缴费 1=已缴费 2=已作废',
`remark` varchar(200) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收费记录表';
收费模块的设计思路是:费用项配置(比如“2025年春季保教费”“3月餐费”),然后批量给班级或全园孩子生成应收记录,家长缴费后更新状态为已缴费。退费则生成退费记录,同时将原收费记录标记为已作废,保证账目可追溯。
2.2 菜单权限设计:Spring Boot里最常被问到的RBAC落地
热搜词里有“springboot菜单角色管理”,说明这是很多人做管理系统必问的点。RBAC模型其实就是三张主表加两张关联表:
- t_menu:菜单表,包含菜单名称、路由地址、父级菜单ID、排序
- t_role:角色表
- t_user_role:用户角色关联表
- t_role_menu:角色菜单关联表
前端根据登录用户返回的菜单列表动态生成侧边栏,后端通过Spring Security的@PreAuthorize注解做接口级权限控制。这里有一个非常实用的细节:
菜单表添加了一个permission字段,对应后端接口的权限标识,比如system:user:add、child:info:edit。前端菜单路由用path字段,后端鉴权用permission字段,两者互补。
2.3 为什么考勤、食谱模块比想象中复杂
考勤模块表面看是打勾记录,实际要考虑这些场景:
- 幼儿入园时登记,离园时必须校验接送人是否为已授权的监护人
- 如果孩子当天请假,需要由家长或老师在考勤记录中标记“请假”,并且请假期间涉及的餐费按天退费
- 每天生成考勤汇总报表:出勤率、迟到名单、未到名单,园长要看这些
食谱模块也有自己的业务逻辑:每周一份食谱,周一到周五,每天要区分早点、午餐、午点、水果,还可以关联食材表做营养分析。数据库设计上要用“食谱主表 + 食谱明细表”的方式:
- t_recipe:周次、发布日期、适用班级年龄段
- t_recipe_item:日期、餐次、菜品名称、食材明细
这种一对多的设计在管理系统中非常常见,掌握一个,其他类似场景都能套用。
3. 实操过程与核心环节实现
3.1 从Spring Initializr初始化项目到可运行的第一步
项目初始化我推荐直接用Spring Initializr(start.spring.io)或者IDEA自带的Spring Initializr。依赖这块,按实际需要勾选:
- Spring Web
- Spring Security
- MyBatis Framework(实际我用MyBatis-Plus,所以后面是手动引入)
- MySQL Driver
- Lombok
- Validation
生成后的pom.xml核心内容注意这些版本:
xml复制<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
MyBatis-Plus要单独引入:
xml复制<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.5</version>
</dependency>
如果你是Java 8环境,千万别把MyBatis-Plus升到3.5.7以上,那个版本开始强制JDK 8以上还能跑,但有些新特性依赖新版Spring Boot,容易出现兼容问题。还有一点,MyBatis-Plus和MyBatis的版本要匹配,自行引入的时候就挑一个稳定的组合,我实测3.5.5 + Spring Boot 2.7.18 + MySQL 8.0.34,跑得很稳。
3.2 application.yml配置:把这些坑提前填上
配置文件这块,我直接把生产可用的配置贴出来,并逐段解释为什么这么写。
yaml复制server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/kindergarten?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 123456
servlet:
multipart:
max-file-size: 50MB
max-request-size: 100MB
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
几个容易踩坑的细节:
JDBC URL里的参数不是随便加的。 serverTimezone=Asia/Shanghai是必须的,否则MySQL 8.0驱动和服务器时区不一致会报错;allowPublicKeyRetrieval=true是MySQL 8.0使用caching_sha2_password认证时需要的,不加会报Public Key Retrieval错误。
map-underscore-to-camel-case: true让数据库的下划线字段自动映射到Java的驼峰属性,这是一定要开的,不然child_id怎么都映射不到childId上。
逻辑删除配置是MyBatis-Plus的利器。幼儿离园时,我们并不是真的DELETE数据,而是把deleted字段置为1。这样历史考勤、收费记录还能关联到幼儿信息,不会因为删除导致数据断裂。注意:表里要真的加一个deleted字段,类型用tinyint,默认0。
3.3 统一返回结果和全局异常处理:少写几百行重复代码
一个管理系统的后端接口,返回格式必须统一。我习惯用一个Result类:
java复制@Data
public class Result<T> {
private Integer code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("操作成功");
result.setData(data);
return result;
}
public static <T> Result<T> error(String message) {
Result<T> result = new Result<>();
result.setCode(500);
result.setMessage(message);
return result;
}
}
再配一个全局异常处理器:
java复制@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<?> handleBusinessException(BusinessException e) {
return Result.error(e.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<?> handleValidException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
return Result.error(message);
}
@ExceptionHandler(Exception.class)
public Result<?> handleException(Exception e) {
log.error("系统异常", e);
return Result.error("系统异常,请联系管理员");
}
}
这样做的好处是:Controller里的代码会非常干净,业务逻辑只需要抛异常或者返回数据,不需要在每一层写try-catch。自定义的BusinessException里可以直接带错误信息,比如throw new BusinessException("该幼儿本月已生成收费记录,请勿重复操作"),前端接受到的就是统一格式的错误提示。
3.4 幼儿档案管理模块:一个标准的增删改查长这样
我拿幼儿档案接口来演示完整的代码链路。
实体类去掉Lombok注解后的核心:
java复制@Data
@EqualsAndHashCode(callSuper = false)
public class Child {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private Integer gender;
private Date birthDate;
private Long classId;
private String avatar;
private String allergyInfo;
private String medicalHistory;
private String fatherName;
private String fatherPhone;
private String motherName;
private String motherPhone;
private String address;
private Date enrollDate;
private Integer status;
private Integer deleted;
}
Controller:
java复制@RestController
@RequestMapping("/api/child")
public class ChildController {
@Autowired
private ChildService childService;
@PreAuthorize("hasAuthority('child:info:list')")
@GetMapping("/page")
public Result<IPage<ChildVO>> page(ChildQuery query) {
return Result.success(childService.pageQuery(query));
}
@PreAuthorize("hasAuthority('child:info:add')")
@PostMapping
public Result<?> add(@Valid @RequestBody Child child) {
Long id = childService.addChild(child);
return Result.success(id);
}
}
Service里有一个要注意的点:新增幼儿时,要校验同一班级下是否存在同名幼儿,这是避免重复建档的常见做法。还有个联动逻辑,新增幼儿时自动生成当月的收费记录,或者在缴费时再生成,这个根据实际需求来。我这边实操时候选择了“新生入园时批量生成一个学期的应收记录”,省得后面一个月一个月去创建。
3.5 文件上传:头像、健康证明怎么优雅处理
幼儿园系统涉及图片的地方不少:幼儿头像、体检报告、事故报告等。最简单的方案是本地存储,但有两点需要注意。
第一,不要把文件直接放在resources目录下。打包成JAR后这个目录是只读的,文件会丢失。推荐配置一个外部磁盘路径,比如/data/kindergarten/upload/,通过Spring Boot的静态资源映射把URL映射到这个物理路径。
java复制@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Value("${file.upload-path}")
private String uploadPath;
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/upload/**")
.addResourceHandler("file:" + uploadPath);
}
}
这样访问http://localhost:8080/upload/avatar/2025/03/xxx.jpg就能直接看到图片。热搜里那条“springboot 如何做资源映射”指的就是这类配置。
上传接口逻辑核心:
java复制@PostMapping("/upload")
public Result<String> upload(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
throw new BusinessException("文件不能为空");
}
String originalFilename = file.getOriginalFilename();
String ext = originalFilename.substring(originalFilename.lastIndexOf("."));
// 校验文件类型,防止上传可执行文件
if (!Arrays.asList(".jpg", ".jpeg", ".png", ".gif", ".pdf").contains(ext.toLowerCase())) {
throw new BusinessException("不支持的文件类型");
}
String fileName = UUID.randomUUID().toString().replace("-", "") + ext;
String datePath = new SimpleDateFormat("yyyy/MM").format(new Date());
File dir = new File(uploadPath + datePath);
if (!dir.exists()) {
dir.mkdirs();
}
file.transferTo(new File(dir, fileName));
return Result.success("/upload/" + datePath + "/" + fileName);
}
第二,做好文件类型和后缀的校验。不能只检查后缀,还要校验文件的Content-Type甚至是文件头字节,防止有人传个jsp或者html伪装成jpg。安全无小事,特别是管理系统里涉及上传的接口,我建议至少做一层魔数校验。
3.6 考勤登记:用Redis预存接送人白名单
考勤模块有个比较典型的场景:离园时保安或老师要确认“这位家长是不是这个孩子的授权接送人”。最简单的做法是每次查询数据库里的监护人表,但高峰期(下午4点半到5点半)大量孩子同时离园,如果所有请求都打到数据库,体验会下降。
实际项目里我在Redis里做了一个接送人白名单的缓存:
java复制// 入园/离园时查询白名单
public boolean checkGuardian(Long childId, String phone) {
String key = "guardian:whitelist:" + childId;
// 用Redis的Set存储接送人手机号
return redisTemplate.opsForSet().isMember(key, phone);
}
孩子信息或监护人信息变更时,主动清除对应缓存,保证下一次查询重新加载。Redis在这里起到了两个作用:一是减少数据库压力,二是让查询延迟从几十毫秒降到几毫秒。这种用小技巧解决实际体验问题的点,在系统里多留几个,面试或写项目总结时会非常有亮点。
3.7 收费模块:批量生成应收记录的典型实现
收费模块最核心的是“批量生成”:选择班级或者全园,选择费用项,点击生成后系统自动为每个在读幼儿创建收费记录。
java复制@Transactional(rollbackFor = Exception.class)
public void batchGenerateFeeRecords(Long feeItemId, Long classId, Date dueDate) {
// 1. 查询费用项
FeeItem feeItem = feeItemMapper.selectById(feeItemId);
// 2. 查询目标幼儿列表
LambdaQueryWrapper<Child> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Child::getStatus, 1);
if (classId != null) {
wrapper.eq(Child::getClassId, classId);
}
List<Child> children = childMapper.selectList(wrapper);
// 3. 防止重复生成
List<Long> existChildIds = feeRecordMapper.selectExistChildIds(feeItemId, dueDate);
// 4. 批量插入
List<FeeRecord> records = children.stream()
.filter(c -> !existChildIds.contains(c.getId()))
.map(c -> buildFeeRecord(c.getId(), feeItem, dueDate))
.collect(Collectors.toList());
if (!records.isEmpty()) {
feeRecordMapper.insertBatch(records);
}
}
这个方法必须加@Transactional,因为生成过程包含查询、过滤、批量插入多个步骤,任何一步失败都不应该留下半截数据。注意rollbackFor要写成Exception.class,这是很多初学容易忽略的点——默认情况下Spring事务只在遇到RuntimeException时回滚,受检异常不会触发回滚。
4. 常见问题与排查技巧实录
4.1 Spring Boot版本选择:高版本不一定是好事
热搜里“springboot版本太高”这个关键词,说明很多人都栽过这跟头。我明确建议:企业项目不用追求最新版,除非有明确的新特性需求。
我遇到过几个典型问题:
- 把Spring Boot升到3.x后,原来的
javax.*包全部变成了jakarta.*,第三方库如果不适配直接编译不过 - Spring Boot 3.x要求JDK 17,很多服务器环境还是JDK 8,部署时直接报class文件版本错误
- 高版本Spring Boot在某些低版本MySQL驱动下连接池初始化会异常
版本对应关系大致是:
| Spring Boot | JDK | 主要变更 |
|---|---|---|
| 2.7.x | 8+ | Javax包,生态最稳 |
| 3.0.x - 3.1.x | 17+ | 切到Jakarta包 |
| 3.2.x+ | 17+ | 功能持续更新,但生态仍需磨合 |
如果是新项目、完全可控环境,可以用Spring Boot 3;如果和老系统集成、团队用的JDK还是8,老老实实2.7.x。这个不是技术水平的体现,是工程决策能力的体现。
4.2 事务失效:一样写@Transactional,为什么有的回滚了有的没回滚
这个项目里我遇到过两次事务失效的坑,都是比较隐蔽的。
第一次是方法自调用。在同一个类中,一个方法调用另一个带@Transactional的方法,事务注解会失效。因为Spring的事务是基于AOP代理实现的,自调用时走的是this.method(),没有经过代理对象,事务拦截器根本没进来。
第二次是异常被吞掉。我在批量生成收费记录时,报表导出后有个统计逻辑,把统计代码放在try-catch里吃了异常,结果前面的数据插入也一起没有回滚。排查了很久才发现是这个问题。
事务使用三条经验:
- @Transactional一定要加
rollbackFor = Exception.class - 事务方法不要在被同类方法内部直接调用,要通过注入自身代理或拆到不同Service
- 事务方法里不要try-catch吞异常,如果必须捕获,记得手动
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()
4.3 Spring Security + JWT登录的那个经典坑
用Spring Security做登录认证,最经典的坑是:放行接口配置顺序错乱,导致登录接口永远被拦截。
java复制@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests()
.antMatchers("/api/auth/login").permitAll() // 放行登录
.antMatchers("/upload/**").permitAll() // 放行静态资源
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
要注意两点:permitAll()的路径要放在anyRequest().authenticated()之前,顺序错了后面的配置会被覆盖。另外,自定义的JWT过滤器要继承OncePerRequestFilter,并且在过滤器里对OPTIONS请求直接放行,否则前后端分离跨域预检请求会被拦截,前端会一直报CORS错误。
JWT这块还有个细节,token过期时间不能太长也不能太短。设太短,老师和家长一天要登录好几次;设太长,安全性没法保障。实际项目里我设置了7天过期,同时在Redis里存了token的黑名单,用户在“修改密码”或“强制下线”时能把指定的token立即失效,而不是干等过期。
4.4 分页查询:MyBatis-Plus的Page和前端参数对接
分页查询也是管理系统的高频操作。MyBatis-Plus的分页插件要单独配置:
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL);
pagination.setOverflow(false);
pagination.setMaxLimit(500L);
interceptor.addInnerInterceptor(pagination);
return interceptor;
}
}
setMaxLimit(500L)这个设置很关键,防止有人传一个超大pageSize把数据库打爆。前端传pageNum和pageSize,后端用Page<T> page = new Page<>(pageNum, pageSize),查询后返回IPage对象,里面包含total、records等字段。
还有个小细节:分页查询的排序字段来自前端的orderByColumn时,不要直接拼接进SQL,否则有SQL注入风险。最安全的做法是白名单校验,只允许对固定的几个字段排序。
4.5 Docker部署:从打JAR包到容器运行的一次解决
很多人在本地IDEA跑得飞起,一到部署就各种问题。“springboot打包到docker desktop”这个热搜我看了很有感触。我记一下最简单的部署路径,适合所有Spring Boot项目。
第一步,本地打包:
bash复制mvn clean package -DskipTests
打包后target目录下生成kindergarten-0.0.1-SNAPSHOT.jar。
第二步,编写Dockerfile:
dockerfile复制FROM openjdk:8-jre-alpine
VOLUME /tmp
COPY kindergarten-0.0.1-SNAPSHOT.jar app.jar
ENV JAVA_OPTS="-Xms256m -Xmx512m"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"]
第三步,构建镜像和运行:
bash复制docker build -t kindergarten:1.0 .
docker run -d --name kindergarten \
-p 8080:8080 \
-v /data/kindergarten/upload:/data/kindergarten/upload \
-e SPRING_PROFILES_ACTIVE=prod \
kindergarten:1.0
这里有个重要的点:上传目录必须通过-v挂载出来,否则容器销毁后文件全没了。数据库连接信息通过环境变量或外部的application-prod.yml注入,不要写死在镜像里。
如果MySQL也在Docker里跑,注意容器间用--link参数或自定义网络通信,别傻傻地在容器里写localhost连数据库。localhost在容器里指的是容器自己,不是宿主机。
5. 数据统计与报表:让园长愿意天天用系统
系统做到后期,我发现一个事实:如果系统只解决录入问题,对管理层来说价值不明显。真正让园长觉得“这系统有用”的,是报表。
我在系统里加了三个比较实用的统计页:
第一个是出勤统计。园长可以按月份查看各班出勤率,点击某一天还能下钻到具体的缺勤名单。这个页面的SQL是核心,用了一条SQL把考勤记录、班级、幼儿表做关联,按日期分组统计。
sql复制SELECT
DATE_FORMAT(a.attendance_date, '%Y-%m-%d') AS date,
c.class_name,
SUM(CASE WHEN a.status IN (1,2) THEN 1 ELSE 0 END) AS attend_count,
COUNT(*) AS total_count,
ROUND(SUM(CASE WHEN a.status IN (1,2) THEN 1 ELSE 0 END) / COUNT(*) * 100, 2) AS attend_rate
FROM t_attendance a
LEFT JOIN t_class c ON a.class_id = c.id
WHERE a.attendance_date BETWEEN #{startDate} AND #{endDate}
GROUP BY date, a.class_id
ORDER BY date DESC
第二个是收费统计。按月统计应收、实收、欠费金额,按班级看缴费进度。这个报表对财务老师来说是刚需,月底对账全靠它。
第三个是幼儿在园人数趋势。按月份统计在园人数,这个数据可以用来辅助园方做师资配比和教室规划。
做报表功能我学到的经验是:不要一上来就上ECharts,先做表格列表,把数据调准确了,再考虑图表展示。很多项目死在“图表很炫,数据对不上”上,这是大忌。
6. 一个小项目里最值钱的经验
整个幼儿园管理系统从数据库设计到上线,前后花了大概三周时间。如果重新做一遍,我会在一开始就做两件事:第一,把统一返回格式和全局异常处理写好,这能让后续所有接口开发效率提升至少30%;第二,把权限模型想清楚,是纯内部系统还是需要家长登录,这决定了整个用户体系的设计。
还有一点个人体会非常深:这类管理系统的业务复杂度不在技术上,而在规则细节上。比如收费是按月还是按学期,退费怎么折算,考勤迟到算不算出勤,这些规则如果不在需求阶段和用户反复确认清楚,开发到一半改起来非常痛苦。别急着写代码,先把规则一条条列出来和用户确认,这是做管理系统最省钱的做法。
最后再分享一个开发小技巧:Spring Boot的spring-boot-devtools一定要在开发时引入,改代码后自动重启,能省下大量等待编译和重启的时间。但生产环境务必排除这个依赖,否则会带来额外开销。希望这份实操记录能给你一些参考,如果你正在做类似的系统,祝你的项目少踩坑、早上线。
