1. 项目到底在解决什么问题,别急着写代码
1.1 社区养老场景里的真实痛点
先说清楚这个项目的业务背景。做社区老人健康信息管理系统,不是简单地给老人建一个电子档案就完事了。社区养老和医院看病不一样,医院是“等人生病再来治”,社区健康管理是“主动盯住老人的健康状态,尽量把问题挡在去医院之前”。这就决定了系统的核心逻辑不是挂号、开药、缴费那一套,而是围绕“档案—体检—评估—干预—随访”这条链路来设计。
我接手这类项目时,走访过不少社区养老服务中心,发现最通用的痛点是这三个:
- 老人健康数据散落在纸质表格、Excel、微信聊天记录里,想查一份完整历史记录,能翻半天。
- 社区工作人员要同时服务几百位老人,谁该复测血压、谁该去接种疫苗、谁的高危指标连续异常,完全靠脑子记,漏掉是大概率事件。
- 家属想了解父母近况,只能打电话问工作人员,沟通成本高,信息还不一定准确。
所以这个系统的核心价值,就是把“散、乱、靠人记”变成“集中、结构化、自动提醒”。管理员统一管,社区医护录数据,家属可以查看到老人的基本健康和体检信息,系统自动做异常预警和用药提醒。这个定位想清楚了,后面所有表和接口的设计就有了依据。
1.2 系统角色与功能模块划分
我习惯在设计表结构之前,先把角色和功能模块定下来。这个系统里我分了三个角色:
- 系统管理员:维护社区、楼栋、工作人员账号,管理全局数据字典,查看统计报表。
- 社区健康管理员/医护人员:维护老人基本信息,录入体检记录、慢病随访记录、用药记录,处置系统预警。
- 老人家属:通过绑定关系查看老人的健康档案、体检报告、用药计划,接收健康提醒。
围绕这三个角色,功能模块拆成下面这几块:
| 模块 | 核心功能 | 对应的核心表 |
|---|---|---|
| 老人档案管理 | 老人基本信息、紧急联系人、居住信息、既往病史 | elder_info、elder_contact |
| 健康体检管理 | 体检记录录入、历史趋势对比、指标异常标记 | health_check_record |
| 慢病管理 | 高血压/糖尿病等慢病建档、定期随访、控制情况评估 | chronic_disease、follow_up_record |
| 用药管理 | 用药计划、服药提醒、依从性记录 | medication_plan、medication_log |
| 预警中心 | 指标超标提醒、漏检提醒、异常数据汇总 | health_alert |
| 家属管理 | 家属绑定、授权查看、消息通知 | family_binding、notification |
| 系统管理 | 用户、角色、菜单、字典、日志 | sys_user、sys_role、sys_menu、sys_dict |
业务模块就是这些,看起来不复杂,但真正做起来,细节非常多。比如“体检记录”里血压高压低压、空腹血糖、总胆固醇这些指标,不同年龄段、不同慢病状态的参考范围都不一样,预警规则不能写死。这个我放到后面第3章详细讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型解析:为什么偏偏是这套组合
2.1 后端选型:SpringBoot2不是落后,是成熟
很多人一看到SpringBoot2会觉得“过时了”,其实在社区养老、政务、医疗这类对稳定性要求极高的场景里,SpringBoot2.7.x仍然是非常主流的选择。SpringBoot3要求JDK17起步,而很多甲方服务器的JDK还停留在8,你强行上SpringBoot3,光适配旧环境就够喝一壶。SpringBoot2.7.x配合JDK8,是目前Java Web项目里兼容性最稳的搭配之一,生态也最成熟,网上资料多,遇到问题基本都能搜到解决方案。
在这个项目里,SpringBoot2主要负责三件事:提供RESTful接口给前端调用;整合MyBatis-Plus操作MySQL;处理JWT登录鉴权和全局异常。我没有引入太复杂微服务那套东西,单体应用足够支撑这个业务规模,运维成本也低。
2.2 前端选型:Vue3组合式API给项目带来了什么
前一段时间Vue3已经完全成为新项目的默认选择了。相比Vue2,Vue3在TypeScript支持、组合式API、响应式性能上都有明显提升。这个项目我用了Vue3 + Vite + Pinia + Element Plus这套组合,开发体验比Vue2时代好很多。
组合式API最大的好处,是把“同一业务逻辑的代码”聚合到一起。比如老人档案页面,以前Vue2的Options API要把数据、计算属性、方法分别放在data/computed/methods里,改一个功能要上下跳好几个区块。Vue3的setup函数里,我可以把老人的加载、体检记录的刷新、预警状态的切换全部写在一个函数块里,维护起来非常直观。
Vite代替Webpack之后,冷启动和热更新速度快了一个量级。做前端页面的体验改善特别明显,改完代码秒级刷新,不用等那几秒甚至十几秒的编译。
2.3 数据层选型:MyBatis-Plus和MySQL8.0的配合
MyBatis-Plus是MyBatis的增强工具,它没有改变MyBatis本身,只是提供了通用的CRUD方法、条件构造器、分页插件、代码生成器这些能力。在这个项目里,单表的基本增删改查我几乎不用写SQL,直接用BaseMapper提供的方法加上LambdaQueryWrapper就能完成。只有多表关联查询、复杂统计报表才手写SQL。
MySQL8.0主要用了几个特性:
- 窗口函数,统计健康趋势、计算环比增长率很好用;
- JSON类型,存储体检指标的多值扩展数据很方便;
- utf8mb4字符集,完整支持生僻字和emoji,老人姓名里有生僻字也能正常存储。
这套组合的搭建成本很低,非常适合中小型管理系统的快速交付。如果你正在学习Java Web开发,或者要做一个毕业设计、接一个中小型外包项目,这套技术栈的参考价值很高。
3. 后端核心实现细节:从建表到接口的完整思考
3.1 数据库设计:老人健康管理系统如何建表才不踩坑
先给出一份我在实际项目中打磨过的核心表结构设计思路。数据库名我用elder_health,统一使用InnoDB引擎、utf8mb4字符集。
老人主表elder_info是这样的:
sql复制CREATE TABLE elder_info (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
elder_no VARCHAR(32) NOT NULL COMMENT '老人编号,规则:SQ+年份+四位流水',
name VARCHAR(64) NOT NULL COMMENT '姓名',
gender TINYINT NOT NULL DEFAULT 0 COMMENT '性别 0-未知 1-男 2-女',
birth_date DATE COMMENT '出生日期',
id_card VARCHAR(18) COMMENT '身份证号',
phone VARCHAR(20) COMMENT '联系电话',
address VARCHAR(255) COMMENT '现居住地址',
community_id BIGINT COMMENT '所属社区ID',
building_no VARCHAR(32) COMMENT '楼栋号',
unit_no VARCHAR(32) COMMENT '单元号',
room_no VARCHAR(32) COMMENT '门牌号',
marital_status TINYINT COMMENT '婚姻状况',
living_status TINYINT COMMENT '居住情况 1-独居 2-与配偶 3-与子女 4-其他',
pension_type TINYINT COMMENT '养老方式 1-居家养老 2-社区日间照料 3-机构养老',
past_history TEXT COMMENT '既往病史,逗号分隔',
allergy_history TEXT COMMENT '过敏史',
emergency_contact VARCHAR(64) COMMENT '紧急联系人姓名',
emergency_phone VARCHAR(20) COMMENT '紧急联系人电话',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1-正常 0-注销',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除',
KEY idx_community (community_id),
KEY idx_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='老人基本信息表';
几个关键设计决策要解释一下:
elder_no用“SQ+年份+流水号”的规则生成,比如SQ2025010001。不要直接用自增ID给老人做业务编号,因为ID暴露在外面对外展示时既不专业也可能被遍历抓数据。- 身份证号只存18位,不做加密存储,但接口返回时必须脱敏,只显示前3位和后4位。这个在接口层统一处理,不要依赖前端。
- 紧急联系人单独设计为
elder_contact表,不放在主表里,因为一个老人可能有多个紧急联系人,放主表就搞成一对多了。
体检记录表health_check_record是数据增长最猛的表,设计时把频繁查询的指标字段单独提出来,扩展类的指标用JSON存:
sql复制CREATE TABLE health_check_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
elder_id BIGINT NOT NULL COMMENT '老人ID',
check_date DATE NOT NULL COMMENT '体检日期',
check_type TINYINT NOT NULL DEFAULT 1 COMMENT '体检类型 1-年度体检 2-季度随访 3-日常自测',
systolic_pressure INT COMMENT '收缩压mmHg',
diastolic_pressure INT COMMENT '舒张压mmHg',
fasting_blood_glucose DECIMAL(5,2) COMMENT '空腹血糖mmol/L',
total_cholesterol DECIMAL(5,2) COMMENT '总胆固醇mmol/L',
triglycerides DECIMAL(5,2) COMMENT '甘油三酯mmol/L',
weight DECIMAL(5,2) COMMENT '体重kg',
height DECIMAL(5,2) COMMENT '身高cm',
heart_rate INT COMMENT '心率次/分',
extra_data JSON COMMENT '扩展指标JSON,如肝功肾功等',
check_org VARCHAR(128) COMMENT '体检机构',
doctor_name VARCHAR(64) COMMENT '体检医生',
summary TEXT COMMENT '小结',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_elder_date (elder_id, check_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康体检记录表';
提示:如果你用MyBatis-Plus的
@TableLogic做逻辑删除,注意唯一索引的设计。比如schema_version这种需要唯一的字段,逻辑删除后再次插入会撞唯一索引。我一般用UNIQUE KEY (elder_id, check_date, deleted)这种带deleted的联合索引,靠deleted的0/1值绕开冲突,但要注意同一个elder只能有一条deleted=0的记录,需要业务上保证。
慢病随访表follow_up_record也是关键表,它记录高血压、糖尿病等慢病的定期随访情况。每个字段的随访结果我都会落一个control_status(控制良好/控制一般/控制不佳),方便后续统计慢病控制率——这是社区健康管理考核里非常重要的指标。
3.2 MyBatis-Plus使用要点:减少样板代码但不失控
MyBatis-Plus在这个项目里主要用在三个地方:通用CRUD、条件构造器、分页插件。
通用CRUD里最常用的是ServiceImpl和IService。我习惯所有业务Service继承ServiceImpl<Mapper, Entity>,这样save、updateById、page这些方法直接能用。比如新增一条体检记录:
java复制@Transactional(rollbackFor = Exception.class)
public void addCheckRecord(HealthCheckRecord record) {
// 1. 参数校验:老人必须存在且未注销
ElderInfo elder = elderInfoMapper.selectById(record.getElderId());
if (elder == null || elder.getStatus() != 1) {
throw new BusinessException("老人不存在或已注销");
}
// 2. 保存体检记录
healthCheckRecordMapper.insert(record);
// 3. 根据体检指标生成健康预警
generateAlertIfNeeded(record);
}
条件构造器里面,LambdaQueryWrapper用得最多,它最大的好处是编译期就能发现字段名拼写错误,不会等到运行时才报错。比如按社区查询老人列表并带上分页:
java复制public Page<ElderInfoVO> queryElderPage(ElderQueryDTO dto) {
Page<ElderInfo> page = new Page<>(dto.getPageNum(), dto.getPageSize());
LambdaQueryWrapper<ElderInfo> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(StringUtils.hasText(dto.getCommunityId()), ElderInfo::getCommunityId, dto.getCommunityId())
.like(StringUtils.hasText(dto.getName()), ElderInfo::getName, dto.getName())
.eq(dto.getGender() != null, ElderInfo::getGender, dto.getGender())
.eq(ElderInfo::getStatus, 1)
.orderByDesc(ElderInfo::getCreateTime);
Page<ElderInfo> result = elderInfoMapper.selectPage(page, wrapper);
// 转VO、脱敏、返回
}
分页插件配置是个容易漏掉的点。我见过很多初学者引了MyBatis-Plus分页依赖但没配置PaginationInnerInterceptor,结果selectPage返回的所有记录还是全表数据,分页完全没生效。正确的配置如下:
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 注意:第二个参数是数据库类型,MySQL就是DbType.MYSQL
PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL);
pagination.setMaxLimit(500L); // 单页最大500条,防止恶意大分页拖垮数据库
interceptor.addInnerInterceptor(pagination);
return interceptor;
}
}
还有@TableField(fill = FieldFill.INSERT)自动填充创建时间这种基础能力,必须配合MetaObjectHandler实现类才生效,不然createTime永远是null。这个坑我也踩过。
3.3 健康预警与统计报表:业务逻辑的核心难点
我认为这个系统最有分量的业务逻辑不是CRUD,而是健康预警的规则设计和统计报表。
健康预警规则的实现,我用的是一个策略模式。先把预警规则固化成枚举,再按类型解析判断:
java复制public enum AlertRuleType {
BLOOD_PRESSURE, // 血压
BLOOD_SUGAR, // 血糖
CHOLESTEROL, // 血脂
MISSED_CHECK, // 漏检
MEDICATION, // 用药异常
}
血压判断规则举个例子:
java复制public List<HealthAlert> checkBloodPressure(HealthCheckRecord record) {
List<HealthAlert> alerts = new ArrayList<>();
if (record.getSystolicPressure() == null || record.getDiastolicPressure() == null) {
return alerts;
}
int systolic = record.getSystolicPressure();
int diastolic = record.getDiastolicPressure();
// 正常高值:收缩压130-139或舒张压85-89
// 高血压1级:140-159或90-99
// 高血压2级:>=160或>=100
if (systolic >= 180 || diastolic >= 110) {
alerts.add(HealthAlert.danger(record.getElderId(), "血压异常危急值",
String.format("收缩压%dmmHg,舒张压%dmmHg,请立即联系家属并安排就医", systolic, diastolic)));
} else if (systolic >= 160 || diastolic >= 100) {
alerts.add(HealthAlert.warning(record.getElderId(), "血压异常偏高",
String.format("收缩压%dmmHg,舒张压%dmmHg,建议短期内复测", systolic, diastolic)));
}
return alerts;
}
注意:预警规则里的阈值只能作为参考,真正做医疗级判断必须要有医生参与校准。系统定位是健康管理辅助工具,不是诊断工具。我在代码里这个意图写得很清楚,每个预警后面都会带一句“请以社区医生评估为准”。
统计报表这块,我用MySQL8.0的窗口函数来做趋势分析,比如统计某社区最近6个月老人体检参与率的变化:
sql复制SELECT
DATE_FORMAT(check_date, '%Y-%m') AS month,
COUNT(DISTINCT elder_id) AS check_count,
LAG(COUNT(DISTINCT elder_id), 1) OVER (ORDER BY DATE_FORMAT(check_date, '%Y-%m')) AS prev_count,
ROUND((COUNT(DISTINCT elder_id) - LAG(COUNT(DISTINCT elder_id), 1)
OVER (ORDER BY DATE_FORMAT(check_date, '%Y-%m'))) /
NULLIF(LAG(COUNT(DISTINCT elder_id), 1)
OVER (ORDER BY DATE_FORMAT(check_date, '%Y-%m')), 0) * 100, 2) AS growth_rate
FROM health_check_record
WHERE check_date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
GROUP BY DATE_FORMAT(check_date, '%Y-%m');
窗口函数的LAG在这里很方便,一条SQL就把环比增长率算出来了。要是放到应用层做,你还得先查上个月数据再在Java里算,麻烦得多。
3.4 接口设计:统一返回、全局异常和JWT
接口设计这块,我坚持几个原则:统一返回结构、统一异常处理、统一鉴权。
统一返回结构我用一个Result<T>类,包含code、message、data三个字段。业务成功返回Result.success(data),业务失败抛BusinessException,由全局异常处理器统一捕获并返回Result.error(code, message)。这样前后端联调时,前端只需要在axios拦截器里统一处理code,不用每个接口各写一套逻辑。
JWT鉴权我用的是jjwt库,登录成功后给前端发一个token,过期时间设为2小时。前端把token放在Authorization头里,后端用一个拦截器校验。放行的接口有:登录接口、验证码接口、家属端查询健康档案接口(需要单独校验家属绑定关系)。其余接口全部走鉴权。
有一类接口要特别小心:家属查看老人健康数据。这里不是只要登录了就能看,必须校验当前登录用户和老人之间是否存在有效的绑定关系。我在FamilyBinding表里维护了elder_id和user_id的绑定,查询时先做一次关系校验:
java复制private void validateFamilyBinding(Long userId, Long elderId) {
Long count = familyBindingMapper.selectCount(
new LambdaQueryWrapper<FamilyBinding>()
.eq(FamilyBinding::getUserId, userId)
.eq(FamilyBinding::getElderId, elderId)
.eq(FamilyBinding::getStatus, 1));
if (count == null || count == 0) {
throw new BusinessException("无权查看该老人的健康数据");
}
}
这个校验逻辑虽然简单,但在真实项目里特别重要。健康数据属于敏感个人数据,如果接口不校验绑定关系,登录用户拿别人的老人ID一遍历,所有健康档案全泄露了。很多毕业设计和外包项目都不注意这个点,我是建议必须做。
4. 前端Vue3实现要点:从页面到组件化的落地
4.1 组合式API的代码组织方式
Vue3的项目里,我习惯把业务逻辑按“功能域”组织进自定义composables里。举一个老人健康档案页的例子。页面数据多、交互复杂,如果全部写在组件里,单文件很快就上千行。我的做法是拆分:
code复制src/
├── api/
│ ├── elder.js # 老人档案相关接口
│ ├── healthRecord.js # 体检记录相关接口
│ └── alert.js # 预警相关接口
├── composables/
│ ├── useElderDetail.js # 老人详情页核心逻辑
│ ├── useHealthRecords.js # 体检记录逻辑
│ └── usePagination.js # 通用分页逻辑
└── views/
└── elder/
├── ElderList.vue # 老人列表页
└── ElderDetail.vue # 老人详情页
以useHealthRecords.js为例,它把体检记录的分页加载、新增编辑弹窗控制、删除确认、指标异常高亮这几件事封装在一起:
javascript复制import { ref, reactive } from 'vue'
import { listHealthRecords, createHealthRecord, updateHealthRecord, deleteHealthRecord } from '@/api/healthRecord'
import { ElMessage } from 'element-plus'
export function useHealthRecords(elderId) {
const loading = ref(false)
const records = ref([])
const total = ref(0)
const queryParams = reactive({
pageNum: 1,
pageSize: 10,
checkDateRange: null
})
async function loadRecords() {
loading.value = true
try {
const params = {
...queryParams,
elderId: elderId.value,
startDate: queryParams.checkDateRange?.[0] ?? null,
endDate: queryParams.checkDateRange?.[1] ?? null
}
const res = await listHealthRecords(params)
records.value = res.data.records
total.value = res.data.total
} finally {
loading.value = false
}
}
function handleDelete(row) {
ElMessageBox.confirm('确认删除该条体检记录吗?', '提示', { type: 'warning' })
.then(async () => {
await deleteHealthRecord(row.id)
ElMessage.success('删除成功')
loadRecords()
})
.catch(() => {})
}
return {
loading, records, total, queryParams, loadRecords, handleDelete
}
}
组件里用起来就很清爽:
html复制<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useHealthRecords } from '@/composables/useHealthRecords'
const route = useRoute()
const elderId = computed(() => route.params.elderId)
const {
loading, records, total, queryParams, loadRecords, handleDelete
} = useHealthRecords(elderId)
onMounted(() => {
loadRecords()
})
</script>
这样的好处是页面模板只关注展示,逻辑全部在组合式函数里,多个页面复用同一套逻辑的时候直接调用同一个函数就行。比如“季度随访列表”和“年检记录列表”虽然页面不同,但底层都是查health_check_record表,复用useHealthRecords就非常方便。
4.2 动态路由与权限控制的实现
本系统的权限模型是“用户—角色—菜单”,登录后会根据角色返回对应的菜单和按钮权限,前端用动态路由实现。Vue3下我的做法是:
- 登录成功后,后端返回该用户的角色列表和路由权限标识列表;
- 前端把权限标识存到Pinia里;
- 在
router.beforeEach守卫里判断用户是否已登录、是否已拉取权限、当前路由是否在权限列表里; - 动态添加路由使用
router.addRoute()。
核心路由守卫大致是:
javascript复制router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
if (!userStore.token) {
if (to.path === '/login') {
next()
} else {
next(`/login?redirect=${to.path}`)
}
return
}
// 已登录且已拉取过权限
if (userStore.permissionsLoaded) {
next()
return
}
// 未拉取过权限,先动态注册路由
try {
await userStore.fetchUserInfo()
const accessRoutes = generateRoutes(userStore.permissions)
accessRoutes.forEach(route => router.addRoute(route))
next({ ...to, replace: true })
} catch (e) {
userStore.logout()
next('/login')
}
})
generateRoutes根据后端的菜单配置,把component字段的字符串映射到实际组件。我封装了一个组件映射表,因为Vite环境下动态import路径不能完全用运行时变量拼接。这是Vue3 + Vite项目里一个常见的坑,路径写不对就直接Build失败或者运行时报“Failed to resolve component”。
4.3 数据可视化:ECharts展示健康趋势
给家属和社区医护展示血压血糖变化趋势,最直观的方式是折线图。我用Apache ECharts的vue-echarts组件来渲染。
按月份展示某位老人最近一年的收缩压变化:
vue复制<template>
<v-chart :option="chartOption" autoresize style="height: 320px;" />
</template>
<script setup>
import { computed } from 'vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent])
const props = defineProps({
dataList: { type: Array, default: () => [] }
})
const chartOption = computed(() => ({
tooltip: { trigger: 'axis' },
legend: { data: ['收缩压', '舒张压'] },
xAxis: { type: 'category', data: props.dataList.map(item => item.checkMonth) },
yAxis: { type: 'value', name: 'mmHg' },
series: [
{
name: '收缩压',
type: 'line',
data: props.dataList.map(item => item.systolicPressure),
markLine: {
data: [{ yAxis: 140, name: '高血压阈值' }],
lineStyle: { color: '#f56c6c', type: 'dashed' }
}
},
{
name: '舒张压',
type: 'line',
data: props.dataList.map(item => item.diastolicPressure),
markLine: {
data: [{ yAxis: 90, name: '高血压阈值' }],
lineStyle: { color: '#e6a23c', type: 'dashed' }
}
}
]
}))
</script>
血压参考线(markLine)一定要加,不然普通人看折线图根本不知道140/90这条标准线在哪里。加完参考线,异常一眼就能看出来。这个小细节对产品体验帮助很大。
5. 部署实践:MySQL8.0使用Docker安装与后端打包避坑
5.1 使用Docker部署MySQL8.0,并完成数据初始化
开发环境我强烈推荐用Docker跑MySQL8.0,省去本机安装的麻烦,尤其是Windows和macOS用户,不用折腾安装包、环境变量、服务注册那些事。一个docker-compose.yml就搞定了:
yaml复制version: "3.8"
services:
mysql8:
image: mysql:8.0
container_name: elder-health-mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: root123456
MYSQL_DATABASE: elder_health
MYSQL_USER: elder_app
MYSQL_PASSWORD: elder_app_pwd
TZ: Asia/Shanghai
command:
- --default-authentication-plugin=mysql_native_password
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --lower_case_table_names=1
ports:
- "3306:3306"
volumes:
- ./mysql_data:/var/lib/mysql
- ./init:/docker-entrypoint-initdb.d
ulimits:
nofile:
soft: 65536
hard: 65536
其中./init目录下放SQL初始化脚本,容器首次启动时会按照文件名字母顺序自动执行。这个目录里我会放01_schema.sql、02_init_data.sql,分别建表和插入初始数据。
几个容易踩的坑说一下:
- MySQL8.0默认的认证插件是
caching_sha2_password,老版本的JDBC驱动(比如5.1.x)连接会报“Unable to load authentication plugin”。我在command里显式指定了mysql_native_password,这样兼容性最好,配合mysql-connector-java8.0.x使用没问题。 lower_case_table_names=1表示表名大小写不敏感。Linux上MySQL默认区分大小写,Windows上默认不区分,如果不加这个参数,一套脚本在开发机(Windows)正常、线上(Linux)就会报“Table not found”。这个参数必须在初始化时设置,中途改会导致MySQL无法启动。- 数据目录
mysql_data要挂载到宿主机,否则容器删掉数据全没了。
初始化脚本执行完,可以用下面的命令验证:
bash复制docker-compose up -d
docker exec -it elder-health-mysql mysql -uroot -p
进入MySQL后执行SHOW DATABASES;,能看到elder_health库就说明初始化成功。
5.2 后端打包、前端构建与Nginx配置
后端打包我用的Maven打包成可执行jar,然后用java -jar启动。打包命令:
bash复制mvn clean package -DskipTests
打包成功后,jar在target/elder-health-server-1.0.0.jar。生产环境我一般写一个start.sh:
bash复制#!/bin/bash
nohup java -Xms512m -Xmx1024m \
-Dspring.profiles.active=prod \
-jar elder-health-server-1.0.0.jar \
> logs/app.log 2>&1 &
echo $! > app.pid
application-prod.yml里配置生产环境的数据库连接、JWT密钥、日志级别。切记不要把生产库密码写在代码仓库里,我用环境变量注入:
yaml复制spring:
datasource:
url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:elder_health}?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${DB_USERNAME:elder_app}
password: ${DB_PASSWORD:elder_app_pwd}
URL里serverTimezone=Asia/Shanghai和allowPublicKeyRetrieval=true这两个参数建议保留。前者解决MySQL8.0时区报错问题,后者解决公钥检索连接失败的问题。
前端构建很简单:
bash复制npm install
npm run build
构建产物在dist目录。我用Nginx托管,配置注意几个点:
nginx复制server {
listen 80;
server_name your-domain.com;
# gzip压缩
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
# 前端静态资源
root /opt/elder-health-web/dist;
index index.html;
# 前端路由history模式,刷新时回退到index.html
location / {
try_files $uri $uri/ /index.html;
}
# 后端接口反代
location /api/ {
proxy_pass http://127.0.0.1:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
注意:
proxy_pass http://127.0.0.1:8080/;最后这个斜杠很关键。带上斜杠,请求/api/elder/list会被转发为http://127.0.0.1:8080/elder/list,也就是把/api前缀剥掉了;如果不带斜杠,会原样转发为http://127.0.0.1:8080/api/elder/list。两种方式都能通,但后端Controller的@RequestMapping路径必须和它匹配,别搞混。
部署完别忘了检查防火墙和云安全组是否放行了80端口。我在一次项目上线时就在这一步卡了半小时——本地curl正常,外网死活访问不了,最后发现是云服务器的安全组没加80端口规则。
6. 常见问题与排查技巧实录
6.1 开发期的高频问题
列几个我在开发过程中反复遇到的坑,算是给后来人排雷:
第一个:MySQL8.0连接失败“Public Key Retrieval is not allowed”。 这个报错非常经典,原因是在MySQL8.0里,客户端默认使用caching_sha2_password认证时,需要获取服务器公钥,但JDBC默认不允许这种操作。解决方式两个:一是URL加上allowPublicKeyRetrieval=true,二是建连接用户时用mysql_native_password插件。我两个都做了,一劳永逸。
第二个:MyBatis-Plus分页不生效。 前面说过,漏配PaginationInnerInterceptor是第一大原因。第二大原因是配置了分页插件但DbType写错了,比如数据库是MySQL写了DbType.OTHER,一样不生效。排查时先看SQL日志,如果LIMIT没出现在SQL里,基本可以确定是拦截器没生效。
第三个:Vue3 + Vite的组件按需引入。 Element Plus按需引入需要配合unplugin-vue-components和unplugin-auto-import,第一次配置很容易报“Cannot read properties of undefined (reading 'xxx')”,多半是vite.config.js里插件的顺序不对。Vite插件的执行顺序是数组顺序,Vue()放前面,Components()放后面,基本就不出问题。
第四个:跨域问题。 开发环境Vite的代理配置:
javascript复制// vite.config.js
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, '')
}
}
}
})
这个方法可以解决开发环境跨域。生产环境用Nginx反代走同域,不需要另外处理CORS。如果你在SpringBoot里又配了CorsFilter又用Nginx反代,会出现“Access-Control-Allow-Origin”重复的问题,要么后端关CORS,要么前端直连后端,不要两套都在。
6.2 健康数据业务中必须处理的边界问题
- 体检日期不能大于当前日期。 我见过有人录入“明天”的体检记录,前端日历组件、后端
@Validated都要拦一道。 - 血压的收缩压必须大于舒张压。 两个值大小写反了是录入时最容易出现的错误,我会在后端校验:
if (systolic <= diastolic) throw new BusinessException("收缩压必须大于舒张压")。 - 老人注销后不能删除历史体检数据。 老人从社区搬走了或者去世了,系统里是设置
status=0注销,而不是物理删除。历史健康数据保留,方便以后统计分析。这是健康管理系统的合规底线,健康档案不能随意销毁。 - 家属绑定关系唯一。 一个用户和一位老人之间只允许有一条有效的绑定记录。防止家属绑了两次,出现重复查询的多余数据。
- 用药提醒时间不能全部相同。 如果用药计划里有多种药,提醒时间全部是同一个点,然后系统每个时刻发一堆提醒,就很烦。我加了条规则:同一老人同一天的提醒时间最多3个,超过3个按优先级排序取前3个,其余延迟到下一时段。
6.3 性能优化与数据量增长
健康管理系统随着运行时间增长,health_check_record表增长速度不容小觑。假设一个社区有500位老人,每位老人每季度一次体检,一年就是2000条;加上慢病随访、日常自测,一年可能上万条。单表数据量到几十万条时,不加索引的查询明显变慢,需要用这些手段:
- 组合索引
(elder_id, check_date)要建好,这是我查单个老人历史记录最核心的路径。 - 统计类SQL尽量用覆盖索引。比如统计某社区体检人数,
elder_id和community_id关联查询,按社区维度建索引。 - 慢查询日志记得开,MySQL8.0里设置
slow_query_log=1和long_query_time=2,找出执行时间超过2秒的SQL,逐个优化。 - 归档策略要提前定。比如超过3年的体检明细,从主表移到
health_check_record_archive归档表,减少主表数据量。归档定时任务我用Spring的@Scheduled,每天凌晨执行一次。
写在最后:一点个人经验
这个项目从需求梳理到最终交付,前前后后花了我大概三周业余时间。最大的感触是,社区老人健康信息管理系统这种项目,业务逻辑本身不复杂,复杂的是那些散落在各个角落的细节——老人可能没有智能手机、家属不常在身边、社区工作人员年纪偏大操作不熟练,这些都会反向影响技术设计。比如页面字体要大、按钮要少、录入表单要尽量简化、体检指标不要用专业缩写。
另外提示一点,涉及老年人健康数据的系统,无论是不是毕设还是商用项目,都要有隐私保护意识。身份证号脱敏、接口权限校验、日志脱敏这三件事别省。数据库备份也要定期做,我写了个简单的mysqldump定时脚本,每天凌晨备份一次到指定目录,保留最近7天,成本很低但关键时刻能救命。
如果你正打算做类似项目,建议先花两天时间把业务需求理清楚,再动手建表。表结构一旦定了,后面改起来代价很高。我最初设计时把体检指标直接做成20个字段,后来发现不同体检机构的指标项差异很大,改成“固定字段+JSON扩展”才解决。这种教训希望你能少踩一次。
