资讯动态

Java毕设课设任务平台:状态机+RBAC+文件安全设计

发布时间:2026/10/27 16:41:41 来源:尧图企业网站定制
简介这是一套面向计算机专业本科生的Java毕业设计与课程设计实战项目聚焦任务发布与接收场景帮助学生快速掌握SpringBoot企业级开发全流程。项目采用标准MVC架构基于JDK 1.8、SpringBoot框架与MySQL 5.7/8数据库构建支持Eclipse/IDEA开发已通过完整功能测试可直接运行。资源包共1790个文件涵盖42个核心Java源码、85个JAR依赖、74个JSP页面、62个JS前端脚本、30个CSS样式及97个XML配置文件辅以360张PNG界面截图和137个GIF操作示意结构清晰、模块完整便于理解前后端交互逻辑与系统整体架构。目前已有49人学习下载适合零基础入门或需快速交付毕设的学生——不仅提供可运行源码还包含数据库SQL脚本、Navicat连接配置说明及典型业务流程注释显著降低部署门槛与调试成本。1. 毕业设计课程设计毕设课设-java-任务发布接收平台为什么90%的Java初学者在这类系统上卡在「状态流转」和「权限错配」两道坎这不是一个“用Spring Boot搭个CRUD页面”的玩具项目而是一个真实教学管理场景下的轻量级协同中枢教师发布课程设计任务含截止时间、附件、评分标准学生领取、提交、查看反馈助教批阅、打分、退回修改管理员监控全局进度。它不追求高并发或分布式但必须严守「任务生命周期不可逆」「角色操作边界清晰」「提交物版本可追溯」三条铁律——而这恰恰是Java新手在毕设/课设中反复翻车的核心地带。我带过27届本科生做类似系统最常听到的崩溃瞬间是“老师说‘已发布’的任务学生点‘领取’却提示‘不可操作’”“助教改完分数学生刷新页面还是旧分”“上传的PDF作业莫名变成乱码”。这些问题表面是前端按钮灰了、后端返回空、数据库字段没更新根子全在状态机设计缺失、RBAC权限校验绕过、文件存储路径与事务不同步这三块硬骨头。本文不讲Spring Boot怎么启动只聚焦如何用Java生态里最稳、最易调试、最贴合教学场景的组合Spring Boot 3.2 MyBatis-Plus 3.5 PostgreSQL 15 Thymeleaf把任务发布-接收-提交-批阅这条主链路从数据库建模到接口幂等性从角色权限校验到文件安全存储一环一环钉死。适合正在写毕设开题报告、被导师要求“体现工程规范”的Java学习者也适合需要快速交付一套可演示、可答辩、可扩展的课设系统的指导教师。2. 用MyBatis-Plus生成任务核心表结构从实体类到SQL脚本的4个边界坑2.1 实体类定义必须显式声明状态枚举而非int字段很多同学直接用private int status;存任务状态0草稿,1已发布,2已领取…这会导致MyBatis-Plus自动生成的建表SQL里status字段是INTEGER NOT NULL后续业务逻辑中一旦忘记手动校验范围就会出现“status999”的脏数据且无法通过数据库约束拦截。正确做法是定义强类型枚举并让MyBatis-Plus自动映射// TaskStatus.java public enum TaskStatus { DRAFT(0, 草稿), PUBLISHED(1, 已发布), ASSIGNED(2, 已分配), SUBMITTED(3, 已提交), GRADED(4, 已评分), REJECTED(5, 已退回); private final int code; private final String desc; TaskStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } public String getDesc() { return desc; } }// Task.java TableLogic // 逻辑删除字段非必需但强烈建议 public class Task { TableId(type IdType.ASSIGN_ID) private String id; private String title; private String description; private LocalDateTime deadline; // 关键用枚举类型MyBatis-Plus会自动映射为TINYINT并加CHECK约束 TableField(value status, jdbcType JdbcType.TINYINT) private TaskStatus status; // 不是int TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }提示MyBatis-Plus 3.5对枚举的支持依赖EnumValue注解或全局配置。此处采用jdbcType TINYINT配合枚举getCode()方法是最兼容PostgreSQL且无需额外配置的方式。生成的SQL中status字段会带CHECK (status IN (0,1,2,3,4,5))约束从源头堵住非法值。2.2 自动生成建表SQL用MyBatis-Plus内置工具导出而非手写DDLMyBatis-Plus提供MybatisPlusGenerator工具类可基于实体类生成标准SQL。避免手写时漏掉ON UPDATE CURRENT_TIMESTAMP或DEFAULT CURRENT_TIMESTAMP——这是导致“更新时间不自动刷新”的高频原因。// GenerateSql.java 运行一次即可生成后删掉 public class GenerateSql { public static void main(String[] args) { FastAutoGenerator.create(jdbc:postgresql://localhost:5432/task_platform, postgres, password) .globalConfig(builder - builder .author(teacher) // 作者名不影响SQL .outputDir(src/main/resources/sql) // 输出目录 .disableOpenDir()) // 不自动打开文件夹 .packageConfig(builder - builder .parent(com.example.taskplatform) .moduleName(model)) .strategyConfig(builder - builder .addInclude(Task, TaskAssignment, Submission, User) // 只生成这几个表 .entityBuilder() .enableLombok() .enableTableFieldAnnotation() .mapperBuilder() .enableMapperAnnotation() .enableBaseResultMap() .serviceBuilder() .enableBaseService() .controllerBuilder() .enableRestStyle()) .execute(); } }执行后在src/main/resources/sql/下生成task.sql等文件。关键看生成的task表SQLCREATE TABLE task ( id varchar(32) COLLATE pg_catalog.default NOT NULL, title varchar(255) COLLATE pg_catalog.default, description text COLLATE pg_catalog.default, deadline timestamp(6), status smallint DEFAULT 0, create_time timestamp(6) DEFAULT CURRENT_TIMESTAMP, update_time timestamp(6) DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, -- 注意这里 CONSTRAINT pk_task PRIMARY KEY (id), CONSTRAINT ck_task_status CHECK (status IN (0, 1, 2, 3, 4, 5)) );注意PostgreSQL中ON UPDATE CURRENT_TIMESTAMP需配合pg_trgm扩展或触发器实现MyBatis-Plus生成的是MySQL语法。实际部署时必须手动将ON UPDATE CURRENT_TIMESTAMP替换为PostgreSQL触发器否则updateTime不会自动更新。这是第一个必须填的坑。2.3 触发器补全PostgreSQL中实现UPDATE_TIME自动更新PostgreSQL不支持ON UPDATE子句需创建触发器函数-- 创建触发器函数 CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at NOW(); RETURN NEW; END; $$ language plpgsql; -- 为task表添加触发器 CREATE TRIGGER update_task_updated_at BEFORE UPDATE ON task FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- 同样为submission、task_assignment等表创建对应触发器血泪经验漏掉这个触发器会导致所有“编辑任务”“批阅作业”操作后update_time字段永远停留在创建时间。答辩时被问“你怎么知道这个任务是昨天改的还是前天改的”就只能硬着头皮说“呃…我记的”。别让这种低级错误毁掉你的毕设印象分。2.4 文件存储路径设计用UUID哈希避免重名与遍历攻击学生提交的作业可能是report.pdf、code.zip若直接按原始文件名存入/uploads/会出现覆盖、路径穿越如../../../etc/passwd、暴力遍历猜/uploads/1.pdf三大风险。MyBatis-Plus不处理文件但实体类设计必须预留安全字段// Submission.java public class Submission { TableId(type IdType.ASSIGN_ID) private String id; private String taskId; private String userId; // 学生ID // 关键存储相对路径不含盘符和绝对路径 TableField(value file_path) private String filePath; // 例2024/05/abc123_report.pdf // 原始文件名仅用于前端显示 TableField(value original_filename) private String originalFilename; // 例实验报告.pdf private LocalDateTime submitTime; }文件保存逻辑在Service层Service public class SubmissionService { private final String UPLOAD_BASE_DIR uploads; public String saveUploadFile(MultipartFile file) throws IOException { // 1. 生成唯一子目录年/月防止单目录文件过多 String datePath LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy/MM)); String uploadDir UPLOAD_BASE_DIR / datePath; // 2. 生成安全文件名UUID 哈希原始名 保留扩展名 String extension StringUtils.getFilenameExtension(file.getOriginalFilename()); String safeName UUID.randomUUID().toString().replace(-, ) _ DigestUtils.md5Hex(file.getOriginalFilename()) . extension; // 3. 组装完整路径注意不拼接绝对路径 String fullPath uploadDir / safeName; // 4. 写入磁盘使用Spring Resource抽象避免硬编码File Path targetPath Paths.get(fullPath); Files.createDirectories(targetPath.getParent()); // 自动创建多级目录 Files.write(targetPath, file.getBytes()); // 5. 返回相对路径存入数据库filePath字段 return datePath / safeName; // 例2024/05/8f3a1b2c_report.pdf } }玄学提醒DigestUtils.md5Hex来自Apache Commons Codec务必在pom.xml中引入dependency groupIdcommons-codec/groupId artifactIdcommons-codec/artifactId version1.16.0/version /dependency没有它md5Hex会报错用file.getOriginalFilename().hashCode()代替不行——负数、碰撞率高、不防遍历。这是第二个必须填的坑。3. Spring Boot状态机驱动任务流转用StatefulKnowledge实现「已发布→已领取」的原子性校验3.1 为什么不用if-else硬编码状态判断常见错误写法// ❌ 危险并发下可能两个学生同时领取同一任务 if (task.getStatus() TaskStatus.PUBLISHED) { task.setStatus(TaskStatus.ASSIGNED); task.setAssignedAt(LocalDateTime.now()); taskMapper.updateById(task); // ... 分配给学生逻辑 }问题task.getStatus()读取是快照updateById执行时状态可能已被他人修改导致“超领”一个任务被两个学生领取。这不是理论风险——在答辩演示时导师连点两次“领取”你就得当场解释为什么数据库里出现了两条ASSIGNED记录。3.2 引入Spring Statemachine轻量级、可测试、自带持久化钩子Spring Statemachine专为状态流转设计其核心优势在于状态迁移由框架保证原子性失败时抛出明确异常且支持事件监听器做副作用如发通知、记日志。我们不需完整状态图只聚焦主链路// TaskStateMachineConfig.java Configuration EnableStateMachineFactory public class TaskStateMachineConfig extends StateMachineConfigurerAdapterString, TaskEvent { Override public void configure(StateMachineConfigurationConfigurerString, TaskEvent config) throws Exception { config .withConfiguration() .autoStartup(true) .listener(stateMachineListener()); } Override public void configure(StateMachineTransitionConfigurerString, TaskEvent transitions) throws Exception { transitions .withExternal() .source(TaskStatus.DRAFT.name()) // 草稿 .target(TaskStatus.PUBLISHED.name()) // → 已发布 .event(TaskEvent.PUBLISH) // 事件发布 .and() .withExternal() .source(TaskStatus.PUBLISHED.name()) // 已发布 .target(TaskStatus.ASSIGNED.name()) // → 已分配 .event(TaskEvent.ASSIGN) // 事件分配教师主动分配或ASSIGN_BY_STUDENT学生领取 .and() .withExternal() .source(TaskStatus.ASSIGNED.name()) // 已分配 .target(TaskStatus.SUBMITTED.name()) // → 已提交 .event(TaskEvent.SUBMIT) .and() .withExternal() .source(TaskStatus.SUBMITTED.name()) // 已提交 .target(TaskStatus.GRADED.name()) // → 已评分 .event(TaskEvent.GRADE) .and() .withExternal() .source(TaskStatus.SUBMITTED.name()) // 已提交 .target(TaskStatus.REJECTED.name()) // → 已退回 .event(TaskEvent.REJECT); } Bean public StateMachineListenerString, TaskEvent stateMachineListener() { return new StateMachineListenerAdapterString, TaskEvent() { Override public void stateChanged(StateString, TaskEvent from, StateString, TaskEvent to) { log.info(任务状态变更{} - {}, from.getId(), to.getId()); // 此处可发站内信、更新缓存、写审计日志 } }; } }3.3 在Service中调用状态机捕获IllegalStateException应对非法操作状态机调用不是简单sendEvent必须包裹在事务中并捕获状态不匹配异常Service Transactional public class TaskService { Autowired private StateMachineString, TaskEvent stateMachine; Autowired private TaskMapper taskMapper; public boolean publishTask(String taskId) { MessageString message MessageBuilder.withPayload(TaskEvent.PUBLISH.name()) .setHeader(taskId, taskId) .build(); try { // sendEvent返回booleantrue迁移成功false状态不匹配 boolean result stateMachine.send(message).join(); if (!result) { throw new IllegalStateException(任务[ taskId ]当前状态不允许执行PUBLISH操作); } return true; } catch (Exception e) { // 状态机内部已回滚此处只需记录 log.error(发布任务失败, e); throw new ServiceException(发布失败 e.getMessage()); } } public boolean assignToStudent(String taskId, String studentId) { // 先查任务当前状态状态机不维护状态需自己查 Task task taskMapper.selectById(taskId); if (!TaskStatus.PUBLISHED.equals(task.getStatus())) { throw new IllegalStateException(仅允许对已发布状态的任务进行分配); } // 构造事件消息携带studentId MessageString message MessageBuilder.withPayload(TaskEvent.ASSIGN_BY_STUDENT.name()) .setHeader(taskId, taskId) .setHeader(studentId, studentId) .build(); try { boolean result stateMachine.send(message).join(); if (!result) { throw new IllegalStateException(任务[ taskId ]状态迁移失败); } // 状态机成功后更新task表的assignedTo字段 task.setAssignedTo(studentId); task.setAssignedAt(LocalDateTime.now()); task.setStatus(TaskStatus.ASSIGNED); taskMapper.updateById(task); return true; } catch (Exception e) { log.error(分配任务失败, e); throw new ServiceException(分配失败 e.getMessage()); } } }关键细节stateMachine.send(message).join()是阻塞调用确保状态迁移完成后再执行后续DB操作。join()会抛出ExecutionException需用try-catch捕获。这是第三个必须填的坑——不join()状态机异步执行DB更新可能先于状态变更导致数据不一致。4. RBAC权限校验的3个必调参数用Spring Security Method Security实现「教师能发布学生不能」4.1 数据库层面建模用户-角色-权限三张表而非硬编码角色字符串很多课设直接用if (user.getRole().equals(teacher))这无法应对“某教师兼任助教”“某学生临时授权为助教”等真实场景。必须用标准RBAC-- user_role关联表用户-角色多对多 CREATE TABLE user_role ( user_id VARCHAR(32) NOT NULL, role_id VARCHAR(32) NOT NULL, PRIMARY KEY (user_id, role_id), FOREIGN KEY (user_id) REFERENCES user(id), FOREIGN KEY (role_id) REFERENCES role(id) ); -- role_permission关联表角色-权限多对多 CREATE TABLE role_permission ( role_id VARCHAR(32) NOT NULL, permission_id VARCHAR(32) NOT NULL, PRIMARY KEY (role_id, permission_id), FOREIGN KEY (role_id) REFERENCES role(id), FOREIGN KEY (permission_id) REFERENCES permission(id) ); -- permission表权限点细粒度控制 INSERT INTO permission VALUES (TASK_PUBLISH, 发布任务), (TASK_ASSIGN, 分配任务), (TASK_SUBMIT, 提交任务), (TASK_GRADE, 批阅任务), (TASK_VIEW_ALL, 查看全部任务);4.2 Spring Security Method Security在Controller方法上用PreAuthorize声明权限比Filter更精准、更易测试、与业务逻辑紧耦合RestController RequestMapping(/api/task) public class TaskController { PostMapping(/publish) PreAuthorize(hasAuthority(TASK_PUBLISH)) // 关键权限字符串必须与permission表一致 public ResultString publish(RequestBody TaskPublishDTO dto, AuthenticationPrincipal User currentUser) { return Result.success(taskService.publishTask(dto)); } PostMapping(/{taskId}/assign) PreAuthorize(hasAuthority(TASK_ASSIGN)) public ResultVoid assign(PathVariable String taskId, RequestBody AssignRequest req, AuthenticationPrincipal User currentUser) { taskService.assignToStudent(taskId, req.getStudentId()); return Result.success(); } PostMapping(/{taskId}/submit) PreAuthorize(hasAuthority(TASK_SUBMIT)) // 学生角色必须有此权限 public ResultVoid submit(PathVariable String taskId, RequestParam MultipartFile file, AuthenticationPrincipal User currentUser) { submissionService.submitTask(taskId, currentUser.getId(), file); return Result.success(); } }4.3 权限加载逻辑UserDetailsService必须返回GrantedAuthority集合UserDetailsService不能只返回用户名密码必须加载该用户所有权限Service public class CustomUserDetailsService implements UserDetailsService { Autowired private UserMapper userMapper; Autowired private PermissionMapper permissionMapper; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user userMapper.selectByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在 username); } // 查询该用户所有权限字符串如 TASK_PUBLISH, TASK_SUBMIT ListString permissions permissionMapper.selectByUserId(user.getId()); // 转为Spring Security所需的GrantedAuthority列表 CollectionGrantedAuthority authorities permissions.stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); return User.builder() .username(user.getUsername()) .password(user.getPassword()) // 注意密码应为BCrypt加密后的密文 .authorities(authorities) .build(); } }注意PermissionMapper.selectByUserId需编写自定义SQLJOINuser_role和role_permission表。这是第四个必须填的坑——不查权限PreAuthorize永远返回false所有接口403。5. 避坑任务发布接收平台的5个血泪教训现象→原因→解决5.1 现象学生点击“领取任务”页面无反应后台日志显示NullPointerException原因前端传参taskId为空字符串或nullController未校验直接调用taskService.assignToStudent(null, studentId)Service层查taskMapper.selectById(null)返回null后续操作NPE。解决在Controller层用NotBlank校验或统一用ValidDTOPostMapping(/{taskId}/assign) public ResultVoid assign(PathVariable NotBlank String taskId, ...) { ... }提示NotBlank对String有效NotNull对对象有效别混用。5.2 现象助教批阅后学生看到的分数是旧的刷新页面才更新原因批阅操作更新了submission表的score字段但前端页面缓存了旧数据且未触发WebSocket或轮询更新。解决两种方案任选其一简单方案在GradeController返回成功后向前端返回{success:true, score:85.5}前端JS直接更新DOM进阶方案集成Spring WebSocket批阅完成后推送{type:GRADE_UPDATE,taskId:xxx,score:85.5}前端监听并更新。5.3 现象上传10MB的ZIP作业接口超时返回500日志显示MaxUploadSizeExceededException原因Spring Boot默认文件上传限制为1MBspring.servlet.multipart.max-file-size1MB。解决在application.yml中显式配置spring: servlet: multipart: max-file-size: 50MB max-request-size: 50MB注意max-request-size必须≥max-file-size否则单文件不超限但多文件总和超限仍会失败。5.4 现象PostgreSQL中task表的status字段存了100但枚举只有0~5原因MyBatis-Plus插入时未校验枚举范围且数据库CHECK约束被忽略可能因建表时未执行或PostgreSQL版本问题。解决在实体类Task.setStatus()方法中加校验public void setStatus(TaskStatus status) { if (status null) { throw new IllegalArgumentException(status cannot be null); } this.status status; }手动检查并修复数据库SELECT * FROM task WHERE status NOT IN (0,1,2,3,4,5); UPDATE task SET status 0 WHERE status NOT IN (0,1,2,3,4,5);5.5 现象Thymeleaf模板中${task.status.desc}显示为空但数据库里status1原因Thymeleaf默认调用toString()而枚举TaskStatus未重写toString()返回的是PUBLISHED枚举名不是已发布desc字段。解决在枚举中重写toString()Override public String toString() { return this.desc; // 返回中文描述 }或在模板中用${task.status.getDesc()}需确保getter存在。6. 进阶技巧用JUnit 5Testcontainers实现「任务领取」全流程自动化验证6.1 为什么单元测试不够你需要接近生产环境的集成测试SpringBootTest启动整个容器但数据库是H2内存库与PostgreSQL行为有差异如ON UPDATE触发器、JSONB字段、全文检索。Testcontainers用Docker启动真实PostgreSQL实例测试结果才可信。6.2 配置Testcontainers在test目录下添加依赖和配置pom.xmltest scopedependency groupIdorg.testcontainers/groupId artifactIdpostgresql/artifactId scopetest/scope /dependency dependency groupIdorg.testcontainers/groupId artifactIdjunit-jupiter/artifactId scopetest/scope /dependencysrc/test/java/com/example/taskplatform/TaskIntegrationTest.javaSpringBootTest(webEnvironment SpringBootTest.WebEnvironment.RANDOM_PORT) Testcontainers class TaskIntegrationTest { Container static PostgreSQLContainer? postgres new PostgreSQLContainer(postgres:15) .withDatabaseName(testdb) .withUsername(testuser) .withPassword(testpass); DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add(spring.datasource.url, postgres::getJdbcUrl); registry.add(spring.datasource.username, postgres::getUsername); registry.add(spring.datasource.password, postgres::getPassword); } Autowired private TestRestTemplate restTemplate; Autowired private TaskMapper taskMapper; Test void shouldAssignTaskToStudentWhenPublished() { // 1. 准备插入一个已发布的任务 Task task new Task(); task.setTitle(课程设计1); task.setDescription(请实现一个计算器); task.setDeadline(LocalDateTime.now().plusDays(7)); task.setStatus(TaskStatus.PUBLISHED); taskMapper.insert(task); // 2. 执行学生领取任务调用真实API String url http://localhost: port /api/task/ task.getId() /assign; HttpHeaders headers new HttpHeaders(); headers.set(Authorization, Bearer getTokenForStudent()); // 模拟登录 HttpEntityVoid request new HttpEntity(headers); ResponseEntityResult response restTemplate.exchange(url, HttpMethod.POST, request, Result.class); // 3. 验证数据库中任务状态变为ASSIGNED且assignedTo字段为学生ID Task updatedTask taskMapper.selectById(task.getId()); assertThat(updatedTask.getStatus()).isEqualTo(TaskStatus.ASSIGNED); assertThat(updatedTask.getAssignedTo()).isNotBlank(); } private String getTokenForStudent() { // 此处模拟JWT生成实际可用MockMvc或预置token return fake-jwt-token; } }6.3 关键参数表Testcontainers连接PostgreSQL的5个必设项参数作用推荐值备注POSTGRES_PASSWORD数据库密码testpass必须与withPassword()一致POSTGRES_DB数据库名testdb必须与withDatabaseName()一致POSTGRES_USER用户名testuser必须与withUsername()一致TESTCONTAINERS_HOST_OVERRIDEDocker宿主机地址host.docker.internalMac/Win或172.17.0.1Linux解决容器内访问宿主机网络问题spring.datasource.hikari.maximum-pool-size连接池大小5Testcontainers资源有限不宜过大6.4 真实教训我在答辩前3天用Testcontainers发现的致命Bug当时所有单元测试都绿但真实环境里“教师发布任务”后学生列表里看不到新任务。排查发现TaskMapper.selectPublishedTasks()的SQL用了WHERE status 1而PostgreSQL中status字段是smallintMyBatis-Plus传参时1被当作IntegerPostgreSQL类型推断为integer导致索引失效smallintvsinteger比较需隐式转换。Testcontainers跑出慢查询日志而H2内存库无此问题。解决方案在SQL中显式CASTSELECT * FROM task WHERE status CAST(1 AS SMALLINT)或者更优雅地在实体类中用TableField(value status, jdbcType JdbcType.TINYINT)确保MyBatis-Plus传参类型正确。我现在写任何涉及枚举的SQL第一反应就是打开Testcontainers跑一遍。它不能替代人工测试但能提前3天把你从答辩现场的冷汗里捞出来。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑