1. 为什么我会做一个勤工俭学管理系统
先说个真实的场景。我认识一位负责学院勤工俭学工作的老师,每个月最头疼的事情就是统计工时。学生在食堂、图书馆、行政楼各个岗位打工,签到表是一沓纸,月底汇总的时候要把几十个人的工时逐条录入Excel,再根据岗位单价算工资。碰上学生漏签、代签、记错时间的情况,还得反复核对,一个岗位一个岗位地打电话问。这个流程效率低不说,学生和老师双方都容易有怨气。2023年我接了这个需求,要做一套基于SpringBoot和微信小程序的勤工俭学助学系统,把岗位发布、学生申请、考勤打卡、工时统计、工资核算、通知公告全部搬到线上。
这个项目最核心的价值就是替代纸质流程,把"岗位—学生—工时—工资"这条链路数字化。学生不用跑办公室交申请表,老师不用月底熬夜对表,财务那边也能直接导出结算数据。整套系统分成微信小程序端(学生用)和Web管理端(老师、管理员用),后端是SpringBoot + MyBatis Plus + MySQL,小程序原生开发。技术栈不花哨,但每个环节都有值得展开讲讲的设计细节。
这篇文章适合正在做类似管理系统的开发者参考,也适合刚学完SpringBoot和小程序开发、想找一个完整项目练手的人。我会把整个系统的拆解思路、关键表结构、登录鉴权、工时统计、订阅消息这几个核心模块的实现逻辑,以及我在实际开发中踩过的坑都写清楚。所有代码示例基于SpringBoot 2.7.x和微信小程序基础库2.30以上版本。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 需求梳理与表结构设计:先想清楚角色和状态流转
2.1 三类角色与核心业务流程
勤工俭学系统看上去简单,但角色一多,流程一长,设计不合理后面就会反复改。我一开始就明确了三类角色:学生、岗位管理员(老师)、系统管理员。学生用小程序端,管理员用Web端,Web端我用的是Vue3 + Element Plus,后端统一走RESTful API。
核心业务流程是这样:
- 管理员发布岗位(包含岗位名称、所属部门、招聘人数、工作内容、时薪、工作时间段)
- 学生在小程序端浏览岗位、提交申请(可附带课程表截图,用于排班)
- 管理员审核申请,通过或驳回,通过后学生进入"在岗"状态
- 学生在岗期间每天打卡签到/签退,记录工时
- 每月月底系统自动汇总工时,按岗位时薪计算工资
- 管理员确认工资单,导出Excel或推送消息给学生确认
- 学生离职或被辞退,岗位名额释放
这条流程定下来之后,整个系统的数据模型就清晰了。我建议做这类项目时不要一上来就建表,先把角色、状态、流程画清楚。状态流转是最容易出Bug的地方,比如申请通过之后才能打卡,离职之后不能再打卡,这些都要在接口层做校验。
2.2 核心表结构:学生、岗位、申请、打卡、工资单
我的数据库里最核心的五张表:
学生表(student)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| openid | varchar(64) | 微信openid,唯一 |
| student_no | varchar(20) | 学号 |
| name | varchar(20) | 姓名 |
| phone | varchar(11) | 手机号 |
| class_name | varchar(50) | 班级 |
| status | tinyint | 0-正常 1-禁用 |
岗位表(job)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| title | varchar(100) | 岗位名称 |
| dept | varchar(50) | 所属部门 |
| intro | text | 岗位描述 |
| hourly_wage | decimal(6,2) | 时薪 |
| headcount | int | 招聘人数 |
| hired_count | int | 已录取人数 |
| work_time | varchar(100) | 工作时间段描述 |
| status | tinyint | 0-招聘中 1-已停止 |
这里有个小设计:hired_count 是冗余字段,用来快速判断岗位是否已满。也可以实时count申请表中status=2(在岗)的记录数,但岗位列表页要频繁展示剩余名额,冗余字段加一个索引查询更快。
申请表(application)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| student_id | bigint | 学生id |
| job_id | bigint | 岗位id |
| resume_text | text | 申请说明 |
| schedule_url | varchar(255) | 课程表截图 |
| status | tinyint | 0-待审核 1-通过 2-驳回 3-已离职 |
| create_time | datetime | 申请时间 |
| review_time | datetime | 审核时间 |
打卡表(attendance)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| student_id | bigint | 学生id |
| job_id | bigint | 岗位id |
| check_in_time | datetime | 签到时间 |
| check_out_time | datetime | 签退时间 |
| work_date | date | 工作日期 |
| duration_minutes | int | 工作时长(分钟) |
工资单表(salary)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| student_id | bigint | 学生id |
| job_id | bigint | 岗位id |
| month | varchar(7) | 结算月份 如2024-05 |
| total_minutes | int | 总工时(分钟) |
| amount | decimal(8,2) | 工资金额 |
| status | tinyint | 0-待确认 1-已确认 |
有一个细节很多人会忽略:打卡表里为什么要冗余job_id?因为一个学生有可能同时干两个岗位(系统里我允许了,但限制最多两个)。如果不冗余job_id,统计某个岗位的工时就得先查申请表再关联,多一层JOIN。打卡时直接从当前在岗申请里拿到job_id写入,统计时按attendance.job_id直接聚合,清爽很多。
最终数据库我建了十五张表,除了这五张核心表,还有通知公告表、学生收藏表、管理员表、操作日志表。操作日志表强烈建议加,后面对账、排查问题都靠它。比如学生说"我打卡了但没记录",日志表里一查就有。
2.3 为什么我选MyBatis Plus而不是JPA
这个项目用MyBatis Plus,主要是图它的LambdaQueryWrapper写条件查询方便,分页插件也集成好了。JPA在简单CRUD上确实更快,但这个系统的查询有个特点:动态条件多。岗位列表要根据部门、状态、关键词过滤;打卡记录要根据日期范围、学生、岗位过滤;工资单要根据月份、状态过滤。用MP的Wrapper拼条件比写@Query注解的JPQL或原生SQL更灵活。
MP的分页插件配置很容易踩坑,尤其是SpringBoot 3.x下需要单独引入mybatis-plus-spring-boot3-starter,类名也变了。我用的是SpringBoot 2.7,所以直接用mybatis-plus-boot-starter,版本3.5.3,分页插件配置如下:
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
如果漏了这个配置,分页查询返回的记录数是对的,但total永远是0,看起来像分页失效。这个坑我在别的项目里踩过一次,这次直接一步到位配好。
3. 小程序登录与鉴权:别在openid上踩坑
3.1 登录流程拆解
微信小程序的登录和传统Web登录不太一样,没有用户名密码,核心是wx.login()拿到临时code,然后后端拿code换openid和session_key。流程如下:
- 小程序端调用
wx.login()获取code - 小程序端把
code发给后端/api/auth/login - 后端调用微信接口
https://api.weixin.qq.com/sns/jscode2session?appid=APPID&secret=SECRET&js_code=CODE&grant_type=authorization_code - 微信返回
openid和session_key - 后端根据
openid查学生表,如果不存在则自动创建一条新用户记录(游客态) - 后端生成自定义登录态
token,返回给小程序,小程序存到wx.setStorageSync - 后续所有请求在header里带
token,后端通过拦截器解析用户身份
这里最关键的一点是:前端永远不要拿到openid。openid是用户的唯一标识,一旦泄露,别人可以伪造请求。正确做法是后端把openid和用户id关联后,返回一个自定义的token(我用的是JWT,但把openid换成了userId作为subject),前端只认这个token。
JWT生成代码简化如下:
java复制String token = Jwts.builder()
.setSubject(String.valueOf(student.getId()))
.setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
token有效期我设了7天。勤工俭学系统使用频率高,学生几乎每天都要打卡,7天免登录体验比较顺畅。如果超过7天,小程序端拦截401后自动重新走一遍登录流程。
3.2 一个隐蔽的Bug:wx.login返回的code只能换一次openid
我在开发时遇到一个很奇怪的问题:第一次登录成功,但退出后再次登录,小程序端报"登录失败"。
排查了很久,最后发现原因不在后端,而在小程序端。我在wx.login()的success回调里同时又调了一次wx.login()去刷新code,导致后一个code覆盖了前一个。而后端拿第一个code去微信换openid,微信那边code已经失效了(一个code只能使用一次,有效期5分钟),所以一直报40029 code无效。
正确的写法是只调用一次wx.login(),拿到code之后立刻传给后端,不要在回调里嵌套调用。另外,在小程序启动时做静默登录,可以用App.onLaunch里的同步逻辑,把登录Promise缓存起来,避免多个页面同时触发重复登录:
js复制let loginPromise = null;
function login() {
if (!loginPromise) {
loginPromise = new Promise((resolve, reject) => {
wx.login({
success: (res) => {
wx.request({
url: `${baseUrl}/api/auth/login`,
method: 'POST',
data: { code: res.code },
success: (resp) => resolve(resp.data),
fail: reject
});
},
fail: reject
});
});
}
return loginPromise;
}
这样不管多少个页面同时调用login(),实际只会发一次登录请求。这个技巧在真实项目中非常实用,能减少大量无效请求。
3.3 后端拦截器:三种角色怎么区分
Web管理端有管理员和岗位管理员,小程序端有学生,三种角色怎么在同一个后端里区分?
我的做法是在登录接口返回的token里,通过userType字段区分角色。JWT的payload里包含userId和userType,拦截器解析token后,把用户信息放到ThreadLocal中,业务代码里通过UserContext.getUserId()获取当前登录用户。
java复制public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("Authorization");
if (StringUtils.isBlank(token)) {
throw new BusinessException(401, "未登录");
}
try {
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
Long userId = Long.valueOf(claims.getSubject());
Integer userType = (Integer) claims.get("userType");
UserContext.set(userId, userType);
return true;
} catch (Exception e) {
throw new BusinessException(401, "登录已过期");
}
}
}
权限校验我一直用@RequiresRole这类自定义注解做在方法上,比如岗位发布接口标注@RequiresRole(ADMIN),学生申请接口标注@RequiresRole(STUDENT)。相比各处硬编码if判断,注解式权限更直观,也不会漏。
4. 核心业务模块实现:岗位、申请、打卡、工资
4.1 岗位发布与申请:如何防止一个人占多个坑
岗位发布功能本身不复杂,无非是CRUD,但有几个业务规则需要处理:
- 岗位人数已满时不能再申请
- 一个学生同时只能在岗一个岗位(我这个项目限制了1个,有的学校允许2个,看需求)
- 学生不能重复申请同一个岗位
- 学生被驳回后可以重新申请
针对"不能重复申请",我在application表加了(student_id, job_id)唯一索引,同时更新逻辑里判断状态。如果学生之前申请被驳回了,再申请时需要把原有记录状态改回待审核,而不是插一条新记录,否则唯一索引就冲突了。最终我用的SQL是INSERT ... ON DUPLICATE KEY UPDATE status = 0, resume_text = VALUES(resume_text)。
岗位申请接口的核心校验逻辑:
java复制public void apply(ApplicationRequest req) {
Long studentId = UserContext.getUserId();
// 1. 查岗位
Job job = jobMapper.selectById(req.getJobId());
if (job == null || job.getStatus() != 0) {
throw new BusinessException("岗位不存在或已停止招聘");
}
// 2. 判断是否已满
if (job.getHiredCount() >= job.getHeadcount()) {
throw new BusinessException("该岗位已招满");
}
// 3. 判断当前学生是否已有在岗岗位
Long count = applicationMapper.selectCount(new LambdaQueryWrapper<Application>()
.eq(Application::getStudentId, studentId)
.eq(Application::getStatus, 1));
if (count > 0) {
throw new BusinessException("您已有在岗岗位,不能重复申请");
}
// 4. 写入申请记录(存在则更新)
Application application = new Application();
application.setStudentId(studentId);
application.setJobId(req.getJobId());
application.setResumeText(req.getResumeText());
application.setScheduleUrl(req.getScheduleUrl());
applicationMapper.insertOrUpdate(application);
}
审批通过时,要在一个事务里做两件事:把申请状态改为通过,同时把岗位的hired_count加1。这两个操作必须放在同一个@Transactional里,否则并发下数据就乱了。
4.2 打卡考勤:校验逻辑和边界情况
打卡是这个系统里最容易出Bug的地方。学生在地铁上、在路上、在食堂都可能打开小程序打卡,各种边界情况都要处理。
我的打卡逻辑要点:
签到:
- 必须处于在岗状态(application.status = 1)
- 当天不能重复签到
- 签到时间范围限制:岗位设置了最早签到时间和最晚签到时间
签退:
- 必须已签到
- 当天不能重复签退
- 签退时间必须晚于签到时间
签到接口:
java复制@Transactional
public void checkIn(CheckInRequest req) {
Long studentId = UserContext.getUserId();
LocalDate today = LocalDate.now();
// 1. 校验在岗状态
Application app = applicationMapper.selectOne(new LambdaQueryWrapper<Application>()
.eq(Application::getStudentId, studentId)
.eq(Application::getStatus, 1)
.last("LIMIT 1"));
if (app == null) {
throw new BusinessException("您当前没有在岗岗位");
}
// 2. 校验当天是否已签到
Long count = attendanceMapper.selectCount(new LambdaQueryWrapper<Attendance>()
.eq(Attendance::getStudentId, studentId)
.eq(Attendance::getWorkDate, today));
if (count > 0) {
throw new BusinessException("今天已经签过到了");
}
// 3. 写入签到记录
Attendance attendance = new Attendance();
attendance.setStudentId(studentId);
attendance.setJobId(app.getJobId());
attendance.setWorkDate(today);
attendance.setCheckInTime(LocalDateTime.now());
attendanceMapper.insert(attendance);
}
这里有一个并发隐患:如果学生手速极快,同一毫秒内点了两次签到,可能出现两条记录。虽然概率极低,但我在attendance表也加了(student_id, work_date)唯一索引兜底,插入时如果冲突捕获DuplicateKeyException,友好提示"请勿重复操作"。
签退时要计算时长,我是这样算的:
java复制public void checkOut(CheckOutRequest req) {
// ... 省略校验
Attendance attendance = attendanceMapper.selectOne(new LambdaQueryWrapper<Attendance>()
.eq(Attendance::getStudentId, studentId)
.eq(Attendance::getWorkDate, today)
.last("LIMIT 1"));
attendance.setCheckOutTime(LocalDateTime.now());
long minutes = Duration.between(attendance.getCheckInTime(), attendance.getCheckOutTime()).toMinutes();
attendance.setDurationMinutes((int) minutes);
attendanceMapper.updateById(attendance);
}
学生迟到早退怎么算?我的方案是:只记录实际时长,不做扣款。因为这个系统的核心是把流程数字化,至于迟到是否扣钱,那是管理制度的范畴,不是技术系统的职责。系统里留一个remark字段,管理员可以在后台手动调整工时。
4.3 每月工资核算:自动汇总 + 人工兜底
工资核算的逻辑不复杂,就是按月汇总打卡时长,乘以岗位时薪。但实际落地要考虑几个问题:
第一,岗位时薪可能会变。 如果岗位在月中调整了时薪,工资怎么算?我的方案是:当月的工资按当月打卡记录所属岗位的"当前时薪"计算。因为薪资调整频率极低,这个方案简单且够用。如果要精确到每次打卡,就需要在打卡记录里冗余快照时薪,一般管理场景用不上。
第二,异常数据怎么处理。 比如学生某天签到忘签退,duration_minutes为0,不能把这天算成0工时,应该让管理员在后台手动补录或修正。
第三,工资单生成后学生需要确认。 学生在小程序端看到本月工资单,包括每条打卡记录明细,确认无误后点"确认"。确认后的工资单不可修改,如果有问题可以发起申诉。
月度工资核算我用了定时任务,每月1号凌晨执行:
java复制@Scheduled(cron = "0 0 1 1 * ?")
public void monthlySalaryCalc() {
// 获取上个月的YearMonth
// 查询上个月所有在岗学生的打卡汇总
// 根据岗位时薪计算金额
// 生成工资单记录
}
这里又遇到一个坑:定时任务跑批的耗时可能超过几秒,如果中途出错需要重跑。我的做法是:先删除上个月已生成但状态为待确认的工资单,再重新生成,保证幂等。如果学生已经确认了,则跳过不覆盖。数据库层面给(student_id, job_id, month)加唯一索引,防止重复生成。
工资核算的核心SQL:
sql复制SELECT a.student_id, a.job_id, SUM(a.duration_minutes) as total_minutes
FROM attendance a
WHERE DATE_FORMAT(a.work_date, '%Y-%m') = #{month}
GROUP BY a.student_id, a.job_id
5. 消息通知与订阅消息:别让小程序静默成孤岛
5.1 微信订阅消息的机制和坑
勤工俭学系统里有很多需要通知学生的场景:申请审核结果、岗位新消息、工资单生成、打卡异常。微信小程序没有主动推送的能力,只能通过订阅消息,而且用户必须主动授权。
很多第一次做小程序的人会误以为订阅消息可以像短信一样随时推送,实际上微信的限制是:
- 一次性订阅:用户每次授权只能推送一次消息
- 长期订阅:仅限特定类目(教育、医疗等),普通开发者基本申请不到
所以我的策略是:在学生提交申请后,弹窗引导学生授权订阅消息。这样管理员审核通过时,系统才能推送一条"审核通过"的通知。学生授权一次只能适配一次通知,所以薪资提醒、审核结果这些关键节点都要让学生提前授权。
小程序端订阅消息触发代码:
js复制wx.requestSubscribeMessage({
tmplIds: ['模板ID_审核结果通知', '模板ID_工资单通知'],
success(res) {
// 用户点击允许后,后端才能主动推送
}
})
后端推送消息时,需要拿着用户的openid、模板ID、页面路径、表单数据去调微信接口。这里有个很隐蔽的问题:access_token是有有效期的(7200秒),而且每个小程序有调用频率限制。我需要做access_token缓存,不能每次都去微信那边取。
java复制@Component
public class WxAccessTokenManager {
private String accessToken;
private long expireTime;
@Scheduled(fixedRate = 60000)
public void refreshToken() {
if (System.currentTimeMillis() > expireTime) {
// 调用微信接口获取新的access_token
// 设置expireTime = 当前时间 + 7000秒(略小于7200,留出余量)
}
}
}
5.2 消息模板的配置注意事项
微信公众平台上需要先申请消息模板。教育类目下的"审核结果通知""工资到账通知"都有现成模板,字段名不一定完全匹配你的数据模型,所以申请之后要做一个字段映射,把系统的数据填充到模板的字段里。
例如审核结果通知模板可能长这样:
code复制审核结果通知
岗位名称:{{thing1.DATA}}
审核结果:{{phrase2.DATA}}
审核意见:{{thing3.DATA}}
对应后端填充代码:
java复制Map<String, Object> data = new HashMap<>();
data.put("thing1", Map.of("value", jobTitle));
data.put("phrase2", Map.of("value", "已通过"));
data.put("thing3", Map.of("value", remark));
这里要注意,模板字段的类型是强制的:thing类型限制20个字以内,phrase类型限制5个字以内,超出会推送失败。我的岗位名称有时候超过20个字,需要在填充前截断。
6. 实际开发中踩过的坑:从配置到部署的完整排错链路
6.1 SpringBoot自动装配的"黑魔法":为什么我的配置不生效
第一次启动项目时,我配置了Redis和MyBatis Plus,但日志里一直报"Failed to configure a DataSource"。排查了一会儿才发现,启动类上的@SpringBootApplication自动扫描的包路径不对。因为我把启动类放在了com.example.school,而Mapper接口在com.example.school.mapper,扫描没问题,但问题出在多个@Configuration类的位置。我把一个RedisConfig放在了启动类子包之外,SpringBoot默认只扫描启动类所在包及其子包,配置类没被加载。
这类问题用一句话总结:SpringBoot的自动装配依赖包扫描,包路径不要乱放。如果你发现某个@Configuration的@Bean一直没有被调用,十有八九是类放到了扫描路径之外。
后来项目用了@MapperScan("com.example.school.mapper")显式指定Mapper接口扫描路径,MyBatis Plus的Mapper就正常了。但Application类本身也要在根包下,这个习惯从一开始就要保持。
6.2 小程序端"获取登录后的微信用户失败"排查实录
热搜词里有一条"小程序获取登录后的微信用户失败:wx1cb4398e1413dce7",这个报错我也遇到过,而且花了大半天才定位到根因。
现象是:小程序在wx.login()成功之后,调用wx.getUserProfile()获取用户头像昵称,弹窗点了允许,但后面依然拿不到用户信息。排查步骤:
- 先确认
wx.login()返回的code正常,且能通过后端拿到openid - 然后看
wx.getUserProfile()是不是在回调里调用。微信官方要求getUserProfile必须在用户点击事件(tap)中调用,不能放在异步回调里 - 我检查自己的代码,发现我第一次是在
App.onLaunch里调用getUserProfile,这违反了规范,所以弹窗直接被拦截 - 改成在页面上放一个"授权登录"按钮,用户点击后调用
getUserProfile,问题解决
新版本微信基础库还出了wx.getUserProfile的替代方案:头像昵称填写能力。用户在页面上直接选择微信头像和昵称填入表单,不需要弹窗授权。这个体验更顺畅,我在系统里改成这种方案:学生首次进入小程序时,先展示一个"完善资料"页面,让用户自行填写学号、姓名、手机号,选微信头像。
核心点:现在拿微信用户信息已经不是主动获取了,而是用户主动填写。小程序的隐私政策越来越严,代码里不要再写wx.getUserProfile的强授权逻辑。
6.3 生产环境部署:Nginx反向代理 + HTTPS + 域名校验
小程序上线有个硬性要求:所有请求域名必须HTTPS,且在小程序后台配置合法域名。开发阶段可以勾选"不校验合法域名",但体验版、正式版必须配好。
我的部署方案:
- 后端打jar包,部署在服务器上,端口8080
- Nginx监听443端口,SSL证书用Let's Encrypt免费申请,反向代理到本机8080
- 小程序后台配置
request合法域名:https://api.example.com - 后端接口统一前缀
/api/
Nginx关键配置:
nginx复制server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
有一个细节:文件上传的接口(学生提交课程表截图、管理员传头像)也需要配一个访问域名。我单独用Nginx映射了一个/uploads/路径到服务器的静态目录。
6.4 前端联调阶段最容易忽略的小程序问题
一是分包的坑。 小程序有2MB主包限制,超过就编译不了。我的项目页面不多,但引入了图标库和二维码库之后总大小接近2.5MB,必须做分包。我把pages/student/下的页面拆到分包subpkgStudent里,主包只保留TabBar相关的首页和个人中心。
tabBar页面必须在主包里,不能放在分包。这是一个很常见的报错点:tabBar page not found。
二是"web-view"组件的限制。 我曾经想在小程序里内嵌一个Web页面展示岗位详情,但web-view的域名必须在小程序后台配置业务域名,而且要求域名ICP备案。个人开发者基本没戏,最后老老实实改成了原生页面渲染。
三是日期组件的时区问题。 学生打卡记录按yyyy-MM-dd存,但后端服务器是UTC时区或云服务器的本地时区不对,会导致跨天判断错误。我在数据库连接串里强制加serverTimezone=Asia/Shanghai,代码里也统一用LocalDate.now()而不是new Date()去拿当前日期。
7. 这套系统的扩展空间与我的反思
功能上线之后,我给它跑了大半年的稳定性验证,整体没有出现严重的线上Bug,但我仍然觉得有几块值得继续完善。
AI辅助排班。 勤工俭学的岗位时间往往是固定的,但学生有课表,能不能自动匹配空闲时间段来排班?这个完全可以做。我在application表里存了课程表截图,但只是存了,没做解析。如果后续用OCR把课程表结构化,再结合岗位时间段做冲突检测,管理人员的工作量至少能再减一半。
考勤异常自动预警。 现在学生忘签退系统不会主动提醒。可以加一个定时任务,每天晚上10点检查当天已签到但未签退的记录,通过订阅消息提醒学生。这个价值很直接,学生满意,管理员工作量也少。
数据大屏。 学院领导可能想看全校勤工俭学的数据概况:各岗位招聘率、各系部用工情况、月度工资总额趋势。这些数据都在库里有,写几个聚合SQL接一个ECharts大屏就行。如果学校有预算,这块可以做成独立的管理驾驶舱。
至于代码层面,如果有时间我会做三件事:把定时任务从单机@Scheduled迁移到XXL-Job,方便多实例部署时有分布式锁保护;把文件上传迁移到OSS而不是本地磁盘;给关键接口加一个简单的幂等控制,防止前端重复提交。
做这类系统我最深的体会是:技术不难,难的是把模糊的业务想法翻译成清晰的流程和数据模型。勤工俭学系统从纸面需求到上线,最难的部分不是SpringBoot的接口怎么写得漂亮,而是老师口中"就是统计一下工时"背后隐藏的几十种边界情况。把边界情况列出来、定义清楚,再用代码一五一十地实现,系统就成了。
