简介:一套基于 Java、SSM(Spring+SpringMVC+MyBatis)、MySQL 与微信小程序的家政服务管理项目,属于高分毕业设计,源码、数据库脚本和论文均包含在内,适合计算机相关专业学生作为毕业设计、课程设计或期末大作业直接参考。系统覆盖家政服务信息管理、订单处理、客户预约、后台管理等核心模块,前后端代码完整,经过严格调试,下载后即可运行,能够快速支撑项目演示与二次开发。压缩包共 1213 个文件、约 16.41MB,以 Java 后端、Vue 管理端、小程序 wxml/wxss/js、MySQL 数据库脚本等类型为主,并附论文、部署说明和批处理脚本,便于环境搭建与功能验证。整体使用 IDEA、微信开发者工具、Maven、Navicat 等主流工具,工程结构清晰。目前已有 86 人学习使用,适合需要完整项目参考或家政业务系统实战的人群,具有较强的应用与借鉴价值。
1. 这个基于 java + ssm + mysql 的家政小程序,到底解决什么问题
一个基于 java + ssm + mysql + 微信小程序的家政项目,本质上是把“找阿姨”这件事拆成三个端:用户在小程序里浏览服务、下单支付、查看订单进度;阿姨或服务人员在微信端(或管理后台)接单、上传服务凭证;管理员在 SSM 后台审核阿姨资质、处理退款投诉、统计营收。选 SSM 而不是 Spring Boot,放在毕业设计这个场景里非常合理——Spring + SpringMVC + MyBatis 的 MVC 结构清晰,每一层都能单独讲出设计理由,答辩时不愁没话说。适合两类人:一是正在选题、需要一套能跑通完整闭环的毕设框架的在校生;二是想快速验证家政 O2O 业务模型、但又没精力从零搭后台的独立开发者。本文不贴完整源码,只把骨架、核心表和最容易翻车的联调环节讲透,你拿到任何一套同类源码,都能按这个思路快速上手改造。
2. 搭建 SSM 后端骨架:从空工程到跑通第一个接口
2.1 为什么是这个组合:SSM 的边界与选型权衡
SSM 由三个组件拼成:Spring 管对象和事务,SpringMVC 接 HTTP 请求,MyBatis 做数据库映射。相比于 Spring Boot 的自动化配置,SSM 的优势在于“每样东西都是显式配置的”,你在 applicationContext.xml 里能看到数据源、事务管理器、Mapper 扫描路径,这对教学和答辩非常友好。但它也有明显的边界:没有内置的依赖管理,jar 包冲突要自己处理;没有自动装配,每个 Bean 都要声明或加注解;分页、参数校验这类常用功能需要额外集成 PageHelper 和 Hibernate Validator。
家政这种业务模型,用户表、阿姨表、订单表、服务项目表四张核心表之间的关系并不复杂,MyBatis 手写 SQL 反而比 JPA 更直观,尤其是订单状态机这种需要UPDATE ... WHERE status = ?的乐观更新场景,MyBatis 的 SQL 控制力更让人放心。而 SpringMVC 的拦截器机制很适合做小程序的登录态校验——写一个 HandlerInterceptor 统一拦截/api/**请求,从 Header 里取 token,查不到就返回 401。这一套做下来,你对 Java Web 的理解是体系化的,不只是“会调接口”。
2.2 初始化工程结构:Maven 坐标与三层分包
拿到源码后第一件事不是打开 IDEA 看代码,而是先建一个干净的 Maven 骨架,然后把源码里的业务代码逐步移植进来。这样你能明确知道每个依赖是干什么用的,而不是被一堆 GAV 坐标淹没。
<dependencies> <!-- Spring MVC --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.2.22.RELEASE</version> </dependency> <!-- MyBatis 核心与 Spring 整合包 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.6</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> </dependency> <!-- 分页插件 --> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.1</version> </dependency> </dependencies>这里有个容易翻车的点:如果你本机装的是 MySQL 8.x,驱动必须用com.mysql.cj.jdbc.Driver,并且连接串里要显式加上serverTimezone=Asia/Shanghai,否则 JDBC 初始化会直接抛时区异常。如果你的源码包是几年前的,驱动可能还是com.mysql.jdbc.Driver,这个类在 MySQL Connector/J 8.0 里已经被移除了。
包结构建议按 controller / service / mapper / entity / common 五层拆,不要按页面拆。家务项目里UserController、OrderController、CategoryController都放在 controller 包下,公共返回体Result<T>放在 common 包下,代码找起来效率高得多。
2.3 Spring 与 MyBatis 整合:数据源、事务与 Mapper 扫描
SSM 整合的核心是让 Spring 容器管理 SqlSessionFactory,这样 MyBatis 的 Mapper 接口可以直接注入到 Service 里,事务也能统一交给 Spring 管理。我把关键配置写成 Java Config 而不是 XML,因为新版本源码里这种写法更常见,AI 辅助工具也更容易分析。
@Configuration @MapperScan("com.example.housekeeping.mapper") @EnableTransactionManagement public class MyBatisConfig { @Bean public DataSource dataSource() { DruidDataSource ds = new DruidDataSource(); ds.setUrl("jdbc:mysql://localhost:3306/housekeeping?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"); ds.setUsername("root"); ds.setPassword("your_password"); ds.setInitialSize(5); ds.setMaxActive(20); return ds; } @Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { SqlSessionFactoryBean factory = new SqlSessionFactoryBean(); factory.setDataSource(dataSource); // 实体类别名包,XML 里可以直接写 resultType="User" factory.setTypeAliasesPackage("com.example.housekeeping.entity"); return factory.getObject(); } }@MapperScan指定的是 Mapper 接口所在的包,factory.setTypeAliasesPackage指定的是实体类包,这两处不要指错。数据源我用了 Druid,因为它的监控页面在调试时非常好用,你能直接看到每条 SQL 的执行时间、慢查询次数和活跃连接数。事务控制直接用@Transactional注解加在 Service 实现类上,家政业务里创建订单、扣减阿姨档期、生成支付记录这三步必须在一个事务里,任何一步失败都要回滚。
2.4 第一个接口:从 HTTP 请求到数据库的完整链路
后端能不能跑通,不写业务,先写一个最简单的登录接口就可以验证。微信小程序登录的流程是:小程序端wx.login()拿到临时 code,传给后端,后端拿 code 去微信接口换 openid,再用 openid 去本地用户表查或建用户。
@RestController @RequestMapping("/api/user") public class UserController { @Autowired private UserService userService; @PostMapping("/login") public Result<UserVO> login(@RequestBody LoginDTO dto) { // dto.getCode() 是小程序端 wx.login 拿到的临时凭证 String openid = wxService.code2Session(dto.getCode()); UserVO user = userService.loginOrRegister(openid); return Result.success(user); } }这段代码的关键不在 Controller 本身,而在loginOrRegister这个服务方法里:先用selectByOpenid查一次用户表,查不到就执行insert,查到了就更新最近登录时间。这里要注意并发场景——如果同一个用户第一次登录时连续点了两次,可能触发重复插入。所以openid字段在数据库里必须建唯一索引,然后在 Service 里捕获DuplicateKeyException,再查一次返回已存在的用户。这条线的理解很重要,因为后续所有接口都是这个模式:参数校验 → 调 Service → 返回统一 Result。
3. 数据库设计:家政业务的实体关系与订单状态机
3.1 六张核心表:用户、阿姨、服务项、订单、评价、管理员
家政项目的数据模型不算复杂,但订单相关的表一定要单独建,不要图省事把订单信息塞在用户表里。完整的最小数据集至少是:用户表(id、openid、昵称、手机号、地址)、阿姨表(id、姓名、头像、服务类别、价格/小时、评分、状态)、服务类别表(id、名称、图标、排序)、订单表(id、用户ID、阿姨ID、服务类别ID、预约时间、地址、金额、状态、备注)、评价表(id、订单ID、评分、内容、时间)、管理员表(id、用户名、密码、角色)。有些源码还会加优惠券表、投诉表,但六张是底线。
建表时有一个高频问题:手机号要不要加密?我的做法是用户表里存一个encrypted_phone字段,用 AES 加密后的密文,后端展示时再解密,避免数据库泄露直接暴露手机号。虽然小程序端有getPhoneNumber可以直接拿微信绑定的手机号,但那个接口需要企业认证的小程序才能用,个人开发者拿不到,所以源码里通常会让你做成“用户手动输入手机号”或者“登录后用手机号+验证码绑定”。
3.2 订单状态机:数值定义与状态流转约束
家政订单的状态流转比普通电商复杂,因为涉及阿姨接单、服务中、完成、取消、退款等多个环节。常见的状态定义:0 待支付、1 待接单、2 已接单/待服务、3 服务中、4 已完成、5 已取消、6 退款中、7 已退款。前端展示“进行中的订单”就是查status IN (1,2,3),历史订单就是status IN (4,5,7)。
CREATE TABLE `orders` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单ID', `user_id` BIGINT NOT NULL COMMENT '下单用户ID', `worker_id` BIGINT NOT NULL COMMENT '接单阿姨ID', `category_id` INT NOT NULL COMMENT '服务类别ID', `amount` DECIMAL(10,2) NOT NULL COMMENT '订单金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态 0待支付 1待接单 2待服务 3服务中 4已完成 5已取消 6退款中 7已退款', `appointment_time` DATETIME NOT NULL COMMENT '预约服务时间', `address` VARCHAR(255) NOT NULL COMMENT '服务地址', `remark` VARCHAR(500) DEFAULT NULL COMMENT '用户备注', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_worker_id` (`worker_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家政服务订单表';建这个表时有三个细节值得注意。第一,amount用DECIMAL(10,2),绝不用FLOAT,否则金额会出现 0.1 + 0.2 != 0.3 的问题;第二,update_time用ON UPDATE CURRENT_TIMESTAMP自动更新,省得每次写updateTime = new Date();第三,status加普通索引idx_status,因为按状态查订单是最高频的查询之一。状态流转的更新语句应该带上当前状态作为条件,这是避免并发脏数据的关键。
-- 阿姨接单:只有当订单处于待接单(1)时才允许接单 UPDATE orders SET status = 2, worker_id = #{workerId} WHERE id = #{orderId} AND status = 1;这条 SQL 是典型的乐观锁写法。如果两个阿姨同时抢这一单,只有一条 UPDATE 会成功,受影响行数等于 0 的那个就知道单子已经被别人抢走了。我见过不少毕设源码把接单写成先 SELECT 再 UPDATE,中间没有状态条件,结果就是两个阿姨都能接同一单,服务现场直接打架。MyBatis 的update方法返回值就是受影响行数,直接在 Service 里判断if (rows == 0) throw new BizException("订单已被接走")。
3.3 MyBatis 手写 SQL:联表查询与统计报表的写法
SSM 项目里 MyBatis 有两种用法:注解 SQL 和 XML 映射。家政项目里我建议简单查询用注解,复杂动态查询用 XML。比如首页的阿姨列表,按评分和价格排序,直接用注解写在 Mapper 接口上就行。
@Select("SELECT * FROM workers WHERE status = 1 AND category_id = #{categoryId} ORDER BY rating DESC, price ASC LIMIT 20") List<Worker> listAvailableWorkers(@Param("categoryId") Integer categoryId);但管理后台的订单统计就复杂了,需要按日分组、统计销售额和订单数。这种 SQL 写在注解里会非常长而且难以格式化,XML 映射文件会舒服很多。另一个原因是 MyBatis 的动态 SQL 标签<if>、<foreach>在注解里写起来要拼字符串,引号地狱能把人逼疯。
<select id="selectOrderStats" resultType="map"> SELECT DATE(create_time) AS day, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders WHERE create_time BETWEEN #{startTime} AND #{endTime} <if test="status != null"> AND status = #{status} </if> GROUP BY DATE(create_time) ORDER BY day DESC </select>这个查询是后台“最近7日订单走势”图表的直接数据来源。resultType="map"在报表场景很方便,不用专门建 VO 类。注意SUM(amount)的结果在 MySQL 里是 DECIMAL,但传给 Jackson 序列化成 JSON 时可能会变成数值,小数位多的字段会带很多 0,前端图表库能接受,但如果要做金额展示,最好在 SQL 里直接CAST(SUM(amount) AS CHAR)转成字符串。
4. 微信小程序端对接:登录、首页与服务详情页
4.1 小程序目录结构:页面、组件与请求封装
小程序端拿到源码后,第一件事是看project.config.json里的appid是否是你自己的,然后看app.js里的全局配置。一个家政小程序的典型目录是:pages 下分index(首页)、category(分类)、orderList(订单列表)、orderDetail(订单详情)、profile(我的)、login(登录页)六个页面,utils 目录下放request.js请求封装和auth.js登录态管理,components 下放服务卡片组件和服务分类组件。
请求封装是小程序端的基建,所有页面都要用它,我一般会把baseUrl和环境切换写在一起。这里有个反复踩坑的点:开发环境用http://127.0.0.1:8080访问本地后端,但微信开发者工具必须勾选“不校验合法域名”,否则请求会被拦截;真机预览时则必须用 HTTPS 域名。
// utils/request.js const BASE_URL = 'http://127.0.0.1:8080'; // 切换环境只需改这一行 function request(path, method = 'GET', data = {}) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + path, method, data, header: { 'Content-Type': 'application/json', 'token': wx.getStorageSync('token') }, success: (res) => { if (res.data.code === 200) { resolve(res.data.data); } else if (res.data.code === 401) { // token 过期,跳转登录页 wx.redirectTo({ url: '/pages/login/login' }); reject(new Error('登录已过期')); } else { wx.showToast({ title: res.data.msg, icon: 'none' }); reject(new Error(res.data.msg)); } }, fail: (err) => reject(err) }); }); } module.exports = { request, BASE_URL };这个封装把三件事统一掉了:baseUrl 的配置、token 的自动携带、业务码的判断。特别注意 code 是后端业务状态码,不是 HTTP 状态码。我见过不少源码把 HTTP 200 直接当业务成功,结果后端返回 200 但业务失败(比如余额不足),前端还是走 success 分支,页面表现就是“下单成功但订单不存在”,数据对不上还要查半天。
4.2 首页数据加载:从 onLoad 到渲染的完整链路
首页的逻辑在小程序端非常典型:进入页面后请求服务分类、请求推荐阿姨列表、渲染滚动列表。看起来简单,但有两个高频问题:一是用户未登录时后端接口返回 401,前端要跳登录页,但首页本身不应该强制登录;二是下拉刷新和触底翻页会重复请求,需要做防重复处理。
Page({ data: { categories: [], workers: [], page: 1, hasMore: true, loading: false }, onLoad() { this.loadCategories(); this.loadWorkers(true); }, async loadCategories() { const cats = await request('/api/category/list', 'GET'); this.setData({ categories: cats }); }, async loadWorkers(reset = false) { if (this.data.loading) return; if (!reset && !this.data.hasMore) return; this.setData({ loading: true }); const page = reset ? 1 : this.data.page + 1; const list = await request(`/api/worker/list?page=${page}&size=10`, 'GET'); this.setData({ workers: reset ? list.records : this.data.workers.concat(list.records), page: page, hasMore: list.records.length === 10, loading: false }); }, onReachBottom() { this.loadWorkers(false); } });这里有一个关键参数:后端接口第几页、每页几条,通过page和size传参。PageHelper 的分页逻辑是“紧跟在下一条 SELECT 之前的 PageHelper.startPage 只对其后第一条查询生效”,如果你的 Mapper 方法里先查了别的表再查 worker 列表,分页就会失效。
4.3 服务详情与下单:价格计算与表单校验
服务详情页的重点是展示阿姨信息、服务价格、评价列表,以及一个“立即预约”的按钮。预约表单通常包含:选择日期时间(小程序端用picker组件)、服务地址(用wx.chooseLocation或手动输入)、备注说明。下单前端的校验至少有:地址不能为空、预约时间不能是过去的时间、价格显示要和后端一致。
价格计算是一个容易出 bug 的点。家政服务的计价方式常见两种:按小时单价(如 50 元/小时)和按次计费(如 保洁 120 元/次)。如果源码支持按小时计费,前端要传服务时长(小时数),后端根据hourly_price * hours计算总价,而不是接收前端传来的总价——否则用户抓到请求包改成 1 元提交,后端也会照单全收。后端正确做法:
@Transactional public Long createOrder(CreateOrderDTO dto) { Worker worker = workerMapper.selectById(dto.getWorkerId()); if (worker == null || worker.getStatus() != 1) { throw new BizException("阿姨不存在或已下班"); } BigDecimal amount = worker.getHourlyPrice() .multiply(BigDecimal.valueOf(dto.getHours())); Order order = new Order(); order.setWorkerId(worker.getId()); order.setAmount(amount); order.setStatus(0); orderMapper.insert(order); return order.getId(); }创建订单时金额永远从数据库里读取的单价算出来,这是安全的底线。同时注意@Transactional要加在 public 方法上,同类内部调用this.createOrder()是走不到代理的,事务会失效,这个坑在毕设答辩时经常被老师追问。
5. 避坑/常见问题/排查:SSM 与小程序联调的 5 个经典翻车现场
5.1 接口报 404 但 Controller 明明写了
现象:请求/api/user/login返回 404,SpringMVC 控制台甚至没有打印任何日志。原因:项目里同时存在 SpringMVC 的 XML 配置和 Java Config,@Controller注解的包扫描路径没有覆盖到 controller 包,或者web.xml里 DispatcherServlet 的url-pattern配成了*.do而不是/。解决:检查spring-mvc.xml或 WebAppInitializer 里的setScanBasePackages路径,确保com.example.housekeeping.controller在扫描范围内;同时确认前端请求的路径里没有多余前缀,比如后端接口是/api/user/login,小程序请求http://127.0.0.1:8080/api/user/login才是对的。
5.2 JSON 序列化时日期变成一串数字
现象:订单列表接口返回appointmentTime: 1718611200000,前端 new Date 后能显示,但格式是乱的。原因:Jackson 默认把java.util.Date序列化成时间戳。解决:在实体类的日期字段上加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8"),或者在 SpringMVC 配置里全局设置 ObjectMapper 的日期格式。注意如果用了 MyBatis 的LocalDateTime而不是Date,Jackson 的jsr310模块也需要单独引入,否则直接报序列化异常。
5.3 小程序端请求一直转圈,后端没收到请求
现象:wx.request的 success 和 fail 都没触发,或者触发了 fail 且报request:fail。原因有几种:开发工具没有勾选“不校验合法域名”;电脑防火墙拦截了 8080 端口;后端以http://localhost:8080启动,而手机预览时localhost指向的是手机自己。解决:开发工具设置里打开“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”;后端启动时server.address=0.0.0.0允许局域网访问;真机调试时 baseUrl 改成电脑的局域网 IP。
5.4 分页参数传了但返回的还是全量数据
现象:前端传page=2&size=10,后端返回了全部订单。原因:PageHelper 的PageHelper.startPage(page, size)后面紧跟的 SQL 不是目标查询;或者有两条查询语句,第一条是辅助查询,分页作用在了错误的对象上;还有一种情况是 MyBatis 的<select>返回结果用了resultType="map",PageHelper 拦截时对 map 类型做了特殊处理。解决:把startPage和pageHelper的<select>之间不要插入任何其他 SQL;辅助查询用单独的 Mapper 方法或者放到startPage之前。
5.5 数据库中文乱码,所有插入的数据都是问号
现象:小程序提交地址“浦东新区”,数据库里变成“??????”。原因:MySQL 表字符集不是 utf8mb4,或者 JDBC 连接串里没有characterEncoding=utf8,另外 MySQL 8.0 默认的utf8mb4_0900_ai_ci排序规则在某些版本下控制台插入没问题但接口写入乱码。解决:建表时统一CHARSET=utf8mb4,连接串加useUnicode=true&characterEncoding=utf8,如果库已建成,用ALTER TABLE orders CONVERT TO CHARACTER SET utf8mb4转一下。
6. 上线前验证与进阶:把单机毕设做成可演示的产品
家政项目做到能跑通主流程只是第一步,真正让它在答辩或演示时不出丑的,是下面几个验证动作。第一个是订单全链路状态流转测试:从创建订单 → 模拟支付 → 阿姨接单 → 开始服务 → 完成 → 评价,每一步都要在管理后台能看到对应状态变化。这里有一个常用技巧,写一个测试用的定时任务,每 10 秒把状态流转一层,这样演示时不用手动去数据库改 state,PPT 讲到哪一步,订单就走到哪一步。
第二个是支付环节的处理。毕设里接微信支付需要企业资质,个人开发者往往开通不了。常见替代方案是“模拟支付”——在小程序端弹出一个支付确认框,点击“确认支付”后直接调用后端的支付回调接口。这个方案的落地做法是在订单表里加一个pay_channel字段,模拟支付时置为mock,这样以后接真实支付时只要替换 Controller 这一层即可,不用动订单表和状态机。注意模拟支付接口必须做签名校验,至少加一个固定 Token,避免有人拿接口刷单。
第三个是日志留痕。SSM 项目里用 Lombok 的@Slf4j在 Service 层打关键日志:订单创建时打用户 ID 和金额、接单时打阿姨 ID、支付回调时打订单号和回调来源。我用过一次血泪经验换来的教训:答辩演示时某个阿姨一直收不到订单,现场查数据库发现两个生产订单记录指向了同一个 worker_id,而没有任何日志能告诉我为什么。从那以后我所有状态变更都要求带log.info。联调时遇到问题,先看后端日志,比前端挨个查快得多。
第四个是数据库初始化的脚本化。源码包里通常有housekeeping.sql,但直接导入可能有坑——如果 SQL 里包含DROP DATABASE,你一不小心就把自己的数据库清空了。我的习惯是先打开 SQL 文件看前 20 行,确认没有危险语句,然后在一个独立 schema 里导入测试数据。给演示准备的种子数据要注意时间字段不要写死,用NOW() - INTERVAL 1 DAY这类相对时间,否则数据放着放着就成了“历史订单”,首页展示就空了。
小程序端本身也有一个值得加的能力:缓存首页数据。家政项目把阿姨列表和分类数据存在wx.setStorageSync,下次启动时先渲染缓存,再后台拉新数据覆盖。这一招在演示时特别有用——会场网络信号差,缓存能保证页面不是白屏,观感完全不一样。实现方式是在app.js的onLaunch里拉一次数据写入 Storage,首页onLoad时先读缓存再走请求。
最后一件事是代码备份和版本标记。我不止一次见过毕设同学改崩了代码想回退,结果发现只剩一份主源码。建议每完成一个稳定版本就用git tag v1.0打个标记,压缩打包前改掉project.config.json里的 appid 并删掉本地调试用的临时文件。整个项目做完,我会习惯性地把源码里的数据库密码改成弱口令如root/123456并写进 README——这不是安全意识淡薄,而是让下一个接手的人能最快跑起来,也避免答辩老师拿着你打包的包换一台电脑就演示不了。这个习惯帮我省了太多事,也希望帮到你。
本文还有配套的精品资源,点击获取