又是校园跑腿,又是JavaWeb,一看就知道是每年毕业季都会冒出来的经典选题。作为一个带过不少毕业生、也翻过无数同类项目源码的人,我太清楚这类系统的套路了:前端一个JSP页面堆功能,后端Servlet塞业务逻辑,数据库几张表来回关联,最后拼一个能跑起来的Demo。但说句实在话,大多数同学交上来的东西,要么功能零散得一塌糊涂,要么代码耦合严重答辩时被老师几个问题问懵。
这篇文章我就以这个“基于JAVAWeb的校园跑腿系统”为例,完整拆一遍从需求分析、数据库设计、核心模块实现到部署上线的全过程。不是给你粘贴大段代码就完事,而是把每个环节“为什么这么做”“踩过哪些坑”都讲清楚。不管你是拿它当毕业设计,还是想自己练手做一个完整的JavaWeb项目,这篇文章都能让你少走不少弯路。
1. 为什么校园跑腿系统是JavaWeb毕设的“天选之子”
每年毕业设计选题的时候,总有人纠结做什么好。做电商系统太烂大街,做社交平台又容易超纲,做个管理信息系统又显得没技术含量。校园跑腿系统恰好卡在一个非常舒服的位置:业务场景接地气、功能模块清晰、技术栈覆盖全面、工作量可控。
1.1 业务需求真实,不愁没话说
校园跑腿这个需求,大学生几乎人人都有感知:拿快递、带饭、代买零食、取文件、送资料。这种业务场景不需要你去凭空想象业务流程,因为你自己就身处其中。这就带来一个天然优势——在开题报告和论文的“需求分析”章节里,你不会无话可写,因为需求是真实存在的,你只需要把它结构化。
另外,跑腿系统的核心是“订单流转”,这本身就是一条非常标准的主线:用户下单 → 系统派单 → 骑手接单 → 送达确认 → 订单评价。围绕这条主线,必然衍生出用户管理、骑手管理、订单管理、支付结算、评价反馈等子模块。主线清晰,支线丰富,做起来既有逻辑性又有完整度。
1.2 技术栈覆盖全面,答辩有的聊
这可能是校园跑腿系统最大的优势——它几乎把JavaWeb的核心技术点全部串起来了:
- 前端:JSP + JSTL + EL表达式 + AJAX + jQuery,够传统但不落伍;
- 后端:Servlet作为控制器,Service层处理业务逻辑,DAO层封装数据库访问,经典三层架构;
- 数据库:MySQL + JDBC + Druid连接池,再加个Redis做缓存(可选加分项);
- 部署:Tomcat容器,Maven管理依赖。
整套技术栈下来,从请求发起、参数封装、业务处理、数据持久化到结果响应,一整条链路上每个环节都有知识点可挖。答辩时老师随便往哪一层问,你都能接得住。
1.3 功能边界明确,不容易跑偏
很多同学做毕设最大的问题不是做不出来,而是越做越偏离方向,比如想加入实时定位、社交聊天、智能推荐等花哨功能,结果把自己坑到延期。校园跑腿系统的功能边界非常清晰:以订单管理为核心,以用户和骑手两个角色为主体,以管理员为监管方。把握好这个三角结构,系统就不会失控。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 系统功能模块拆解:从用户需求到功能落地的完整映射
功能拆分这事儿看上去简单,但实际操作中特别容易犯一个毛病——想到哪拆到哪,拆出来的功能模块之间逻辑混乱。我习惯用“角色 × 业务动作”的方法来做功能梳理,把每种角色能干什么、对应哪些业务流程,先写清楚再动手建表。
2.1 三个角色的权限边界设计
校园跑腿系统涉及的参与者主要有三类:普通用户(下单方)、骑手(接单方)、管理员(平台方)。
普通用户端(PC端为主):
- 注册登录、个人信息维护、密码修改;
- 发布跑腿订单(选择订单类型、填写起止地点、设置小费金额、备注特殊要求);
- 查看订单状态(待接单 / 已接单 / 配送中 / 已完成 / 已取消);
- 确认收货、对骑手进行评价。
骑手端(这里建议做成同一个系统里的子模块,不做独立APP,否则工作量大到失控):
- 接单大厅查看待接单列表、按距离或小费排序;
- 抢单操作、我的接单列表管理;
- 更新订单状态(标记为“配送中”“已送达”);
- 查看今日/本周收入统计。
管理员端:
- 用户管理(禁用/启用账号,处理举报);
- 骑手入驻审核、接单数据监控;
- 订单全流程监管(异常订单处理、超时提醒);
- 平台费用统计、数据看板展示。
2.2 每个模块背后的业务逻辑
这里我挑几个容易被忽略的业务细节来说,因为这些细节往往是答辩时老师最喜欢问的点,同时也是你系统“有没有真正动脑子”的体现。
第一个是订单状态的流转约束。不是任何状态都能随便跳转的,比如“已完成”的订单不能被取消,“待接单”的订单不能被直接标记为“配送中”。这个约束如果在前端做了但后端没写,那系统就是纸糊的。后端Service层必须写状态校验逻辑:
java复制public void updateOrderStatus(int orderId, String newStatus, int operatorId) {
Order order = orderDao.findById(orderId);
// 校验操作者权限(是接这个单的骑手才允许更新状态)
if (!order.getRiderId().equals(operatorId)) {
throw new BusinessException("无权操作该订单");
}
// 校验状态流转是否合法
if (!isValidTransition(order.getStatus(), newStatus)) {
throw new BusinessException("非法的订单状态变更: " + order.getStatus() + " -> " + newStatus);
}
orderDao.updateStatus(orderId, newStatus);
}
private boolean isValidTransition(String from, String to) {
Map<String, List<String>> rules = new HashMap<>();
rules.put("PENDING", Arrays.asList("ACCEPTED", "CANCELLED"));
rules.put("ACCEPTED", Arrays.asList("DELIVERING", "CANCELLED"));
rules.put("DELIVERING", Arrays.asList("COMPLETED"));
// ...
return rules.containsKey(from) && rules.get(from).contains(to);
}
第二个是抢单的并发问题。如果多个人同时抢同一个订单,后端不做控制就会出现一单多接的情况。最简单的方案是使用数据库乐观锁,在更新订单骑手ID时加上状态条件:
sql复制UPDATE t_order SET rider_id = ?, status = 'ACCEPTED'
WHERE id = ? AND status = 'PENDING'
通过受影响行数是否为1来判断抢单是否成功,受影响行数为0说明被别人抢先了。
第三个是用户和骑手的关系。有些同学会在数据库里设计一个user表里加个role字段来区分,这种做法可行,但更规范的是做用户表和骑手表分开,因为骑手有接单量、评分、接单状态这些额外属性。我建议设计成:用户表存基础账号信息,骑手表存扩展业务信息,之间用user_id关联,是1对1关系。
2.3 附带系统:管理员数据看板
题目里有个“智能订单与兼职管理系统”,所谓“智能”我用最简单可靠的方式落地——数据统计分析。管理员登录后看到的不只是数据列表,而是一组统计图表:今日订单量、订单类型分布、各骑手接单排行榜、近7天订单趋势。这部分可以用ECharts来画图,后端提供一个统计查询接口返回JSON,前端异步加载渲染。
java复制@WebServlet("/admin/stats")
public class StatsServlet extends HttpServlet {
protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
// 查询今日订单量
int todayOrders = statsDao.countTodayOrders();
// 查询各骑手接单排行
List<RiderRank> rankList = statsDao.riderRankLastWeek();
// 查询近7日订单趋势
List<TrendPoint> trend = statsDao.getTrend(7);
Map<String, Object> result = new HashMap<>();
result.put("todayOrders", todayOrders);
result.put("rankList", rankList);
result.put("trend", trend);
// 输出JSON
resp.setContentType("application/json;charset=UTF-8");
resp.getWriter().write(new Gson().toJson(result));
}
}
3. 数据库设计:五张核心表搞定整个系统
很多人看不起数据库设计,觉得不就是建几张表的事吗。实际上,数据库设计决定了你后面写代码是越写越顺畅还是越写越痛苦。我在带学生的时候有个经验——凡是一张表超过20个字段的,基本就是设计有问题;凡是表之间关联超过3层的,查询起来就是灾难。这套跑腿系统的核心数据模型可以用五张表完整覆盖。
3.1 核心表结构详解
用户表(t_user)
存放所有注册用户的账号信息,是系统的身份认证基础。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT 自增主键 | 用户ID |
| username | VARCHAR(50) 唯一 | 用户名 |
| password | VARCHAR(100) | 密码(MD5加密存储) |
| phone | VARCHAR(20) | 手机号 |
| role | TINYINT | 角色(1-用户 2-骑手 3-管理员) |
| avatar | VARCHAR(200) | 头像路径 |
| status | TINYINT | 状态(0-禁用 1-正常) |
| create_time | DATETIME | 注册时间 |
骑手表(t_rider)
用户表是登录凭证,骑手表才是业务主体。用户注册后如果要成为骑手,还需要额外提交入驻申请。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT 自增主键 | 骑手ID |
| user_id | INT | 关联用户表ID |
| real_name | VARCHAR(20) | 真实姓名 |
| id_card | VARCHAR(18) | 身份证号 |
| school | VARCHAR(50) | 所在学校 |
| status | TINYINT | 审核状态(0-待审核 1-通过 2-拒绝) |
| rating | DECIMAL(2,1) | 综合评分 |
| total_orders | INT | 总接单量 |
| balance | DECIMAL(10,2) | 账户余额 |
订单表(t_order)
整个系统的核心表,所有业务动作最终都落在订单上。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT 自增主键 | 订单ID |
| order_no | VARCHAR(32) 唯一 | 订单编号 |
| user_id | INT | 下单用户ID |
| rider_id | INT 可空 | 接单骑手ID |
| order_type | TINYINT | 订单类型(1-取快递 2-代买 3-代送 4-其他) |
| pick_up_location | VARCHAR(100) | 取件地点 |
| delivery_location | VARCHAR(100) | 送达地点 |
| pick_up_contact | VARCHAR(50) | 取件联系方式 |
| delivery_contact | VARCHAR(50) | 送达联系方式 |
| tip_amount | DECIMAL(10,2) | 小费金额 |
| remark | VARCHAR(255) | 备注信息 |
| status | VARCHAR(20) | 状态值 |
订单状态流转(重要)
状态字段我用的是VARCHAR,存的是PENDING、ACCEPTED、DELIVERING、COMPLETED、CANCELLED这些英文枚举值。有些同学喜欢用数字,比如0、1、2、3,这在保存时沒有问题,但后期调试和写统计SQL时非常痛苦,你要不停地去查注释文件才能想起3代表什么。用可读性更强的字符串枚举值,实际开发效率更高。
评价表(t_review)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT 自增主键 | 评价ID |
| order_id | INT | 关联订单ID |
| user_id | INT | 评价人ID |
| rider_id | INT | 被评价骑手ID |
| rating | TINYINT | 评分(1-5) |
| content | VARCHAR(500) | 评价内容 |
| create_time | DATETIME | 评价时间 |
管理员操作日志表(t_admin_log)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT 自增主键 | 日志ID |
| admin_id | INT | 操作管理员ID |
| action | VARCHAR(200) | 操作内容 |
| target_id | INT | 操作对象ID |
| create_time | DATETIME | 操作时间 |
3.2 用SQL测试验证表结构
建完表之后别急着写代码,先用几条SQL把核心业务逻辑跑一遍,确认表结构能支撑业务场景,这一步能省下后面大量的返工时间。
sql复制-- 查询某个用户发布的全部订单(按时间倒序)
SELECT * FROM t_order WHERE user_id = 1 ORDER BY create_time DESC;
-- 查询待接单的订单(用于接单大厅展示)
SELECT * FROM t_order
WHERE status = 'PENDING'
AND user_id != 1
ORDER BY tip_amount DESC, create_time ASC;
-- 统计每个骑手本周的接单量
SELECT r.rider_name, COUNT(o.id) AS order_count
FROM t_rider r
LEFT JOIN t_order o ON r.id = o.rider_id
WHERE YEARWEEK(o.create_time) = YEARWEEK(NOW())
GROUP BY r.id
ORDER BY order_count DESC;
-- 查询超时未接单的订单(超过30分钟仍为PENDING)
SELECT * FROM t_order
WHERE status = 'PENDING'
AND create_time < DATE_SUB(NOW(), INTERVAL 30 MINUTE);
这些SQL如果你拿着一张表一个字段地写,你会发现逻辑根本理不顺,而先把表设计好、再反推SQL,同时用真实数据验证,基本能保证核心业务链路是跑得通的。
4. 技术选型与项目搭建:为什么是经典三层架构
校园跑腿系统的技术选型,我建议直接走“经典JavaWeb栈”——Servlet + JSP + MySQL + Tomcat + Maven。这不仅是教学场景的最优解,也是毕业设计答辩老师最容易认可的方案。
4.1 技术栈的取舍分析
当前JavaWeb开发有两条路线:一是基于Servlet/JSP的传统模式,二是基于Spring Boot的现代框架模式。作为毕设,我建议优先选传统Servlet模式,原因有几个。
第一,工作量匹配。Spring Boot虽然开发效率高、配置少,但它把很多底层细节封装掉了。比如在处理HTTP请求时,Spring MVC用DispatcherServlet统一入口,一个注解搞定路由映射,这在开发上很方便,但答辩时老师问“请求是怎么被处理的”这类底层问题时,你只能干瞪眼。用原生Servlet,从web.xml的映射配置到doGet/doPost中手动处理请求,整个过程你是完全可见的、可描述的。
第二,知识对得上课程。绝大多数高校的JavaWeb课程教的还是Servlet + JSP这套体系,用课堂上学过的东西做毕设,至少逻辑是连贯的,遇到问题也有据可查。如果直接上Spring Boot,你大概率只是在“抄配置”,核心原理完全没吃透。
第三,便于展示深度。毕设答辩看重的是你是否理解了自己所做的内容。你用原生Servlet能讲清楚“过滤器如何做登录拦截”“监听器如何统计在线人数”“连接池如何管理数据库连接”,这些细节在Spring Boot里全被自动配置掩盖了。用传统技术栈,意味着你可以把系统讲深、讲透。
也有人会问,那不用Maven行不行?我的答案是:用,一定要用。Maven解决了依赖管理和构建流程两大痛点,而且你在简历里写上“熟练使用Maven进行项目构建与管理”,面试官也不会觉得奇怪。项目里引入Maven后,所有Jar包依赖在pom.xml里声明,别人clone项目后一键构建,这在团队协作和代码评审时体验极好。
4.2 项目目录结构与包名规划
三层架构的核心价值在于“层与层之间的依赖是单向的”,即Web层依赖Service层,Service层依赖DAO层,DAO层依赖数据库。这样做的好处是隔离变化:换数据库不影响业务逻辑,改页面样式不动核心代码。
我采用的包结构如下:
code复制com.campus.running
├── controller // Web层(Servlet)
│ ├── user // 用户端相关Servlet
│ ├── rider // 骑手端相关Servlet
│ └── admin // 管理端相关Servlet
├── service // 业务逻辑层
│ ├── UserService.java
│ ├── OrderService.java
│ ├── RiderService.java
│ └── StatsService.java
├── dao // 数据访问层
│ ├── UserDao.java
│ ├── OrderDao.java
│ └── ...
├── entity // 实体类,对应数据库表
│ ├── User.java
│ ├── Order.java
│ ├── Rider.java
│ └── ...
├── util // 工具类
│ ├── DBUtil.java // 数据库连接工具
│ ├── MD5Util.java // 加密工具
│ └── Result.java // 统一返回结果封装
└── filter // 过滤器
├── LoginFilter.java
└── EncodingFilter.java
4.3 JDBC连接池配置
数据库连接这块,直接new一个Connection用完后关闭的做法在生产环境是不可行的,每次创建连接的开销太大,高并发下很容易把数据库拖垮。要用连接池,原理就是事先创建一批空闲连接放在池子里,用的时候取,用完归还,省去反复创建销毁的过程。这里我用Druid,Alibaba开源的连接池,自带监控页面,代码简单、社区成熟。
在web.xml中配置Druid的启动监听器:
xml复制<context-param>
<param-name>druidConfigLocation</param-name>
<param-value>classpath:druid.properties</param-value>
</context-param>
<listener>
<listener-class>com.alibaba.druid.support.jdbc.DruidDataSourceFactory</listener-class>
</listener>
druid.properties配置如下:
properties复制driverClassName=com.mysql.cj.jdbc.Driver
url=jdbc:mysql://localhost:3306/campus_running?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8
username=root
password=123456
initialSize=5
maxActive=20
minIdle=5
maxWait=60000
有了连接池之后,DAO层拿连接的方式变成:
java复制public class DBUtil {
private static DataSource dataSource;
static {
try {
Properties props = new Properties();
props.load(DBUtil.class.getClassLoader().getResourceAsStream("druid.properties"));
dataSource = DruidDataSourceFactory.createDataSource(props);
} catch (Exception e) {
e.printStackTrace();
}
}
public static Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
}
这里有个细节:配置文件里的url一定要加serverTimezone=Asia/Shanghai,否则新版MySQL驱动会报时区错误。另外characterEncoding=utf8必须加,不然中文入库会变成乱码。这些坑我当年都踩过,写出来是希望大家少走弯路。
5. 核心模块实现:订单流程是最值得花时间的部分
系统的功能点很多,但真正体现技术含量和业务复杂度的是订单模块。这一节我会重点讲下单、接单、配送完成这三个关键节点,各给出完整可参考的代码逻辑。
5.1 用户下单:事务与数据一致性
下单这个动作看似简单——就是往订单表插一条数据,但需要考虑的数据完整性问题很多:下单人必须是登录状态;订单信息必填项校验;小费金额不能为负数;派单模式下需要自动匹配骑手。
我用一个下单的完整逻辑来说明:
java复制public class OrderServiceImpl implements OrderService {
private OrderDao orderDao = new OrderDaoImpl();
private RiderDao riderDao = new RiderDaoImpl();
@Override
public boolean createOrder(Order order) throws BusinessException {
// 1. 参数校验
if (order.getPickUpLocation() == null || order.getPickUpLocation().isEmpty()) {
throw new BusinessException("取件地址不能为空");
}
if (order.getDeliveryLocation() == null || order.getDeliveryLocation().isEmpty()) {
throw new BusinessException("送达地址不能为空");
}
if (order.getTipAmount() == null || order.getTipAmount() < 0) {
throw new BusinessException("小费金额不能为负数");
}
// 2. 生成订单编号:前缀+时间戳+随机数
String orderNo = "CR" + System.currentTimeMillis()
+ String.format("%04d", new Random().nextInt(10000));
order.setOrderNo(orderNo);
order.setStatus("PENDING"); // 初始状态:待接单
order.setCreateTime(new Date());
// 3. 插入数据库
int rows = orderDao.insert(order);
return rows > 0;
}
}
这里有个容易被忽视的问题——订单编号的生成方案。在分布式场景下,currentTimeMillis + 随机数的碰撞概率不低,但作为单体毕设项目,这种方式简单可靠,完全够用。但如果想做得更严谨一点,可以用“日期 + 用户ID + 流水号”的组合方式,比如202406151030001,这样既保证可读性,又能有效降低重复概率。
5.2 骑手抢单:防止并发超卖的核心代码
骑手抢单是订单系统中最容易出并发问题的环节。设想这个场景:某个订单的小费特别高,30个骑手同时盯着这个订单,服务器同时收到30个抢单请求,如果处理不当,最终可能出现多个骑手都认为自己抢单成功的情况。
处理这个问题的核心思路是“数据库层面的原子更新”,而不是“先查再改”。先查再改在高并发下一定出问题——两个请求同时查到订单状态是PENDING,然后同时执行UPDATE,数据库的行锁会让后执行的等待,最终两个请求都更新成功,但成功的条件判断没生效。
正确做法是在UPDATE语句中带上“当前状态为PENDING”这个条件:
java复制public boolean grabOrder(int orderId, int riderId) {
String sql = "UPDATE t_order SET rider_id = ?, status = 'ACCEPTED', accept_time = NOW() " +
"WHERE id = ? AND status = 'PENDING'";
try (Connection conn = DBUtil.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, riderId);
ps.setInt(2, orderId);
int rows = ps.executeUpdate();
// 受影响行数为1说明抢单成功,为0说明订单已被别人抢走
return rows == 1;
} catch (SQLException e) {
e.printStackTrace();
return false;
}
}
这条SQL执行时,数据库会对命中的行加行锁。当两个抢单请求并发执行时,第二个请求的UPDATE会被阻塞,等第一个请求提交事务后,第二个请求执行时发现status已经不是PENDING了,UPDATE条件不匹配,受影响行数为0,抢单失败。从根源上避免了超卖问题。
5.3 骑手接单列表与状态更新
骑手登录后的主界面应该展示“我的接单列表”,按状态分组展示。这里查询要用到两表联查——订单表关联用户表拿下单人的联系方式。
sql复制SELECT o.id, o.order_no, o.order_type, o.pick_up_location,
o.delivery_location, o.tip_amount, o.status, o.create_time,
u.username, u.phone
FROM t_order o
JOIN t_user u ON o.user_id = u.id
WHERE o.rider_id = ?
ORDER BY
CASE o.status
WHEN 'ACCEPTED' THEN 1
WHEN 'DELIVERING' THEN 2
ELSE 3
END,
o.create_time DESC
这个SQL用到了CASE WHEN来按优先级排序:配送中的排前面、已接单未配送的排中间、已完成的排后面。这种排序逻辑用Java代码来实现也可以,但在SQL层面处理效率更高,代码也更简洁。
状态更新是骑手端的核心操作。骑手接单后,要依次经历“配送中”“已完成”两个状态的更新。前面已经提到了状态流转校验,这里不再重复。关键是要注意:更新状态和更新骑手收入应该放在同一个事务里。完成订单后,小费金额要累加到骑手余额里,这个操作必须和订单状态更新一起提交或者一起回滚,不能出现订单已完成但骑手余额没到账的情况。
5.4 管理员强制取消:异常订单的处理
实际运营中,肯定会有一些异常订单需要管理员介入,比如骑手接单后2小时都不配送,或者下单人恶意下单不支付。管理员应该有一个“强制取消”按钮,后台逻辑要足够健壮——取消之后,需要给下单用户发送系统消息,如果骑手已接单还要释放骑手(把rider_id置空或从订单中移除)。
java复制public void adminCancelOrder(int orderId, int adminId, String reason) {
Order order = orderDao.findById(orderId);
if (order == null) {
throw new BusinessException("订单不存在");
}
Connection conn = null;
try {
conn = DBUtil.getConnection();
conn.setAutoCommit(false); // 开启事务
// 1. 更新订单状态
orderDao.updateStatus(conn, orderId, "CANCELLED");
// 2. 记录管理员操作日志
adminLogDao.insert(conn, adminId, "取消订单", orderId, reason, new Date());
// 3. 如果是已接单状态,给骑手和用户发送系统通知
if (order.getRiderId() != null) {
notifyDao.insert(conn, order.getRiderId(), "订单已被管理员取消",
"订单" + order.getOrderNo() + "已被管理员取消,原因:" + reason);
}
notifyDao.insert(conn, order.getUserId(), "订单已被管理员取消",
"原因:" + reason);
conn.commit(); // 提交事务
} catch (Exception e) {
if (conn != null) {
try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); }
}
throw new BusinessException("取消失败:" + e.getMessage());
} finally {
if (conn != null) {
try { conn.close(); } catch (SQLException e) { e.printStackTrace(); }
}
}
}
6. 用户登录与权限控制:过滤器链的正确打开方式
登录模块看起来是每个项目都有的“标准配置”,但真正写得规范的人不多。这里我重点说两个点:会话管理的方式和权限控制的实现思路。
6.1 登录状态如何在多个页面间保持
JavaWeb中最常用的会话保持机制是HttpSession。用户登录成功后,把关键信息写入Session:
java复制@WebServlet("/login")
public class LoginServlet extends HttpServlet {
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String username = req.getParameter("username");
String password = MD5Util.md5(req.getParameter("password"));
UserService userService = new UserServiceImpl();
User user = userService.login(username, password);
if (user != null) {
// 登录成功,将用户信息放入Session
HttpSession session = req.getSession();
session.setAttribute("loginUser", user);
session.setAttribute("userId", user.getId());
session.setMaxInactiveInterval(30 * 60); // 30分钟超时
// 根据角色跳转到不同首页
if (user.getRole() == 2) {
resp.sendRedirect("rider/index.jsp");
} else if (user.getRole() == 3) {
resp.sendRedirect("admin/index.jsp");
} else {
resp.sendRedirect("user/index.jsp");
}
} else {
req.setAttribute("errorMsg", "用户名或密码错误");
req.getRequestDispatcher("login.jsp").forward(req, resp);
}
}
}
这里有个细节:密码必须以密文形式存放和比对。我见过有同学直接在数据库里存明文密码,答辩时老师问“如何保证账户安全”整个人都僵住了。MD5加密虽然已经被证明不安全,但在毕设项目里作为演示足够,如果愿意多花点心思可以做加盐处理:
java复制public static String md5WithSalt(String password, String salt) {
return MD5Util.md5(password + salt);
}
6.2 过滤器实现统一的登录拦截
全部页面都写一遍“判断是否登录”这段代码不现实,也容易漏写导致安全漏洞。JavaWeb提供了一套机制解决这个问题——Filter过滤器。写一个统一的登录过滤器,配置好要拦截的路径,所有未登录请求都会被拦截并跳转到登录页。
java复制@WebFilter(urlPatterns = {"/*"})
public class LoginFilter implements Filter {
private static final List<String> WHITE_LIST = Arrays.asList(
"/login.jsp", "/login", "/register.jsp", "/register",
"/css", "/js", "/images", "/error.jsp"
);
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
String uri = req.getRequestURI();
String path = uri.substring(req.getContextPath().length());
// 放行白名单中的路径
for (String prefix : WHITE_LIST) {
if (path.startsWith(prefix)) {
chain.doFilter(request, response);
return;
}
}
HttpSession session = req.getSession(false);
if (session == null || session.getAttribute("loginUser") == null) {
// 未登录,跳转到登录页
resp.sendRedirect(req.getContextPath() + "/login.jsp");
return;
}
chain.doFilter(request, response);
}
}
6.3 角色权限的精细控制:不能让用户进管理后台
登录过滤器只能解决“是否登录”的问题,不能解决“谁来访问”的问题。如果设计不当,普通用户直接输入URL就能进入管理员页面,那就出大事了。所以要做角色层面的权限控制。
比较优雅的做法是,把需要管理员权限的路径维护成一个清单,在过滤器里做二次校验。以管理员为例:
java复制public class AdminAuthFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpSession session = req.getSession(false);
User user = session != null ? (User) session.getAttribute("loginUser") : null;
if (user == null || user.getRole() != 3) { // role=3为管理员
resp.setContentType("text/html;charset=UTF-8");
resp.getWriter().write("<script>alert('无权访问管理员页面');"
+ "window.location.href='" + req.getContextPath() + "/login.jsp'</script>");
return;
}
chain.doFilter(request, response);
}
}
然后在web.xml里用不同的URL前缀来区分权限区域:/admin/*走管理员权限过滤器,/rider/*走骑手权限过滤器,/user/*走普通用户权限过滤器。这样可以非常清晰地对三类角色进行垂直隔离。
7. 环境搭建与部署:从零到能跑通全流程
这一节给从零起步的同学,把整个环境的配置步骤走一遍。如果你已经配过环境,可以直接跳到部署部分。
7.1 JDK安装与环境变量配置
JDK是Java的运行基础,版本选择1.8最稳。下载安装后,需要配置三个环境变量:
- JAVA_HOME:
C:\Program Files\Java\jdk1.8.0_202(换成你自己的安装路径) - PATH:
%JAVA_HOME%\bin,追加到系统Path中 - CLASSPATH:
.;%JAVA_HOME%\lib\dt.jar;%JAVA_HOME%\lib\tools.jar
配置完成后,在命令行运行java -version,能输出版本信息说明环境OK。
注意:如果cmd里输入java -version提示“不是内部或外部命令”,基本是PATH没配对。还有一个老生常谈的坑——配完之后要重新打开一个cmd窗口,原窗口不会自动加载新的环境变量。
7.2 MySQL数据库准备
创建数据库和导入建表SQL脚本:
bash复制mysql -u root -p
进入MySQL命令行后,执行建库脚本:
sql复制CREATE DATABASE campus_running DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE campus_running;
SOURCE D:/path/to/init.sql;
这里强调一下:数据库字符集一定用utf8mb4而不是utf8。utf8mb4是utf8的超集,占4字节,可以存emoji和生僻字。跑腿系统的备注信息里学生很可能写emoji表情,如果用utf8,插入时就会报错Incorrect string value。这个坑很隐蔽,排查起来极费时间,直接用utf8mb4从根上规避。
7.3 Tomcat部署与Maven打包
如果项目用的是Maven构建,在项目目录下执行:
bash复制mvn clean package -DskipTests
打包完成后,target目录下会生成一个campus-runing.war文件。这个war包可以有两种部署方式:
方式一:IDEA直接部署(开发阶段)
在IDEA的Run Configuration里配置Tomcat Server,Deployment里添加Artifact,点击运行即可。
方式二:外部Tomcat部署(演示或答辩阶段)
把war包复制到Tomcat的webapps目录下,启动Tomcat时它会自动解压部署。访问地址为http://localhost:8080/campus-running/。
如果Tomcat端口被占用,修改conf/server.xml中的<Connector port="8080">改为其他端口。
7.4 部署过程中容易踩的坑
第一个坑:JDK版本和Tomcat版本不兼容。Tomcat 9及以上版本要求JDK 8+,Tomcat 10则要求JDK 11+,而且Tomcat 10的包名从javax.*变成了jakarta.*,如果是照着老教程学的项目,部署到Tomcat 10上会出现“ClassNotFoundException: javax.servlet.http.HttpServlet”这类错误。稳妥方案:Tomcat 9 + JDK 8,这是无数前辈验证过的黄金组合。
第二个坑:数据库驱动版本不对。MySQL 5.7和MySQL 8.0用的是不同的JDBC驱动类名和URL格式:
- MySQL 5.7:
com.mysql.jdbc.Driver,url可不用serverTimezone - MySQL 8.0:
com.mysql.cj.jdbc.Driver,url必须带serverTimezone
如果驱动和数据库版本不匹配,启动时会报错ClassNotFoundException或者连接超时。
第三个坑:字符编码。部署到服务器后中文乱码,十有八九是三个地方编码不一致:数据库表的字符集、JDBC URL的characterEncoding参数、JSP页面的pageEncoding。检查顺序:JSP页面 → 数据库连接URL → 数据库表结构。
8. 智能派单策略:让“智能”名副其实
题目里写了“智能订单”,如果只是手动去接单大厅选单,答辩时很可能被质疑“智能在哪里”。所以我在系统里加了两个自动化策略,不算复杂,但能让系统真正有“智能”的感觉。这里讲清楚两种策略的实现方案,你可以按需选择实现。
8.1 策略一:自动派单——基于骑手距离开放式的算法
下单时,系统自动把订单推送给小费期望匹配的骑手。但这个项目的定位就是校园跑腿,没有接入高德或百度地图API(接入的话增加的工作量太大),我就用“接单量最少优先 + 综合评分最高优先”的加权算法来匹配:
java复制public List<Rider> matchRiders(Order order, int limit) {
// 1. 查所有状态为“接单中”的骑手
List<Rider> availableRiders = riderDao.findByStatus("ACTIVE");
// 2. 按接单量升序,评分降序综合排序
availableRiders.sort((r1, r2) -> {
int compareByOrders = Integer.compare(r1.getTotalOrders(), r2.getTotalOrders());
if (compareByOrders != 0) return compareByOrders;
return Double.compare(r2.getRating(), r1.getRating());
});
// 3. 返回前limit个候选骑手
return availableRiders.subList(0, Math.min(limit, availableRiders.size()));
}
排序的逻辑是:接单量少的新手优先获得订单机会(负载均衡),如果接单量一样,评分高的优先(质量保证)。这套逻辑虽然朴素,但确实是目前很多众包平台在用的基础调度策略——优先推给“最有可能接单”的人。
8.2 策略二:自动取消超时未接单的订单
用户下单后,如果长时间没有骑手接单,就可以自动取消。这个功能用JDK自带的ScheduledExecutorService就能实现,不需要引入Quartz这种重量级框架。
java复制public class TimeoutCancelTask {
private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
public void start() {
// 每30秒扫描一次,取消超过30分钟未接单的订单
scheduler.scheduleAtFixedRate(() -> {
List<Order> timeoutOrders = orderDao.findTimeoutOrders(30);
for (Order order : timeoutOrders) {
orderDao.updateStatus(order.getId(), "CANCELLED");
// 给用户发送系统消息
notifyDao.insert(order.getUserId(), "订单已超时取消",
"您的订单" + order.getOrderNo() + "超过30分钟无人接单,系统已自动取消");
}
}, 0, 30, TimeUnit.SECONDS);
}
}
这个任务要在系统启动时跟着启动。如果用Servlet 3.0以上的规范,最简单的方式是写一个@WebListener监听器:
java复制@WebListener
public class AppInitListener implements ServletContextListener {
private TimeoutCancelTask task;
public void contextInitialized(ServletContextEvent sce) {
task = new TimeoutCancelTask();
task.start();
System.out.println("定时任务已启动");
}
public void contextDestroyed(ServletContextEvent sce) {
if (task != null) task.shutdown();
}
}
这样应用一部署启动,后台就自动跑上了超时检测。演示的时候可以现场把超时时间临时改成1分钟,给老师演示一下“下单后不接单,1分钟后自动取消”的效果,这比嘴上说“有智能策略”要有说服力得多。
9. 前端页面设计与交互:让人一眼看出功能布局
前端这块虽然不需要做得多么惊艳,但有一个原则要守住——用户进到系统3秒内能看懂自己该干什么。跑腿系统的前端页面我按三个用户端做了差异化设计。
9.1 用户端首页:突出“下单”主操作
用户登录后直接看到的是下单表单,不要整一堆欢迎语或者数据展示占版面。表单的设计逻辑应该是:用户选订单类型 → 填取件和送达信息 → 填小费 → 提交。我推荐下单页做成一个卡片式的单列表单,界面清爽不冗余。
表单提交用AJAX异步处理,成功之后直接跳转到“我的订单”列表页,减少页面的整页刷新:
javascript复制$.ajax({
url: 'order/create',
type: 'POST',
data: $('#orderForm').serialize(),
dataType: 'json',
success: function(res) {
if (res.code === 200) {
alert('下单成功!等待骑手接单...');
window.location.href = 'user/myOrders.jsp';
} else {
alert(res.msg);
}
},
error: function() {
alert('网络异常,请稍后重试');
}
});
9.2 骑手端接单大厅:高价值订单一眼抓住
骑手进入系统后,最关心的是“有没有活干、哪个活值钱”。接单大厅默认展示所有待接单的订单,使用ECharts或纯CSS实现按小费金额的排序。同时把“小费金额”用醒目颜色标出来,让抢单效率更高。
接单按钮做成一个button标签,点击后调抢单接口。抢单失败(被别人抢先)时前端弹出提示并刷新列表,成功则跳转订单详情。这里的核心交互就两条:按钮点了要有即时的反馈,失败不能静默。
9.3 管理端数据看板:图表说话
管理端首页做一个数据看板,订单趋势图用ECharts的折线图,骑手排行用柱状图,订单类型分布用饼图。数据来源全部来自之前写的/admin/stats接口。
javascript复制$.getJSON('admin/stats', function(data) {
// 近7天订单趋势
var trendChart = echarts.init(document.getElementById('trendChart'));
trendChart.setOption({
title: { text: '近7日订单量趋势' },
xAxis: { type: 'category', data: data.trend.map(t => t.day) },
yAxis: { type: 'value' },
series: [{ type: 'line', data: data.trend.map(t => t.count) }]
});
});
三张图放在一排,管理员进入后台就能对平台运行情况有个总览。这套数据看板写代码的时间不长,但对系统完整度加分非常大。
10. 测试用例设计与避坑:拿得出手的项目都要过这一关
很多同学做完项目直接打包提交,从没认真做过一次完整的系统测试。结果答辩现场演示时,不是这个按钮点了没反应,就是那边数据为空。这里我给出一套核心流程的测试用例,照着跑一遍,能挡掉90%的明显Bug。
10.1 核心功能测试用例表
| 编号 | 测试项 | 操作步骤 | 预期结果 | 实际结果 |
|---|---|---|---|---|
| TC01 | 用户注册 | 填写用户名/密码/手机号,点击注册 | 注册成功,跳转登录页 | 需验证数据库出现新记录 |
| TC02 | 用户登录 | 输入正确账号密码 | 登录成功,进入用户首页 | 需验证Session有值 |
| TC03 | 用户登录-错误密码 | 输入错误密码 | 提示“用户名或密码错误” | 不得有500错误 |
| TC04 | 未登录访问 | 直接URL访问user/myOrders.jsp | 重定向到登录页 | 不得直接显示数据 |
| TC05 | 发布订单 | 填写全部必填项,提交 | 跳转我的订单,状态为待接单 | 数据库出现PENDING记录 |
| TC06 | 骑手抢单 | 两个账号同时抢同一单 | 只有一个人抢单成功 | 查询订单,rider_id只有一个 |
| TC07 | 订单状态流转 | 骑手从接单到送达 | 状态依次为已接单→配送中→已完成 | 每个环节数据库状态正确 |
| TC08 | 骑手收入 | 完成订单后查收入 | 余额增加小费金额 | 与订单金额一致 |
| TC09 | 管理员强制取消 | 管理员取消异常订单 | 订单状态变为已取消,用户和骑手收到通知 | 通知入库成功 |
| TC10 | 普通用户访问后台 | 普通用户直接访问admin/index.jsp | 拦截并提示无权访问 | 不得进入管理页面 |
10.2 并发抢单的模拟测试
单靠浏览器手动测试并发场景不够真实,因为两个浏览器同时点击很难做到真正“同时”。建议用JMeter或者简单写个多线程测试脚本来模拟并发:
java复制public class ConcurrentGrabTest {
public static void main(String[] args) throws Exception {
int threadCount = 10;
CountDownLatch latch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger(0);
for (int i = 0; i < threadCount; i++) {
final int riderId = i + 1;
new Thread(() -> {
try {
latch.await(); // 所有线程同时放行
String sql = "UPDATE t_order SET rider_id = ?, status = 'ACCEPTED' "
+ "WHERE id = 1 AND status = 'PENDING'";
// 执行并判断受影响行数
int rows = executeUpdate(sql, riderId);
if (rows > 0) {
successCount.incrementAndGet();
}
} catch (Exception e) {
e.printStackTrace();
} finally {
latch.countDown();
}
}).start();
}
Thread.sleep(2000);
System.out.println("抢单成功次数:" + successCount.get());
// 期望输出:抢单成功次数:1
}
}
如果你测出来抢单成功次数大于1,说明并发控制是有漏洞的——大概率是用了“先查询再更新”而非“条件更新”导致的。
10.3 防御性编程:避免系统崩溃的几个小习惯
测试过程中发现的大部分问题,都可以通过一些防御性的编码习惯提前规避:
- 所有参数必须做非空校验,不要相信前端的校验结果,因为前端校验可以通过改JS绕过轻松绕过;
- 所有数据库操作必须捕获SQLException,不能把异常抛到页面上显示给用户;
- 登录密码一律加密存储,不允许明文入库;
- 分页查询必须写,不允许一次把全表数据加载到内存;
- 重要操作(删除、取消、强制修改)别忘写操作日志,这是答辩时体现系统完整度的重要加分项。
11. 论文撰写与答辩准备的几个实战经验
项目做完只是第一步,毕设成绩由三块构成:论文(50%)、系统演示(30%)、答辩表现(20%)。很多人代码写得挺顺,论文和答辩却拉了胯。我针对跑腿系统这个选题给几个具体建议。
11.1 论文各章节的写作重心
论文的核心章节一般包含以下几块,但每块的投入比例要科学分配:
- 第一章 绪论:重点写背景和意义。不要泛泛而谈“随着互联网的发展”,要聚焦到“校园快递取送痛点”这个具体场景,写清楚为什么需要一个校园跑腿平台,当前校园快递代取有什么问题,这类平台在高校场景下的价值是什么。
- 第二章 相关技术介绍:把JDBC、Servlet、JSP、MySQL、Tomcat每个技术的定义、特点、在系统中的作用写清楚,避免整段复制百度百科,最好用一两句“本系统采用X技术是因为Y原因”来体现你的理解。
- 第三章 需求分析:核心是功能性需求和非功能性需求。功能性需求按角色来写(用户端、骑手端、管理端各有哪些功能点),非功能性需求写响应时间、安全性、易用性、可维护性。
- 第四章 系统设计:包含总体架构、功能模块划分、数据库E-R图和数据表设计。这一章是评阅老师最关注的内容,把表结构放上去,字段含义写清楚,外键关系画明白。
- 第五章 系统实现:按模块写关键功能的实现思路和核心代码。这里要注意,代码别大段大段粘贴,每个关键方法配1-3行注释说明逻辑即可。截图放系统的运行效果图,页面要整洁。
- 第六章 系统测试:先写测试环境配置,再放测试用例表和测试结果。把我前面给的那套测试用例表放进去,再补上缺陷修复记录,测试章节就非常充实了。
11.2 答辩时最可能被追着问的几个问题
答辩不是看你背了多少代码,而是考察你有没有真正理解这个系统。针对跑腿系统,下面这几个问题几乎是必问的,提前备好答案:
问:系统为什么采用三层架构?各层的职责是什么?
答:三层架构将数据访问(DAO层)、业务处理(Service层)和请求控制(Web层)分离。Web层负责接收请求和返回响应,Service层实现具体业务规则,DAO层封装数据库操作。这样做的好处是职责单一、解耦,改动数据库实现不用改业务代码,改动页面展示不影响后台逻辑。
问:抢单是怎么避免并发下重复接单的?
答:通过数据库条件更新实现乐观锁。更新语句中带上status='PENDING'条件,数据库的行锁机制确保同一时刻只有一个事务能成功修改该行,通过判断受影响行数来确定是否抢单成功。
问:密码是怎么存储的?为什么不存明文?
答:密码用MD5加密后存储。数据库泄露时,攻击者无法直接获取明文密码。虽然MD5可通过彩虹表反查,但配合加盐可以大幅提升破解成本。
问:如果骑手完成订单后用户不确认收货怎么办?
答:可以设计超时自动确认机制。订单在配送中状态超过30分钟后自动标记为完成,同时骑手收到对应的服务费。避免因用户不及时操作导致骑手资金被长时间冻结。
问:数据库表之间是什么关系?为什么要这么设计?
答:用户表和骑手表是1对1关系,因为一个用户注册后可能申请成为骑手;订单表和用户表是多对1关系,一个用户可以下多单但一单只属于一个用户;订单和骑手是多对1(实现层面简化为外键关联,不额外建中间表),这种方式能满足系统需求且查询效率更高。
11.3 现场演示的细节策略
演示环节不要只点一遍功能就结束了,要有章法,让老师看到系统的完整性和思考深度:
- 先演示登录,顺手说一下加密方式;
- 演示发单,填信息的时候说一句“前端做了必填项校验,后端Service层也做了二次校验”,体现安全意识;
- 发单之后切到骑手账号演示接单,趁机讲一下“为了防止并发抢单,这里用了乐观锁”,这是标标准准的加分回答;
- 骑手接单之后演示状态流转,展示订单从待接单到完成的全过程;
- 最后切到管理员账号演示数据看板,提一句“这些数据来自后台统计接口,前端用ECharts渲染”;
- 中途如果有机会,提一句“系统里还有一个定时任务,会扫描超时未接单的订单并自动取消”,比什么都强。
演示过程中打死也不要出现的操作是——当着老师的面刷新一个空页面,或者某个按钮点了没反应。所以演示前一定按那套测试用例把核心链路走三遍。
12. 最后说两句掏心窝子的建议
做完这个项目,我的体会有三点,写成建议分享给正在做或者准备做这个选题的人。
第一,千万不要把重心放在“页面多好看”上。你用的就是JSP+Bootstrap,不需要去卷什么Vue3+ElementPlus,毕设的核心是系统的逻辑严谨性和技术完整性。页面上按钮能点、流程走得通、状态不乱变,比什么都重要。与其花三天调一个美化细节,不如花两小时把订单状态机的约束逻辑写完整。
第二,项目做完之后一定要重新在干净环境里部署一遍。很多同学的电脑上是装好了各种驱动、依赖的“温室环境”,代码在温室里能跑,换一台电脑就不行了。答辩前半个月,找一台完全没装过Java的电脑,从JDK安装开始,一步步部署。这个过程能暴露出一大堆你平时根本发现不了的问题——数据库版本不兼容、字符编码不对、缺少依赖Jar包等等。提前踩过这些坑,答辩那天才不会翻车。
第三,做完一个模块就测一个模块,攒到最后一起测是灾难。一个页面试完再往下走,比全部写完再回来找Bug效率高十倍。我在带毕设的时候见过太多人,前三周一个页面没跑通,最后一个月疯狂赶工,提交的系统bug满地都是。把开发和测试交织在一起推进,每天都能看到系统在往前走,心态也会稳很多。
这个选题虽然常见,但常见的好处是:参考的前人经验多、网上资料全、遇到Bug时能搜到解决方案的概率高。把它老老实实做完、弄懂、讲清楚,拿个不错的成绩是完全有可能的。
