1. 项目整体设计与技术选型思路
1.1 为什么选择 SpringBoot2 + Vue3 这套前后端组合
社区养老服务平台这类项目,说白了就是给社区里的老人、家属、护工和运营管理方搭一个线上协同系统。核心场景很明确:老人或者家属在小程序或者网页上提交服务需求(比如助餐、助洁、助医、陪护),平台侧生成工单并派给护工,护工上门服务后回填记录,管理方查看工单完成率、健康档案数据、服务评价等信息。
我做完这个项目最大的感受是:技术栈选得稳,比选得新更重要。SpringBoot2.x 目前仍然是企业级项目里覆盖率最高的后端框架,生态成熟、资料多、出问题容易排查;Vue3 配合组合式 API 和<script setup>写法,开发体验比 Vue2 时代好了一个档次,而且 Element Plus 组件库已经相当完善,做管理端和可视化的效率非常可观。这套组合既不会像 SSM 裸配那样到处写 XML 配到崩溃,又不像微服务全家桶那样把简单问题复杂化——单机部署、单体应用、前后端分离,恰好是社区养老这类中小型平台最合理的工程形态。
再说 MyBatis-Plus。很多人会问“为什么不用 JPA”,我的理由很简单:社区养老的业务模型不复杂,但查询逻辑很杂——按老人姓名模糊查、按工单状态统计、按时间段聚合服务量、按机构ID隔离数据,这些场景 MyBatis-Plus 的 LambdaQueryWrapper 写起来非常直接,动态条件拼接几乎就是一行 lambda 的事。JPA 在关联查询和动态条件组合上反而容易写出让人头大的 Specification。MyBatis-Plus 保留了 SQL 的可控性,又用内置 CRUD 方法把单表操作压缩到极致,是这种“多表查询为主、单表操作为辅”的项目里效率最优的选择。
MySQL 8.0 没什么好争论的,窗口函数、公用表表达式、更好的 UTF-8 支持,加上默认的caching_sha2_password认证插件,无论做统计报表还是处理中文数据都比 5.7 舒服。这个项目里我用到了几个窗口函数做服务量趋势统计,在 5.7 上要写子查询嵌套,在 8.0 里一行ROW_NUMBER() OVER (PARTITION BY ...)就搞定。
1.2 核心模块拆分与边界设计
整个系统我在设计阶段就把它拆成了五个核心域:
- 用户与权限域:平台管理员、护工人员、老人、家属四种角色,基于角色的访问控制,JWT 做无状态认证。
- 老人档案域:老人基础信息、健康档案、家属绑定关系、居住地址和紧急联系人。
- 服务运营域:服务项目定义、工单创建、派单、接单、服务完成、评价回访,这是全系统最核心的状态机。
- 健康数据域:血压、血糖、心率、用药提醒、体检记录,支撑健康趋势图表展示。
- 内容与公告域:政策通知、社区活动发布、公告轮播图。
边界设计上有一条原则:老人档案域和服务运营域必须物理隔离。老人基本信息只允许新增和修改,不允许物理删除,只能做逻辑删除(deleted字段标记);所有涉及服务工单的表必须保留完整的操作日志。做养老平台和做普通电商不一样,数据要经得起审计追溯,服务记录一旦产生就不能消失。这条设计原则我在项目文档里重点写了,审查的时候也的确是被问得最多的点。
2. 数据库模型设计与核心表结构
2.1 角色权限与用户体系怎么落表
先看用户体系。我用了四张表,而不是常见的“一张用户表 + 一张角色表”就完事:
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', username VARCHAR(50) NOT NULL COMMENT '登录名', password VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码', phone VARCHAR(20) COMMENT '手机号', real_name VARCHAR(50) COMMENT '真实姓名', role_type TINYINT NOT NULL COMMENT '角色类型 1-管理员 2-护工 3-老人 4-家属', avatar_url VARCHAR(255) COMMENT '头像', status TINYINT DEFAULT 1 COMMENT '状态 1-启用 0-禁用', deleted TINYINT DEFAULT 0 COMMENT '逻辑删除标记', create_time DATETIME, update_time DATETIME, UNIQUE KEY uk_username (username) ) COMMENT '系统用户表';这里有个细节值得说道:我故意没有把老人和护工的扩展信息全部塞进sys_user。因为护工有技能证书、服务区域、评分等级这些字段,老人又有关联家属、紧急联系人、健康标签这些字段,强行合并会造出一张超级宽表,查询性能和维护性都差。所以我分别建了elder_info和worker_profile做扩展表,主表只保留统一的身份字段。这种“主表 + 扩展表”的模式在处理多角色的时候比到处建关联表清爽得多。
家属和老人的绑定关系我用了一张关联表elder_family_ref,字段就是elder_id、family_user_id、relationship(父子、夫妻等)。为什么单独建表而不是在老人表里加一个family_id?因为一个老人可以绑定多个家属,一个家属理论上也可以关联多位老人(比如同时照顾父母双方),多对多的关系用关联表是最自然的表达。
2.2 服务工单状态转换表设计
工单表是业务核心,我把它设计成带状态机的结构:
CREATE TABLE service_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '工单编号,格式:FW+日期+序列号', elder_id BIGINT NOT NULL COMMENT '老人ID', service_project_id BIGINT NOT NULL COMMENT '服务项目ID', worker_id BIGINT COMMENT '护工ID,派单后写入', status TINYINT DEFAULT 0 COMMENT '状态:0-待派单 1-已派单 2-服务中 3-已完成 4-已取消 5-待回访', appoint_time DATETIME COMMENT '预约上门时间', address VARCHAR(255) COMMENT '服务地址', remark VARCHAR(500) COMMENT '备注说明', actual_start_time DATETIME COMMENT '实际开始时间', actual_end_time DATETIME COMMENT '实际结束时间', evaluate_score TINYINT COMMENT '评价分数 1-5', evaluate_content VARCHAR(500) COMMENT '评价内容', deleted TINYINT DEFAULT 0, create_time DATETIME, update_time DATETIME, KEY idx_elder (elder_id), KEY idx_worker (worker_id), KEY idx_status (status), UNIQUE KEY uk_order_no (order_no) ) COMMENT '服务工单表';我把状态字段设计成TINYINT数字而不是字符串,原因很简单:数字占空间小、索引效率高,同时可以在代码里用枚举类统一映射,避免字符串拼写不一致的问题。
这里要重点说明的是evaluate_content和回访状态的关系。业务上要求:工单完成之后 48 小时内必须有回访记录,回访分数和内容填完之后,工单才真正进入终态。这个约束如果在代码里写死容易漏,所以我把它做成了定时任务扫描:每五分钟查一次status = 3 AND actual_end_time < NOW() - INTERVAL 48 HOUR AND id NOT IN (SELECT order_id FROM follow_up_log)这样的条件,生成待回访提醒。这种“数据库条件 + 定时任务补偿”的写法,比单纯在接口层校验可靠得多。
2.3 健康档案表的动态字段处理
健康数据这块一开始我踩过坑。第一版设计是把血压、血糖、心率这些指标直接做成字段,结果发现不同老人的数据完整度差异巨大——有人只测血压,有人同时记录血糖和体重,空字段太多。后来我改成了“检测记录 + 指标值”的 EAV 简化模式:
CREATE TABLE health_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, elder_id BIGINT NOT NULL, record_type VARCHAR(30) COMMENT '检测类型:blood_pressure / blood_sugar / heart_rate / weight', metric_value VARCHAR(100) COMMENT '检测值,如 120/80、6.2mmol/L、72次/分', measure_time DATETIME COMMENT '检测时间', device_source VARCHAR(50) COMMENT '数据来源:手动录入/智能设备', remark VARCHAR(255), deleted TINYINT DEFAULT 0, KEY idx_elder_time (elder_id, measure_time) ) COMMENT '健康检测记录表';metric_value用字符串存,可能有人会觉得不严谨,但真实场景下血压值是120/80这种带斜杠的格式,血糖值要带单位,用 DECIMAL 反而存不了。我的做法是查询统计时在代码里做类型转换,展示和图表用原始字符串,计算平均值时用CASE WHEN配合正则提取数字。这套方案实测下来最灵活,不用为了支持新指标频繁改表结构。
3. 后端核心业务逻辑实现
3.1 JWT 认证与四角色鉴权细节
认证这块我用的 Sa-Token 而不是 Spring Security,这个选型可能有人不理解。我的考量是:Spring Security 对这种中小型项目来说学习曲线和配置成本偏高,过滤器链一多就难以调试;Sa-Token 提供了开箱即用的登录、权限、踢人下线功能,注解式鉴权写起来极其简洁。社区养老平台的角色只有四个,用 Sa-Token 的@SaCheckRole注解就够了:
@RestController @RequestMapping("/api/order") public class ServiceOrderController { // 只有护工可以接单 @SaCheckRole("worker") @PostMapping("/accept") public Result accept(@RequestBody AcceptOrderDTO dto) { orderService.acceptOrder(dto.getOrderId()); return Result.success(); } }权限控制的核心不只是角色判断,还有数据范围隔离。护工只能看到派给自己的单,管理员能看全部,老人只能看自己的记录。这个我通过 AOP 切面 + 自定义注解实现:在查询接口的 Service 层注入当前登录人的上下文,MyBatis-Plus 的条件构造器自动拼接worker_id = 当前用户ID或elder_id = 当前用户ID。这样即使前端恶意传参,后端也天然隔离,不只是“隐藏按钮”的假权限。
JWT 这块要特别注意过期时间。我把 access token 设了 2 小时,refresh token 设了 7 天。为什么不做 24 小时的长 token?因为养老服务涉及老人的住址、健康信息等敏感数据,token 泄露的风险窗口越短越好。为此我实现了一个刷新接口,在前端 axios 拦截器里检测 401 后自动携带 refresh token 换取新 token,用户感知上就是“无感续期”,体验和长 token 没有区别,安全性却高得多。
3.2 工单派单与状态流转实现
工单状态流转是整个项目里最容易写乱的部分。我用了状态机校验的方式,而不是在每个接口里散落地写 if-else:
public enum OrderStatus { PENDING(0, "待派单"), ASSIGNED(1, "已派单"), SERVING(2, "服务中"), FINISHED(3, "已完成"), CANCELED(4, "已取消"), FOLLOWED(5, "已回访"); private static final Map<Integer, List<Integer>> TRANSITIONS = new HashMap<>(); static { TRANSITIONS.put(0, Arrays.asList(1, 4)); // 待派单 -> 已派单 / 取消 TRANSITIONS.put(1, Arrays.asList(2, 4)); // 已派单 -> 服务中 / 取消 TRANSITIONS.put(2, Arrays.asList(3, 4)); // 服务中 -> 已完成 / 取消 TRANSITIONS.put(3, Arrays.asList(5)); // 已完成 -> 已回访 // 已取消、已回访为终态,不再流转 } public static boolean canTransfer(int from, int to) { List<Integer> allowed = TRANSITIONS.get(from); return allowed != null && allowed.contains(to); } }每个改变状态的 Service 方法第一步都是调用OrderStatus.canTransfer(oldStatus, newStatus),不合法直接抛业务异常。这样做有两个好处:第一,状态流转规则集中在一处,审查代码时看这个枚举就明白全部业务规则;第二,将来加新状态(比如“已退款”)只需要改这一处,不会出现漏改的情况。
派单逻辑用了优先级匹配:先找服务区域匹配、且当前待派单数量最少的护工,避免有人忙死有人闲死。具体 SQL 是在worker_profile表里按区域过滤后,用ORDER BY pending_order_count ASC取第一条,这个字段在派单成功后通过事务更新,保证不会并发派给同一个人。事务里我先SELECT ... FOR UPDATE锁住护工记录,再更新工单状态,防止并发请求抢同一个护工。
3.3 健康趋势统计的窗口函数应用
统计报表模块我做了两个核心接口:老人近 30 天血压趋势、社区月度服务量分布。血压趋势我用 MySQL 8.0 的窗口函数直接算移动平均值:
SELECT DATE_FORMAT(measure_time, '%Y-%m-%d') AS day, AVG(CAST(SUBSTRING_INDEX(metric_value, '/', 1) AS DECIMAL)) AS avg_systolic, AVG(CAST(SUBSTRING_INDEX(metric_value, '/', -1) AS DECIMAL)) AS avg_diastolic, COUNT(*) AS measure_count FROM health_record WHERE elder_id = ? AND record_type = 'blood_pressure' AND measure_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) AND deleted = 0 GROUP BY DATE_FORMAT(measure_time, '%Y-%m-%d') ORDER BY day;这里有个 SQL 写法上的坑:SUBSTRING_INDEX(metric_value, '/', 1)取的是左值收缩压,-1取的是右值舒张压,但如果某条数据格式是120/80 mmHg,-1会取到80 mmHg导致强转失败。所以我在写入时就做了数据清洗,只存纯数值不存单位——展示层的单位由前端拼接。这条经验在项目文档里也标注了,做医疗类数据时原始数据清洗永远比查询时清洗更可靠。
月度服务量分布我用了一条 CTE 查询,按月份统计各服务项目的订单数,这个 SQL 我优化了两次才达到满意效果,第一次用子查询嵌套扫描了全表,第二次加上了deleted和create_time的联合索引,执行时间从 2.1 秒降到了 200 毫秒级别。
4. 前端工程化与页面落地
4.1 Vue3 项目结构与路由权限设计
前端这块我用的 Vite 做构建工具。相比 Webpack,Vite 的冷启动速度和热更新体验好太多,日常开发几乎不用等待编译;生产构建用 Rollup 打包,体积也控制得住。加上项目本身不涉及太复杂的兼容性要求,Vite 是 Vue3 项目的最优选。
目录结构分了两端:管理后台和老人客户端。管理后台用的是经典布局——左侧菜单 + 右侧内容区,路由按模块拆文件:
// router/index.js 核心示例 const routes = [ { path: '/login', component: () => import('@/views/login/index.vue'), }, { path: '/dashboard', component: Layout, children: [ { path: 'elder', component: () => import('@/views/elder/index.vue'), meta: { roles: ['admin'] } }, { path: 'order', component: () => import('@/views/order/index.vue'), meta: { roles: ['admin', 'worker'] } }, { path: 'health', component: () => import('@/views/health/index.vue'), meta: { roles: ['admin', 'elder'] } }, ], }, ];路由守卫我用的是前置守卫加白名单机制。判断逻辑是这样的:白名单内的页面(登录页、注册页、公告页)直接放行;其余路由先检查本地是否有 token,没有就跳登录页并记录redirect参数,登录成功后跳回原页面;有 token 就根据用户角色信息做动态路由注册。管理端和客户端共用登录逻辑,但注册完动态路由后渲染的组件不同,这个“同一认证体系、两端差异化路由”的架构减少了大量重复代码。
4.2 接口封装与 axios 拦截器
接口层的封装直接决定了开发效率。我先定义了一个统一返回体Result<T>,后端约定格式为{ code: 200, message: "success", data: {...} },前端封装如下:
// utils/request.js const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000, }); service.interceptors.request.use((config) => { const token = localStorage.getItem('access_token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); service.interceptors.response.use( (response) => { const res = response.data; if (res.code === 200) { return res.data; } if (res.code === 401) { return handleTokenRefresh(); } return Promise.reject(new Error(res.message)); }, (error) => Promise.reject(error) );这个拦截器里最容易出问题的是 token 刷新时的并发请求。如果同时有 5 个接口返回 401,5 个都会触发handleTokenRefresh(),导致 refresh token 被重复使用,后端把它标记失效后连续失败。我的解决思路是用一个全局变量记录刷新状态:第一个请求进入时设置isRefreshing = true,刷新完成前其他 401 请求把回调函数 push 进一个队列,刷新完成后统一重放。这属于前端并发控制的经典套路,写对了才能保证用户体验顺畅。
4.3 地图定位与日历组件集成的坑
老人服务上门需要地图选点,我接的是高德地图 JS API 2.0。这里有一个很容易踩的坑:Vite 构建时如果直接在 index.html 里加地图 Script,刷新页面时地图脚本会和路由懒加载产生竞争,偶发 error 提示“地图尚未加载完成”。解决方案是把地图脚本加载封装成 Promise,在调用地图组件的地方await loadMapScript()后再初始化实例:
let mapPromise = null; export function loadMapScript() { if (mapPromise) return mapPromise; mapPromise = new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = `https://apis.map.com.cn/maps?v=2.0&key=${import.meta.env.VITE_MAP_KEY}`; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); return mapPromise; }日历组件我用的 Element Plus 的el-calendar,但默认月份切换和自定义日期内容都要写date-cell插槽。我封装了一个ServiceCalendar组件,把待执行工单按日期聚合后渲染成日历下方的卡片列表,点击日期能看到当天所有工单。这个组件用了 Vue3 的computed监听当前月份变化,重新请求当月工单数据,因为接口返回的数据量不大,我直接在前端做了按日期分组,没让后端加聚合参数——简单场景下前端处理比多请求一版后端接口划算得多。
5. 本地部署运行全流程
5.1 环境准备与数据库初始化
如果你拿到这套源码想在本地跑起来,第一步是检查环境版本。踩过坑的人都知道,SpringBoot2.7.x 对应 Java 8 或 11 都可以,但如果你本机装的是 Java 17,某些版本组合下需要额外添加--add-opens参数才能跑起来,这个报错会让人误以为是代码问题。我建议直接用 Java 11,兼容性最好。
数据库初始化方面,项目源码里附带的 SQL 文件包含建表语句和演示数据。导入时注意执行顺序:先建库community_care,再执行完整 SQL。因为 MySQL 8.0 默认字符集是utf8mb4,这个不需要额外设置,但如果你手动建库却忘记了指定字符集,后续存入 emoji 表情数据就会报错。用源码里的init.sql文件就没这个问题,里面已经写好了:
CREATE DATABASE IF NOT EXISTS community_care DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE community_care; SOURCE community_care.sql;演示数据我准备了大约 20 位老人、8 名护工、60 条工单记录、300 条健康检测数据,足够把列表分页、趋势图表、派单匹配这些功能都演示出来。如果教程文档里没提到测试账号,登录页一般会有演示账号提示,找不到就看 SQL 文件里sys_user表 insert 语句,密码是统一用 BCrypt 加密的,初始密码通常是123456。
5.2 后端启动配置与常见报错
后端配置的核心是application.yml。需要注意三个地方:端口、数据库连接、JWT 密钥。
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/community_care?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 你的数据库密码 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0serverTimezone=Asia/Shanghai这个参数不写会报时区错误,或者出现时间差 8 小时的问题。这里我要提醒一句:数据库连接串里的时区、Jackson 的时区、MySQL 会话时区必须三段保持一致,有一处不一致就会出现插入时间和查询时间对不上的诡异问题。
启动报错最常见的两类:第一类是Public Key Retrieval is not allowed,这个在连接串里加allowPublicKeyRetrieval=true就能解决,原因是 MySQL 8.0 默认使用 caching_sha2_password 认证,需要安全连接获取公钥;第二类是端口被占用,Mac 和 Linux 用lsof -i:8080,Windows 用netstat -ano | findstr 8080,找到进程 kill 掉就行。这些都不是代码问题,但能卡住你半小时。
启动成功看到Tomcat started on port(s): 8080之后,先用浏览器访问http://localhost:8080/api/ping确认后端在线,再去跑前端。
5.3 前端安装与代理配置
前端环境要求 Node.js 16 以上,推荐 18 LTS 版本。进入web目录后依次执行:
npm install npm run devnpm install如果速度特别慢,先看是不是 npm 源的问题,执行npm config set registry https://registry.npmmirror.com换源再装。如果 node_modules 已经装得乱七八糟的,直接删除后重新安装,比手动修依赖快得多。
前端和后端联调最大的坑是跨域。我在 Vite 里配置了开发代理,这样前端访问/api时实际被转发到http://localhost:8080,浏览器侧没有跨域问题:
// vite.config.js export default defineConfig({ server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, }, }, }, });如果你不用代理而是直接在后端配@CrossOrigin或 CORS 过滤器,要注意预检请求的问题:前端用Authorization头属于非简单请求,会先发一次OPTIONS预检,后端如果没放行OPTIONS方法,就会出现“请求成功但拿不到响应”的现象。这个排查起来绕,不如前端代理顺手。
6. 常见问题排查与避坑实录
6.1 字段自动填充失效问题
MyBatis-Plus 的create_time和update_time自动填充是个容易忽略的坑。很多人只写了:
@TableField(fill = FieldFill.INSERT) private LocalDateTime createTime;然后发现新增数据时create_time是 null。原因是没有实现MetaObjectHandler接口,或者实现了但没注册成 Spring Bean。完整写法是这样的:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }strictInsertFill和strictFill的区别是前者只在字段值为空时填充,后者强制覆盖。如果你用了数据库层的DEFAULT CURRENT_TIMESTAMP又同时用代码填充,会导致两边冲突,建议统一由代码管理时间字段,数据库只保留DATETIME类型不带默认值。
6.2 分页查询返回 total 为 0 的修复
这个问题的症状是:列表数据能查到,但total永远是 0,前端分页组件显示不出页码。原因是 MyBatis-Plus 的分页插件没有正确注册。在 3.4.0 之前的版本,分页插件是PaginationInterceptor,之后改成了MybatisPlusInterceptor加PaginationInnerInterceptor的写法,旧配置在新版本里直接不生效但不报错,特别迷惑人。正确写法是:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }还有一个隐藏坑:分页插件必须加载在持久层扫描之前。如果你不小心在启动类上写了@MapperScan并且包里又有同名配置类,插件注册顺序错乱也会导致分页失效。建议配置类单独建包,不要和启动类堆在一起。
6.3 逻辑删除字段导致的唯一索引冲突
这个坑相当隐蔽。我用deleted做了逻辑删除,然后又给业务表加了唯一索引,比如uk_elder_phone (phone, deleted)。问题来了:逻辑删除记录的deleted值是1,新增同号码记录时deleted是0,唯一索引不冲突——这是对的。但如果你两次逻辑删除同一条记录,第二次把它从1改成0再删成1,这没问题;如果要恢复数据,把deleted从1改回0,而此时恰好有一条活跃记录占用了相同的业务字段,就会撞索引。
我当初就因为在elder_info表的手机号上加了uk_phone_deleted (phone, deleted),结果一个测试账号被删了又重建了两次,重建时deleted=0的唯一索引已经存在,直接报错。解决办法一是放弃复用业务号码,二是在逻辑删除前查询确认索引唯一性,三是对deleted字段改用时间戳方案——删除时写入删除时间而不是固定1。我个人推荐第三种,既能保留历史删除记录,又天然解决唯一索引冲突,虽然表里会多个字段,但好处多于麻烦。
6.4 大数据量导出内存溢出
运营后台需要导出工单报表到 Excel,我第一版用的是 EasyExcel 同步导出,数据量达到五万条时,异步任务一次性读取全量数据再写文件,内存直接爆掉,报OutOfMemoryError。后来改成了分批查询 + 流式写入:每 5000 条查一次,用EasyExcel.write(response.getOutputStream())开一个输出流,逐批写入并手动清理实体引用。再配合一个异步任务表和进度查询接口,前端导出的体验从“转圈十分钟然后失败”变成了“两秒返回任务 ID,轮询进度条,下载完成”。
这条经验对任何带报表导出功能的管理系统都适用:导出永远不要同步跑在 HTTP 请求线程里,否则数据量一大,连接超时、内存溢出、重试风暴全都会来。
结尾
最后再分享一个小经验。这个项目做完之后我整理过一份检查清单,每次部署前都过一遍:数据库连接串有没有加时区和公钥参数、JWT 密钥有没有换掉默认值、定时任务是否按环境区分开关、跨域配置有没有开放到具体域名而不是*、分页插件是否正确注册。大部分线上问题回头看都是这些不起眼的配置引起的,代码逻辑反而很少出大问题。
做社区养老平台这类系统,技术难度本身不算高,难的是把业务规则想清楚——工单状态怎么流转、数据怎么审计、权限怎么隔离、异常情况怎么兜底。这套源码如果你能跑起来学着改造一遍,把状态机、逻辑删除、JWT 刷新、前端并发控制这些点吃透,比看了十篇教程都管用。下次接到类似的“xx 管理平台”需求,你就知道怎么下手了。