资讯动态

SSM+Vue+微信小程序健康管理系统的前后端开发实战

发布时间:2026/9/16 9:18:02 来源:尧图企业网站定制
简介基于 Java SSM 与 Vue 的微信小程序健康管理系统源码面向 Java 后端、Vue 前端及微信小程序开发者也适合毕业设计或课程项目参考。系统采用 SpringBoot/SSM 搭建后端MySQL 持久化数据Vue 与小程序端负责页面展示与交互可覆盖健康数据录入、查看与管理等常见场景帮助初学者理解前后端分离项目的完整结构。资源共 1137 个文件、大小 16.61MB包含 112 个 Java 源码、126 个 Vue 组件、154 个 JS 脚本、66 个 WXML 页面、62 个 WXSS 样式另有 SQL 数据库脚本、Maven 依赖配置、PNG/SVG 图标图片与 bat 构建运行脚本等目录划分清晰便于按模块阅读。已有 256 人学习下载。通过源码可快速掌握 SSM 与小程序端的接口对接方式、Vue 管理后台的页面组织方法以及 MySQL 表结构设计思路配套的 1-install、2-run、3-build 脚本也有助于本地一键搭建运行环境适合二次开发或系统学习。1. 健康管理系统为什么还选 SSM Vue 微信小程序这套组合健康管理类小程序这两年的需求密度很高从个人健康档案、体检报告解读到慢病随访本质上都是「用户端采集数据 管理端审核干预」的双端结构。后台管理用 Vue 搭单页应用用户端跑在微信小程序里中间由 SSM 提供 REST API——这套组合不是最时髦的却是目前中小型健康管理项目里交付速度最快、招人最容易、部署成本最低的方案之一。SSM 的 Spring 管业务对象、SpringMVC 管路由、MyBatis 管 SQL三层边界清楚业务逻辑写在 Service 里Controller 只做参数接收和响应封装对健康数据这种需要频繁调整查询口径的领域特别合适。Vue 负责后台的档案管理、数据看板、干预任务配置小程序负责用户的每日打卡、指标录入和报告查看。前后端通过 JSON 交互鉴权用 token不走 session天然适配小程序的无 cookie 环境。下面按「数据层 → 接口层 → 小程序端 → 管理后台联调」的顺序把一条完整链路上的代码和参数讲透。2. SSM 数据层设计健康档案、指标记录与检查报告的 MySQL 表结构2.1 健康管理系统的核心实体拆分健康管理系统的领域模型比普通业务系统多一层「时序」属性。用户的基本信息是静态的但血压、血糖、心率、体重这些指标是持续产生的流式数据。在设计表时要把「用户档案」「指标记录」「报告文件」分开避免把多值属性塞进用户表。常见的表划分是这样的user用户表openid、昵称、头像、性别、出生日期、身高、既往病史family_member家庭成员表用户可能给父母、孩子建档需要 member 维度health_record指标记录表type 区分血压/血糖/心率/体重value 存数值unit 冗余单位health_report报告表存放体检报告、检验单的图片 URL 和解析结果health_remind提醒计划表记录随访提醒、用药提醒的配置这里特别要注意health_record表的设计。很多第一次做健康类项目的开发者会把收缩压、舒张压、血糖值各设计成独立字段导致后续每加一种指标就要改表结构。更合理的做法是用type value unit的纵表结构新增指标类型只加枚举不动表结构。CREATE TABLE health_record ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 用户ID, member_id bigint(20) DEFAULT NULL COMMENT 家庭成员IDNULL表示本人, record_type varchar(20) NOT NULL COMMENT 指标类型blood_pressure/blood_sugar/heart_rate/weight, record_value varchar(50) NOT NULL COMMENT 指标值血压存120/80血糖存5.6, unit varchar(10) DEFAULT NULL COMMENT 单位mmHg/mmol/L/bpm/kg, measure_time datetime NOT NULL COMMENT 测量时间, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_type_time (user_id, record_type, measure_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这条建表语句的关联键和联合索引是重点。idx_user_type_time覆盖了查询中最常见的条件组合「某个人的某类指标按时间排序」业务层做趋势图、统计最近 7 天平均值都走这个索引。record_value用 varchar 而不是 decimal是因为血压值本身就是一个120/80的字符串统一用 varchar 可以兼容不同指标的数据形态。2.2 MyBatis 动态 SQL按家庭成员过滤记录列表SSM 里的数据访问层用 MyBatis 的 XML 来管理 SQL特别是健康记录列表这种需要组合查询条件的接口注解方式写起来长串的script很难维护。XML 里用where和if标签拼条件是 MyBatis 最常用的组合。select idselectRecordList resultTypemap SELECT id, user_id, member_id, record_type, record_value, unit, measure_time FROM health_record where if testuserId ! null AND user_id #{userId} /if if testmemberId ! null AND member_id #{memberId} /if if testrecordType ! null and recordType ! AND record_type #{recordType} /if if teststartTime ! null AND measure_time gt; #{startTime} /if if testendTime ! null AND measure_time lt; #{endTime} /if /where ORDER BY measure_time DESC LIMIT #{offset}, #{pageSize} /select这里有两个容易踩的坑。第一个是 XML 里的小于号必须转义成lt;否则 XML 解析直接报错。第二个是分页用的#{offset}和#{pageSize}是预编译参数不会引起 SQL 注入但如果用${}拼接就必须做白名单校验。MyBatis 的${}是字符串拼接#{}是占位符在分页、排序字段这类场景里要格外小心。2.3 Service 层处理健康数据的业务规则记录指标数据时存在一个常见业务要求血压超过 140/90 或血糖超过 7.0 时要自动给用户生成一条预警。这个逻辑放在 Service 层而不是数据库触发器里原因是可以复用 Spring 的声明式事务同时把预警规则独立成方法方便后期调整阈值。Service public class HealthRecordService { Autowired private HealthRecordMapper healthRecordMapper; Autowired private HealthRemindService healthRemindService; Transactional(rollbackFor Exception.class) public void addRecord(HealthRecord record) { healthRecordMapper.insert(record); // 血压大于等于140/90或血糖大于等于7.0时生成预警提醒 if (isAbnormal(record)) { healthRemindService.generateWarn(record.getUserId(), record.getRecordType(), record.getRecordValue(), record.getMeasureTime()); } } private boolean isAbnormal(HealthRecord record) { if (blood_pressure.equals(record.getRecordType())) { String[] parts record.getRecordValue().split(/); return Integer.parseInt(parts[0]) 140 || Integer.parseInt(parts[1]) 90; } if (blood_sugar.equals(record.getRecordType())) { return Double.parseDouble(record.getRecordValue()) 7.0; } return false; } }这段代码关键是Transactional(rollbackFor Exception.class)。Spring 默认只回滚 RuntimeException如果generateWarn抛出的是 checked exception不加rollbackFor会导致记录插入成功但预警生成失败数据不一致。实际项目里建议把预警逻辑改成异步消息先保证主流程写入成功再通过 MQ 或定时任务补偿预警避免接口响应时间被无关逻辑拖长。3. 打通微信小程序登录与 SSM 接口鉴权从 code 换 session 到自定义 token3.1 微信小程序端 wx.login 获取 code 的时序微信小程序的特点是天然无 cookie每次请求都带不了 session id所以登录态必须自己管理。整体流程是小程序调用wx.login()拿到临时 code把 code 发给后端后端拿着 code appid secret 去微信的接口换 openid拿到 openid 后查用户表存在则直接登录不存在则自动注册最后后端生成一个 token 返回给小程序后续所有请求都在 header 里带这个 token。小程序端的代码逻辑// pages/login/login.js wx.login({ success: (res) { if (res.code) { wx.request({ url: https://api.example.com/api/auth/login, method: POST, data: { code: res.code }, success: (resp) { const { token, userInfo } resp.data.data; wx.setStorageSync(token, token); wx.setStorageSync(userInfo, userInfo); wx.switchTab({ url: /pages/index/index }); } }); } else { console.error(登录失败, res.errMsg); } } });注意wx.login的 code 有效期只有 5 分钟且只能用一次。如果后端返回 401小程序端需要重新调用wx.login刷新 code不能直接重放旧 code。这是一个很容易忽略的细节首次调试联调时一半的 401 错误都是这个原因。3.2 SSM 后端接收 code 并换 openid 的完整实现后端接收 code 后调用微信接口这里不要用 RestTemplate 的默认实现。健康管理项目可能涉及大量用户并发登录连接池配置不合理会把微信接口的调用拖垮。建议使用带连接池的 HttpClient或者至少给 RestTemplate 设置合理的超时时间。RestController RequestMapping(/api/auth) public class AuthController { Autowired private UserService userService; Autowired private StringRedisTemplate stringRedisTemplate; PostMapping(/login) public Result login(RequestBody LoginRequest request) { // 1. 用 code 换 openid String url https://api.weixin.qq.com/sns/jscode2session?appid appid secret secret js_code request.getCode() grant_typeauthorization_code; String response HttpUtil.get(url); JSONObject json JSONObject.parseObject(response); String openid json.getString(openid); if (StringUtils.isBlank(openid)) { return Result.error(code 无效或已过期); } // 2. 查用户不存在则注册 User user userService.findByOpenId(openid); if (user null) { user new User(); user.setOpenid(openid); user.setCreateTime(new Date()); userService.register(user); } // 3. 生成 token存 Redis 并设置过期时间 String token UUID.randomUUID().toString().replace(-, ); stringRedisTemplate.opsForValue().set(token: token, String.valueOf(user.getId()), 7, TimeUnit.DAYS); return Result.success(new HashMapString, Object() {{ put(token, token); put(userInfo, user); }}); } }代码里生成 token 用的 UUID实际生产建议换成 JWT 或带用户标识的签名 token方便在网关层直接解析用户身份不用每次查 Redis。用 Redis 存 token 的好处是可以在管理后台一键踢人下线缺点是每次请求多一次 Redis 查询。用 JWT 则反过来天然无状态但无法主动失效。健康管理项目因为有隐私数据需要支持用户注销和异常登录处理我一般偏向 Redis 方案。3.3 SpringMVC 拦截器实现 token 鉴权token 的校验不能散落在各个 Controller 里用 SpringMVC 的拦截器统一处理是 SSM 项目的标准做法。拦截器里先判断请求路径是否在白名单内再解析 header 中的 token。public class AuthInterceptor implements HandlerInterceptor { Autowired private StringRedisTemplate stringRedisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求否则前端跨域请求会失败 if (HttpMethod.OPTIONS.toString().equals(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (StringUtils.isBlank(token) || !token.startsWith(Bearer )) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未登录或token已过期\}); return false; } token token.substring(7); String userId stringRedisTemplate.opsForValue().get(token: token); if (StringUtils.isBlank(userId)) { response.setStatus(401); response.getWriter().write({\code\:401,\msg\:\token无效\}); return false; } // 把 userId 放入 requestController 里直接获取 request.setAttribute(userId, Long.parseLong(userId)); return true; } }这个拦截器里有三个要注意的细节。第一是OPTIONS预检请求必须放行否则 Vue 管理后台的跨域请求在浏览器层就被拦掉了。第二是返回 401 时 Content-Type 要设置成application/json;charsetUTF-8否则前端拿到的 response 是一段 HTML 解析报错。第三是拦截器里的stringRedisTemplate注入在 SpringMVC 的配置里要开启注解扫描很多项目在这里漏配导致 NPE。注册拦截器还需要在 SpringMVC 的配置文件里声明mvc:interceptors mvc:interceptor mvc:mapping path/api/**/ mvc:exclude-mapping path/api/auth/login/ mvc:exclude-mapping path/api/auth/register/ bean classcom.example.interceptor.AuthInterceptor/ /mvc:interceptor /mvc:interceptors路径映射的坑在于/api/**的写法。/api/*只匹配一级路径/api/**才能匹配所有子路径。如果 Controller 的 RequestMapping 嵌套了两层以上的路径用单星号会导致拦截器不生效这是一个非常隐蔽的问题接口明明报了 401 却排查不到拦截器逻辑。4. 微信小程序端健康数据的采集、提交与趋势展示4.1 封装 request 请求自动携带 token 和统一错误处理小程序端每次请求都要手动在 header 里加 token 会写大量重复代码。常见的做法是封装一个request.js工具在请求拦截器里统一加 token在响应拦截器里统一处理 401 跳转登录。// utils/request.js const request (url, method, data) { return new Promise((resolve, reject) { const token wx.getStorageSync(token); wx.request({ url: getApp().globalData.baseUrl url, method: method || GET, data: data || {}, header: { Content-Type: application/json, Authorization: token ? Bearer token : }, success: (res) { if (res.statusCode 401) { wx.removeStorageSync(token); wx.navigateTo({ url: /pages/login/login }); reject(new Error(未登录)); return; } if (res.data.code 200) { resolve(res.data.data); } else { wx.showToast({ title: res.data.msg || 请求失败, icon: none }); reject(new Error(res.data.msg)); } }, fail: (err) { wx.showToast({ title: 网络异常, icon: none }); reject(err); } }); }); }; module.exports request;这里有个值得注意的坑微信小程序的wx.request在 HTTPS 证书过期或域名未配置时不会走fail回调而是直接报 errno。调试阶段一定要在微信开发者工具里勾选「不校验合法域名」才能看到真实接口报错。生产环境则必须在公众平台配置 request 合法域名否则小程序发布后所有请求都会被拦截。4.2 小程序端提交健康指标数据的页面逻辑健康记录的提交流程中血压和血糖的输入交互不同。血压需要两个输入框分别填收缩压和舒张压然后拼成120/80的字符串血糖只需要一个输入框。小程序端的页面逻辑如下// pages/record/record.js const request require(../../utils/request); Page({ data: { recordType: blood_pressure, systolic: , diastolic: , bloodSugar: , measureTime: , typeOptions: [ { label: 血压, value: blood_pressure }, { label: 血糖, value: blood_sugar }, { label: 心率, value: heart_rate }, { label: 体重, value: weight } ] }, onLoad() { // 默认测量时间为当前时间 this.setData({ measureTime: this.formatTime(new Date()) }); }, onTypeChange(e) { this.setData({ recordType: e.detail.value }); }, submitRecord() { const { recordType, systolic, diastolic, bloodSugar, measureTime } this.data; let recordValue ; if (recordType blood_pressure) { if (!systolic || !diastolic) { wx.showToast({ title: 请完整填写收缩压和舒张压, icon: none }); return; } recordValue systolic / diastolic; } else if (recordType blood_sugar) { if (!bloodSugar) { wx.showToast({ title: 请输入血糖值, icon: none }); return; } recordValue parseFloat(bloodSugar).toFixed(1); } request(/api/health/record, POST, { recordType: recordType, recordValue: recordValue, measureTime: measureTime }).then(() { wx.showToast({ title: 保存成功, icon: success }); // 跳转回首页刷新列表 wx.navigateBack(); }); } });页面里没有列举心率和体重的分支实际项目中心率和体重是纯数字输入逻辑比血压简单很多。把提交逻辑都写在submitRecord里代码会越来越臃肿更好的做法是把不同类型的校验规则抽到配置表里type 对应 validator 函数方便新增指标类型时不动页面主逻辑。4.3 用 ec-canvas 图表组件展示健康趋势健康管理的核心价值之一是趋势可视化小程序端最常用的是 echarts 的微信小程序定制版ec-canvas组件。在pages/trend/trend.json里引入组件然后在页面中配置图表数据。// pages/trend/trend.js import * as echarts from ../../ec-canvas/echarts; Page({ data: { ec: { onInit: (canvas, width, height, dpr) { const chart echarts.init(canvas, null, { width, height, devicePixelRatio: dpr }); canvas.setChart(chart); this.chart chart; return chart; } } }, onShow() { this.loadTrendData(); }, loadTrendData() { const request require(../../utils/request); request(/api/health/trend?typeblood_pressuredays7).then((data) { this.chart.setOption({ xAxis: { type: category, data: data.dates }, yAxis: { type: value, name: mmHg }, series: [{ name: 收缩压, type: line, data: data.systolicList, smooth: true, areaStyle: { opacity: 0.2 } }, { name: 舒张压, type: line, data: data.diastolicList, smooth: true }] }); }); } });ec-canvas 的一个常见坑是组件的 canvas 宽高由父容器决定ec-canvas标签的外层 view 必须显式设置宽高否则图表渲染出来是 0 高度。另外在页面初次渲染时onInit里的this.chart可能还没有赋值如果要异步请求数据后setOption需要保证onLive周期里 chart 已经初始化完成。5. Vue 管理后台的接口对接与联调排错技巧5.1 axios 实例封装与请求拦截Vue 管理后台负责健康档案管理、用户列表、指标预警配置等操作。接口对接的第一步是封装 axios 实例统一 baseURL、超时时间和 token 携带。// src/utils/request.js import axios from axios; const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 15000 }); service.interceptors.request.use(config { const token localStorage.getItem(admin_token); if (token) { config.headers[Authorization] Bearer token; } return config; }, error { return Promise.reject(error); }); service.interceptors.response.use(response { const res response.data; if (res.code ! 200) { // 业务错误统一提示 Message.error(res.msg || 请求失败); return Promise.reject(new Error(res.msg)); } return res.data; }, error { // HTTP 状态码错误处理 if (error.response error.response.status 401) { localStorage.removeItem(admin_token); router.push(/login); } Message.error(error.message || 网络异常); return Promise.reject(error); }); export default service;这里的VUE_APP_BASE_API是环境变量开发环境配/dev-api通过 vue.config.js 的 devServer.proxy 代理到后端地址生产环境直接配正式域名这样就规避了跨域问题。不要把完整的后端地址直接写在 axios 的 baseURL 里前后端分离项目一旦后端换端口所有请求地址都要改代理模式只需要改一处配置。5.2 Vue 管理后台跨域与代理配置SSM 后端一般跑在 8080 端口Vue 开发服务器跑在 9528 或 3000没有代理配置的情况下请求必然跨域。在vue.config.js里配置代理是最省事的方案。// vue.config.js module.exports { devServer: { port: 9528, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: /api } } } } };changeOrigin: true这个参数容易被忽略。后端如果做了域名白名单校验代理不修改 Origin 头就会导致请求被拒。另外pathRewrite的使用需要和后端实际路径对齐有些团队后端接口路径本身带/api前缀有些不带这里写错会出现 404 但浏览器 Network 面板里请求看起来完全正常的情况。5.3 SSM 接口联调中的 4 个典型错误排查联调阶段遇到最多的问题集中在数据类型、日期格式、跨域和字段命名四个方向。整理成排查表比反复打断后端同事更高效。现象可能原因排查方式前端传数字后端接到的却是 nullJSON 字段名不一致或类型转换失败对比前端请求 payload 和后端实体字段名SSM 默认 JSON 序列化要求字段完全匹配时间字段差了 8 小时后端没用 Jackson 的时区配置默认 GMT在 SpringMVC 配置里设置spring.jackson.time-zoneGMT8请求能到达后端但拿不到返回值响应 header 缺少跨域配置在后端加 CorsFilter 或用CrossOrigin注解列表接口返回的字段全部变成首字母大写后端实体没有JsonProperty且前端期望小写统一用 Lombok 的Data或给字段加别名这里的时区问题是 SSM 项目的经典坑。MySQL 的 datetime 类型不带时区后端通过 JDBC 读取日期时用 JVM 默认时区前端 JS 的new Date()用的是浏览器时区一旦服务器时区和本地不一致就会看到 8 小时偏移。排查时先看后端接口直接返回的 JSON 里的时间字段是否正确再去前端找问题不要一上来改数据库。5.4 用 VConsole 或 Charles 抓包验证小程序请求小程序端的调试比 Web 端麻烦开发者工具的 Network 面板只显示基础请求信息看不到请求体和响应体的详细耗时。生产环境的问题排查可以用微信自带的 vConsole在app.js里引入后手机上点击右下角的绿色按钮就能看到 console 日志和网络请求详情。// app.js const vConsole require(./utils/vconsole.min.js); App({ onLaunch() { if (config.env production) { new vConsole(); } } });vConsole 在正式环境默认会隐藏入口需要点击悬浮按钮打开。注意小程序发布版本不要带 vConsole否则用户也能看到请求日志有信息泄露风险。更稳妥的方式是在构建时通过环境变量控制是否引入。6. 最后一块拼图用聚合查询把健康数据变成管理后台可用的趋势报表管理后台的健康报表不需要在小程序端反复请求明细数据正确做法是让 SSM 后端直接提供聚合接口通过 SQL 的日期函数完成分组统计。一次查询返回 7 天的日均值比小程序端拉取 200 条记录再前端算平均值节省 95% 的流量和时间。SELECT DATE_FORMAT(measure_time, %Y-%m-%d) AS date, ROUND(AVG(CASE WHEN record_type blood_pressure THEN CAST(SUBSTRING_INDEX(record_value, /, 1) AS DECIMAL(5,1)) END), 1) AS avg_systolic, ROUND(AVG(CASE WHEN record_type blood_pressure THEN CAST(SUBSTRING_INDEX(record_value, /, -1) AS DECIMAL(5,1)) END), 1) AS avg_diastolic, ROUND(AVG(CASE WHEN record_type blood_sugar THEN CAST(record_value AS DECIMAL(5,1)) END), 2) AS avg_blood_sugar FROM health_record WHERE user_id #{userId} AND measure_time #{startTime} AND measure_time #{endTime} GROUP BY DATE_FORMAT(measure_time, %Y-%m-%d) ORDER BY date ASC;这条 SQL 里用到了三个 MySQL 函数SUBSTRING_INDEX拆分血压的分子分母CASE WHEN在多指标记录表里做列转置DATE_FORMAT按天分组。注意ROUND的第二个参数控制小数位血压保留 1 位、血糖保留 2 位字段含义不同精度就不能统一。这个查询的索引策略也值得提一下WHERE条件里的user_id和measure_time正好命中前文建的联合索引但在GROUP BY里使用DATE_FORMAT之后索引对排序不再有效数据量大时可以考虑把日期冗余一列存成 date 类型再做普通分组。后端 Mapper 对应的接口方法和 Service 调用的关键在返回类型的处理MyBatis 查询多条记录加聚合字段时最方便的方式是直接返回ListMapString, Object避免为每张报表建 VO 类。public interface HealthRecordMapper { ListMapString, Object selectDailyTrend(Param(userId) Long userId, Param(startTime) Date startTime, Param(endTime) Date endTime); }Controller 层把Map里的日期字段转成前端需要的字符串格式后直接返回。Map的 key 是 SQL 里的别名Vue 端拿到avg_systolic后做折线图的数据源就够了。趋势报表接口的关键是让 SQL 完成全部聚合Java 层只做透传不要把GROUP BY或AVG写在内存里那样 JVM 的 GC 压力会随用户量线性增长SQL 数据库天然擅长这类统计。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价