ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Java养老管理系统源码实战:Spring Boot + MyBatis-Plus 后端工程设计与实现

Java养老管理系统源码实战:Spring Boot + MyBatis-Plus 后端工程设计与实现

简介:面向老年人养老服务场景的Java项目源码,基于Java开发的中州养老系统设计源码,是一套涵盖用户界面、业务处理与数据配置的完整养老信息化平台。资源包共115个文件,包括98个Java源文件、13个XML配置文件、1个YML、1个JSON及Git忽略文件与文本说明,整体压缩包仅351KB,结构紧凑且便于部署。Java源文件构成系统主体,覆盖登录控制、房间类型管理、访问记录、公共工具类等模块;XML与YML则用于数据库连接、框架参数及网络通信配置,Git忽略文件辅助维护项目整洁。项目划分为common、web、service三个子模块,分别提供通用工具封装、Web交互处理与核心业务逻辑,可支撑养老信息管理、服务预约、健康监测等典型功能。已有347人学习查阅,适合Java初学者或养老服务相关开发者参考其分层架构、模块划分与配置方式,快速获取一套可运行的完整项目设计思路与实现参考。

1. 中州养老系统是什么:从一张Excel表到一整套Java后端工程

在很多二线城市的养老机构和社区日间照料中心,“管理”还停留在Excel加纸质台账的阶段:老人入住登记、护理员排班、每日服务记录、月底结算,全靠人工对账。中州养老系统就是冲着这个场景来的——一个Java后端工程,用Spring Boot把老人档案、护理工单、收费结算揉成几个互相咬合的模块,最后以源码和设计文档的形式整体交付。对开发者来说,它解决的真正问题不是“多个界面能点一点”,而是后端基本功:状态机怎么流转、账单怎么算、并发怎么防。读源码的人,既有要交Java课程设计、Java毕业设计的在校生,也有想给小型机构攒内部系统的工程开发。

2. 技术选型与工程骨架:为什么是Spring Boot + MyBatis-Plus,而不是一上来就上微服务

2.1 选型理由:业务体量决定架构复杂度

先看业务体量再来选型。社区养老院一般是一百到三百张床位,护理员三四十人,一天产生的服务工单在几百到上千条。这个量级下的数据特征非常明显:表多、表间关联强、字段状态复杂,但并发峰值很低,管理端十几个用户同时操作已经算高峰。面对这种项目,微服务、MQ、Redis缓存那些分布式组件几乎都没必要,反而会把一个本来半天能跑通的单体工程拆得七零八落。所以最常见的做法是把技术栈锚定在 Spring Boot + MyBatis-Plus + MySQL,再配一点常用工具类,就能覆盖“老人档案、护理工单、结算统计”这条主线。

具体版本上,我的习惯是 Spring Boot 2.7.x + JDK 8,而不是一上来追 Spring Boot 3。原因很现实:目前大量院校和企业里的Java基础环境还停留在 JDK 8,网上能搜到的 java 面试题、java 基础教程也多数基于 JDK 8;Spring Boot 3 强制要求 JDK 17,如果你目标机器上没有对应版本,启动阶段就会先栽一个跟头。如果是在全新服务器上部署,想用 JDK 17 配 Spring Boot 3.2 也没问题,但下面的源码示例我仍然按 2.7.x 写,这和 MyBatis-Plus 3.5.x 配合最熟。

前端部分,标题里只提了Java,我就默认后台管理界面用最简单的 Vue 2 + ElementUI,或者直接挂若依那套模板。核心是后端接口要稳,前端只是消费这些接口。用这种方式的另一个好处是,课程设计或毕设答辩时,你可以讲清楚“后端某一层在哪、为什么这么分层”,比包一层开箱脚手架的解释更可控。

2.2 Maven 多模块划分:把业务和基础设施分开

既然要做的是设计源码,工程组织方式得让人顺着目录就能看懂。常见做法是Maven多模块,而不是把一个工程全部堆在src下面。我一般这样分:

zhongzhou-parent(父POM,管理公共依赖版本) ├── zhongzhou-framework(框架层:配置、拦截器、通用工具、JWT鉴权) ├── zhongzhou-system(系统层:用户、角色、权限、登录) ├── zhongzhou-business(业务层:老人档案、护理工单、收费结算) └── zhongzhou-admin(启动模块:Controller入口、启动类)

依赖方向要严格向下:admin 依赖 business,business 依赖 system 和 framework,谁也不能反向依赖。这样拆的好处是,后面做测试或者换数据库时,你只动框架层,业务层不用跟着改。很多毕业设计源码是把 controller/service/mapper 全平铺在一个包里,看起来也能跑,但想扩一个“报销管理”模块时,目录就开始乱,类之间互相耦合,这是我在实际评审里见得最多的翻车点。

2.3 最小可运行骨架:pom.xml 与 application.yml

父POM里锁定三个东西即可:Spring Boot 版本、MyBatis-Plus 版本、一些工具类版本。需要完整拉一套可直接编译的依赖时,把下面这段贴到 zhongzhou-parent 的 pom.xml:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <properties> <java.version>1.8</java.version> <mybatis-plus.version>3.5.3.1</mybatis-plus.version> <jjwt.version>0.11.5</jjwt.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>${mybatis-plus.version}</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>${jjwt.version}</version> </dependency> </dependencies>

这里有一个平时容易被忽略的参数:spring-boot-starter-parent 本身会把日志、测试等常用依赖带进来,所以不需要单独引 spring-boot-starter-test;后面第6章要写集成测试时,直接复用父POM依赖即可。jjwt 我选 0.11.x,因为它的 API 拆成 api/impl/jackson 三个包,避免了老版本里一堆短横线依赖永远导不全的问题。

然后是 application.yml。我偏爱把配置写在 yml 而不是 properties,因为层级结构对“数据源 + MyBatis-Plus + Jackson”这种多个框架的配置,可读性更好:

server: port: 8080 servlet: context-path: /api spring: datasource: url: jdbc:mysql://127.0.0.1:3306/zhongzhou_elderly?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: zhongzhou123 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: delFlag logic-delete-value: 1 logic-not-delete-value: 0 mapper-locations: classpath*:mapper/**/*.xml

几个参数值得记一下。url 里的 serverTimezone=Asia/Shanghai 是必须的,不加它,MySQL 8 驱动在 JDK 8 下会因为时区差异报错;context-path 统一成 /api 之后,所有 Controller 的路径前面自动带上 /api,部署时前端反向代理直接按这个前缀转发。logic-delete-field 表示全局逻辑删除字段名,实体里出现 delFlag 字段后,MyBatis-Plus 会自动把它拼成 update 和 delete 的条件,而不是物理删除。这个功能在老人档案上很有用:老人退住只是状态变化,档案不能真删,否则历史账单对不上。

mapper-locations 这条容易漏。如果你的数据访问层没有用注解SQL,而是把SQL写到 resources/mapper 下的 XML,就必须声明这个位置。漏掉它的直接后果是项目能启动,但只要一查带复杂条件的列表,就会报 Invalid bound statement (not found)。

3. 数据库设计与核心表:elderly_info、service_order 与结算闭环

养老系统的数据库设计不追求模型花哨,但每张表的状态字段必须能自圆其说。下面我用三张最核心的表把整个业务串起来。

3.1 老年人档案表:别把“年龄”当字段存

先建老人档案表 elderly_info。这里最容易犯的错是设计一个 age 字段,每年年初还要写定时任务给所有人年龄加一。正确做法是只存 birth_date 出生日期,年龄由查询时计算,或者交给前端按生日算。这样数据不变,逻辑也干净。建表语句如下:

CREATE TABLE `elderly_info` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `elderly_no` varchar(20) NOT NULL COMMENT '档案编号,如20250101', `name` varchar(30) NOT NULL COMMENT '老人姓名', `gender` tinyint NOT NULL DEFAULT 1 COMMENT '1男 2女', `birth_date` date DEFAULT NULL COMMENT '出生日期', `id_card` varchar(18) DEFAULT NULL COMMENT '身份证号', `phone` varchar(20) DEFAULT NULL COMMENT '紧急联系电话', `bed_no` varchar(20) DEFAULT NULL COMMENT '床位号', `care_level` tinyint NOT NULL DEFAULT 3 COMMENT '护理等级:1全护 2半护 3自理', `checkin_date` date DEFAULT NULL COMMENT '入住日期', `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:1登记 2在住 3外出 4退住', `del_flag` tinyint NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1删除', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_elderly_no` (`elderly_no`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='老人档案表';

这里要解释三个设计决策。第一,elderly_no 生成规则别玩花,直接用“年月日+每日流水号”最简单,比如 20250101001,生成逻辑放在 service 层而不是数据库,否则并发下容易出现重复编号。第二,status 和 del_flag 是两个不同维度:status 是业务状态(在住/外出/退住),del_flag 是数据是否对用户可见;一个退住老人只是 status 变成4,但 del_flag 不能变,否则历史报表就缺数据。第三,所有金额都不在这张表出现,老人档案只存“照护等级”,费用由第三章的工单流程推导。

3.2 护理工单表:状态机是这单业务的主心骨

老人入住后,每天发生的是护理员执行各种服务:送餐、助浴、巡房、康复理疗。每一件事都应该落到一张 service_order 工单上,而不是散落在聊天记录和值班日志里。这张表的设计直接决定了系统能不能统计出“这个月每个护工做了多少单,每位老人用了多少服务”:

CREATE TABLE `service_order` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `order_no` varchar(32) NOT NULL COMMENT '工单号,如20250101001', `elderly_id` bigint NOT NULL COMMENT '老人档案ID', `worker_id` bigint DEFAULT NULL COMMENT '护理员ID', `service_type` tinyint NOT NULL COMMENT '服务类型:1助浴 2送餐 3巡房 4康复理疗', `plan_time` datetime DEFAULT NULL COMMENT '计划服务时间', `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0待派单 1待执行 2已完成 3已取消', `finish_time` datetime DEFAULT NULL COMMENT '实际完成时间', `remark` varchar(255) DEFAULT NULL COMMENT '备注', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_elderly_id` (`elderly_id`), KEY `idx_worker_id` (`worker_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='护理工单表';

工单的状态流转只有四个值,0待派单到1待执行,再到2已完成,或者任意状态到3已取消。这个状态机要收敛,不要允许待执行直接跳已完成再跳回待派单。我在代码里会用“条件更新”来强制这个流转,比如派单接口的 SQL 一定是 update ... set status=1 where id=? and status=0,只有影响行数为1,前端才提示“派单成功”。这种防御写法是并发场景下的保命招,后面第5章会专门展开。

3.3 收费结算:从服务记录到应收账单的闭环

月底结算在养老机构里是很容易扯皮的事,因为服务是零散的,费用却有包月、按次、陪护加收等好几种。我的建议是不要试图用一张表装下所有费用,而是拆“汇总表 + 明细表”两层:

CREATE TABLE `settle_bill` ( `id` bigint NOT NULL AUTO_INCREMENT, `bill_no` varchar(32) NOT NULL COMMENT '账单号,如202501', `elderly_id` bigint NOT NULL, `period` varchar(7) NOT NULL COMMENT '账期,如2025-01', `total_amount` decimal(10,2) NOT NULL DEFAULT 0.00, `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待确认 1已确认 2已收款', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_elderly_period` (`elderly_id`, `period`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='月度结算汇总表'; CREATE TABLE `settle_bill_detail` ( `id` bigint NOT NULL AUTO_INCREMENT, `bill_id` bigint NOT NULL, `source_order_id` bigint NOT NULL COMMENT '来源工单ID', `fee_type` varchar(20) NOT NULL COMMENT '费用类型:床位费/护理费/餐费/理疗费', `amount` decimal(10,2) NOT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_bill_id` (`bill_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='结算明细表';

结算的生成规则:每月1号,对上一个账期里 status=2 的已完成工单按 elderly_id 分组汇总,再叠加固定费用项目(床位费、基础护理费),生成 settle_bill 和 settle_bill_detail。这样做的好处是每笔应收都有出处,老人家属来问“这300怎么来的”,可以直接从明细反查到具体某一天的工单。注意 total_amount 不要用数据库的触发器来算,而是在 Java 事务里先查明细再写汇总,这样对比对账的时候有日志可查。

4. 核心接口落地:JWT 登录、档案 CRUD 和工单状态流转

数据库设计到位后,代码层面的主干就是三块:登录鉴权、档案管理、工单流转。

4.1 JWT 登录与权限控制:拦截器里做角色校验

养老系统不需要复杂的OAuth体系,管理端用户量就几十人,用 JWT + HandlerInterceptor 是最常见也是性价比最高的做法。我一般会写一个 JwtUtil,只负责生成和解析 token:

public class JwtUtil { private static final byte[] SECRET = "zhongzhou-elderly-system-2025-secret".getBytes(StandardCharsets.UTF_8); private static final long EXPIRE_MILLIS = 24 * 60 * 60 * 1000L; private static final Key KEY = Keys.hmacShaKeyFor(SECRET); public static String createToken(Long userId, String username, String role) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .claim("role", role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_MILLIS)) .signWith(KEY, SignatureAlgorithm.HS256) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder().setSigningKey(KEY).build() .parseClaimsJws(token).getBody(); } }

然后配一个拦截器,只拦截需要登录的接口,比如 /api/elderly/** 和 /api/order/**,登录接口本身放行:

@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String auth = request.getHeader("Authorization"); if (StringUtils.hasText(auth) && auth.startsWith("Bearer ")) { try { Claims claims = JwtUtil.parseToken(auth.substring(7)); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { // token过期或签名错误,统一落401 } } response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } }

这里的两个参数值得说明。SECRET 在演示代码里写死了,真正部署时我会放到 application-prod.yml 里,用 @Value("${jwt.secret}") 注入,避免密钥随源码泄露。EXPIRE_MILLIS 设24小时,管理端用户一天登录一次够用;如果机构要求更细的权限(比如只能看不能改),可以在 claim 里多放一个 role,然后在 Controller 方法上用注解或者硬编码判断,这一点在课程设计的演示里很加分。

4.2 档案 CRUD 用 MyBatis-Plus 还是原生 SQL:分页条件怎么拼

老人档案的 CRUD 相对模板化。我的经验是:单表增删改查、条件查询、分页,直接用 MyBatis-Plus 的 LambdaQueryWrapper;一旦出现多表关联、跨表统计、复杂动态SQL,再上 XML 写原生SQL。这样既不吃全用 XML 的苦,也不至于把越来越复杂的SQL塞进注解。

下面是一个典型的档案分页查询实现:

@Service public class ElderlyInfoServiceImpl implements ElderlyInfoService { private final ElderlyInfoMapper elderlyInfoMapper; public ElderlyInfoServiceImpl(ElderlyInfoMapper elderlyInfoMapper) { this.elderlyInfoMapper = elderlyInfoMapper; } @Override public PageResult<ElderlyInfo> pageQuery(ElderlyPageQuery query) { LambdaQueryWrapper<ElderlyInfo> wrapper = Wrappers.<ElderlyInfo>lambdaQuery() .eq(query.getStatus() != null, ElderlyInfo::getStatus, query.getStatus()) .like(StringUtils.hasText(query.getName()), ElderlyInfo::getName, query.getName()) .eq(StringUtils.hasText(query.getCareLevel()), ElderlyInfo::getCareLevel, query.getCareLevel()) .orderByDesc(ElderlyInfo::getCreateTime); Page<ElderlyInfo> page = elderlyInfoMapper.selectPage(new Page<>(query.getPageNum(), query.getPageSize()), wrapper); return new PageResult<>(page.getTotal(), page.getRecords()); } }

注意这里的动态条件写法:eq 的第一个参数是布尔值,为 true 才拼这个条件,否则不拼。用这种方式,三个筛选条件无论前端传不传,都不会产生多余的 SQL 片段,也不用手动去拼字符串。另一个参数是 new Page<>(pageNum, pageSize),它必须配合第5章要讲的分页插件才真正生效,否则这个分页对象会被忽略,SQL会查全表。实际项目中,我还会在 Page 上把 searchCount 保持默认 true,让 MyBatis-Plus 自动发一条 count 语句。

档案新增时也别忽略基础校验:身份证号不存满18位、手机号格式不对,这些都属于典型的脏数据入口。我会在 Service 层做一层手工校验,而不是靠数据库报错兜底,因为前端可以绕过校验直接调接口,数据库报错返回给用户的提示又太生硬。

4.3 工单派单与状态流转:影响行数大于0才算数

工单流转是整个系统里最需要谨慎的代码,因为它涉及并发和状态一致性。派单接口的 Service 方法我这样写:

@Transactional(rollbackFor = Exception.class) public boolean assignOrder(Long orderId, Long workerId) { int rows = serviceOrderMapper.update(null, Wrappers.<ServiceOrder>lambdaUpdate() .set(ServiceOrder::getWorkerId, workerId) .set(ServiceOrder::getStatus, 1) .set(ServiceOrder::getUpdateTime, new Date()) .eq(ServiceOrder::getId, orderId) .eq(ServiceOrder::getStatus, 0)); return rows > 0; }

这段代码的核心是 .eq(ServiceOrder::getStatus, 0)。更新时强制带上原来状态,等于在数据库层面做了乐观锁:如果两个管理员同时点派单,数据库的更新锁会让第二个请求的 update 影响 0 行,接口就返回失败提示“该工单已被处理”,这就避免了重复派单。同理,完成工单时也应该带上 status=1:

@Transactional(rollbackFor = Exception.class) public boolean finishOrder(Long orderId) { int rows = serviceOrderMapper.update(null, Wrappers.<ServiceOrder>lambdaUpdate() .set(ServiceOrder::getStatus, 2) .set(ServiceOrder::getFinishTime, new Date()) .eq(ServiceOrder::getId, orderId) .eq(ServiceOrder::getStatus, 1)); return rows > 0; }

这个模式下,工单每走一步都是“声明我要把某个状态变成什么,同时也要求它当前就是什么”。我在实际项目里用它扛住了不少重复提交、双击按钮、接口重试的请求,成本极低,收益很直接。@Transactional 放在这里的作用是,如果后续要在同一事务里写操作日志或更新台账,任何一个环节抛异常,状态更新也会一起回滚,不会出现“工单状态变了但日志没记上”的尴尬局面。

5. 避坑与排查:养老系统源码落地最常见的5个翻车现场

这一章列几个我在维护和评审养老系统源码时真实见过的坑,每一条都按“现象-原因-解决”来写。

5.1 MyBatis-Plus 实体类生成建表 SQL 不生效

现象:写完实体类,启动工程,控制台没有建表日志,数据库里也没出现表。有人会想到“mybatisplus根据java实体类生成创建表的sql语句”这个功能,但 MyBatis-Plus 并不在运行时自动建表。它只有代码生成器,能在开发期根据数据库表反向生成实体,或者根据实体反向生成建表SQL,但这是开发工具行为,不是运行时的建表策略。

原因:把 MyBatis-Plus 当成 Hibernate 的 ddl-auto 用了。Spring Data JPA 可以通过配置在启动时自动建表,MyBatis-Plus 不行。

解决:两条路任选。要么手写 sql 建表文件,在 application.yml 里配 spring.sql.init.schema-locations,让启动时执行;要么用 FastAutoGenerator 在开发期生成 SQL 和代码。FastAutoGenerator 的配置片段长这样:

FastAutoGenerator.create("jdbc:mysql://127.0.0.1:3306/zhongzhou_elderly?serverTimezone=Asia/Shanghai", "root", "zhongzhou123") .globalConfig(builder -> builder.author("zhou").outputDir("/tmp/zhongzhou-gen")) .packageConfig(builder -> builder.parent("com.zhongzhou.business")) .strategyConfig(builder -> builder.addInclude("elderly_info", "service_order", "settle_bill")) .execute();

这段代码的 addInclude 参数决定了生成哪些表,outputDir 指定输出目录。注意生成出来的实体类字段命名默认是驼峰风格,如果数据库字段是 create_time,实体就是 createTime,这正好和 yml 里的 map-underscore-to-camel-case 配套。跑完生成器后,建议把实体类里所有数字类型字段逐个核对一遍,尤其是 decimal 对应的 Java 类型,默认生成 BigDecimal,如果业务里不涉及精度运算,可以用 BigDecimal 保留,别自己改成 Double。

5.2 实体带 age 字段导致查询报错,以及日期类型的空指针

现象:老人档案实体里加了 private Integer age,数据库没有 age 列,一查询就报 Unknown column 'age' in 'field list'。另一个现象是 birth_date 存的值是 null,查询出来之后业务代码直接拿它做计算,抛空指针。

原因:第一个是实体类字段和表字段没对齐,MyBatis-Plus 默认把实体所有属性当表字段映射。第二个是日期字段本身可空,代码没有判空。

解决:对于非表字段,加 @TableField(exist = false) 注解,明确告诉 MyBatis-Plus 这个字段不参与SQL映射。在实体里,age 要么不写,要写就用 @TableField(exist = false) 标记,并在 getter 里现场用 birthDate 计算,比如:

@TableField(exist = false) public Integer getAge() { if (this.birthDate == null) { return null; } return Period.between(this.birthDate, LocalDate.now()).getYears(); }

这样既不用维护 age 列,也不会出现常年不改的脏数据。至于日期空指针,统一在取值处做防御式判断,不要试图在数据库层面把所有日期都填上 1900-01-01 这种假值,那样会让报表和前端日历都出现更奇怪的问题。

5.3 结算重复生成,和工单重复取消

现象:点击“生成月度账单”按钮后,同一位老人的账单生成了两条一模一样的记录,总金额翻倍。点击取消工单也出现类似问题,同一条工单被取消了两次。

原因:这是典型的不带状态条件的更新。取消工单的常见错误写法是 update service_order set status=3 where id=?,第一条语句执行后数据已经变成3,第二条再执行,照样成功,因为 where 里只有 id 没有状态条件。这样数据库并不知道“已经被取消过的单子不该再被取消”,于是结果被重复累积。

解决:所有状态变更都带前置状态条件,并且在事务里先查再写。取消工单应该写成 update service_order set status=3 where id=? and status in (0,1)。同理,生成结算单时用数据库唯一约束兜底:

ALTER TABLE settle_bill ADD UNIQUE KEY uk_elderly_period (`elderly_id`, `period`);

这样即使代码里漏了幂等判断,数据库也会拒绝第二次插入同一老人同一账期的汇总账单。

5.4 分页查询慢且总数不对

现象:列表接口没走 limit,一次查出几千条数据,前端卡死;或者 count 查询返回的结果是全表总数。

原因:MyBatis-Plus 的分页插件没有注册。很多人以为用 Page 对象就会自动分页,实际上必须配置 MybatisPlusInterceptor,否则分页参数被忽略,SQL 查全表。

解决:在配置类里加一个 Bean:

@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }

PaginationInnerInterceptor 就是那个真正往 SQL 尾部拼 limit 的核心。它有一个参数 maxLimit,默认值是无限制,我建议在管理端把它设成100,防止有人用 pageSize=99999 这种参数把数据库拖垮。数据量超过几百万行之后,深分页仍是痛点,我会把按 id 倒序的列表改成“上一页游标”方式,用 create_time 或 id 做续传条件,而不是继续用 offset。

5.5 jar 包部署后总是 401 或 404

现象:源码在 IDE 里跑得好好的,打成 jar 之后部署到服务器,接口要么 401,要么 404,前端页面也打不开。

原因:这里通常有三种情况混在一起。第一,JWT 的 SECRET 在代码里写死,换服务器环境后 token 签名验证失败,导致401。第二,context-path 或静态资源路径没配,前端打包后放在 static 目录下,请求 /api 后端接口没问题,但 index.html 里如果不带前缀就404了。第三,用 java -jar 启动时工作目录变了,配置文件没跟上。

解决:把密钥、数据库密码、端口这些易变项全部外置到 application-prod.yml,打包时用 --spring.profiles.active=prod 启动;静态资源放到 src/main/resources/static 下,并确认 server.servlet.context-path 与前端请求前缀一致。遇到 401 先看拦截器日志,token 解析失败是不是密钥不匹配;遇到 404 先确认后端有没有接口,再去排查前端资源请求路径。这一套排查思路比打开源码找半天更有效率。

注意:第5.1条里提到的“实体类生成建表SQL”是开发期工具,不是生产依赖。在生产环境,建表应该走受控的 SQL 脚本,而不是让谁都能运行生成器改库。

6. 进阶验证:不打开浏览器也能把养老工单闭环跑通

6.1 用 JUnit 跑通“建档-派单-完成”闭环

源码交付后,最怕的是“能启动但业务链是断的”。我建议在工程里放一个集成测试,用一个内存库把主链路验证一遍。常见做法是用 H2 的 MySQL 模式,在测试配置里把数据源切到 H2,然后跑一次完整流程:

@SpringBootTest class ElderlyFlowTest { @Autowired private ElderlyInfoMapper elderlyInfoMapper; @Autowired private ServiceOrderMapper serviceOrderMapper; @Test void elderCareFlow() { ElderlyInfo e = new ElderlyInfo(); e.setName("张奶奶"); e.setBirthDate(LocalDate.parse("1940-05-01")); elderlyInfoMapper.insert(e); ServiceOrder order = new ServiceOrder(); order.setElderlyId(e.getId()); order.setServiceType(3); serviceOrderMapper.insert(order); int assigned = serviceOrderMapper.update(null, Wrappers.<ServiceOrder>lambdaUpdate() .set(ServiceOrder::getStatus, 1) .eq(ServiceOrder::getId, order.getId()) .eq(ServiceOrder::getStatus, 0)); assertEquals(1, assigned); } }

这个测试的价值在于把状态流转固化下来:以后谁改动工单逻辑,只要测试不过,就能立刻看出是派单还是完成断了。配合乐观锁写法,还能验证并发下不会被二次派单。

6.2 部署验证与一个审计留痕的小习惯

打包部署后,别急着点界面,先用命令确认后端活着:

java -Xms256m -Xmx512m -Dspring.profiles.active=prod -jar zhongzhou-admin.jar

-Xms 和 -Xmx 是初始堆和最大堆,200位老人的规模给512M上限足够。启动后先 curl 健康检查接口,确认返回 200 再联调页面。

我最后想分享一个教训:早期做一个类似系统,我把取消工单写成了 update status=3 where id=?,结果月底结算怎么都对不上。后来养成两个习惯:所有业务表都带 status 和 update_time;每次状态变更写审计日志,记录谁在什么时间把哪条单从什么状态改成了什么状态。这样就算业务算错也能倒查。这套习惯现在也放到你手里的养老系统上,希望能帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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