SSM任务众包系统核心设计:状态机与并发抢单实战
2026/9/16 9:08:09 网站建设 项目流程

简介:这套基于 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 图,再写建表 SQL

2.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),不要用floatdouble,浮点精度问题在结算时会出大丑。第二个是“状态到底跟谁走”,任务有任务状态,接单记录有交付状态,两者有关联但不要混在一个字段里——任务处于“进行中”时,接单记录可能还没有交付;任务“已完成”时,接单记录必须对应“验收通过”。两张表各自维护自己的状态字段,用任务 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_timeON UPDATE CURRENT_TIMESTAMP会在任意字段更新时自动刷新,方便排查“这条数据什么时候被改过”。需要提醒的是,utf8mb4是必须的,MySQL 的utf8存不了 emoji,用户任务描述里一旦出现特殊字符,写入直接报错,这类问题在实机演示时最尴尬。

2.2.2 MyBatis 的 ResultMap 如何映射状态字段

SSM 中 MyBatis 的实体映射不建议在数据库层做复杂转换。最省事的方案是:status字段直接映射为Integer,实体类里提供getStatusEnum()方法,在业务层按需转换。

<resultMap id="taskResultMap" type="com.example.entity.Task"> <id property="taskId" column="task_id"/> <result property="publisherId" column="publisher_id"/> <result property="title" column="title"/> <result property="reward" column="reward"/> <result property="status" column="status"/> <result property="version" column="version"/> <result property="createTime" column="create_time"/> </resultMap>

这里有一个关键点:insertupdate不要使用resultMap的自动映射去做“全字段更新”。常见做法是在 Mapper XML 里写显式 SQL,只更新需要变更的字段。全字段更新会把versionstatus等关键字段无脑覆盖,是并发问题的常见来源。MyBatis 的resultMap只负责查询结果映射,写操作必须由手工 SQL 精确控制。

3. 在 SSM 里落地核心业务:任务发布、接单与列表查询的代码写法

3.1 Spring MVC 分层设计:Controller 做参数校验,业务逻辑下沉到 Service

SSM 项目的分层习惯是: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) { // 条件更新: 只有 status=1(招募中) 时才允许更新成功 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,而是把状态条件放进UPDATEWHERE。它同时完成了“检查状态”和“更新状态”两步,天然避免了先查后改之间的空档期。唯一要注意的是rows == 0时不需要主动抛异常,返回false让上层提示“手慢了,任务已被抢走”,数据库层并没有发生任何变更,不需要回滚。

3.2 MyBatis 手写 SQL 与列表查询的数据权限

3.2.1 任务列表的分页 SQL 与状态过滤

任务大厅是众包系统里访问量最大的页面,查询条件通常是“只看招募中的任务,按发布时间倒序,分页展示”。SSM 项目里分页有两个选择:手写LIMIT #{offset}, #{pageSize}或者引入 PageHelper。手写更可控,PageHelper 更省事,两者的坑在第 5 章会提到。这里给出手写分页的 Mapper 写法:

<select id="selectTaskPage" resultMap="taskResultMap"> 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 调用需要计算 offset:offset = (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 也放进grabTaskWHERE条件里:

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。数据库的行锁天然保证同一条记录的并发更新是串行的,关键在于把业务条件写进UPDATEWHERE里。这是 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,然后都执行更新,最终任务被两个人接走。正确写法是把状态条件直接放进 SQL:

UPDATE tb_task SET status = 2, accept_worker_id = #{workerId}, version = version + 1 WHERE task_id = #{taskId} AND status = 1

UPDATE返回的影响行数就是判断依据:返回 1 表示当前用户抢单成功,返回 0 表示状态已被别人改掉。这个方案不需要事务也能保证正确性,因为单条UPDATE自身就是原子的。把它放进一个更大的事务里当然也可以,但事务的用途在这里不是保证并发安全,而是保证后续“插入接单记录”与“更新任务状态”的一致性。

4.1.2 乐观锁 version 字段解决状态覆盖

抢单只是众包系统里并发问题的一种。另一个高频场景是:管理员审核任务、接包者交付成果、发布者验收通过,这三个动作都可能修改同一条任务记录的状态。如果两个操作同时发生,后提交的更新可能把先提交的内容覆盖掉。比如管理员刚把任务审核为“招募中”,接包者立刻把状态改为“进行中”,如果两个操作都基于旧的状态值做全量更新,数据就乱了。

version字段专门用来解决这种状态覆盖问题。更新时带上version = #{oldVersion},更新成功后version + 1,下次更新必须用新版本号。MyBatis 里这样写:

<update id="updateTaskStatus"> 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 name="logImpl" value="STDOUT_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的结果截个图,放进论文里就是一张漂亮的性能分析插图。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询