☰
微信小程序+SSM社区养老系统开发实战:从订单到派单全流程
2026/10/14 16:46:50 网站建设 项目流程

简介:一份基于微信小程序与SSM框架的社区养老服务系统毕业设计源码案例,适合计算机相关专业学生用于毕设、课程设计或期末大作业,也适合想积累项目实战经验的学习者。系统覆盖预约护理、健康管理、日常照料和文化活动等功能,完整呈现小程序前端与Spring/SpringMVC/MyBatis后端的整合思路,模块划分清晰、便于二次开发。压缩包共1709个文件,大小约88.39MB,其中273个js脚本、218个java类承担前后端核心逻辑,160个vue页面、144个wxss样式和142个wxml模板负责界面展示,并附sql数据库脚本、mp4演示视频、使用说明及环境安装说明,便于从零复现运行环境。目前已有79人学习下载。项目源码经过导师指导与调试,可顺畅运行,配合演示视频和文档能快速梳理业务模块与SSM框架落地方法,还可学习数据表与接口设计,为社区养老信息化设计提供高质量参考。

1. 小程序+SSM 做社区养老系统:为什么这个方向值得认真做

微信小程序社区养老服务系统,前端跑在微信里,后端由 SSM(Spring+SpringMVC+MyBatis)支撑,是毕业设计里实用性和完成度都比较高的一个方向。它的核心价值在于把家属预约助餐、保洁、陪医等服务,管理员派单、服务人员接单、老人健康档案管理串成一条完整业务链。这个系统难的不是某个技术点,而是角色多、状态多、流程长,能把这些理清楚并完整跑通,答辩时天然有东西可讲。适合正在选毕业设计题目、或者想系统性掌握小程序+SSM 全栈开发的同学参考。

2. 系统拆解与技术选型:养老服务业务流程和 SSM 后端的边界划分

在动手写代码之前,先把业务边界划清楚。很多同学翻车不是因为代码写不出来,而是不知道哪些逻辑该放前端、哪些放后端、哪些放数据库,最后代码全揉在 Controller 里,改一个功能动全身。这一章先把养老系统的业务模块拆开,再说 SSM 三层各自管什么,最后理一条完整请求链路出来。

2.1 养老服务系统的四个核心业务模块与角色权限设计

社区养老服务系统本质上是一个服务预约平台,围绕“服务”二字展开。我一般会把业务拆成四个模块。第一个是用户与角色模块,系统里至少有三类角色:老人或家属负责下单,服务人员负责接单执行,管理员负责派单和审核。设计上不需要给每类角色单独建表,用一张 user 表加 role 字段区分即可,登录后根据 role 返回不同的首页或菜单。

第二个是服务项目管理模块,助餐、保洁、陪医、陪伴这几类服务,每类有名称、价格、详情、上下架状态。这个模块逻辑最简单,但上下架状态必须做,否则管理员没法临时停掉某个服务,也没法解释为什么用户还能下单一个已经停掉的项目。第三个是订单流转模块,这是整个系统的核心。订单从用户提交开始,经历待接单、已接单、服务中、已完成、已取消这几个状态。状态不能想怎么跳就怎么跳,比如已取消的订单不能再变成已完成,这块建议在 Service 层做状态流转校验。

第四个是健康档案模块,家属可以定期帮老人记录血压、心率等数据,服务人员在陪医服务时也能参考。这个模块虽然代码量不大,但在答辩时很能体现“需求分析”的完整性。风险点在于老人信息涉及隐私,项目里要注意脱敏展示,前端列表不要直接显示完整手机号。

角色权限上,用一张表说明常见的划分:

角色查看范围核心操作对应端
老人/家属服务列表、自己的订单、健康档案下单、取消订单、填健康数据微信小程序
服务人员派给TA的订单接单、更新服务状态小程序/管理端
管理员所有订单、用户、服务项目派单、上下架服务、查看统计管理后台

权限控制不要只在前端做,前端隐藏入口只是体验层面的处理,真正的校验必须在后端。常见做法是写一个 HandlerInterceptor,在 Controller 执行前把 token 解析成 userId 和 role 放到请求上下文,需要管理员权限的接口再单独校验 role,而不是每个方法里重复写判断。

2.2 为什么用 SSM 而不是 Spring Boot:三层架构在养老项目里的职责

选题时很多同学会纠结:现在新项目不都用 Spring Boot 吗,为什么还要用 SSM?我的看法是,如果你的毕业设计题目明确写了 SSM,或者导师指定了这个组合,那就按 SSM 来,没有必要为了“技术新”去换框架。SSM 三件套在养老服务这种业务逻辑清晰、数据表关系简单的项目里完全够用,而且手动配置反而逼你把框架原理搞清楚,答辩时被问“Spring 和 SpringMVC 什么关系”不至于答不上来。

SSM 三层的职责边界要这样理解:Spring 管对象和事务,Service 层的类加上 @Service 注解交给容器管理,涉及多张表更新的方法(比如下单同时扣减库存)用 @Transactional 保证要么全成功要么全回滚。SpringMVC 管路由和参数绑定,小程序发来的 HTTP 请求由 DispatcherServlet 分发到对应 Controller,@RequestBody 把 JSON 转成 Java 对象,@RequestMapping 定义 URL 和请求方式。MyBatis 管 SQL,它不帮你生成 SQL,而是让你把 SQL 写在 XML 或注解里,再把查询结果映射成实体对象。养老服务系统的订单查询条件多、组合多,MyBatis 的动态 SQL 正好派上用场。

这三层不要互相越界。Controller 里只做参数接收和结果封装,Service 里写业务规则,Mapper 只做数据读写。我见过不少同学的代码在 Controller 里直接 new ServiceImpl,或者把 SQL 逻辑写死在 Java 里循环拼接,这样的代码答辩时一问一个准。

2.3 小程序端到 MySQL 的完整请求链路与统一返回格式

明确了职责边界,再看一条请求是怎么走通的。用户在服务列表页点“立即预约”,小程序通过 wx.request 发起 POST 请求,数据是 JSON 格式;请求到达 Tomcat 后由 SpringMVC 分发给 OrderController;Controller 调用 OrderService 里的 createOrder 方法;Service 里先做业务校验,再通过 OrderMapper 把订单插入数据库。返回路径是反过来的,最终以 JSON 响应交给小程序渲染。

这里有一个直接影响开发效率的约定:后端所有接口统一返回一个 Result 对象,结构固定为三部分,code 表示业务状态码(200 成功、401 未登录、400 参数错误、500 异常),msg 是给前端看的提示文案,data 是真正的业务数据。接口一旦统一,小程序端就能做一个 request 封装,统一处理 loading、错误提示、登录失效跳转,不用每个页面重复写一套。

public class Result { private int code; private String msg; private Object data; public static Result success(Object data) { Result r = new Result(); r.code = 200; r.msg = "success"; r.data = data; return r; } public static Result error(int code, String msg) { Result r = new Result(); r.code = code; r.msg = msg; return r; } // getter / setter 省略 }

code 什么时候用 401 而不是 500,要定个规则:用户未登录或 token 过期用 401,这是前端要主动处理的;参数填错用 400,前端要给出对应提示;系统内部异常用 500,前端只弹“系统繁忙”。状态码不统一的话,小程序端的 request 封装会越写越乱。

注意:Result 类放到 common 包下,所有 Controller 统一返回它。如果某个接口直接返回实体对象,前端拿到的结构一会儿是 {code,msg,data},一会儿是裸对象,等于没有约定,排查问题时会非常痛苦。

3. 后端落地:数据表设计、订单接口和 MyBatis 动态 SQL 完整写法

业务边界定好、请求链路理清之后,后端开发就是照着拆好的模块逐层写。这一章把最核心的三条路径完整走一遍:先看五个关键数据表怎么建,再看订单创建接口三层怎么写,最后看订单列表的多条件查询怎么用 MyBatis 动态 SQL 做。

3.1 核心数据表设计:用户、服务项目、订单、健康档案、公告

数据库设计直接决定后期开发效率,表建得不好,写 Mapper 的时候会非常痛苦。我对养老系统的建表原则是:能用一张表解决的不用两张,状态字段用 tinyint 存数字,所有时间字段统一用 datetime,字符集统一用 utf8mb4。

CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(100) NOT NULL COMMENT 'MD5或BCrypt加密存储', `real_name` varchar(50) DEFAULT NULL, `phone` varchar(20) DEFAULT NULL, `role` tinyint(4) DEFAULT 1 COMMENT '1-家属用户 2-服务人员 3-管理员', `address` varchar(200) DEFAULT NULL, `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `service_item` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL, `type` tinyint(4) DEFAULT NULL COMMENT '1-助餐 2-保洁 3-陪医 4-陪伴', `price` decimal(10,2) DEFAULT NULL, `detail` text, `image_url` varchar(200) DEFAULT NULL, `status` tinyint(4) DEFAULT 1 COMMENT '1-上架 0-下架', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `service_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) DEFAULT NULL COMMENT '业务订单号,前端展示用', `user_id` bigint(20) DEFAULT NULL, `item_id` bigint(20) DEFAULT NULL, `service_time` datetime DEFAULT NULL, `address` varchar(200) DEFAULT NULL, `remark` varchar(500) DEFAULT NULL, `status` tinyint(4) DEFAULT 0 COMMENT '0-待接单 1-已接单 2-服务中 3-已完成 4-已取消', `staff_id` bigint(20) DEFAULT NULL COMMENT '接单服务人员', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `health_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) DEFAULT NULL, `blood_pressure` varchar(20) DEFAULT NULL COMMENT '血压值,例如 120/80', `heart_rate` varchar(20) DEFAULT NULL, `record_date` date DEFAULT NULL, `remark` varchar(500) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `notice` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `title` varchar(100) DEFAULT NULL, `content` text, `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

字符集这里要说明一下:一定要用 utf8mb4 而不是 utf8。MySQL 的 utf8 只支持三个字节的编码,家属填个生僻字或者表情符号就会插入失败,报错还不直观,换成 utf8mb4 一次省心。role 字段用 tinyint 而不是字符串枚举,是因为数字在索引和存储上更省空间,后端用常量类定义好三个角色值,不要散落硬编码在代码里。

order_no 订单号不要用自增 id 直接暴露给用户,建议在 Service 层生成,格式类似“日期+时间戳+随机数”。status 字段给了 5 个状态,状态只允许按 0→1→2→3 的方向推进,4 是取消态,取消只能在 0 或 1 时发生,这个规则放 Service 层校验,不要依赖前端传什么就是什么。

3.2 服务预约接口:Controller → Service → Mapper 三层串起来

订单创建是整个系统最核心的接口,完整写一遍三层代码。先看 Controller 层:

@RestController @RequestMapping("/api/order") public class OrderController { @Autowired private OrderService orderService; @PostMapping("/create") public Result create(@RequestBody OrderCreateDTO dto, @RequestAttribute("userId") Long userId) { // 基础参数校验,只做非空判断,业务规则放到 Service if (dto.getItemId() == null || dto.getServiceTime() == null) { return Result.error(400, "服务项目和预约时间不能为空"); } ServiceOrder order = orderService.createOrder(userId, dto); return Result.success(order); } }

dto 是一个只接收参数的类,字段就是 itemId、serviceTime、address、remark 这几个。userId 从拦截器解析 token 后塞进 Request 上下文,Controller 直接通过 @RequestAttribute 取,不需要前端再传一个 userId 进来。这一点很多同学会写成前端直接传 userId,那别人改个 userId 就能替别人下单了,是一个很明显越权漏洞,答辩被问概率极高。

再看 Service 层:

@Service public class OrderServiceImpl implements OrderService { @Autowired private OrderMapper orderMapper; @Override @Transactional public ServiceOrder createOrder(Long userId, OrderCreateDTO dto) { // 检验服务项目是否存在且处于上架状态 ServiceItem item = orderMapper.findItemById(dto.getItemId()); if (item == null || item.getStatus() != 1) { throw new BizException("服务项目不存在或已下架"); } ServiceOrder order = new ServiceOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setItemId(dto.getItemId()); order.setServiceTime(dto.getServiceTime()); order.setAddress(dto.getAddress()); order.setRemark(dto.getRemark()); order.setStatus(0); // 新订单默认待接单 order.setCreateTime(new Date()); orderMapper.insert(order); return order; } }

这里有两个关键点。第一,@Transactional 注解必须加,虽然当前的 createOrder 只插入了一张表,但后续如果要加“下单同时给服务人员发通知”的逻辑,事务就能保证通知失败时订单也能回滚,不会出现订单建了但通知没发出去的脏数据。第二,服务项目必须查一次库并且校验上架状态,不能只靠前端传来的 itemId 直接插入订单,否则下架的服务还能被下单。

Mapper 接口这边就是标准的 MyBatis 写法:

public interface OrderMapper { int insert(ServiceOrder order); ServiceItem findItemById(Long itemId); List<ServiceOrder> listOrders(@Param("userId") Long userId, @Param("status") Integer status, @Param("offset") int offset, @Param("pageSize") int pageSize); }

insert 的 SQL 用 useGeneratedKeys 把自增 id 回填到实体上,这样可以省掉查一次数据库拿订单号。findItemById 返回 ServiceItem 而不是拼字符串,是为了复用实体类型,避免每个方法都建一个中间对象。

3.3 MyBatis 多条件查询订单:动态 SQL 与分页参数

订单列表页要支持按用户查、按状态查、按时间段查,组合方式很多。如果每个组合写一条 SQL,这接口就没法维护了。MyBatis 动态 SQL 就是为这个场景设计的,XML 写法如下:

<select id="listOrders" resultType="com.example.entity.ServiceOrder"> SELECT * FROM service_order <where> <if test="userId != null"> AND user_id = #{userId} </if> <if test="status != null"> AND status = #{status} </if> <if test="startTime != null"> AND create_time &gt;= #{startTime} </if> <if test="endTime != null"> AND create_time &lt;= #{endTime} </if> </where> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>

where 标签会自动处理两个问题:如果下面所有 if 都不成立,它不会生成 WHERE 关键字,整张表按时间倒序返回;如果第一个条件成立,它会自动去掉开头多余的 AND。这是 MyBatis 里最实用的一个标签,写多条件查询时比在 Java 代码里拼接 SQL 字符串干净得多。

分页这里没有用 PageHelper 插件,而是手动算 offset。原因是在毕业设计里引入插件会多一个配置环节,而且 PageHelper 偶尔会出现分页失效的诡异情况,排查起来像玄学。手动算 offset 的逻辑很简单:第 page 页,每页 pageSize 条,offset 就是 (page - 1) * pageSize。参数传进来的时候,Service 层做一个默认值处理:page 默认 1,pageSize 默认 10,pageSize 上限 50,防止有人一次拉走全表。

resultType 映射还有一个容易踩的坑:如果表字段是下划线命名(create_time),实体属性是驼峰命名(createTime),必须在 mybatis-config.xml 里开启驼峰映射,否则查出来的对象所有带下划线的字段全是 null。这个配置在避坑章节会专门展开。

4. 小程序端实现:页面注册、登录态管理与下单全流程

后端接口就绪后,开始写小程序端。微信小程序的技术栈是 WXML、WXSS、JavaScript,本质上是一个被微信限制了 API 的浏览器环境。页面的写法、组件和事件绑定跟 Vue 有些像,但又有自己的生命周期和 API 约束,比如必须通过 wx.request 发请求、必须用 wx.login 拿登录凭证。

4.1 小程序目录结构与 app.json 页面注册顺序

一个规范的小程序项目目录是这样的:

miniprogram/ ├── app.js // 全局逻辑,App() 实例 ├── app.json // 全局配置,页面注册在这里 ├── app.wxss // 全局样式 ├── pages/ │ ├── index/ // 首页,服务列表 │ │ ├── index.wxml │ │ ├── index.js │ │ ├── index.wxss │ │ └── index.json │ ├── detail/ // 服务详情 │ ├── order/ // 下单页 │ ├── orders/ // 我的订单列表 │ ├── profile/ // 个人中心 │ └── login/ // 登录页 └── utils/ └── request.js // 封装 wx.request

app.json 里的 pages 数组有讲究:数组第一个元素就是小程序的启动页,正常情况应该放首页;每个页面的路径不用写 .wxml 后缀;tabBar 里引用的页面必须已经在 pages 数组里注册过,而且 tabBar 页面数量不能超过 5 个。

{ "pages": [ "pages/index/index", "pages/detail/detail", "pages/order/order", "pages/orders/orders", "pages/profile/profile", "pages/login/login" ], "window": { "navigationBarBackgroundColor": "#1b8a5a", "navigationBarTitleText": "社区养老服务", "navigationBarTextStyle": "white" }, "tabBar": { "color": "#999999", "selectedColor": "#1b8a5a", "list": [ { "pagePath": "pages/index/index", "text": "首页" }, { "pagePath": "pages/orders/orders", "text": "订单" }, { "pagePath": "pages/profile/profile", "text": "我的" } ] } }

tabBar 里的 iconPath 和 selectedIconPath 没有写,因为图标文件不存在的话编译会直接报错。如果不想准备图标,宁可不配 tabBar,改用普通页面加导航跳转,也别随便放一个不存在的路径。这是血泪经验,项目初始化时最容易在这里翻车。

4.2 wx.login 换 token:登录态建立与失效处理

小程序登录和传统网页登录不一样。小程序不能直接用账号密码登录,而是先调用 wx.login 拿一个临时 code,再把 code 发给后端,后端用 code 去微信接口换 openid。换到 openid 后,后端认为用户合法,生成一个自定义 token 返回给小程序,小程序把 token 存起来,后续所有请求带上就行。

// utils/request.js const BASE_URL = 'http://localhost:8080'; function request(path, method, data) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + path, method: method || 'GET', data: data || {}, header: { 'content-type': 'application/json', 'Authorization': wx.getStorageSync('token') || '' }, success: (res) => { // 业务码 200 才视为成功,401 跳登录页 if (res.statusCode === 200 && res.data.code === 200) { resolve(res.data.data); } else if (res.data.code === 401) { wx.navigateTo({ url: '/pages/login/login' }); reject(res.data); } else { wx.showToast({ title: res.data.msg || '请求失败', icon: 'none' }); reject(res.data); } }, fail: (err) => { wx.showToast({ title: '网络异常', icon: 'none' }); reject(err); } }); }); }

这段代码里最关键的是 401 分支:token 失效了不能再调 wx.login 自动续期,而是跳登录页让用户重新登录。自动续期的方案也可以做,但要在后端加刷新 token 机制,复杂度上来了,毕业设计没必要。401 跳转登录页的做法简单、可解释,答辩时讲得清楚。另一个细节是请求头里的 Authorization 字段,后端拦截器从这里取 token,前端所有请求都走这个封装,就不会存在某个页面忘记带头的问题。

登录页拿到 code 后调后端:

// pages/login/login.js wx.login({ success: (res) => { if (!res.code) { wx.showToast({ title: '登录失败', icon: 'none' }); return; } request('/api/auth/login', 'POST', { code: res.code }) .then((data) => { wx.setStorageSync('token', data.token); wx.switchTab({ url: '/pages/index/index' }); }); } });

这里注意一个坑:wx.login 取到的 res.code 只能用一次,后端 code2session 成功之后这个 code 就作废了。如果同一页面里有多个请求都依赖登录结果,一定要先登录取 token,再发业务请求,不要每个请求里都执行一次 wx.login。

4.3 服务列表页与预约下单的衔接

首页加载服务列表时,在 onLoad 生命周期里调用封装好的 request:

// pages/index/index.js Page({ data: { serviceList: [] }, onLoad: function () { this.loadServices(); }, loadServices: function () { const self = this; request('/api/service/list', 'GET') .then((list) => { self.setData({ serviceList: list }); }); }, goDetail: function (e) { // 取>// pages/order/order.js submitOrder: function () { const { itemId, serviceTime, address, remark } = this.data; if (!serviceTime) { wx.showToast({ title: '请选择预约时间', icon: 'none' }); return; } if (!address) { wx.showToast({ title: '请填写服务地址', icon: 'none' }); return; } request('/api/order/create', 'POST', { itemId: itemId, serviceTime: serviceTime, address: address, remark: remark }).then((order) => { wx.redirectTo({ url: '/pages/orders/orders?status=0' }); }); }

下单成功后习惯用 wx.redirectTo 而不是 wx.navigateTo 跳转:redirectTo 会关闭当前下单页,用户按返回键不会回到已提交的页面再重复提交一次,这是个很小但很实用的体验细节。预约时间的组件建议用微信原生 picker 的 mode="date" 和 mode="time" 组合,不要让用户手输,减少格式错误。

5. 避坑指南:养老系统从开发到答辩的 5 个常见踩坑点

这一章是血泪经验合集。下面 5 个坑几乎每次带新人或看同学项目时都会遇到,每个都按现象、原因、解决三步写清楚,遇到相同症状直接对号入座。

5.1 wx.request 请求能通但后端收到的参数全是 null

现象:后端日志里能看到请求进来了,但 Controller 方法参数对象里的字段全是 null,或者 @RequestBody 的 DTO 直接报“Required request body is missing”。

原因:小程序端 wx.request 发 POST 时,如果 data 传的是普通对象而没有 JSON.stringify,并且 header 里没设 content-type 为 application/json,后端 @RequestBody 就解析不到内容。反过来还有一种情况:后端接口用 @RequestParam 接收,但前端传的是 JSON,字段当然也对不上。

解决:小程序端统一在 request 封装里设置 header 的 content-type 为 application/json,data 传 JSON.stringify({...})。后端 POST 接口统一用 @RequestBody 接对象。两边只要有一边不对,排查起来会非常耗时,所以约定要在一开始就定好。

5.2 MyBatis 查出来的实体字段全是 null

现象:数据库里明明有数据,SQL 在数据库客户端执行也有结果,但接口返回的 JSON 里实体字段全是 null,尤其是带下划线的字段。

原因:最常见的是没开启驼峰映射。表字段 user_id 对应实体属性 userId,如果 MyBatis 不知道要自动转换,映射结果就是 null。另一个原因是实体字段没有生成 getter/setter,JSON 序列化时取不到值,IDE 不一定会报错。

解决:在 mybatis-config.xml 里加一行配置:

<settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings>

加了这行之后,user_id 会自动映射到 userId。加完还不行,就检查实体类是不是只有字段没有 getter,用了 Lombok 的 @Data 却忘了装插件,或者实体字段名和表字段名拼写不一致,这类问题 IDE 不会直接报红,但运行时就是 null。

5.3 wx.login 的 code 被重复使用,后端报 code 无效

现象:用户第一次能登录成功,第二次打开小程序或换个页面再登录,后端调微信接口换 openid 时返回错误码 40029。

原因:微信接口的 code 是一次性的,用了一次就作废,而且有效期只有 5 分钟。很多同学在多个页面的 onLoad 里都调 wx.login,或者每次发请求都先 wx.login,导致把上一次的 code 又发了一遍。

解决:只在登录页或 App 启动时调一次 wx.login 拿 code。换到的 token 存到 wx.setStorageSync,后续请求从 storage 取 token 用。只有收到 401 时才跳回登录页重新走 wx.login 流程,不要在 request 封装里每次自动重新 login。

5.4 开发者工具能通,真机预览请求直接 fail

现象:微信开发者工具里接口调得通,数据正常,但用手机真机预览的时候,所有请求全部失败,报 request:fail。

原因:小程序真机环境不会把 localhost 解析成你的电脑。开发者工具默认开了“不校验合法域名”,真机预览如果没有同时勾选这个选项,或者请求的地址是 localhost,就直接被拦住。这是开发环境和小程序线上环境的差异导致的。

解决:开发阶段在微信开发者工具的详情面板里勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”。真机调试时把 BASE_URL 里的 localhost 改成电脑的局域网 IP,比如 192.168.x.x,后端 Tomcat 也要监听 0.0.0.0 而不是 127.0.0.1。要发布上线,必须配一个备案过的 HTTPS 域名并在小程序后台加入合法域名列表,这一步绕不过去。

5.5 数据库时间比实际时间少了 8 小时

现象:前端展示的订单创建时间比真实时间晚了 8 个小时,或者反过来快了 8 个小时,而且只有时间部分不对。

原因:MySQL 驱动连接 URL 里没有指定 serverTimezone,JVM 和数据库服务器时区不一致。MySQL 8.x 默认按 UTC 时间存储,中国是 UTC+8,差了 8 小时。如果 JSON 序列化时 Jackson 没有指定时区,时间还会再偏一次。

解决:JDBC 连接 URL 加上时区参数,完整写法是:

jdbc:mysql://localhost:3306/elder_care?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai

同时在 Spring 的 Jackson 配置里设置时间格式yyyy-MM-dd HH:mm:ss和time-zone: GMT+8。时间字段在 Java 实体里用 Date 或 LocalDateTime 类型,不要用 String 存。这个坑很隐蔽,因为它只在特定环境下出现,而且日志里不容易看出来,属于典型的看着哪里都对但结果不对的问题。

6. 答辩前自测清单与一个低成本缓存优化

系统跑通之后,离答辩还有一段距离。我的习惯是拿出一张纸,把每个角色的完整流程手写一遍,然后照着走:用家属账号下一单、取消一单、查看订单状态变化;用服务人员账号接单、更新状态;用管理员账号派单、下架一个服务项目、再回来确认小程序端已经看不到下架项目。这三个流程能完整走通,系统的主干就稳了。

然后加试几条异常路径:不登录直接点下单,看是否跳登录页;把服务项目下架后带着旧链接访问详情页,看是否有明确提示;断网时点提交按钮,看 loading 是否正常关闭。异常路径往往比正常路径更容易暴露赶工痕迹,答辩时老师最喜欢挑这些地方问。

如果还想做点优化,推荐一个低成本方案:对服务项目列表这类读多写少的数据做内存缓存。实现方式很简单,在 Service 层用一个 ConcurrentHashMap 做缓存,定时刷新,当管理员上下架服务时主动清掉缓存。代码大致是这样:

@Service public class ServiceItemService { // 读多写少的场景,用内存缓存降低数据库压力 private final ConcurrentHashMap<Long, ServiceItem> cache = new ConcurrentHashMap<>(); private volatile long lastRefresh = 0L; private static final long EXPIRE_MS = 60 * 1000; public List<ServiceItem> listOnSale() { refreshIfExpired(); return cache.values().stream() .filter(item -> item.getStatus() == 1) .collect(Collectors.toList()); } private void refreshIfExpired() { long now = System.currentTimeMillis(); if (now - lastRefresh > EXPIRE_MS) { List<ServiceItem> items = itemMapper.findAll(); cache.clear(); items.forEach(item -> cache.put(item.getId(), item)); lastRefresh = now; } } }

这个方案的思路是:服务项目表数据量小、变更频率低,每分钟最多查一次数据库就够了,其余请求直接走内存。答辩时讲这个方案,一句话就能说清价值:读多写少的场景用缓存降低数据库压力。这比硬套一个 Redis 进去更经得起追问,因为你可以把缓存原理、失效策略、并发安全讲透,而 Redis 如果只是引入却没解决实际问题,很容易被问住。

我自己的教训是:毕业设计做得好的同学,不是用了多新潮的技术,而是把一条主流程从下单到服务完成做到没有任何逻辑缺口,再把异常路径补上,最后让系统能讲出自洽的业务故事。把这三件事做完,比纠结框架版本重要得多。希望帮到你。

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

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

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

立即咨询