SSM任务众包系统核心设计:状态机与并发抢单实战 简介这套基于 Java SSM 的任务众包系统毕业设计资料面向计算机相关专业学生及开发者可作为毕业设计、课程设计或项目练手的完整参考。资源包含项目源码、数据库脚本、使用文档与答辩相关材料覆盖前台任务发布/接取与后台管理等功能模块代码已在 Windows/macOS 10/11 环境验证运行通过方便在此基础上扩展或直接用于答辩演示。整个压缩包共 1161 个文件约 18.62MB其中以 HTML/CSS/JS 前端页面、JSP 动态页面、Java 业务代码、SQL 数据库脚本及 jar 依赖库为主另有 DOC 文档与 XML 配置等文件类型齐全目录结构清晰。目前已有 160 人学习下载适合需要完整项目方案、参考代码实现或系统学习 SSM 框架整合的读者。1. 任务众包系统的核心不在“众包”而在“状态机”——用 Java SSM 复刻一个可答辩的工程很多人在动手做任务众包系统时第一反应是把它当成一个“带用户登录的CRUD”。实际写完会发现任务发布、接单、交付、验收、结算这一圈走下来真正难的不是增删改查而是任务在每一步如何正确地切换状态以及当两个人同时抢一个单子时数据库怎么保证只有一个人能抢到。这类系统在结构上和电商订单系统几乎同构都有用户、商品任务、订单接单记录、资金流水只是交易的对象从“实物”换成了“待完成的活儿”。所以它能同时覆盖 SSM 框架的绝大部分考点Spring MVC 分层、MyBatis 手写 SQL、声明式事务、并发控制、数据库设计。这篇就按“领域建模 → 表结构 → 核心业务 → 并发与事务 → 排错与演示”的顺序把一套能跑、能答辩、能讲清楚设计理由的 SSM 任务众包系统拆开讲。可以照着做也可以拿这份思路去检查你自己项目里的表设计和事务边界。2. SSM 任务众包系统的数据库模型先拆 ER 图再写建表 SQL2.1 任务众包领域模型拆解谁在发任务、谁在接任务任务众包系统的角色通常有三种发布者、接包者工人、管理员。发布者发布任务并支付赏金接包者浏览任务列表、抢单、交付成果管理员负责审核任务和处置纠纷。围绕这三个角色核心业务可以压缩成一条主线任务发布 → 平台审核 → 接单 → 执行 → 交付 → 验收 → 结算。每一条线都是一组状态落库后就是一张张关联表。从 ER 图出发最少需要四张核心表tb_user用户表存发布者和接包者的公共信息用role字段区分身份tb_task任务表记录标题、赏金、截止时间、当前状态tb_acceptance接单表也叫“任务认领表”记录谁在什么时间接走了哪个任务tb_wallet_log资金流水表记录赏金的冻结、释放和结算。如果还要做评价再补一张tb_comment但四张表已经能把主干业务讲清楚。2.1.1 用户、任务、接单、结算四张核心表的字段设计先看一张字段设计参考表后续所有 SQL 和代码都围绕它展开表名核心字段说明tb_useruser_id, username, password, role, balance, credit_scorerole 用 1/2/3 表示发布者、接包者、管理员tb_tasktask_id, publisher_id, title, description, reward, status, version, deadlinestatus 用 0-5 表示待审核、招募中、进行中、待验收、已完成、已关闭tb_acceptanceaccept_id, task_id, worker_id, accept_time, deliver_note, statusstatus 表示交付状态和任务状态分开维护tb_wallet_loglog_id, user_id, amount, type, ref_id, create_timetype 区分冻结、扣款、入账、退款字段设计上有两个地方容易踩坑。第一个是金额类型赏金一定用DECIMAL(10,2)不要用float或double浮点精度问题在结算时会出大丑。第二个是“状态到底跟谁走”任务有任务状态接单记录有交付状态两者有关联但不要混在一个字段里——任务处于“进行中”时接单记录可能还没有交付任务“已完成”时接单记录必须对应“验收通过”。两张表各自维护自己的状态字段用任务 ID 关联逻辑才清晰。2.1.2 状态字段用 tinyint 还是 varchar常见做法是用tinyint加注释而不是varchar存中文。原因有三个查询更快、存储更省、不会出现“改成已完成”和“改成 已完成”这种肉眼难查的空格不一致。代价是代码里读出来是数字需要一层翻译。我的习惯是数据库注释里写清楚每个数字的含义Java 侧用枚举类定义常量。public enum TaskStatus { PENDING(0, 待审核), RECRUITING(1, 招募中), PROCESSING(2, 进行中), DELIVERED(3, 待验收), FINISHED(4, 已完成), CLOSED(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.getStatus() 2这种写法过两周再看根本想不起 2 是什么。统一用TaskStatus.PROCESSING.getCode()IDE 能提示、评审能看懂、答辩时还能讲一段“用枚举收敛状态常量”的设计理由。desc字段可以直接拿来渲染前端状态标签省一层 switch。2.2 用 SQL 脚本建库建表任务表从建表语句到索引设计2.2.1 任务表的完整建表语句CREATE TABLE tb_task ( task_id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT 任务ID, publisher_id INT UNSIGNED NOT NULL COMMENT 发布者ID, 关联 tb_user.user_id, 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已关闭, accept_worker_id INT UNSIGNED DEFAULT NULL COMMENT 接包者ID, 抢单成功后写入, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, deadline DATETIME DEFAULT NULL COMMENT 截止时间, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, KEY idx_status_create (status, create_time), KEY idx_publisher (publisher_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 任务表;这段建表语句里值得在答辩时展开讲的有三处第一个是status字段后面的注释把每个数字的含义全写清楚了这是数据库自文档化的手段第二个是version字段用来做乐观锁后面第 4 章会专门讲它在抢单场景中的作用第三个是idx_status_create复合索引任务列表最常见的查询条件是“按状态过滤 按时间倒序”这个索引能直接命中。update_time的ON UPDATE CURRENT_TIMESTAMP会在任意字段更新时自动刷新方便排查“这条数据什么时候被改过”。需要提醒的是utf8mb4是必须的MySQL 的utf8存不了 emoji用户任务描述里一旦出现特殊字符写入直接报错这类问题在实机演示时最尴尬。2.2.2 MyBatis 的 ResultMap 如何映射状态字段SSM 中 MyBatis 的实体映射不建议在数据库层做复杂转换。最省事的方案是status字段直接映射为Integer实体类里提供getStatusEnum()方法在业务层按需转换。resultMap idtaskResultMap typecom.example.entity.Task id propertytaskId columntask_id/ result propertypublisherId columnpublisher_id/ result propertytitle columntitle/ result propertyreward columnreward/ result propertystatus columnstatus/ result propertyversion columnversion/ result propertycreateTime columncreate_time/ /resultMap这里有一个关键点insert和update不要使用resultMap的自动映射去做“全字段更新”。常见做法是在 Mapper XML 里写显式 SQL只更新需要变更的字段。全字段更新会把version、status等关键字段无脑覆盖是并发问题的常见来源。MyBatis 的resultMap只负责查询结果映射写操作必须由手工 SQL 精确控制。3. 在 SSM 里落地核心业务任务发布、接单与列表查询的代码写法3.1 Spring MVC 分层设计Controller 做参数校验业务逻辑下沉到 ServiceSSM 项目的分层习惯是Controller 层处理 HTTP 请求、参数绑定、返回统一结果Service 层承载业务规则、事务控制Mapper 层只做数据读写。任务发布这个功能点刚好能把三层完整串一遍。Controller 层不应该出现new Task()或者任何金钱计算的代码。它的职责是拿到前端请求转成 DTO交给 Service再把结果封装成Result返回。这样做的理由是将来如果从 JSP 换成前后端分离Controller 是唯一需要改的层Service 和 Mapper 完全不受影响。3.1.1 任务发布接口的最小实现Controller RequestMapping(/task) public class TaskController { Resource private TaskService taskService; PostMapping(/publish) ResponseBody public Result publish(RequestBody TaskPublishDTO dto) { // 参数校验可以交给 validate 框架, 核心业务全部下沉到 service taskService.publish(dto); return Result.ok(发布成功); } }Service public class TaskServiceImpl implements TaskService { Resource private TaskMapper taskMapper; Resource private WalletLogMapper walletLogMapper; Override Transactional(rollbackFor Exception.class) public void publish(TaskPublishDTO dto) { // 1. 创建任务, 初始状态为待审核 Task task new Task(); task.setPublisherId(dto.getPublisherId()); task.setTitle(dto.getTitle()); task.setDescription(dto.getDescription()); task.setReward(dto.getReward()); task.setStatus(TaskStatus.PENDING.getCode()); taskMapper.insert(task); // 2. 同步冻结发布者余额 walletLogMapper.freezeBalance(dto.getPublisherId(), dto.getReward(), task.getTaskId()); } }代码的逻辑说明publish方法做了两件事插入任务记录 冻结发布者余额并且被Transactional(rollbackFor Exception.class)包住。这里的核心设计是“先冻结不直接扣款”任务还在待审核状态时钱不能动审核通过后才算真正进入招募流程。如果任务被驳回或者关闭冻结资金要走单独的解冻流程。参数说明rollbackFor Exception.class很重要Spring 默认只对RuntimeException回滚如果手抛了一个Exception的子类事务不生效。第二这里没有把“审核”逻辑放进发布方法里发布者提交的任务一律先落地为“待审核”由管理员后台决定是否进入“招募中”这是任务众包系统区别于普通发帖系统的关键规则。3.1.2 接单接口为什么必须是一个事务任务发布之后接包者看到“招募中”的任务可以抢单。抢单这个过程必须保证任务从“招募中”变成“进行中”同时插入一条接单记录两步操作要么都成功要么都失败。这个事务的边界非常典型直接在业务层用注解解决。Transactional(rollbackFor Exception.class) public boolean acceptTask(Long taskId, Long workerId) { // 条件更新: 只有 status1(招募中) 时才允许更新成功 int rows taskMapper.grabTask(taskId, workerId); if (rows 0) { return false; } // 插入接单记录 Acceptance acceptance new Acceptance(); acceptance.setTaskId(taskId); acceptance.setWorkerId(workerId); acceptance.setStatus(0); // 0-执行中 acceptanceMapper.insert(acceptance); return true; }grabTask的 SQL 写死状态条件这是整个抢单逻辑的灵魂。代码里先执行更新如果影响行数为 0说明任务状态已经不是“招募中”直接返回失败只有更新成功才插入接单记录。这样即使两个请求同时到达数据库层面的行锁也能保证只有一个请求能更新成功。UPDATE tb_task SET status 2, accept_worker_id #{workerId}, update_time NOW() WHERE task_id #{taskId} AND status 1这段 SQL 是典型的乐观锁思路没有显式SELECT ... FOR UPDATE而是把状态条件放进UPDATE的WHERE。它同时完成了“检查状态”和“更新状态”两步天然避免了先查后改之间的空档期。唯一要注意的是rows 0时不需要主动抛异常返回false让上层提示“手慢了任务已被抢走”数据库层并没有发生任何变更不需要回滚。3.2 MyBatis 手写 SQL 与列表查询的数据权限3.2.1 任务列表的分页 SQL 与状态过滤任务大厅是众包系统里访问量最大的页面查询条件通常是“只看招募中的任务按发布时间倒序分页展示”。SSM 项目里分页有两个选择手写LIMIT #{offset}, #{pageSize}或者引入 PageHelper。手写更可控PageHelper 更省事两者的坑在第 5 章会提到。这里给出手写分页的 Mapper 写法select idselectTaskPage resultMaptaskResultMap SELECT task_id, publisher_id, title, reward, status, create_time FROM tb_task WHERE status #{status} ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} /select对应的 Java 调用需要计算 offsetoffset (pageNum - 1) * pageSize。列表查询里不查description大字段只查列表页需要的字段详情页再单独用selectTaskById取全量。description在表结构里是TEXT如果列表查询使用SELECT *结果集传输和resultMap映射都会白白浪费性能数据量大时这差距非常明显。3.2.2 自己不能接自己的单行级数据权限的边界任务众包系统有一个业务规则发布者不能接自己发布的悬赏任务避免自问自答骗赏金。这个校验可以放在 Service 层查一次任务表的publisher_id和当前用户 ID 比较。常见做法是Task task taskMapper.selectTaskById(taskId); if (task.getPublisherId().equals(workerId)) { throw new BusinessException(不能接自己发布的任务); }这段校验的问题在于它先查了一次数据库而这个查询和随后的grabTask更新之间有一个时间窗口。理论上用户可以在查询通过后、更新完成前伪造 ID 再打一次请求绕开校验。如果要严谨可以把发布者 ID 也放进grabTask的WHERE条件里UPDATE tb_task SET status 2, accept_worker_id #{workerId} WHERE task_id #{taskId} AND status 1 AND publisher_id ! #{workerId}这样就把“非本人”的约束下沉到了数据库层条件更新配合带条件的UPDATE行级数据权限的判断粒度更细。对于毕业设计来说前面的 Service 层校验已经够用后者可以作为答辩时的加分点主动提出来。4. 并发抢单与状态一致性从数据库层面杜绝“一单多接”4.1 并发问题从哪里来一单多接、重复交付、超量结算任务众包系统的并发压力不体现在高流量而体现在“同一个任务被多个用户同时抢”。一个任务赏金 500 元两个用户同时点“立即接单”如果代码是先SELECT判断状态是“招募中”再UPDATE改成“进行中”两个请求都可能通过查询最后造成一单多接。这就是经典的“先检查后执行”竞态条件。解决思路不复杂把检查和更新合并为一条原子 SQL。数据库的行锁天然保证同一条记录的并发更新是串行的关键在于把业务条件写进UPDATE的WHERE里。这是 SQL 级别的乐观锁也是 SSM 项目里性价比最高的并发方案不需要引入 Redis、ZooKeeper 等额外组件。4.1.1 用数据库条件更新防止一单多接前面第 3 章已经展示了grabTask的基本写法这里把它和普通的错误做法做对比。错误做法是Task task taskMapper.selectTaskById(taskId); // 先查 if (task.getStatus() 1) { task.setStatus(2); taskMapper.updateById(task); // 后改 }这个写法在单线程下完全正确但两个线程同时执行时它们会读到同一个status 1然后都执行更新最终任务被两个人接走。正确写法是把状态条件直接放进 SQLUPDATE tb_task SET status 2, accept_worker_id #{workerId}, version version 1 WHERE task_id #{taskId} AND status 1UPDATE返回的影响行数就是判断依据返回 1 表示当前用户抢单成功返回 0 表示状态已被别人改掉。这个方案不需要事务也能保证正确性因为单条UPDATE自身就是原子的。把它放进一个更大的事务里当然也可以但事务的用途在这里不是保证并发安全而是保证后续“插入接单记录”与“更新任务状态”的一致性。4.1.2 乐观锁 version 字段解决状态覆盖抢单只是众包系统里并发问题的一种。另一个高频场景是管理员审核任务、接包者交付成果、发布者验收通过这三个动作都可能修改同一条任务记录的状态。如果两个操作同时发生后提交的更新可能把先提交的内容覆盖掉。比如管理员刚把任务审核为“招募中”接包者立刻把状态改为“进行中”如果两个操作都基于旧的状态值做全量更新数据就乱了。version字段专门用来解决这种状态覆盖问题。更新时带上version #{oldVersion}更新成功后version 1下次更新必须用新版本号。MyBatis 里这样写update idupdateTaskStatus UPDATE tb_task SET status #{newStatus}, version version 1 WHERE task_id #{taskId} AND version #{oldVersion} /update执行后检查rows 0说明版本号不匹配数据在读取后已经被别人改过需要重新加载最新数据再决定下一步动作。乐观锁适合“冲突不频繁、但要求数据准确”的业务任务众包系统完全符合。这里有一个取舍如果系统做大了任务状态变更变成高频操作乐观锁的重试就会变多那时再考虑引入 Redis 分布式锁在 SSM 这个量级里数据库条件更新加版本号已经足够。4.2 Transactional 在 SSM 里的正确用法与常见误用4.2.1 事务注解失效的三种场景把Transactional放在 Service 类上是 SSM 项目里最常见的声明式事务写法。它好用的前提是方法调用必须经过 Spring 代理对象。下面三种场景会直接让事务失效排查线上问题时按顺序检查场景原因解决方案同类内部调用this.anotherMethod()绕过代理对象拆到不同 Service或注入自身代理方法不是publicCGLIB 代理无法拦截非 public 方法改为 public异常被 catch 后没有抛出事务无法感知异常手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()或重新抛异常第一类场景最隐蔽。面试中常被问到的“Spring 事务失效”问题在任务众包系统里很容易复现acceptTask里调用同类的checkPublisher()后者上面标了Transactional但检查操作不会走代理事务根本不会开。实际业务中把“校验”和“更新”拆到两个 Service或者干脆把校验条件写进 SQL既清晰又安全。4.2.2 什么时候该把事务拆成两个任务验收通过是一个典型的“跨领域”操作要把任务状态改成“已完成”要把赏金从冻结变成已支付给接包者还要给发布者扣减余额。这三步是不是必须在一个事务里我的判断是核心的资金和状态变更必须在一个事务里但“发站内信通知”这种可延迟的副作用要放在事务外。SSM 里常见的处理方式是Transactional(rollbackFor Exception.class) public void finishTask(Long taskId, Long workerId) { // 1. 更新任务状态为已完成 // 2. 解锁冻结金额, 给接包者加钱 // 3. 给发布者扣费 taskMapper.finishTask(taskId); walletLogMapper.settle(taskId, workerId); } public void finishTaskWithNotify(Long taskId, Long workerId) { finishTask(taskId, workerId); // 事务内 messageService.sendNotify(taskId); // 事务外 }finishTaskWithNotify没有加Transactional它先调用内部事务方法完成核心更新事务提交成功后再做通知。如果通知失败核心业务不回滚但可以在messageService里做重试或记录失败日志。事务边界的本质是回答一个问题哪几步操作失败后必须“撤销一切”只有资金和状态必须通知不需要。把这个逻辑讲清楚比单纯展示Transactional注解更能体现设计能力。5. 让 SSM 项目从“能跑”到“能答辩”SQL 日志、分页坑与演示状态重置5.1 控制台 SQL 日志的开启与慢 SQL 识别排查 SSM 项目问题时第一步永远是看 MyBatis 实际执行的 SQL。在mybatis-config.xml里加一行配置即可settings setting namelogImpl valueSTDOUT_LOGGING/ /settings开启后控制台会打印每条 SQL 的完整语句和参数。答辩前提前打开一旦演示中数据不对可以直接指着日志说“这条 UPDATE 影响行数为 0说明状态已经过期”。生产环境不建议用STDOUT_LOGGING会大量刷日志调试用足够。5.2 PageHelper 分页的坑count 查询和多表关联如果引入com.github.pagehelper.PageHelper分页有两个坑必须知道。第一个PageHelper 只对紧随其后的第一条查询生效。调用PageHelper.startPage(pageNum, pageSize)后必须先执行selectTaskPage如果中间插了任何别的查询分页就被污染了。第二个多表关联查出的总条数可能不准PageHelper 的 count 查询会尝试自动生成SELECT COUNT(0)遇到复杂 SQL 时容易报错或生成错误语句。稳妥做法是分页查询的 SQL 保持简单复杂统计手动写 count 语句。5.3 演示前的状态重置技巧毕业设计最尴尬的场景是演示到“接单”时发现任务已经是“进行中”点不动了。准备一个重置数据脚本演示前一键回到初始状态UPDATE tb_task SET status 1, accept_worker_id NULL, version 0 WHERE status IN (2, 3); DELETE FROM tb_acceptance;这个脚本把处于“进行中”和“待验收”的任务全部回退到“招募中”并清空接单记录。注意它只重置中间状态已完成和已关闭的任务保留这样任务大厅看起来既有历史数据又有可操作的新任务。演示结束后检查一下日志看看有没有超过 200ms 的慢 SQL顺手把执行计划EXPLAIN的结果截个图放进论文里就是一张漂亮的性能分析插图。本文还有配套的精品资源点击获取