
简介基于Java SSM、Vue和Android的养老院管理系统源码包适合毕业设计、课程实训、全栈开发练习或养老机构信息化改造参考。项目以Spring容器管理事务SpringMVC处理请求分发MyBatis简化数据持久化Vue构建响应式后台界面并接入Android端实现移动查看与管理核心业务覆盖老人档案、床位分配、护理记录、家属沟通等环节。压缩包共1541个文件约23.75MB以Java源码、Vue组件、JavaScript逻辑文件为主体配以PNG/JPG/SVG图标图片、CSS样式、XML配置、SQL脚本和bat启动命令目录结构按功能拆分导入后即可运行调试。目前已有83人学习下载。包内还包含完整的前后端实现、数据库表设计及一键启动脚本采用前后端分离并复用后端接口供Android调用既可直接节省环境搭建时间也可作为理解SSM集成、Vue组件化与移动端数据交互的实战范本。1. 养老院管理系统源码拆解Android端、SSM后端与Vue管理端各管什么一个两百张床位的养老院护工每天要查房、量血压、记录吃药办公室要随时知道哪个床位空着、哪位老人换了护理等级家属又总想第一时间了解老人状态。以前靠纸质本子和微信群数据对不上事后也查不到。后来用了这套“Java-ssm-vue基于Android的养老院管理系统”源码Android端给护工录入SSM后端统一管接口和权限Vue管理后台给办公室做床位统计和人员配置一条链路才真正闭环。这条技术栈最直接的价值是“三端分离”业务动作发生在手机端管理动作发生在后台数据规则统一收敛在后端。适合有Spring、MyBatis基础、想在Android端落地的开发者也适合小团队接养老信息化项目时快速起一个最小可用版本。你不需要把每个模块都做到极致能先把登录、老人档案、护理等级这条主线跑通就已经能支撑真实业务。下面按我平时的落地顺序讲先定表结构再写后端接口再接Android端最后处理联调里的坑。每一步都有最小代码照着改就能用。2. 先定数据模型养老院核心表结构与权限边界动手写代码前先画表因为三端都靠同一套数据。很多源码包让人先跑起来再改表其实容易翻车。养老院系统的核心就三件事老人档案、床位占用情况、护理等级变化。把这三种关系理清权限边界才有落脚点。2.1 老人档案、床位与护理等级三张主表的关系设计老人主表里会存姓名、性别、生日、当前床位、当前护理等级、紧急联系人、入院时间、状态。床位单独建表因为房间号和床号会被多个业务引用比如调房、统计空床。护理等级不能只在老人表里存一个当前值还要有变更记录。原因是“现在是什么等级”和“什么时候从半自理变成了全护理”是两个完全不同的需求只更新一个字段历史就丢了。所以老人表的bed_id指向床位表的id变更日志表通过elder_id关联老人。表关系不复杂但有一个容易被忽略的点老人和床位不是强绑定。老人临时离院、转院床不能一直标记占用也不能直接删老人记录。我一般会在老人表加status字段用1-在院0-离院做逻辑删除宁可留脏数据也不能把审计轨迹丢掉。护理等级变更更是如此每次调级都要写明原因和操作人。2.2 角色权限管理员、护工、家属三种账号的菜单与数据隔离养老院系统不需要太复杂的权限模型经典的 RBAC 三张表就够用用户表、角色表、菜单表再加上用户角色关联和角色菜单关联。但真正要花心思的不是菜单而是数据隔离。管理员能看到全院数据护工只能看到自己负责的区域家属只能看到和自己绑定的老人。如果只在 Vue 端把菜单按钮隐藏后端接口不限制护工用 Android 端拼接参数就能查到别人负责的老人这是安全漏洞。所以设计表的时候就要预留隔离字段。老人表放一个area_id护工表放关联的area_id或通过中间表维护。所有列表接口在后端拼接 SQL 时强制带area_id条件不让前端随便传。管理员要小范围越权时可以做一个“模拟身份”功能后端临时把自己切到对应区域而不是把全量数据暴露出来。2.3 用 SQL 建表核心字段与状态枚举下面是常见的建表语句覆盖老人、床位和护理等级变更日志三张表-- 老人主表一个老人对应一张在住床位 CREATE TABLE elder ( id int NOT NULL AUTO_INCREMENT COMMENT 主键, name varchar(50) NOT NULL COMMENT 老人姓名, gender tinyint DEFAULT 0 COMMENT 0-男 1-女, birthday date DEFAULT NULL COMMENT 出生日期, bed_id int DEFAULT NULL COMMENT 当前床位ID关联bed.id, care_level tinyint DEFAULT 1 COMMENT 护理等级1-自理 2-半自理 3-全护理, guardian_name varchar(50) DEFAULT NULL COMMENT 紧急联系人, guardian_phone varchar(20) DEFAULT NULL COMMENT 联系电话, area_id int DEFAULT NULL COMMENT 所属区域/楼层用于护工数据隔离, status tinyint DEFAULT 1 COMMENT 1-在院 0-离院, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT老人档案表;这段 SQL 里有几个字段是特意设计的gender用tinyint而不是varchar省空间且方便前端映射care_level用固定枚举不要存“半自理”这种中文否则改一次枚举值要动一堆数据bed_id允许为空因为老人可能临时离院但档案还在area_id是数据权限的关键过滤字段后面所有查询都要带上。时间字段用datetime而不是timestamp因为养老院项目会有历史归档需求datetime的范围更大显示也更直观。-- 床位表 CREATE TABLE bed ( id int NOT NULL AUTO_INCREMENT, room_no varchar(20) NOT NULL COMMENT 房间号, bed_no varchar(20) NOT NULL COMMENT 床号, area_id int DEFAULT NULL COMMENT 区域/楼层, status tinyint DEFAULT 0 COMMENT 0-空闲 1-占用 2-维修, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT床位表; -- 护理等级变更记录表 CREATE TABLE care_level_log ( id int NOT NULL AUTO_INCREMENT, elder_id int NOT NULL COMMENT 老人ID, old_level tinyint NOT NULL, new_level tinyint NOT NULL, reason varchar(255) DEFAULT NULL COMMENT 变更原因, operator_id int NOT NULL COMMENT 操作人, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT护理等级变更日志;care_level_log里的reason我一般要求非空不为难使用的人而是逼着操作者想清楚为什么调级。operator_id记录操作人后面出纠纷时能追责。注意如果你拿到的源码包自带 SQL先以包内为准但字段语义可以参考这套设计重点看有没有area_id和变更日志缺的话建议补上再动代码。3. 后端SSM落地Controller-Service-Mapper三层与鉴权拦截器后端是 Android 端和 Vue 管理后台共同依赖的服务。SSM 指 Spring、SpringMVC、MyBatis虽然现在 Spring Boot 更普及但很多养老院项目源码仍是 SSM 风格。核心分层是 Controller-Service-Mapper理解清楚这一层后面接什么都顺。3.1 SSM 各层怎么分工为什么养老院项目不需要微服务Controller 只做参数接收、路由映射、统一返回不写业务。Service 放业务规则比如调床时要同时更新床位状态和写日志这个动作必须在一个事务方法里不能散落在 Controller 里。Mapper 只负责 SQL 映射不做计算。养老院这个场景并发很低几十个护工同时在线一个 SSM 应用加一台 MySQL 足够。选 SSM 而不是微服务不是因为微服务不好而是没必要。微服务要拆服务、做注册中心、处理分布式事务对一个几百人的内部系统来说只会增加运维负担。你甚至可以把整个应用打成一个 war 包扔进 Tomcat管理后台和 APP 都访问同一个服务成本最低排查问题也直观。3.2 登录与Token鉴权拦截器里的用户上下文登录方案我一般用 token不用 session。原因是 Android 端和 Vue 端都要访问接口session 依赖 Cookie在原生 App 里处理麻烦token 放请求头更通用。登录成功后后端返回一个 UUID 字符串同时把 token 和用户 ID 存到缓存里之后请求通过拦截器校验。拦截器是 SSM 里很常见的写法继承HandlerInterceptorAdapter在请求进入 Controller 之前做校验public class TokenInterceptor extends HandlerInterceptorAdapter { Autowired private TokenService tokenService; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 登录接口本身不需要校验先放行避免死循环 if (request.getRequestURI().contains(/api/login)) { return true; } String token request.getHeader(token); if (token null || !tokenService.checkToken(token)) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未登录或登录过期\}); return false; } UserContext.set(tokenService.getUserIdByToken(token)); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 请求结束后清理当前线程的用户ID防止线程池复用串数据 UserContext.clear(); } }这段代码里最容易被忽略的是afterCompletion里的清理动作。Tomcat 会复用线程处理多个请求如果你把用户 ID 放在 ThreadLocal 里不清除下一个请求会拿到上一个用户的 ID轻则数据串重则越权。preHandle里先判断 URI 包含/api/login就放行否则所有/api/**请求都要先过 token 校验。拦截器写好后要注册进 SpringMVCpublic class WebConfig implements WebMvcConfigurer { Autowired private TokenInterceptor tokenInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(tokenInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/login); } }拦截路径用/api/**登录接口单独排除。这样同一个 token 机制同时覆盖 Android 端和 Vue 管理端前端不用区分两套登录。如果源码是 XML 配置就在spring-mvc.xml里用mvc:interceptors做同样的事思路一样。3.3 一个完整的“老人列表”接口从Mapper到Controller的代码路径我习惯先写接口再写页面这里以“老人列表”为例把 MyBatis 的动态 SQL 和三层调用串起来。先定义一个 Mapper 接口public interface ElderMapper { ListElder selectByCondition(Param(keyword) String keyword, Param(careLevel) Integer careLevel, Param(areaId) Integer areaId); }XML 里写动态 SQL注意status 1作为默认条件后续所有AND都接在它后面不会拼出语法错误select idselectByCondition resultTypeElder SELECT id, name, gender, birthday, bed_id, care_level, guardian_name, guardian_phone, area_id, status FROM elder WHERE status 1 if testareaId ! null AND area_id #{areaId} /if if testkeyword ! null and keyword ! AND (name LIKE CONCAT(%, #{keyword}, %) OR guardian_name LIKE CONCAT(%, #{keyword}, %)) /if if testcareLevel ! null AND care_level #{careLevel} /if ORDER BY id DESC /select这里有两个细节。CONCAT(%, #{keyword}, %)而不是%#{keyword}%后者在某些 MyBatis 版本下解析会出问题前者是稳定的写法。#{}是预编译占位符能防 SQL 注入不要用${}直接拼接用户输入尤其 keyword 这种字符串容易被注入。Service 层做得很薄因为当前场景没有复杂事务Service public class ElderServiceImpl implements ElderService { Autowired private ElderMapper elderMapper; Override public ListElder listElder(String keyword, Integer careLevel, Integer areaId) { return elderMapper.selectByCondition(keyword, careLevel, areaId); } }Controller 暴露接口RestController RequestMapping(/api/elder) public class ElderController { Autowired private ElderService elderService; GetMapping(/list) public Result list(RequestParam(required false) String keyword, RequestParam(required false) Integer careLevel, RequestParam(required false) Integer areaId) { return Result.ok(elderService.listElder(keyword, careLevel, areaId)); } }Result是统一返回体一般含code、msg、data三个字段Android 端只要判断code就能决定是否走业务逻辑。areaId现在允许前端传是方便调试真正上线时应当从UserContext里取防止护工传别人的区域 ID。管理员可以传空护工不能。4. Android端实现网络请求、登录态保持与页面骨架Android 端是这个系统的前场护工每天打开它干活。它的核心功能就三块登录、看老人列表、点进详情做查房记录。代码量不大但网络层和登录态如果不做对后面整个 App 都会很难调。4.1 Android端为什么选原生Java OkHttp/Retrofit而不是HTML5壳有些项目图省事直接用 WebView 套 Vue 页面Android 端只留一个壳。但养老院场景会频繁用到相机拍老人照片、录音记录异常情况WebView 调这些原生能力要通过桥接很别扭。护工可能在信号不好的走廊、地下层操作原生 App 更容易做本地缓存和失败重试WebView 页面一旦资源加载失败就是白屏。所以源码用原生 Java 是合理选择。网络层我推荐 OkHttp RetrofitRetrofit 用注解声明接口代码可读性强底层还是 OkHttp 负责连接。配合 Gson 解析 JSON开发效率比手写HttpURLConnection高一截。Java 版本不需要上协程Retrofit 的回调足够支撑这个小项目。4.2 登录与Token存储SharedPreferences还是File登录成功后后端返回token和用户信息。App 侧要把 token 存起来下次启动直接判断有没有 token有就进主页没有就重新登录。最简单的做法是SharedPreferences它是应用私有目录下的键值对存储不需要申请存储权限也不怕别的应用读到。不要写到一个自定义.txt文件里那要处理路径、权限、异常纯属给自己挖坑。public class TokenManager { private static final String SP_NAME nursing_home_sp; private static final String KEY_TOKEN token; private static final String KEY_USER_ID userId; public static void saveLogin(Context context, String token, int userId) { SharedPreferences sp context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE); sp.edit().putString(KEY_TOKEN, token) .putInt(KEY_USER_ID, userId) .apply(); } public static String getToken(Context context) { SharedPreferences sp context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE); return sp.getString(KEY_TOKEN, ); } public static int getUserId(Context context) { SharedPreferences sp context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE); return sp.getInt(KEY_USER_ID, 0); } public static void logout(Context context) { SharedPreferences sp context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE); sp.edit().clear().apply(); } }apply()是异步落盘可以在 UI 线程直接调commit()是同步写可能卡主线程一般不推荐。登录成功调用saveLogin退出登录调用logout逻辑非常直接。如果项目对安全性要求高可以换成加密存储但养老院内部系统有 token 过期兜底普通SharedPreferences够用。4.3 老人列表页的网络层与页面骨架请求、加载中、空数据三态先定义 Retrofit 接口public interface ApiService { POST(api/login) CallBaseResponseLoginData login(Body LoginRequest request); GET(api/elder/list) CallBaseResponseListElder listElder(Query(keyword) String keyword, Query(careLevel) Integer careLevel); }BaseResponse是统一包装类public class BaseResponseT { private int code; private String msg; private T data; public boolean isSuccess() { return code 200; } }接着用 OkHttp 拦截器统一给每个请求加 tokenOkHttpClient okHttpClient new OkHttpClient.Builder() .addInterceptor(chain - { Request original chain.request(); String token TokenManager.getToken(context); Request.Builder builder original.newBuilder(); if (!token.isEmpty()) { builder.header(token, token); } return chain.proceed(builder.build()); }) .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .build();拦截器里判断 token 非空才加避免登录接口也带一个空的tokenheader导致后端误判。超时时间给了 10 秒养老院现场网络不好可以调到 15 秒但不要再长否则页面会长时间卡在加载状态。Activity 里调列表接口时要把页面状态拆成加载中、空数据、错误三态private void loadElderList() { showLoading(); String keyword etSearch.getText().toString().trim(); apiService.listElder(keyword, null) .enqueue(new CallbackBaseResponseListElder() { Override public void onResponse(CallBaseResponseListElder call, ResponseBaseResponseListElder response) { if (response.isSuccessful() response.body() ! null response.body().isSuccess()) { ListElder list response.body().getData(); if (list null || list.isEmpty()) { showEmpty(暂无老人数据); } else { adapter.submit(list); showContent(); } } else { showError(接口返回异常); } } Override public void onFailure(CallBaseResponseListElder call, Throwable t) { showError(网络连接失败); } }); }onResponse里要同时判断 HTTP 状态码和业务code二者都通过才展示数据。onFailure不一定是断网JSON 解析失败也会走这里所以错误提示用“网络连接失败”这种模糊话术可以接受。三态可以用一个ViewSwitcher维护加载、空、错误分别对应三个 view后续加下拉刷新也方便。5. 联调阶段排查手册跨域、日期格式与Android本机访问的避坑清单三端联调是这个项目真正花时间的地方。遇到问题别当玄学按现象、原因、解决三步走。下面四条是我见过最高频的也是新手动辄卡半天的点。5.1 现象Android模拟器访问本机后端一直超时浏览器却正常原因Android 模拟器里的localhost是它自己不是开发机。后端跑在开发机的 8080 端口模拟器访问http://localhost:8080会打到模拟器自身自然超时。解决Android 官方模拟器访问开发机要用http://10.0.2.2:8080真机调试时执行adb reverse tcp:8080 tcp:8080之后手机可以继续用http://localhost:8080如果后端在另一台服务器就填局域网 IP并确认手机和服务器在同一个网络。把 baseUrl 放在一个常量类里改起来一处生效不要散落在各页面。5.2 现象Vue管理端请求接口报跨域错误接口调试工具却正常原因浏览器有同源策略Vue 开发服务跑在 9528 端口后端在 8080响应头里没有 CORS 信息。接口调试工具不走浏览器所以能调通。这不是后端接口坏了是浏览器拦截了响应。解决在后端 SpringMVC 配置里加一个 CORS 过滤器Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(http://localhost:9528); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/api/**, config); return new CorsFilter(source); }注意生产环境不要把来源放开成*最好限定为管理后台的域名。如果开了setAllowCredentials(true)来源就不能再是*要用addAllowedOriginPattern精确配置否则部分浏览器会直接拒绝。如果你不打算动后端也可以在 Vue 的vue.config.js里配devServer.proxy转发/api开发环境能规避跨域但生产环境还是要靠 Nginx 反向代理。5.3 现象birthday 字段返回一串数字或带T的UTC时间原因后端实体字段如果是java.util.DateJackson 默认会序列化成毫秒时间戳Android 端拿到1725350400000这种东西解析时一个不小心就是乱码如果实体用了LocalDate不同 Jackson 版本输出的格式还可能不一样要么带T要么带00:00。解决统一日期格式。在 SpringMVC 配置里定制 ObjectMapperObjectMapper objectMapper new ObjectMapper(); objectMapper.setDateFormat(new SimpleDateFormat(yyyy-MM-dd HH:mm:ss)); objectMapper.setTimeZone(TimeZone.getTimeZone(GMT8));如果你用的是LocalDate就在实体字段上加注解JsonFormat(pattern yyyy-MM-dd) private LocalDate birthday;Android 端对应字段用String接收不要试图转成 Date 再转一次时区多转一次很容易多出 8 小时。5.4 现象登录成功之后Android调其它接口还是401原因最常见是请求头名字不一致。后端拦截器读的是tokenAndroid 代码里却写成了Authorization另一种情况是 token 存在了内存 static 变量里App 一杀进程就丢还有可能是 token 有效期太短刚登录完测试时正好过期。解决先统一 request header 名称都叫token。Android 在 OkHttp 拦截器里用builder.header(token, token)后端拦截器读request.getHeader(token)。排查顺序建议先拿接口调试工具带 token 手动调一次/api/elder/list通了就说明后端没问题问题在 Android 端的 header 或 baseUrl。token 有效期养老院场景给 7 天比较合理后台管理端给 1 天分开配置。6. 从源码到自己部署验证接口连通性的三种手段与后续改造技巧先把后端启动起来用 curl 验证接口是最快的定位手段。登录接口这样测curl -X POST http://localhost:8080/api/login \ -H Content-Type: application/json \ -d {username:admin,password:123456}拿到 token 后再带 token 打列表接口curl http://localhost:8080/api/elder/list?keywordcareLevel1 \ -H token: 上面返回的token如果这步都不通别急着打开 Android Studio先解决后端问题。这是第一步。第二步是 Vue 管理端。启动后看浏览器 Network 面板确认请求是否真的发出。如果看到红色的 CORS Error说明是跨域如果请求发出去但状态码是 401说明 token 没带或不对。Vue 开发阶段建议直接用vue.config.js里的devServer.proxy把/api转发到后端能少处理很多跨域问题。第三步是 Android 端。在 OkHttp 上挂一个日志拦截器把请求和响应打出来看。添加 OkHttp 日志库后用HttpLoggingInterceptor等级设BODY能看到每一次实际请求的 URL、header 和响应 JSON。发布版本时一定要把等级设为NONE否则老人姓名、联系方式这些隐私会全量落进日志。代码跑通之后如果要拿这套源码去做真实项目优先改三个点第一所有核心业务表加tenant_id为以后集团多院区预留隔离第二加一张care_task表把护工的体温、血压、喂药记录存下来这是实际付费功能第三老人呼叫、异常指标推送用 WebSocket 或消息推送不要一直轮询。很多同学改这种源码时第一件事就是去改老人表加字段结果后面要护理等级变更历史时补日志补到后悔。我自己现在改这类系统前会先问一句这个字段变化后需要审计吗床位调整需要护理等级调整需要用药记录需要唯独“备注”这种不用。想清楚再动手能省掉后面一大半返工。希望帮到你。本文还有配套的精品资源点击获取