简介:这是一份基于Java与SSM框架的任务众包系统毕业设计项目包,适合计算机相关专业学生用于毕业设计、课程设计或项目初期演示。包内含完整的前端页面、后端业务逻辑与数据库设计,覆盖任务发布、接单、审核、个人中心等常见众包场景,学习或二次开发都比较方便。资源共1161个文件,以HTML/CSS/JS静态页面、JSP动态页面、Java源码、Jar依赖包、SQL脚本及项目说明文档等为主,整体压缩后约18.62MB,结构清晰,便于按模块查阅。该项目已通过导师指导并获答辩95分,代码在mac及Windows 10/11环境下运行测试成功,附带数据库与使用说明,可直接部署演示或作为课程作业参考。目前已有169人学习下载,是一份经过实践检验的高分毕业设计参考资料。
1. 任务众包系统的毕业设计,为什么值得用 Java + SSM 二次开发
每到开题季,“基于 java+SSM 的任务众包系统”一直是名单上的高频题。它解决的场景很直白:有人发布设计、文案、跑腿这类零散任务,有人接单做完拿报酬,平台负责撮合、验收和担保。相比单点登录、商品管理这类题库常客,众包系统有完整的业务闭环,能聊的角色、状态、并发、结算都是现成的,答辩时不愁没话说。而且 SSM 是三层架构里最清晰的一套组合:Spring 管对象和事务,SpringMVC 管请求路由和参数绑定,MyBatis 管数据库操作,每一层都能讲出原理。它适合两类人:一是需要一份能改能跑的完整项目撑起毕设的学生,二是想补 Java Web 工程底子的新人。拿到“源码 + 数据库 + 使用文档”全套资料后,我建议别急着解压就跑,先按数据库脚本、依赖版本、部署顺序三步走,后面的坑会少一大半。
2. SSM 框架选型和工程骨架:先从 3 个依赖看清三层架构
2.1 Spring、SpringMVC、MyBatis 在众包业务里的职责切分
任务众包系统里,一次“发布任务”的请求大概要经过这样一条链路:浏览器把标题、描述、赏金、截止时间提交给 SpringMVC 的 Controller,Controller 调用 Spring 管理的 TaskService,Service 里通过 MyBatis 的 Mapper 往 task 表插入一条记录,整个方法被事务包裹。这条链路上三个框架各管一段,职责非常干净。
Spring 的核心价值是 IoC 和事务。在众包项目里,TaskService、TaskMapper、UserMapper 这些 Bean 都由 Spring 容器创建和注入;抢单、结算这类多步骤操作需要在方法上打@Transactional,事务管理器也是 Spring 在管。SpringMVC 的价值在路由和参数绑定,它把/task/publish这个 URL 映射到某个方法,把前端传的 JSON 或表单参数自动装进 Java 对象,省去手工 parse 的重复劳动。MyBatis 的价值是把 SQL 和 Java 代码拆开,sql 写在 XML 里,数据库的增删改查一目了然,也方便后期排查慢查询。
为什么毕业设计依旧选 SSM 而不是直接上 Spring Boot?一方面很多学校选题列表还停留在 SSM 时代;另一方面,SSM 的配置全靠 XML 和注解显式完成,Spring Boot 把这些全藏进了自动配置里。用 SSM 做系统,答辩时可以很具体地讲出“我配置了数据源、配置了事务、配置了拦截器”,而 Spring Boot 项目很难讲清楚这些细节是被谁初始化的。java 面试也经常拿“SSM 和 Spring Boot 的区别”来问,做过一遍传统配置,这类题基本不会慌。
2.2 用 Maven 搭好 SSM 工程的最小文件结构
拿到一份 SSM 项目源码,先看它的包结构是不是标准的三层。常见做法是按 controller、service、dao、pojo 分包,资源和配置放在 resources 下,这样无论谁来接手都好找。一个最小可运行的工程结构长这样:
src/main/java/com/example/task/ ├── controller/ # TaskController、UserController、AdminController ├── service/ # TaskService 接口 + impl 实现 ├── dao/ # TaskMapper、UserMapper 接口 ├── pojo/ # User、Task、TaskAccept 实体 └── interceptor/ # LoginInterceptor src/main/resources/ ├── spring/ │ ├── applicationContext.xml # 数据源、事务、Mapper 扫描 │ └── springmvc.xml # 注解驱动、视图解析器、拦截器 └── mybatis/mapper/ # TaskMapper.xml 等 SQL 映射文件 src/main/webapp/ ├── WEB-INF/web.xml └── static/ # css、js、图片这个结构里 controller 只接收参数和返回视图,service 处理业务和事务,dao 只做单表操作,pojo 是数据库表的映射对象。很多新手喜欢把业务写在 Controller 里或把 SQL 注解直接怼在 Mapper 接口上,短期内能跑,但数据和业务逻辑会越缠越死。
Maven 依赖是整个项目的地基。SSM 项目最常见的 Maven 配置长这样,核心是 Spring、SpringMVC、MyBatis 以及连接池:
<properties> <spring.version>4.3.30.RELEASE</spring.version> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.10</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>1.3.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.30</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.9</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>3.1.0</version> <scope>provided</scope> </dependency> </dependencies>这里有几个容易出错的点:Spring 4 系列最高只能用到 4.3.x,不要搭配 servlet-api 4.0,容易在启动时出现版本冲突;mysql-connector-java 8 和 druid 1.2.x 搭配比较稳,如果项目里用的是 5.x 的 MySQL 驱动,URL 写法完全不同。provided表示 servlet-api 只在编译期参与,运行期由 Tomcat 提供,这行不写清楚,本地跑没问题,部署到 Tomcat 就报ClassNotFoundException。
2.3 核心配置文件:数据源、扫描和视图解析器一次配齐
SSM 项目的配置核心是两个 XML:applicationContext.xml管 Spring 容器和数据源,springmvc.xml管 Web 层。applicationContext.xml里最重要的三样是组件扫描、数据源、事务管理器:
<context:component-scan base-package="com.example.task"> <context:exclude-filter type="annotation" expression="org.springframework.stereotype.Controller"/> </context:component-scan> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/taskcrowd?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="root"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="typeAliasesPackage" value="com.example.task.pojo"/> <property name="mapperLocations" value="classpath*:mybatis/mapper/*.xml"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>逻辑说明:ComponentScan 先扫除 Controller 以外的所有 Bean,避免 Controller 在容器里被注册两次;SqlSessionFactoryBean 告诉 MyBatis 去哪里找 Mapper 接口对应的 XML;事务管理器绑在同一个数据源上,这样@Transactional注解才能真正生效。参数说明:URL 里的&是 XML 转义后的&,直接写&会导致 XML 解析失败;serverTimezone=Asia/Shanghai在 MySQL 8 下必填,否则日期会差 8 小时。
springmvc.xml则聚焦 Web 层:开启注解驱动、配置视图解析器、注册拦截器。一个常见的配置写法是:
<mvc:annotation-driven/> <mvc:default-servlet-handler/> <context:component-scan base-package="com.example.task.controller"/> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean> <mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/static/**"/> <bean class="com.example.task.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>mvc:default-servlet-handler是让 Tomcat 处理静态资源,不配置的话 css、js 全部 404。到这里,一套 SSM 工程的最小骨架就立起来了:Tomcat 启动时读 web.xml,web.xml 加载这两个 Spring 配置文件,容器初始化完成后开始接 HTTP 请求。后续所有业务代码都是往这个骨架里填肉。
3. 数据库设计:任务表的状态机、并发抢单和资金结算三张核心表
3.1 用户、任务、订单三张核心表的字段设计
任务众包系统的核心实体不多,但表设计直接决定代码好不好写。我一般先设计三张主表:用户表、任务表、接单表,再补一张评论表和一张资金流水表。用户表要区分发单方和接单方,不能只靠一个角色字段糊弄,后面所有权限判断都基于它:
CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL COMMENT '登录账号', `password` VARCHAR(100) NOT NULL COMMENT '密码,存 MD5 或 BCrypt', `nickname` VARCHAR(50) DEFAULT NULL, `avatar` VARCHAR(255) DEFAULT NULL, `role` TINYINT NOT NULL DEFAULT 0 COMMENT '0发单人 1接单人 2管理员', `balance` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '账户余额,担保用', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;任务表是整个系统的核心,字段要覆盖发单人、任务内容、赏金、状态、截止时间和乐观锁版本号。其中status和version两个字段是最容易被忽略又最重要的。version不是业务字段,而是并发控制字段,后面抢单时全靠它:
CREATE TABLE `task` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `publisher_id` BIGINT NOT NULL COMMENT '发单人 uid', `title` VARCHAR(100) NOT NULL COMMENT '任务标题', `description` TEXT COMMENT '任务详细要求', `reward` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '赏金', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待接单 1进行中 2待验收 3已完成 4已取消 5超时', `deadline` DATETIME DEFAULT NULL COMMENT '接单截止时间', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status_deadline` (`status`, `deadline`), KEY `idx_publisher` (`publisher_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;接单表记录“谁在什么时候接了哪个任务”,一张表中一个任务最多一条有效接单记录。这里不要用order做表名,order 在 SQL 里是保留字,裸写会报语法错误。可以用task_accept或task_apply:
CREATE TABLE `task_accept` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `task_id` BIGINT NOT NULL, `worker_id` BIGINT NOT NULL COMMENT '接单人 uid', `accept_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `finish_time` DATETIME DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_task` (`task_id`), KEY `idx_worker` (`worker_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;三段建表 SQL 组合起来,业务闭环已经能撑住大半。评论表和流水表按需补充,评论表关联 task_id、评价人和被评价人,流水表记录每次赏金冻结和解冻,字段思路都是外键加金额加状态,不再展开。
3.2 索引设计:为什么不用物理外键,还要建联合索引
很多学生做毕设喜欢在表上加FOREIGN KEY物理外键,图一个数据库层面的一致性。我一般不建议在毕业设计里用物理外键,原因很简单:任务众包系统里 task、user、task_accept 之间是强关联,但开发期数据迁移、测试数据清空、批量导入都会受外键约束限制;更重要的是,MyBatis 的关联查询本来就不靠数据库外键,而是靠 XML 里的手动 join,物理外键除了增加维护成本没有实际收益。项目里更常见的是逻辑外键:task.publisher_id指向user.id,查询靠索引,不靠约束。
索引设计上最值得花心思的是task表的状态和时间组合。列表页最常见的查询是“显示所有待接单且未过期的任务”,对应的 SQL 是WHERE status = 0 AND deadline > NOW(),所以建了idx_status_deadline联合索引。如果只在 status 上建单列索引,deadline 的范围过滤仍然要回表,列表页任务一多就容易慢。另一个高频查询是“某人发布的所有任务”,对应publisher_id,所以单建了idx_publisher。
接单表的uk_task (task_id)唯一索引不是随手加的,它保证同一个任务只会被成功接单一次。哪怕代码里并发判断漏了,数据库层面也能兜住最后一道防线。这条唯一索引在答辩时值得主动提,它属于“数据库层面防并发重复”的加分点。
3.3 任务状态流转:状态机比 if else 可维护
任务状态的流转是众包系统最容易写乱的业务逻辑。如果代码里到处是if(status == 0){ ... } else if(status == 1){ ... },改一个状态规则就要满项目找。更稳的做法是在数据库层面先定死合法流转路径,代码只允许固定方向跳转。
task 表的状态机可以定义为五条合法路径:
- 0 待接单 -> 1 进行中:接单人抢单成功
- 1 进行中 -> 2 待验收:接单人提交成果
- 2 待验收 -> 3 已完成:发单人确认验收
- 0 待接单 -> 4 已取消:发单人主动撤单
- 0 待接单 -> 5 超时:定时任务自动关闭
在这个设计里,状态只能向前走,不能回退,也不能跳级。实现时最有效的手段不是在 Java 里判断,而是在 SQL 里把期望状态写进WHERE条件。比如接单动作的更新语句:
UPDATE task SET status = 1, version = version + 1 WHERE id = #{taskId} AND status = 0 AND version = #{version}这段 SQL 的含义是“只有当前状态还是 0 且版本号没变的任务,才能被我改成 1”。如果同时有两个接单人都执行这条 SQL,数据库的行锁会保证只有一条成功,另一条影响行数为 0,业务层看到 0 就知道抢单失败。这就是状态机和并发控制的结合,也是整套系统里最值得在答辩时展开讲的地方。
4. 核心功能实现:登录拦截、任务发布与乐观锁抢单
4.1 Controller + Service + Mapper 三层怎么拆
三层拆分的原则是:Controller 不写 SQL,Mapper 不写业务,Service 不直接暴露原始数据。以发布任务为例,Controller 只负责接收参数和返回结果,Service 负责拼装和校验数据,Mapper 只对 task 表做 insert。一个典型的 TaskService 接口和实现大概长这样:
public interface TaskService { boolean publishTask(Long publisherId, TaskVO taskVO); boolean acceptTask(Long taskId, Long workerId); PageResult<TaskVO> listTasks(String keyword, Integer status, int pageNum, int pageSize); }实现类里发布任务的逻辑非常直接:
@Service public class TaskServiceImpl implements TaskService { @Resource private TaskMapper taskMapper; @Transactional(rollbackFor = Exception.class) public boolean publishTask(Long publisherId, TaskVO taskVO) { User user = userMapper.selectById(publisherId); if (user == null || user.getRole() != 0) { throw new BusinessException("只有发单人才可以发布任务"); } Task task = new Task(); task.setPublisherId(publisherId); task.setTitle(taskVO.getTitle()); task.setDescription(taskVO.getDescription()); task.setReward(taskVO.getReward()); task.setDeadline(taskVO.getDeadline()); task.setStatus(0); return taskMapper.insert(task) > 0; } }参数说明:@Transactional放在 Service 实现类上而不是 Controller 上,是因为事务要覆盖整个业务方法,Controller 只要一个返回值,事务边界必须落在 Service 方法外层。rollbackFor = Exception.class表示任何异常都回滚,不写这个属性的话 Spring 默认只对 RuntimeException 回滚,自定义 BusinessException 没继承 RuntimeException 时就会提交脏数据,这里是很常见的失误。命令行提示:先校验角色再插入任务,校验逻辑放在事务方法里避免出现“任务已经插入但角色校验其实失败了”的错乱。
4.2 登录与角色权限拦截器
登录拦截是众包系统必须有的基础能力,不能让用户绕过登录直接发起抢单请求。实现方式是在 SpringMVC 的拦截器里统一校验 session 中的用户。一个简单的 LoginInterceptor:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object userId = request.getSession().getAttribute("userId"); if (userId == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } return true; } }逻辑说明:preHandle 返回 false 时 SpringMVC 直接终止请求,不会再调用后面的 Controller。拦截器的判定依据是 session 里是否存在 userId,登录成功时要在 LoginController 里手动塞进 session。注意/login、/static/**这些路径要在 springmvc.xml 的mvc:exclude-mapping里排除,否则登录页本身都会被拦住,形成死循环。
角色权限的校验不建议放在拦截器里,因为拦截器能拿到的是请求路径而不是用户角色,硬在拦截器里判断角色会让配置复杂化。常见做法是在 Service 方法入口做一次检查,比如发布任务检查user.getRole() == 0,接单检查user.getRole() == 1,管理员操作用单独的 AdminInterceptor 拦截/admin/**路径。职责分配清楚后,后面加角色不会牵连到登录拦截器。
4.3 抢单并发控制:乐观锁 version + 事务注解
抢单是众包系统里最考验并发控制的功能。两个接单人同时点击“立即接单”,如果代码是“先查状态再更新”,就会出现两个人都查询到 status=0,然后都执行 update,最终数据库状态被覆盖,任务被两个人同时接走。解决思路是第二章加上的 version 字段发挥作用。
Service 层的抢单核心方法:
@Transactional(rollbackFor = Exception.class) public boolean acceptTask(Long taskId, Long workerId) { Task task = taskMapper.selectById(taskId); if (task == null || task.getStatus() != 0) { return false; } // 检查当前用户不是发单人 if (task.getPublisherId().equals(workerId)) { throw new BusinessException("不能接自己发布的任务"); } int rows = taskMapper.compareAndSetStatus(taskId, workerId); if (rows == 0) { throw new BusinessException("手慢了,任务已被别人接走"); } taskAcceptMapper.insert(new TaskAccept(taskId, workerId)); return true; }对应的 Mapper XML 是:
<update id="compareAndSetStatus" parameterType="map"> UPDATE task SET status = 1, version = version + 1 WHERE id = #{taskId} AND status = 0 </update>逻辑说明:compareAndSetStatus这个更新语句里带了status = 0条件,数据库执行时会锁住这一行,天然保证两个并发请求只有一个能更新成功。影响行数为 0 就说明当前行的状态已经不是 0,抢单失败。参数说明:这里没有把 version 写进条件,简化后靠 status 条件已经足够保证状态不冲突,但如果状态从 0 变成 1 之后又有别的逻辑把状态改回 0,就必须带上 version 一起判断。更严格的写法是AND version = #{version},version 在 selectById 时取出,适合状态可回退的业务。
这里还有一点容易被忽视:先 select 再 update,两个请求都能查到 status=0,但这不影响结果,因为最终判断在 update 的影响行数上,而不是 Java 层的 if。这种“以数据库结果为准”的思路,比在 Java 里加 synchronized 或锁标志位要可靠得多,也更好向答辩老师解释。
4.4 任务列表的分页与条件查询
任务列表页是众包系统的高频接口,一般需要按关键词、状态分页查询。手写 LIMIT 分页是最直观、最容易讲清楚的方式,不需要引入 PageHelper 这种外部插件,答辩时被问到“分页原理”也能直接说出来。Mapper XML 如下:
<select id="listTasks" resultType="com.example.task.pojo.Task"> SELECT id, title, description, reward, status, deadline, create_time FROM task WHERE 1 = 1 <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR description LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="status != null"> AND status = #{status} </if> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>逻辑说明:<if>是 MyBatis 动态 SQL,keyword 为空就不拼搜索条件,status 为 null 就不按状态过滤。like查询用 CONCAT 拼百分号,避免直接在 Java 里拼 SQL 串造成注入风险。参数说明:#{}是预编译参数,MyBatis 会把它变成 JDBC 的?占位符,能有效防 SQL 注入;${}是直接拼字符串,能不用就不用。offset 的计算在 Service 里完成:offset = (pageNum - 1) * pageSize,Java 的 int 溢出在这里可能性很小,但仍建议用 long 计算。
任务列表返回后还要关联发单人的昵称和头像,列表页才会有人情味。这一步不要用 N+1 查询,更合适的做法是查出本页 task 后,收集所有 publisherId,再用一条SELECT * FROM user WHERE id IN (...)批量查出用户信息,在 Service 里装配成 VO。N+1 是 MyBatis 关联映射里最爱挖的坑,也是面试官爱问的点。
5. SSM 项目避坑指南:依赖冲突、日期格式化、懒加载的 4 个真实案例
5.1 Maven 依赖冲突:JUnit 5 和 Spring 4 一起用,测试直接罢工
现象:项目里配了 JUnit 5,跑单元测试时要么报NoClassDefFoundError,要么提示InvalidAlgorithmParameterException,甚至 Spring 的@TransactionalTestExecutionListener都加载不进来。
原因:Spring 4.3.x 的 spring-test 从诞生起就是基于 JUnit 4 写的,它和 JUnit Jupiter 引擎之间没有完整的适配层。很多毕设项目从网上东拼西凑 pom 依赖,spring-test 4.3 配 junit-jupiter 5.7,两个框架在 ClassLoader 层面直接打架。
解决:要么把 spring-test 统一成 4.3.x + junit 4.12,要么把整组 Spring 升到 5.x 再配 JUnit 5。对毕业设计来说最省事的是回退 JUnit 4,代码改动最小。pom 里这样锁定:
<dependency> <groupId>org.springframework</groupId> <artifactId>spring-test</artifactId> <version>4.3.30.RELEASE</version> <scope>test</scope> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.12</version> <scope>test</scope> </dependency>参数说明:Spring 5 需要 JDK 8 以上,如果本地 JDK 比较老,不要强行升级 Spring。依赖冲突的排查方法也值得记一下:在 IDE 里打开 pom,用 dependency hierarchy 视图看依赖树,找到被重复引入的包,再用exclusions排除即可。
5.2 日期前后端格式不一致:LocalDateTime 用不好就是个黑匣子
现象:前端传了一个2024-05-20 10:00:00的字符串给后端,Controller 直接报 400,参数绑定失败。后端返回 JSON 时,日期字段显示成2024-05-20T10:00:00或者一个数字数组,前端渲染出来完全不是人话。
原因:SpringMVC 的默认数据绑定对java.util.Date支持尚可,但对 Java 8 的LocalDateTime支持很弱。Spring 4.3 时代 Jackson 默认没有注册 JSR310 模块,LocalDateTime 被当成普通对象序列化,于是输出成数组结构;请求反序列化时也因为没有匹配的格式,直接报类型转换错误。这块当时很容易变成黑匣子,光靠猜解决不了,要看异常栈里是 Jackson 还是类型转换器在报错。
解决:先在 pom 加上 JSR310 支持:
<dependency> <groupId>com.fasterxml.jackson.datatype</groupId> <artifactId>jackson-datatype-jsr310</artifactId> <version>2.9.10</version> </dependency>再在实体字段上同时用好@JsonFormat和@DateTimeFormat:
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime deadline;逻辑说明:@JsonFormat管 JSON 序列化和反序列化,也就是 @ResponseBody 输出和 @RequestBody 接收;@DateTimeFormat管表单形式的参数绑定,也就是前端用application/x-www-form-urlencoded提交时。两个注解职责不同,很多人只加了一个,另一个场景照样 400。参数说明:如果要全局统一格式,可以在 springmvc.xml 里给 ObjectMapper 注册JavaTimeModule,并设置spring.jackson.date-format,但这个全局配置对 LocalDateTime 不一定生效,不如直接注解字段来得直接。
5.3 中文乱码:配置了编码过滤器仍然乱码,问题出在 JSON 转换器
现象:POST 请求的中文参数存进数据库变成???,或者前端拿到的 JSO 数据从头到尾都是乱码。已经配置了 CharacterEncodingFilter,乱码依旧。
原因:乱码根源不止一个。第一层是 Tomcat 的 URI 解码,POST 表单不加过滤器会按 ISO-8859-1 解码;第二层是 SpringMVC 的 HTTP 消息转换器,如果项目里手动注册过StringHttpMessageConverter这类 Bean,默认编码是 ISO-8859-1,不指定的情况下输出到响应体就是乱码;第三层是数据库连接 URL 里没加characterEncoding=utf8,存库阶段再出一次问题。
解决:三层依次堵住。web.xml 里注册 Spring 提供的 CharacterEncodingFilter,并设置高优先级:
<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>逻辑说明:forceEncoding设为 true,代表请求和响应都强制使用 UTF-8,不设的话过滤器只处理请求。数据库连接 URL 里也要有characterEncoding=utf8。排查时可以按“前端请求头 -> Tomcat 解码 -> Spring 容器 -> 数据库连接”的顺序断点,一般定位到第三步就知道问题出在哪了。
5.4 懒加载报错:no session 时触发关联查询会抛 LazyInitializationException
现象:任务列表页加载正常,但访问task.getPublisher().getNickname()时后台抛LazyInitializationException,提示could not initialize proxy - no Session。
原因:MyBatis 或 Hibernate 的关联对象默认懒加载,task.getPublisher()返回的是一个代理对象,真正执行查询的时机在访问属性那一刻。而 Service 事务方法结束后,MyBatis 的 SqlSession 已经关闭,再访问关联属性就找不到连接,于是抛异常。这类问题在“Controller 直接返回实体、页面 JSP 里取嵌套属性”时最容易出现。
解决:不要在 JSP 或 Controller 里踩代理对象的属性。更稳的方式是在 Service 方法内完成关联查询,直接返回装配好的 VO。任务列表的 Mapper 里改成显式 join,让关联数据一次性查出来:
<select id="selectTaskVO" resultType="com.example.task.vo.TaskVO"> SELECT t.id, t.title, t.reward, t.status, t.deadline, u.nickname AS publisherName FROM task t LEFT JOIN user u ON t.publisher_id = u.id WHERE t.id = #{id} </select>逻辑说明:这条 SQL 在事务内一次查出任务和发单人昵称,返回后关联属性是普通值,不再是代理对象。还有一种做法是配置openSessionInView,让 SqlSession 的生命周期延长到整个 HTTP 请求,但这相当于把数据库连接握在整个请求周期里,并发一高连接池立刻吃紧,毕业设计里靠它偷懒可以,答辩时说不清楚原理反而扣分。我一般倾向把这类问题扼杀在 Service 层,而不是延长 Session。
5.5 数据库连接时区与 SSL 告警:连接串上必须锁定 serverTimezone
现象:项目启动时控制台出现一堆WARN: Establishing SSL connection without server's identity verification is not recommended,同时数据库里存的时间比真实时间慢 8 小时。
原因:MySQL 8 的 JDBC 驱动默认要求指定时区,如果不写serverTimezone,驱动会取 JVM 默认时区,往往和数据库时区对不上。SSL 告警是 MySQL 8 在非 SSL 连接下的正常提示,不影响功能,但很吵。
解决:把 JDBC URL 写成这样:
jdbc:mysql://localhost:3306/taskcrowd?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true在 XML 里要写成&转义,在 Java 常量或 properties 文件里可以直接写&。参数说明:useSSL=false关闭加密连接,适合本地开发;allowPublicKeyRetrieval=true是 MySQL 8 非 SSL 连接下认证必须要开的,否则偶尔会报Public Key Retrieval is not allowed。serverTimezone可以写Asia/Shanghai或者GMT%2B8,推荐前者,含义直观。
6. 进阶收尾:任务过期自动关闭状态,用 @Scheduled 减少一个后台 Job
6.1 定时任务配置与 cron 参数
众包系统的任务有截止时间,但如果只靠用户手动取消,就会有一堆“待接单”状态的任务挂在列表里永远不消失。更常见的做法是加一个定时任务,每隔几分钟自动把已过截止时间且仍处于待接单状态的任务改成超时。Spring 自带的@Scheduled足够胜任,不需要引入 Quartz。
先在 springmvc 或 applicationContext 配置里开启定时任务扫描:
<task:annotation-driven scheduler="taskScheduler"/> <bean id="taskScheduler" class="org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler"> <property name="poolSize" value="2"/> </bean>然后写一个独立的定时任务组件:
@Component public class TaskTimeoutScheduler { @Resource private TaskMapper taskMapper; @Scheduled(cron = "0 */5 * * * ?") public void autoCancelTimeoutTasks() { taskMapper.cancelTimeoutTasks(); } }对应的 Mapper 更新语句:
<update id="cancelTimeoutTasks"> UPDATE task SET status = 5, version = version + 1 WHERE status = 0 AND deadline < NOW() </update>逻辑说明:@Scheduled(cron = "0 */5 * * * ?")表示每 5 分钟执行一次,秒位为 0,分位*/5,小时和日期通配都走默认。参数说明:cron 表达式是六位,和 Linux 的五位 cron 不一样。如果不想记 cron,还可以用@Scheduled(fixedDelay = 300000),代表当前一次执行完成后再等 5 分钟跑下一次,对短任务来说更直观。更新语句里deadline < NOW()是 MySQL 端的时间比较,没有依赖应用服务器的时间,避免多机部署时时间不一致。
这个定时任务在答辩里是个天然加分点:大多数同题项目只会做人工取消,没考虑任务超时后的自动清理。谁做了,谁就能在“系统设计”环节多讲一个演进点。我自己的习惯是,处理这类定时任务时不要只在代码里写逻辑,还要在数据库后台留一张task_timeout_log,记录每次批量更新了几条任务,这样线上如果出现了误更新也有后悔药可查。做 SSM 众包系统也一样,每一步都留痕、可排查,会比单纯把功能跑通更有工程味道;希望这篇笔记能帮到正在和毕设缠斗的你。
本文还有配套的精品资源,点击获取