ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

养老中心管理系统开发复盘:从需求建模到技术落地的实践要点

养老中心管理系统开发复盘:从需求建模到技术落地的实践要点 1. 开工前先把“养老中心管理系统”这几个字拆开1.1 真实业务里系统为什么不是一堆CRUD页面接手养老中心管理系统那阵子我脑子里最大的疑问不是SpringBoot怎么写而是养老中心的业务到底要管到什么程度。后来在某个养护机构蹲了三天现场跟着运营负责人、护士长、前台收费员各聊了一圈才意识到如果只按照“老人、床位、缴费”三个表去做系统那只是做了一个漂亮的后台玩具。老人从咨询、入住评估、分配床位、健康监测、护理执行到月度结算、退住结算中间有一大堆状态流转和业务规则任何一个环节漏掉了系统上线后运营人员都会回到Excel。比如最简单的“入住登记”并不是管理员在页面上点一下“新增老人”就完事。老人入院前要有评估单确定护理等级不同等级对应不同护理频次和收费标准评估通过后要锁定床位而床位可能在同一时间被不同家属看中入住后护士要维护健康档案护工每天按护理计划执行任务。这些流程处理不好系统的日常使用率会非常低。所以第一件事不是写代码而是画业务流程图把角色、状态、操作边界都定义清楚。1.2 我最终定的模块清单经过几轮需求梳理我最终把系统切成八个模块并明确哪些是第一期必须做哪些可以放到迭代里。模块核心功能第一期是否做档案管理老人档案、入住登记、评估、协议是床位管理楼栋/楼层/房型、床位状态、转床是护理管理护理计划、任务派发、执行记录是健康管理生命体征录入、慢病随访、异常提醒是费用管理预存、账单、退款、日常缴费是餐饮活动报餐、活动报名否放二期家属端查看老人动态、在线缴费、预约探视是系统管理用户角色、菜单、操作日志、数据字典是建议第一期砍掉餐饮活动因为报餐涉及食堂库存和供应商对账和核心养老护理流程耦合度不高先做好老人、护理、费用三条主线运营跑顺了再加不迟。家属端我保留了查看动态和缴费这对提升机构口碑很关键。1.3 角色权限决定了功能设计的先后顺序另一个容易被忽略的点是不同角色看同一个老人数据关注点完全不同。院长或管理员要看入住率、本月收入、护理任务完成率护士要看老人健康指标变化趋势护工只需要看今天分配给我的任务清单财务只看账单和流水家属只能看自己家老人的信息和缴费记录。这就意味着后端设计不能只做一套统一的老人列表而是从一开始就要考虑数据权限和字段过滤。我在系统里定义了五类后台角色超级管理员、中心管理员、护士、护工、财务外加一个家属端登录角色。菜单权限沿用RBAC数据权限则通过后端的“数据作用域”处理这个后面专门讲。先想清楚角色再安排页面和接口能少走很多弯路。2. 技术选型复盘SpringBoot为核心不等于无脑引入全家桶2.1 为什么是SpringBoot MyBatis-Plus MySQL Redis这版系统最终用了SpringBoot 2.7 MyBatis-Plus 3.5.3 MySQL 8.0 Redis 6。说实话这个组合谈不上炫但非常适合养老中心这种业务逻辑复杂、团队水平参差不齐的项目。SpringBoot本身已经提供了自动配置和生态2.7版本我特意没上3.x原因是当时项目依赖的部分组件对SpringFramework 6和JDK17的兼容还没完全验证而现场运维环境还在JDK8团队也最熟JDK8。技术选型不是越新越好稳定和可维护才是第一位。持久层为什么选MyBatis-Plus而不是JPA养老系统的列表查询有很多动态条件比如按护理等级、楼栋、是否欠费组合过滤用MyBatis-Plus的QueryWrapper写起来非常直观单表操作不需要手写SQL遇到复杂报表统计又可以直接在XML里写SQL团队容易掌控。MySQL作为主存储没有争议业务量级一天几千次操作远没到需要上分库分表的程度。Redis在这里承担三件事登录token状态存储、热点缓存、分布式锁。2.2 项目结构和核心依赖清单项目采用标准的单体分层结构虽然叫“管理系统”但不需要一上来就拆微服务。一个SpringBoot应用包名按业务域划分避免controller太重。依赖大致如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency代码结构上我习惯按业务域而不是按技术层建包比如elder、bed、nursing、health、charge包每个包里面有controller、service、mapper、entity、dto。这样后续改需求时少跨好几个包维护成本明显低。全局配置单独放config包安全相关放security包公共响应体、异常处理、工具类放common包。2.3 版本组合里的一个隐藏坑版本这关我踩过一个很典型的坑SpringBoot 2.7以后把Spring MVC的路径匹配策略默认改成了PathPatternParser而当时项目里集成的接口文档组件还在用老的AntPathMatcher策略结果应用能正常启动但访问接口文档页面全是空白排查很久才发现是路径匹配策略不兼容。解决办法是在application.yml里显式配置spring: mvc: pathmatch: matching-strategy: ant_path_matcher这个问题不只在文档组件上出现某些老版本拦截器或静态资源映射也会受牵连。所以我的建议是先用SpringBoot 2.7把项目跑通不要一次性把文档组件、代码生成器、监控组件全部升到最新最好锁定一套经过验证的版本组合避免启动都正常但功能静默失效的诡异问题。3. 数据库设计老人档案、床位的“状态”、费用流水3.1 老人主表与扩展表的拆分逻辑老人档案我拆成了两张表elder和elder_ext。基本档案存姓名、性别、出生日期、身份证号、联系电话、紧急联系人、婚姻状况等不常变的信息入住扩展信息存入住日期、护理等级、主治医生、责任护士、床号、饮食禁忌、过敏史、既往病史等与入住状态强相关的信息。这样拆的原因是老人可能会有多次入住记录每次入住对应的护理等级和床号都不一样把入住信息塞在主表里会导致数据混乱。身份证号这类敏感字段我没在数据库里明文存而是用AES加密后存储接口返回时自动脱敏。这个决定在需求阶段被吐槽过说查询麻烦但养老行业涉及老年人个人信息保护后患远比查询麻烦可怕。考虑到查询条件里可能有身份证号模糊搜索我额外建了一个不可逆的哈希列用于精确匹配加密列用于展示算是兼顾了安全和查询。3.2 床位状态机与防并发分配床位是整个系统的核心资源设计上必须有明确的状态机。我的bed表里状态分为EMPTY、LOCKED、OCCUPIED、MAINTENANCE四种。空置床可以被申请锁定锁定床在评估通过后转为占用如果评估失败或家属放弃锁定床要释放回空置。维护中的床位不参与分配。这里最大的坑是并发分配。两个家属同时看中同一张空床如果代码写成先select床位状态再update一定会出现超卖。我最终用的是“条件更新”方案UPDATE bed SET status LOCKED, lock_user_id #{elderId} WHERE id #{bedId} AND status EMPTY执行后判断受影响行数等于1才表示锁定成功。这样数据库层面就把并发拦住了不需要引入分布式锁。转床操作同理一个事务里先旧床释放、更新老人扩展表、再新床锁定任何一步失败整体回滚。3.3 费用预存与账单最容易膨胀的两张表费用模块我设计了四张核心表账户余额表、账单表、账单明细表、支付流水表。老人入住时可以选择预存模式账户余额表维护当前可用余额每月月初定时生成当月账单包含床位费、护理费、餐费等明细每次缴费或消费都会同时写支付流水。所有金额字段一律用DECIMAL(10, 2)Java侧对应BigDecimal绝对不要用double这一点写进团队规范。费用计算有个细节退住时不是简单取消当月账单而是按实际居住天数重算。比如某老人6月10日退住月费3000元需要把账单改成共计10天的费用并按天分摊同时释放床位、更新账户余额。如果只做“整月收费退住清零”财务对账会非常痛苦。每一步金额变动都要记录原因方便家属来问时能说清楚。3.4 查询优化给高频列表设计联合索引数据库表设计完之后一定要回头检查索引。养老系统的查询模式比较固定后台老人列表按护理等级和状态过滤床位管理按楼栋查空床费用流水按老人和时间范围查询。我给高频查询建了这几个联合索引elder(status, care_level)、bed(area_id, status)、charge_flow(elder_id, create_time)。注意不是每个查询字段都建索引而是按最常用的等值加范围条件组合来建否则写入性能和存储成本都会受影响。当时有个统计接口“首页展示今日新增老人、入住率、空床数”我一开始写成一个复杂的多表联查数据量上来后很慢。后来用EXPLAIN看了一下发现关联顺序和索引选择都不理想于是改成三个独立查询分别查计数再用Redis缓存结果接口耗时从2秒降到30毫秒。这也说明数据库设计不要指望靠一个“万能SQL”搞定所有统计。4. 权限与安全老人数据不是谁都能看4.1 权限模型基于角色的RBAC不够要加数据权限养老中心系统的权限要分两层功能权限和数据权限。功能权限是“你能不能用这个菜单”属于RBAC数据权限是“你能看哪些老人的数据”这一层很容易被忽略。我一个护工账号如果把所有老人列表都返回给他不仅不合适还容易造成护理任务混乱。我的做法是每个用户关联岗位和数据范围数据范围有几种全部数据、本部门数据、本楼层数据、本人负责数据、仅关联家属数据。在代码里用自定义注解DataScope标记需要过滤的接口AOP切面根据当前用户信息往查询条件里追加SQL片段。这样做的好处是业务代码里不用到处写if判断缺点是切面逻辑要控制好不能影响所有查询接口。4.2 Spring Security JWT Redis的具体落法登录认证我用了Spring Security框架而不是自己写拦截器因为账号密码加密、会话管理、接口放行规则这些安全细节自己手写容易漏。实际落法是这样用户登录成功后生成一个JWT作为accessToken有效期设成2小时同时生成一个refreshToken存到Redis用来刷新accessToken。JWT里放了用户ID和会话IDRedis里以login:token:{userId}:{jti}为key保存当前可用的会话信息。为什么没有用纯JWT无状态方案因为养老系统需要主动登出、密码重置后踢掉旧会话、单用户多地登录限制功能。如果只靠JWT过期时间这些操作无法立即生效。有了Redis这层状态登出时直接删除key修改密码时删除该用户所有key权限变更也能实时失效。代价是每次请求多一次Redis查询但系统规模完全扛得住。4.3 数据权限拦截让护工只能看到自己负责的老人具体实现上我的DataScope切面大致做了三件事先从Spring Security上下文拿到当前用户再根据用户的角色和数据范围确定过滤策略最后修改Mapper的查询参数。比如护工角色自动给查询条件加上elder_id in (护理任务表里的assigned_elder_ids)护士角色则加floor_id 当前护士管理的楼层家属角色只能按elder_id 家属绑定老人ID过滤。有一点必须提醒数据权限不能只在前端隐藏按钮后端所有查询都要经过过滤。当时我图省事有一个导出Excel接口没走公共查询逻辑结果护工能导出全中心老人数据这在测试环境根本发现不了直到真实运营场景才暴露。后来我把导出功能也接到同一套查询服务并单独加了操作日志记录谁在什么时间导出了多少条数据都有据可查。5. 三条核心业务线的实现细节5.1 入住登记状态流和事务边界入住登记是整个系统最长的流程之一我把它拆成五个状态PENDING_EVAL、EVAL_PASS、BED_LOCKED、CHECKED_IN、CHECKED_OUT。每个状态变更都要求有操作记录方便后续审计。代码层面正式入住这个动作放在一个事务里更新老人状态为已入住、更新床位状态为占用、创建首月待缴账单、初始化健康档案。这里的事务边界我特别注意事务内只做数据库操作发送短信、推送通知全部放到事务提交后或异步执行。否则短信接口超时会拖着整个入住流程占用数据库连接接口响应慢不说还容易造成重复通知。锁床和入住之间如果间隔太久比如家属两天后才来办入住我会用一个定时任务检查超过24小时仍处于BED_LOCKED的申请自动释放床位并给销售发提醒。护工不用天天手工查哪些床被无效占用。5.2 健康巡检批量录入服务与异常提醒健康管理模块的需求很简单护工每天例行给老人量体温、血压、血糖把数据录进系统。但如果让护工一条一条点“新增”一天几百个老人会累出怨气。我设计了一个批量接口接收一个老人ID数组加测量数据列表后端批量写入health_record表并把最新一条同步到老人健康档案冗余字段方便列表页直接展示当前状态。录入后系统要做异常判断不同年龄段和慢病类型的阈值不一样。我在数据字典里维护了可配置的阈值表比如收缩压高于160记为偏高。一旦出现异常会生成一条待处理预警由护士确认是否需要通知家属。通知动作通过异步线程池执行不阻塞主流程。这个模块上线后使用率非常高因为护工发现比手写纸质记录省时间还不用重复抄表。5.3 费用结算幂等和按天拆账费用结算踩过的最大坑是重复计费。定时任务每天凌晨1点生成当日餐饮费和护理费如果任务执行到一半机器重启或者SQL超时重试就可能重复生成账单导致老人账户被重复扣费。解决办法是在账单明细表上加唯一约束字段组合是elder_id bill_date bill_type插入时使用INSERT ... ON DUPLICATE KEY UPDATE。这样即便任务重复执行第二次也只是更新不会新增重复明细。退住结算也是容易出错的地方。我把退住费重算做成一个独立服务输入退住日期内部按“总月费/当月天数*已住天数”计算应收金额然后和账户余额比较多退少补。整个重算必须在一个事务里完成且要记录每次调整的操作人和原因。上线后财务反馈最多的问题是“为什么账单和银行流水差几分钱”后来统一规范所有金额计算用RoundingMode.HALF_UP保留两位才彻底解决。6. 上线之后踩过的四个坑6.1 定时任务在集群环境里重复执行项目上线初期只部署了一台服务器定时任务跑得好好的。后来为了服务稳定性上了两台结果每天凌晨账单任务两个节点同时执行数据库里出现重复明细。虽然加了唯一约束没造成重复扣费但日志里大量报错也不好看。最终用分布式锁解决了执行任务前先在Redis里setnx lock:generate_bill 1 ex 30拿到锁的节点才执行执行完释放。如果任务可能超过30秒锁时间要设置成任务耗时的两倍以上或者加看门狗续期。业务量不大的项目这个方案已经够用。6.2 一个统计SQL把接口拖到超时“首页统计”这个接口我给过教训。最初版本用一条SQL把老人表、床位表、支付表join在一起通过一个子查询统计每个老人当月费用。测试环境两万条数据时响应500毫秒看着没问题运营跑了两个月数据到二十万条接口直接超时。后来用EXPLAIN看执行计划发现问题出在or条件和日期格式化函数导致索引失效。改成两个聚合查询分别统计人数和费用再用Redis缓存五分钟问题彻底解决。这里想多说一句接口性能测试一定要用接近线上量级的模拟数据。我见过很多项目用几百条数据测试接口响应秒开上线三周开始卡问题都出在索引和全表扫描上。养成写SQL后顺手跑一遍EXPLAIN的习惯能省掉后面很多麻烦。6.3 移动端浮点数展示错误家属端小程序上线后有家属反馈缴费页面余额显示不对明明是233.10元页面显示成233.1还有一笔账显示成了233.0999999999。排查下来是后端把BigDecimal直接序列化成JSON前端再用JavaScript解析浮点数精度丢失。解决办法有两个后端在序列化配置里把BigDecimal统一转成String返回前端拿到金额一律按字符串处理不在前端做加减乘除。最终我两者都做了后端序列化成字符串是兜底前端组件再额外做格式化双保险。顺带提一下接口文档里金额字段的示例值也最好写成字符串不然前后端联调时容易各按各的理解来等发现精度问题时已经上线不少天了。6.4 备份不能只靠数据库服务器本地盘有一次数据库服务器磁盘报警我下意识觉得“反正有自动备份”结果一查备份文件也在同一块磁盘上一旦磁盘损坏或主机宕机备份和库一起没。那之后我把备份策略改成两段式每天凌晨用mysqldump导出全量SQL压缩后上传到远程对象存储保留最近7天同时开启MySQL binlog保留3天用于增量恢复。恢复能力不能光看备份脚本跑没跑成功还要定期找一台临时实例做恢复演练不然等真要用的时候才发现备份文件损坏就晚了。我会在后台加一个“备份状态”列表展示最近一次备份时间、文件大小、是否上传成功。运维只需要每天早上看一眼列表比翻服务器日志靠谱很多。做这套养老中心管理系统给我最大收获的一句话是技术架构本身不复杂复杂的是把业务流程里那些“人情味”的细节翻译成代码。比如退住按天算钱、护工批量录入体温、家属端只看到自己老人的信息——这些需求听起来都很小但每一条都在真实影响老人和家属的感受。如果重新做一次我会在需求阶段把评估环节的护理等级变化和费用联动抠得更细因为那是养老机构最关注也最容易出纠纷的地方。写代码的人多去现场待几天比多换几套技术栈管用得多。现在看到家属端能随时收到老人健康异常提醒护工不用加班抄体温表我觉得那几晚通宵改SQL是值得的。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进