每年到三四月,我后台私信里十个有八个就是这类问题:“毕设是校园服务小程序,怎么办?”“帮洗平台的源码能跑起来,但是登录失败,求远程看下。”说实话,前几年这类校园 O2O 项目的主力还是“旧衣回收”“跑腿代取快递”,现在“基于 Spring Boot 的大学校园帮洗服务平台”成了高频选题。它为什么受欢迎?因为业务不复杂,但也绝不是做一个洗衣店官网那么简单:学生在小程序下单,配送角色上门取脏衣服,送到洗衣店清洗,洗完再送回宿舍。这条闭环如果只是靠一张 order 表存几条记录,答辩的时候一定被问住。
所以这篇我想按自己从零搭这套系统的顺序,把后端 Spring Boot 骨架、数据库表设计、微信小程序登录、订单状态流转,以及联调排错这些最容易卡壳的地方完整说一遍。源码结构按可讲解、可扩展的方式来组织,字符串里也不会埋没在业务之外的东西。写给准备拿它毕业设计的人,也写给想找 Spring Boot + 小程序全栈项目练手的新手。
1. 帮洗服务不是“登记表”:先把业务角色与闭环想清楚
1.1 三种角色和一条服务闭环
做系统前最忌讳一上来就建表。帮洗服务本质上是一个三方撮合平台,角色至少有三个:
- 学生用户:在小程序里选择洗衣类型、填写取衣地址、下单支付、查看订单进度。
- 跑腿/配送员:能查看可接订单、上门取衣服、送到洗衣店、洗完再送回宿舍。
- 洗衣店工作人员或管理员:接收衣物、更新清洗状态、处理异常订单、维护洗衣项目和价格。
我见过不少半成品把这三个角色压成一个“管理员”,结果整个系统做成了后台录入单,用户下单后只能靠人工改订单备注。演示起来就是单纯的增删改查,完全没有业务性。帮洗的核心价值在于服务闭环:用户下单 -> 配送员接单取衣 -> 洗衣店清洗 -> 配送员送回 -> 用户确认完成。这一条链路缺了任何一环,项目就退化成信息登记表了。
1.2 功能边界:毕设版本该做哪些
真正做的时候不用一步到位,第一版我建议不做成电商大而全的购物车,也不做复杂评价体系。最小可行版本按角色拆:
| 端 | 功能 |
|---|---|
| 用户小程序端 | 微信登录注册、洗衣分类展示、下单/预约、地址管理、订单列表/详情、取消订单、余额或模拟支付 |
| 配送端小程序 | 可接订单列表、接单、标记已取衣、标记已送回 |
| Web 管理端 | 洗衣分类与价格维护、订单状态管理、用户管理、基础数据统计 |
这些功能已经足够覆盖开题报告里的多数用例图。优惠券、邀请有礼、投诉反馈、消息推送这种,后面有时间就加,没时间就留在“系统展望”章节写一句“后续可扩展”,反而符合毕业设计的正常工作量。
我在指导时发现一个很实际的问题:很多同学一上来就复制别人源码,但连自己系统里有哪几个角色、谁把状态从“清洗中”改成“待送回”都说不出来。这个必须先想明白,因为它直接决定了权限拦截怎么写、表怎么建、接口怎么分。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 后端骨架与选型复盘:Spring Boot 版本、MyBatis Plus 和项目结构
2.1 版本组合怎么定最省心
工具链选择有个朴素的逻辑:不是越新越好,而是资料越多越稳。网上很多学生项目停留在 JDK 8 + Spring Boot 2.x。如果本机就是 JDK 8,硬装 Spring Boot 3.x 会直接编译失败,因为 Spring Boot 3.0 要求 JDK 17 起步,连 javax.servlet 都改成了 jakarta.servlet。我见过大量后台私信问“springboot版本太高怎么办”,其实不是版本高,是运行环境和代码示例不匹配。
推荐一套最稳的组合:
| 组件 | 推荐版本 | 原因 |
|---|---|---|
| JDK | 8 或 17 | 与 Spring Boot 版本严格匹配 |
| Spring Boot | 2.7.18 | 支持 JDK 8,教程多,生态兼容性最好 |
| MyBatis Plus | 3.5.x | 单表 CRUD 不再写 XML,适合毕设提速 |
| MySQL | 8.0 或 5.7 | 数据导入、字符集成熟 |
| 权限方案 | JWT + 拦截器 | 小程序请求头携带简单,无 Session 概念 |
| 小程序前端 | 微信原生 | 不用再引入 uniapp 编译链,查错更直接 |
如果你本机是 JDK 17,也可以上 Spring Boot 3.2.x,但必须会看新老资料差异。我自己比较建议学生时代用 2.7.18,省下来的时间都值得用在业务代码上。
2.2 项目目录和接口路径约定
这类项目不需要搞 Maven 多模块,单模块按包分层就够。我通常推荐下面这种结构:
text复制src/main/java/com/campus/laundry/
├── common/ # 统一返回 R、全局异常、常量
├── config/ # WebMvc 配置、拦截器、跨域配置
├── controller/ # 对外接口
├── service/ # 业务逻辑接口和实现
├── mapper/ # MyBatis Plus 的 Mapper 接口
├── entity/ # 数据库实体
├── dto/ # 接收前端入参
└── vo/ # 返回给前端的视图对象
这里有一个经验点:Controller 的路径最好按端区分。小程序端用 /app/**,Web 管理端用 /admin/**,这样拦截器也简单:/app/** 校验用户 token,/admin/** 校验管理员 token,登录注册接口直接放行。很多新手把接口全乱写,后续所有接口都要自己判断身份,代码维护成本很高。
2.3 基础配置、统一返回和鉴权思路
application.yml 里最容易出错的是数据库连接参数,给出一个示例:
yaml复制server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/campus_laundry?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
global-config:
db-config:
id-type: auto
serverTimezone=Asia/Shanghai 不能随便去掉,不然数据库连接时可能报时区错误。所有后端接口返回统一结构 { code: 0, msg: "success", data: ... },小程序端只需要判断 code,前端代码能省掉大量重复的报错处理。
对于一个没有超高并发需求的毕设,不需要盲目引入 Spring Security。一个拦截器加 JWT 完全够用。JWT 生成的时机放在登录成功后,把 userId 和 openid 作为 payload,后续接口通过拦截器解析 token,再把 userId 放到 ThreadLocal 或请求参数里给 Service 层使用。代码简洁,而且答辩时好讲。
还要提醒一句:像校园帮洗这种规模的状态流转,不要硬上 Flowable 之类的工作流引擎。状态少、角色固定,用代码里的状态机更直观,也更容易解释。引入重量级框架只会给自己增加提问风险。
3. 订单是系统的心脏:数据库表设计、金额精度和状态机
3.1 核心表拆解:用户、地址、洗衣分类、订单
数据库是我认为整个源码里最值得反复看的部分。它不是越多越好,而是要把订单轨迹讲明白。基础表大概这些:
user:用户表,核心字段是 openid、昵称、头像、手机号address:地址表,冗余联系人、联系电话、校区、宿舍楼栋、详细地址、是否默认clothes_category:洗衣分类表,比如普通洗衣、羽绒服清洗、鞋子清洗order:订单主表order_detail:订单明细表,记录每个洗衣分类的数量和单价order_status_log:订单状态日志表,这个表强烈建议加
order 主表典型字段设计要达到“只看这一行就能还原订单场景”的效果:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| order_no | varchar | 业务单号,不建议直接用 id 对外展示 |
| user_id | bigint | 下单用户 |
| courier_id | bigint | 配送员,接单后写入,可空 |
| pickup_address_id | bigint | 取衣地址 |
| delivery_address_id | bigint | 送回地址 |
| status | tinyint | 当前状态,见状态机 |
| total_amount | decimal(10,2) | 总金额 |
| discount_amount | decimal(10,2) | 优惠金额 |
| pay_amount | decimal(10,2) | 应付实付金额 |
| pay_type | varchar | 微信支付/余额支付/模拟支付 |
| remark | varchar | 用户备注 |
| create_time | datetime | 下单时间 |
| update_time | datetime | 最后更新时间 |
关于地址,我建议非必要不去 JOIN 用户地址表。订单一旦生成了,用户后面修改地址不应该影响历史订单。实战中可以直接在 order 主表冗余一份“取件人姓名、电话、位置文本”,或者用 address_id 加历史快照。用 address_id 查原地址也能查到,但如果用户把那条地址删了,订单详情页就尴尬了。对毕设来说,订单表里保留一份地址快照字段是性价比最高的选择。
3.2 从待支付到已完成:订单状态机怎么落地
状态机是答辩高频问题。参考下面这套状态设计:
| 状态码 | 状态名 | 谁触发 | 下一步 |
|---|---|---|---|
| 0 | 待支付 | 用户下单创建 | 用户支付后到 1 |
| 1 | 待取件 | 支付完成 | 配送员接单并取衣后到 2 |
| 2 | 清洗中 | 配送员已送达洗衣店 | 洗衣店清洗完成到 3 |
| 3 | 待送回 | 洗衣店告知可送回 | 配送员送回后到 4 |
| 4 | 已完成 | 用户确认或超时自动完成 | 无 |
| 5 | 已取消 | 用户支付前取消/后台关闭 | 无 |
代码落地时用枚举加 Service 层判断,不用过度设计。核心是每个“动作”只能允许特定状态迁移。比如用户“确认收货”这个动作,只有在状态为“待送回”时执行;用户“取消订单”,只允许在状态为“待支付”或“待取件”时执行,否则直接抛业务异常。
状态日志表的价值在于时间线。每次订单状态变更,往 order_status_log 插一条记录:order_id、from_status、to_status、操作人类型、备注、创建时间。前端订单详情页的时间线,直接查这张表就行。答辩时老师问“你怎么知道一双鞋洗到哪一步了”,你把这张表拿出来讲,比嘴上说一百句“用状态字段存起来了”都有说服力。
3.3 金额计算的三个不要
金额部分有三个常见错误,我重点讲一下。
第一,不要用 float/double 做金额字段和金额运算。Java 里 0.1 + 0.2 会出现精度问题,答辩现场被演示出来会非常难看。数据库字段全部用 decimal(10,2),Java 端用 BigDecimal。
第二,前端传什么你都信,等于把价格漏洞露给用户。下单时前端只提交某个洗衣分类的 id 和数量,后端必须重新查表得到当前单价,再自己计算 totalAmount、discountAmount、payAmount,不能接受前端把总金额直接传过来。客户端永远可以被篡改。
第三,不要把下单和支付写成一个接口。后面要加真实微信支付或模拟支付,都会很痛苦。正确做法是:先创建订单,状态为待支付,再调用支付接口成功后把状态改成待取件。这样订单数据和资金数据有界限,哪怕支付回调失败,订单也不会凭空消失。
4. 小程序登录与请求链路:身份绑定要避开的关键坑
4.1 wx.login 和后端 code2Session
小程序端没有传统 Cookie,用户名密码对微信生态也不合适。常用方案是:小程序调用 wx.login 拿到临时 code,把 code 发给后端,后端用 code 调微信的 code2Session 接口换 openid,再拿 openid 和本地用户表比对。
小程序端逻辑类似这样:
js复制wx.login({
success: async (res) => {
if (res.code) {
const data = await request('/auth/login', 'POST', { code: res.code })
wx.setStorageSync('token', data.token)
wx.setStorageSync('userInfo', data.userInfo)
}
}
})
后端核心逻辑简写:
java复制String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appid
+ "&secret=" + secret
+ "&js_code=" + code
+ "&grant_type=authorization_code";
String json = restTemplate.getForObject(url, String.class);
// 解析 JSON,拿到 openid 和 session_key
这里最容易踩的坑:很多网上项目源码里自带一串别人的 appid,比如形如 wx1cb... 的字符串,你下载下来之后没替换,或者后端 application.yml 里的 appid 和 secret 也还是原先写死的,就会出现“小程序获取登录后的微信用户失败”这类问题。验证方法是登录小程序公众平台后台,找到自己小程序的 AppID 和 AppSecret,把前端 project.config.json、后端配置文件里的 appid/secret 全部同步替换。
还要注意,微信 2022 年后调整了用户头像昵称获取规则,wx.getUserProfile 已经不像早期那样能稳定拿到完整头像昵称。对帮洗服务这种场景,只需要用 wx.login 确定用户身份,头像昵称可以不做强依赖,或者让用户在小程序里手动填写昵称、用微信头像选择的组件自行设置。
4.2 小程序请求封装与 token 管理
小程序端我会封装一个统一 request。好处是所有请求都自动带 token、统一错误提示、统一跳登录,不用在每个页面重复写。
js复制const request = (url, method = 'GET', data = {}) => {
return new Promise((resolve, reject) => {
wx.request({
url: 'http://localhost:8080' + url,
method,
data,
header: {
Authorization: wx.getStorageSync('token') || ''
},
success: (res) => {
if (res.data.code === 0) {
resolve(res.data.data)
} else if (res.data.code === 401) {
// token 失效,重新登录
} else {
wx.showToast({ title: res.data.msg, icon: 'none' })
reject(res.data)
}
},
fail: reject
})
})
}
module.exports = { request }
这里有一点需要解释:开发时可以把 request 的 baseURL 直接写成 http://localhost:8080,但在真机调试时 localhost 指向手机自己,永远不通。后面第五部分会再说联调环境怎么处理。
4.3 从“登录成功”到“真正能下单”的状态映射
登录只是第一步。真正写页面的时候,你会发现订单状态和按钮渲染是绑定的。订单状态是 0,就显示“去支付”和“取消订单”;状态是 1 且当前用户是配送员,才显示“接单”按钮;状态是 2,用户端只能看不能操作,等待清洗完成。这个逻辑建议用常量映射统一管理,不要在多个页面里反复硬编码数字。
我见过新手在每个页面写 if (order.status === 1),整个项目至少有几十处魔法数字。改成常量文件后,哪怕后面要调状态码,也只改一个文件,所有订单相关页面跟着变,排查也方便。
4.4 消息推送:可作为扩展,但别低估门槛
有同学想让“洗衣完成”时给用户发微信订阅消息,这是很好的扩展点。但现实是微信订阅消息要求先在小程序后台申请模板,还要用户在小程序里主动点击授权一次,你才能发送一次消息,而且一次性
