上个月到一家民办养老院做需求调研,院长翻开电脑给我看他的管理方式——三本Excel台账、一抽屉纸质护理单、收费记录散落在微信聊天记录里。老人临时入住时,要翻半天档案才能确认床位和缴费情况;月底核算护理费能算到半夜。这种场景多了,我就特别想表达一个观点:养老机构缺的不是一套"能录数据"的软件,而是一套真正匹配业务运转节奏的企业级敬老院管理系统。
这个项目采用的技术栈非常主流:SpringBoot负责后端接口,Vue负责前端页面,MyBatis封装数据库访问,MySQL作为底层存储。整套源码覆盖了老人档案、床位管理、护理记录、费用结算、家属探访、员工权限等核心模块。这篇文章我会把系统的需求拆解、数据库设计、后端关键实现、前端权限控制、部署上线流程和个人踩坑经验完整展开,适合正在做Java全栈项目的同学、打算做课程设计的读者,也适合真的想给养老机构落地一套管理系统的朋友参考。
1. 从Excel到企业级系统:敬老院管理的真实痛点
很多人在看这类管理系统源码时,第一反应是"这不就是个CRUD吗"。如果只做增删改查,那确实没什么难度。但养老机构的业务一旦认真梳理,就会发现它不是简单的信息登记,而是多条业务线同时运转。
1.1 养老机构日常运营绕不开的五件事
第一件是老人入住与档案管理。从入院评估开始,要记录老人的基本身份信息、家属联系方式、紧急联系人、既往病史、当前健康状况、用药禁忌等。这些信息不只是填一张表,它直接决定后续的护理方案和收费等级。
第二件是床位管理。养老院的床位不是简单的"空/满",它涉及楼栋、楼层、房间、床位四级维度,还有普通床、护理床、单人间、双人间等不同类型,以及不同护理等级对应的不同收费标准。更麻烦的是,床位不只是被"人"占用,还可能处于"维修""预留""隔离观察"等非空闲状态。
第三件是护理任务记录。护工的日常工作——测体温、量血压、送餐、提醒服药、洗澡协助、翻身护理——每一项都需要留下记录。这份记录既是服务质量凭证,也是家属探访时最关心的内容,未来发生纠纷时还是重要的溯源依据。
第四件是费用管理。入住要交押金和首月费用,每月会生成护理费、餐饮费、床位费、医疗耗材费等不同的账单。不同老人享受不同护理等级,收费单价不同;中途入住或退住要按天折算;有些费用支持月结,有些要临时补缴。这里最容易出现财务对不上账的问题。
第五件是员工与权限管理。护工、护士、财务、院长、系统管理员,不同角色能看的页面和能做的操作完全不同。护工只需要录入护理记录、查看自己负责的老人,财务能看到全部收费流水但不能改护理方案,院长要的是全院的统计报表而不是某个床位详情。
1.2 "企业级"到底比课程设计多在哪
我见过很多课程设计级别的养老院系统,功能表写得很全,但落地就会碰壁。差距体现在四个维度:
- 状态流转:一张床从空闲到占用,不是改一个字段那么简单,还得联动老人的入住状态、费用账单的生成、护理任务的分配。课程设计常常只改了一个status,系统里就出现"人还在住但床位空闲"的矛盾数据。
- 权限粒度:企业级系统不会只靠前端v-if来藏按钮,而是后端接口也要校验角色,前端拿到的菜单是根据权限动态生成的,而不是写死的。
- 数据安全与操作留痕:谁在什么时间改了哪位老人的费用记录,都要有日志。不该被导出或删除的数据要做软删除,而不是物理DELETE。
- 并发与事务:养老院虽然不如电商高并发,但收费、入住这类操作同样可能被重复提交,一个接口同时更新多张表时必须保证事务一致性。
理解了这些痛点,再回头看这套SpringBoot+Vue+MyBatis+MySQL的源码,思路就会清晰很多:技术只是骨架,真正有含金量的是业务规则怎么用代码表达。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库先行:把敬老院业务翻译成表结构
业务梳理清楚之后,我习惯先设计数据库,再写后端代码。因为表的关联关系一旦定下来,接口和页面基本就有数了。这个系统的核心表可以按业务域分成几组:人员资料、床位资源、护理过程、费用交易、系统权限。
2.1 老人档案、床位与护理记录的核心设计
先看最核心的几张表:
sql复制-- 老人档案表
CREATE TABLE elderly (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL COMMENT '老人姓名',
id_card VARCHAR(18) NOT NULL COMMENT '身份证号',
birthday DATE NOT NULL COMMENT '出生日期',
gender TINYINT COMMENT '0女 1男',
health_status VARCHAR(255) COMMENT '健康情况摘要',
care_level TINYINT COMMENT '护理等级 对应收费等级',
emergency_contact VARCHAR(50) COMMENT '紧急联系人',
emergency_phone VARCHAR(20) COMMENT '紧急联系电话',
status TINYINT DEFAULT 1 COMMENT '1在住 0退住 2请假外出',
created_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_id_card (id_card)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='老人档案';
身份证号建立唯一索引,是为了防止同一个老人被重复登记。实际场景里经常出现家属分别来办入住、前台录入两遍的情况,没有这个唯一约束,后面所有关联数据都会乱。
sql复制-- 床位表
CREATE TABLE bed (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
building_no VARCHAR(20) COMMENT '楼栋编号',
floor_no VARCHAR(20) COMMENT '楼层',
room_no VARCHAR(20) COMMENT '房间号',
bed_no VARCHAR(20) COMMENT '床号',
bed_type TINYINT COMMENT '1单人间 2双人间 3护理床',
status TINYINT DEFAULT 0 COMMENT '0空闲 1占用 2维修 3预留',
elderly_id BIGINT COMMENT '当前入住老人ID,空闲时为空',
UNIQUE KEY uk_room_bed (building_no, floor_no, room_no, bed_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='床位表';
这里最关键的字段是elderly_id。床位和老人是"一对一"关系,通过这个字段可以直接关联查询,避免单独维护一张复杂的关联表。uk_room_bed唯一索引保证同一栋楼、同一楼层、同一房间下不会出现两个相同床号。
护理记录表建议这样设计:
sql复制CREATE TABLE care_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
elderly_id BIGINT NOT NULL,
nurse_id BIGINT NOT NULL COMMENT '记录人ID',
temperature DECIMAL(4,1) COMMENT '体温',
systolic_pressure INT COMMENT '收缩压',
diastolic_pressure INT COMMENT '舒张压',
diet_condition VARCHAR(100) COMMENT '饮食情况',
medicine_detail VARCHAR(255) COMMENT '用药情况',
excreta VARCHAR(100) COMMENT '排泄情况',
remark VARCHAR(500),
record_time DATETIME NOT NULL COMMENT '护理发生时间',
KEY idx_elderly_time (elderly_id, record_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='护理记录';
record_time我设计为业务时间,而不是数据插入时间。因为护工可能补录凌晨的记录,如果用created_time默认值,统计"今天所有生命体征数据"就会不准确。
2.2 费用流水与状态字段的业务含义
费用这块,普通的做法是给每个老人建一个"余额"字段,每月扣费。但企业级系统不能这么干,因为余额字段一旦算错,根本没有办法追溯。更可靠的做法是费用流水表,所有金额变动都记流水,余额通过流水实时汇总。
sql复制CREATE TABLE charge_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
elderly_id BIGINT NOT NULL,
charge_type TINYINT COMMENT '1床位费 2护理费 3餐饮费 4押金 5退费',
amount DECIMAL(10,2) NOT NULL,
pay_status TINYINT DEFAULT 0 COMMENT '0待支付 1已支付 2已退款',
operator_id BIGINT NOT NULL COMMENT '操作人员ID',
remark VARCHAR(255),
created_time DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_elderly_status_time (elderly_id, pay_status, created_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收费记录';
为什么不只保存"已支付"的数据?因为实际场景里,很多老人是月底统一结账,月中会产生大量"待支付"流水。财务需要随时看到"本月应收是多少、已收多少、还有哪些没交"。如果把未支付的数据直接过滤掉,月底对账就无从谈起。
2.3 索引设计的一点经验
很多初学朋友在建表时不加索引,数据量小的时候没感觉,等到养老院积累了上千位老人、每人每天一条护理记录后,单表轻松超过几十万行,列表查询就会明显变慢。
我常用的索引原则是三个:
- 查询最频繁的条件字段放联合索引首位,比如
(elderly_id, record_time)既能支持按老人查护理史,也能支持按时间段批量统计。 - 状态字段不要单独建索引,因为状态值只有0和1,区分度太低;要跟其他字段组合起来用。
- 身份证、手机号这类长度较长的字段,可以配合业务建唯一索引,保证数据唯一性的同时加速精确匹配。
3. SpringBoot后端落地的关键设计
后端是整个系统的"规则执行者",所有业务状态流转和权限判断都在这一层完成。SpringBoot本身已经把配置简化了很多,真正的坑往往出在项目结构和MyBatis使用习惯上。
3.1 分包结构与三层架构约定
这套源码我按标准的分层结构组织,每次看代码都能很快定位:
code复制com.example.eldercare
├── controller // 接口层,只做参数接收和结果封装
├── service // 业务层,承载核心业务规则
├── mapper // MyBatis接口层
├── entity // 数据库实体
├── vo // 视图对象(给前端用的组合结构)
├── common // 统一返回结果、异常处理、常量
└── config // 安全、跨域、拦截器等配置
有两点想特别提醒:
- controller里的方法要"薄",参数校验、组合查询、权限判断都放到service里。否则一旦业务规则复杂,controller会变成一坨无法测试的代码。
- 数据库实体entity和前端视图vo一定要分开。比如老人档案表里有个
emergency_phone,这个字段对护工而言不需要展示,如果直接把entity暴露给前端,每个接口都会带上大量无用甚至敏感字段。用vo做裁剪不仅是规范问题,更是数据安全问题。
3.2 MyBatis集成:配置文件里最容易出错的两个点
我在application.yml里最常用的两个配置:
yaml复制spring:
datasource:
url: jdbc:mysql://localhost:3306/eldercare?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.eldercare.entity
configuration:
map-underscore-to-camel-case: true
map-underscore-to-camel-case这一行非常关键。MySQL里的字段是emergency_contact,Java实体里是emergencyContact,开启驼峰映射后MyBatis会自动转换,否则你会发现查询结果里emergencyContact永远是null。这个错很难排查,因为SQL不报错,就是字段为空。
另外,SpringBoot 2.x默认使用的是com.mysql.cj.jdbc.Driver,如果你们项目里还写着老版的com.mysql.jdbc.Driver,启动时会有Driver警告;MySQL 8.0的连接字符串必须带serverTimezone,否则按美国时区计算,时间会差好几个小时。
3.3 分页和条件查询:PageHelper的标准用法与坑
系统里老人列表、费用流水、护理记录这些页面,全部需要分页。PageHelper是MyBatis生态里最常用的分页插件,用法很简单:
java复制@Service
public class ElderlyServiceImpl implements ElderlyService {
@Override
public PageInfo<ElderlyVO> pageElderly(ElderlyQuery query) {
// 注意:必须在查询语句执行前调用
PageHelper.startPage(query.getPageNum(), query.getPageSize());
List<ElderlyVO> list = elderlyMapper.selectElderlyList(query);
return new PageInfo<>(list);
}
}
但有几个坑,是实际项目中一定会遇到的:
startPage只对紧接着的第一条查询生效。如果你的Service方法先查了一次别的数据,再查PageHelper后面那条列表,分页参数就会错乱。所以PageHelper.startPage和列表查询之间不要插入任何其他数据库操作。- 不要在controller里调用
startPage。controller只负责接收参数和返回结果,分页动作放在service里。否则将来增加切面或缓存时,分页的边界会变得不可控。 - 查询条件的"模糊查询"不要用
${}拼接。MyBatis的XML里,模糊查询正确的写法是:
xml复制<select id="selectElderlyList" resultType="com.example.eldercare.vo.ElderlyVO">
SELECT e.id, e.name, e.id_card, e.care_level, b.room_no, b.bed_no
FROM elderly e
LEFT JOIN bed b ON e.id = b.elderly_id
<where>
<if test="name != null and name != ''">
AND e.name LIKE CONCAT('%', #{name}, '%')
</if>
<if test="status != null">
AND e.status = #{status}
</if>
</where>
ORDER BY e.created_time DESC
</select>
用#{name}传参并配合CONCAT拼接百分号,既安全又能走索引优化;用${}拼接百分号虽然也能跑,但要小心SQL注入问题。
3.4 事务与状态一致性:入住登记可能要同时改三张表
以"给老人办理入住"为例,这个操作不是只insert一条老人记录就完了。一套完整流程至少包含:
- 新增老人档案,状态设为"在住"
- 更新床位表,把床位状态改为"占用",并关联老人ID
- 生成第一笔押金和首月床位费、护理费的待支付流水
这三个操作必须在一个事务里完成,否则就有可能发生"老人档案写入了但床位状态没改"的脏数据。SpringBoot里实现非常简单:
java复制@Transactional(rollbackFor = Exception.class)
public Long checkIn(ElderlyCheckInDTO dto) {
// 1. 幂等校验:同一张床不能重复入住
Bed bed = bedMapper.selectByIdForUpdate(dto.getBedId());
if (bed == null || !bed.getStatus().equals(0)) {
throw new BusinessException("该床位不可用");
}
// 2. 写入老人并更新床位
Long elderlyId = elderlyMapper.insert(dto.buildElderly());
bedMapper.occupy(dto.getBedId(), elderlyId);
// 3. 生成费用流水
chargeService.createCheckInCharges(elderlyId, dto);
return elderlyId;
}
这里我特别说一下selectByIdForUpdate。多个人同时抢最后一张床的时候,虽然养老院场景并发量不高,但接口如果不加锁,两条请求同时读到"床位空闲",就会产生重复入住。FOR UPDATE会给这一行加悲观锁,保证读到空闲床位的请求串行化处理。这也是"企业级"和演示项目拉开差距的地方。
SpringBoot 2.x下事务默认只在抛出RuntimeException时回滚,所以写@Transactional建议显式指定rollbackFor = Exception.class,避免某些检查异常吞掉导致数据不一致。
4. Vue前端:从登录页到权限控制
前端的价值不只是把页面画出来,更在于怎么把后端提供的权限和数据流畅地呈现给不同角色。这套系统用的Vue技术栈,我建议保持"路由-状态-请求"三条线清晰。
4.1 前端项目结构与路由设计
页面按业务域组织,跟后端模块一一对应:
code复制src
├── api // 接口请求封装
├── router // 路由与守卫
├── store // Pinia 状态管理
├── views
│ ├── system // 用户、角色、菜单
│ ├── elderly // 老人档案
│ ├── bed // 床位管理
│ ├── care // 护理记录
│ ├── charge // 费用管理
│ └── visit // 探访登记
├── components
└── directives // 自定义指令(按钮权限)
路由设计有个容易被忽略的点:不要把需要权限的页面都写死在静态路由表里。否则即使前端通过v-if隐藏了菜单,用户直接改URL还是能访问到不该看的页面。我推荐的做法是登录后根据用户角色动态组装路由,再router.addRoute()注册进去。
4.2 axios封装与token刷新
axios必须做统一封装,否则每个页面都写一遍token逻辑,后期维护会痛不欲生。核心代码大致长这样:
javascript复制// src/api/request.js
import axios from 'axios'
import { useUserStore } from '@/store/user'
const service = axios.create({
baseURL: '/api',
timeout: 15000
})
service.interceptors.request.use(config => {
const userStore = useUserStore()
if (userStore.token) {
config.headers['Authorization'] = 'Bearer ' + userStore.token
}
return config
})
service.interceptors.response.use(
response => {
const res = response.data
if (res.code !== 200) {
if (res.code === 401) {
// 登录失效,清token并跳转登录页
}
return Promise.reject(new Error(res.message || '请求失败'))
}
return res
},
error => {
return Promise.reject(error)
}
)
注意response拦截器里不只是处理网络错误,还要处理后端返回的业务错误码。很多前端工程只判断HTTP状态码,结果后端返回code: 500业务异常时,页面拿到一堆残缺数据,排查半天。
Token过期的情况,我一般在前端做两件事:一是401时自动跳到登录页并提示重新登录;二是在请求拦截器里对即将过期的token做一次刷新。养老院的管理人员普遍不是技术人员,让他们频繁重新登录是很糟糕的体验。
4.3 角色权限:护工、财务、院长看到的菜单不一样
权限这块,源码里一般会有"用户-角色-菜单权限"三张表。登录后后端返回当前用户拥有的权限码列表,前端据此渲染菜单和按钮。
比如一个护工登录后,他需要的菜单是"我的老人""护理记录""我的排班",而财务需要"费用流水""退费审核""月度报表"。这些不应该在前端用一串if-else写死,而应该由后端根据角色返回。
我建议的权限码设计是细粒度权限码,比如:
elderly:list查看老人列表elderly:update修改老人信息charge:create创建费用charge:refund执行退费
后端在接口上做同名校验,前端用自定义指令控制按钮显隐:
javascript复制// 自定义指令 v-permission
app.directive('permission', {
mounted(el, binding) {
const userStore = useUserStore()
const required = binding.value
if (required && !userStore.permissions.includes(required)) {
el.parentNode?.removeChild(el)
}
}
})
vue复制<button v-permission="'charge:refund'">退费</button>
这样的好处是:权限判断逻辑只写一遍。前端控制体验,后端控制安全,两者用同一个权限码串起来,不会出现前端隐藏了按钮但接口照样能调的问题。
4.4 动态菜单的一个容易踩的坑
动态路由要根据后端返回的菜单结构来生成,但很多后端返回的菜单是带icon和component字符串的。前端拿到后要把它转换成组件引用,不能直接拿来当渲染组件。常见做法是维护一个本地映射:
javascript复制const modules = import.meta.glob('../views/**/*.vue')
function loadView(componentPath) {
return modules[`../views/${componentPath}.vue`]
}
还有一点:动态路由添加之后,刷新页面会丢失。因为刷新后Pinia状态重置,动态路由需要重新从后端拉取然后再次注册,所以一定要在路由守卫里处理"有token但路由未生成"的场景,否则就会出现"登录后正常、刷新后白屏"的经典问题。
5. 完整跑通一套源码的实操记录与避坑
拿到这套源码后,很多人第一步就卡在"怎么把项目跑起来"。这个问题说起来简单,实际涉及JDK、Node、MySQL三个环境,中间任何一个版本不匹配都会折腾半天。我把自己的完整实操过程写出来。
5.1 环境准备:JDK、Node、MySQL版本怎么选
我建议按这个组合来:JDK 8或JDK 17、SpringBoot 2.7.x、Node 14或16、MySQL 5.7或8.0。
这里有几个原因:
- SpringBoot 2.7.x配JDK8是最稳的组合,打包小、启动快、兼容性好。如果你用的是SpringBoot 3.x,那就得JDK17起步,但不少老项目的依赖还不完全兼容,没必要为了新而新。
- Node版本方面,Vue2项目建议Node14/16,Vue3+Vite项目建议Node16/18。如果你拿到的源码是Vue CLI创建的,用太新的Node版本偶尔会碰到OpenSSL兼容报错。
- MySQL 5.7和8.0在驱动配置上略有差异。8.0的驱动类是
com.mysql.cj.jdbc.Driver,连接串要加serverTimezone;5.7可以用老驱动,但也建议统一切到新驱动,减少麻烦。
5.2 前端依赖安装踩坑记录
前端安装依赖的时候,最折磨人的就是node-sass。这个包在Windows上经常编译失败,报错信息又长又乱。踩过几次之后,我的处理方案是:
- 如果项目里用的是
node-sass,直接把package.json里的node-sass替换成sass,并把引入语句从import 'node-sass'风格的写法调整成import 'sass'。 - 如果npm安装速度慢或者卡住,可以临时切换镜像源:
npm config set registry https://registry.npmmirror.com。 - 别忘记执行
npm install之后检查一下node_modules里有没有缺失的包,遇到ERR! code MODULE_NOT_FOUND大多是某次中断安装导致依赖不完整,删掉node_modules和package-lock.json重装基本能解决。
5.3 后端启动失败的典型排查思路
后端SpringBootApplication启动类跑起来,最常见的三个报错,我按出现概率排个序:
第一个:数据库连不上。
code复制Cannot create PoolableConnectionFactory
先ping一下数据库通不通,然后检查连接串里的serverTimezone有没有写,用户名密码是否正确。注意MySQL 8.0的认证插件默认是caching_sha2_password,如果你的驱动版本太老会报Public Key Retrieval is not allowed,连接串里加一个allowPublicKeyRetrieval=true可以解决,但长期使用更建议升级驱动。
第二个:Mapper找不到。
code复制Invalid bound statement (not found): com.example.eldercare.mapper.ElderlyMapper.selectPage
这一步排查顺序是:确认application.yml里mapper-locations路径对不对、XML文件有没有放在对应目录、接口类上有没有加@Mapper注解或者在启动类上加@MapperScan。最常见的问题就是XML的路径写的是classpath:mapper/*.xml,但实际XML文件放在resources/db/mapper下面。
第三个:端口冲突。
code复制Port 8080 was already in use
直接用netstat -ano | findstr 8080查出占用进程,杀掉或者把SpringBoot端口改成8081。这个方法在Windows和Linux上都有效。
5.4 拿到一个全栈项目怎么快速上手
源码能跑通只是第一步,真正读懂它才是收获。我自己的阅读顺序是:
- 先看SQL脚本,了解数据库有哪些表、表之间什么关系。这一步最省力,能快速理解业务全貌。
- 从"登录"功能入手,追踪一条完整链路:前端登录页 → 调用后端接口 → 后端校验用户 → 签发token → 前端存储并跳转 → 动态拉取菜单。
- 找一个你感兴趣的模块,比如"入住登记",按controller → service → mapper的顺序读下去,重点看事务和状态流转。
- 如果手头只有打包好的jar包,没有源代码,可以用IDEA自带的反编译插件或者常见的反编译工具恢复出可读代码。不过反编译只能看实现,注释和原始设计思路基本丢失,真正想要长期维护还是需要源码。
6. 企业级系统的加分项:日志、安全与易维护性
CRUD写清楚能让系统"用起来",但一套面向真实机构长期运营的系统,还需要一些"关键时刻救命"的设计。这些内容在演示项目里往往没有,但真实场景里一定绕不开。
6.1 操作日志与登录日志
企业级系统必须有操作日志。谁在什么时间改了哪笔费用、谁把某位老人的护理等级从二级调成了一级,这些行为都要留痕。实现思路不复杂:写一个AOP切面,拦截加了@OperationLog注解的方法,把当前用户、操作模块、操作内容、IP、耗时记录到日志表。
java复制@Aspect
@Component
public class OperationLogAspect {
@Around("@annotation(operationLog)")
public Object around(ProceedingJoinPoint point, OperationLog operationLog) throws Throwable {
long start = System.currentTimeMillis();
// 解析操作人、请求参数、方法名称,写入日志表
return point.proceed();
}
}
日志表可以单独建库或单独分区,防止日志表太大拖慢业务表查询。我建议至少保留一年以上的日志,养老行业对操作追溯的要求很高。
6.2 接口安全:JWT+过滤器+XSS防护
这套系统用的是JWT做身份认证,拦截器里校验token有效性。要注意的是,拦截器只解决"你是谁",不解决"你能干什么"。所以每个管理接口上还要加权限判断,建议在拦截器的preHandle里先校验token,再校验权限码。
还有一个容易被忽视的安全点:XSS攻击。理想情况下前端的富文本输入要做白名单过滤,后端接口也要防止用户提交恶意脚本。更严谨的做法是写一个全局过滤器,统一包装请求体,对参数中的特殊字符做转义。
这里有个小陷阱:如果过滤器对所有请求统一处理,上传二进制文件(比如PDF、图片)时可能会被误伤。所以过滤器要先判断Content-Type,如果是multipart/form-data或纯二进制内容就直接放行,只处理JSON和表单文本参数。
6.3 收费操作别让人重复提交
费用相关接口最怕重复提交。财务手快点了两次"确认收款",就产生两条一模一样的流水。这个问题简单有效的解法是幂等令牌:
- 前端进入收费页面时向后端申请一个
requestId,也就是"本次操作凭证"。 - 提交付款时带上这个
requestId。 - 后端在处理前先查一次
requestId是否已经处理过,处理过就直接返回成功。
java复制public void createCharge(ChargeCreateDTO dto) {
String requestId = dto.getRequestId();
if (stringRedisTemplate.hasKey("charge:idempotent:" + requestId)) {
return; // 已处理过,直接放行
}
// 真正执行收费逻辑
chargeRecordMapper.insert(dto.toRecord());
stringRedisTemplate.opsForValue().set("charge:idempotent:" + requestId, "1", 10, TimeUnit.MINUTES);
}
这样即使同一个请求被重复提交、或者网络抖动导致前端重试,也不会产生重复数据。
6.4 健康检查与优雅停机
最后说一个很小但很实际的点:SpringBoot项目部署在养老院本地服务器上时,建议加上spring-boot-starter-actuator,暴露/actuator/health健康检查接口。这样就算网络环境复杂,也能通过监控迅速判断服务是否存活。
最后再分享一点个人体会
这套系统从设计到落地,真正让我花时间的地方不是写代码,而是"把养老院的线下规则翻译成线上状态机"。比如一张床位什么时候算空闲、押金什么情况下扣减、护理记录哪些字段必须填写,这些都要跟一线护工和财务反复确认。做完这个项目之后,我发现凡是运营管理类系统,需求访谈时报出来的往往都是"我们要一个功能",但真正落地时全是"这个状态和那个状态之间怎么流转"的细节。
如果你正在跑这套源码,我的建议是别急着改业务,先把老人入住到退住的完整链路走一遍,再对照数据库字段去理解每一步的数据变化。把入住、退住、月度计费三个核心流程背后的表关联吃透,再去看其他模块都会变得非常轻松。希望这篇拆分能帮你少走一些弯路。
