☰
SpringBoot2+Vue3+MyBatis-Plus实战:社区疫情管理系统开发全流程
2026/10/5 3:01:19 网站建设 项目流程

1. 为什么选这条技术栈:从实际开发痛点说起

先交代一下背景。我做的这个"中小社区疫情信息管理系统",目标用户是社区居委会、物业和网格员这类基层角色,核心业务就是居民健康信息登记、体温及行程上报、疫苗接种记录、出入管理、异常情况预警这几个模块。说穿了,它就是一个典型的管理信息系统(MIS),业务逻辑不算复杂,但有几个硬指标必须满足:第一,数据要能快速登记和查询,不能等半天;第二,权限要分清,普通居民只能看自己,网格员能管本辖区,管理员要掌握全局;第三,上线环境五花八门,部署要尽量省事。

这个定位决定了技术选型不能盲目追新。SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0 这套组合,正好是我在实际开发中反复验证过、踩过坑也填过坑的稳定搭配。SpringBoot2生态成熟,网上资料多,遇到问题基本都能搜到解决方案;Vue3有组合式API,写页面逻辑比Vue2顺手很多,配合Element Plus做后台界面效率特别高;MyBatis-Plus解决了我最头疼的单表CRUD重复劳动,内置的Wrapper查询条件构造器写复杂查询也很快;MySQL8.0在性能、窗口函数、JSON支持方面都够用,而且Docker镜像部署非常方便。

如果你现在准备做毕业设计、课设,或者公司需要快速搭建一个类似的社区管理系统,这篇文章就是照着这个项目来拆解的。我会把从数据库设计、后端接口、前端页面到部署上线的完整链路都过一遍,重点讲那些文档里不会写、但实际开发一定会遇到的坑。文章的工具版本和配置都是我在这个项目里实际用过的,直接抄作业就能跑起来。

2. 业务模块与数据库表设计:表结构先想清楚,后面少返工

2.1 核心模块的职责划分

这类社区疫情管理系统,光看标题容易以为只是"登记一下"那么简单,真做起来模块边界很容易模糊。我在设计阶段就把业务拆成了六个模块,每个模块的职责非常单一:

  • 居民管理:维护住户的基本档案,姓名、身份证号、手机号、楼栋单元门牌号、户籍类型(常住/租住/临时),这是整个系统的数据基座。
  • 健康上报:居民日常上报体温、症状、健康码颜色、行程卡是否带星,支持每日多次上报,只保留最新一条用于决策。
  • 出入管理:登记社区出入口的人员进出记录,关联体温检测结果,异常体温直接标记预警。
  • 疫苗接种管理:记录接种针次、疫苗厂商、接种日期,用于统计接种覆盖率。
  • 异常预警:从健康上报和出入记录中自动筛选异常数据,生成待处理工单,网格员处理后留痕。
  • 公告通知:管理员发布防疫通知、风险区域提示,居民端可见。

这六个模块之间是有数据关联的,但表设计上我没做太多外键约束。原因后面细说,MyBatis-Plus环境下我更倾向于在业务层维护关联逻辑,数据库只负责存数据和基础索引。

2.2 关键表结构设计与字段细节

先看最核心的几张表。居民表是所有业务的源头,字段设计直接决定后面所有统计好不好写:

CREATE TABLE `resident` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `name` VARCHAR(32) NOT NULL COMMENT '姓名', `id_card` VARCHAR(18) NOT NULL COMMENT '身份证号', `phone` VARCHAR(11) NOT NULL COMMENT '手机号', `building_no` VARCHAR(16) COMMENT '楼栋号', `unit_no` VARCHAR(16) COMMENT '单元号', `room_no` VARCHAR(16) COMMENT '门牌号', `resident_type` TINYINT DEFAULT 0 COMMENT '户籍类型:0常住 1租住 2临时', `health_status` TINYINT DEFAULT 0 COMMENT '健康状态:0正常 1异常 2隔离', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除标记', PRIMARY KEY (`id`), UNIQUE KEY `uk_id_card` (`id_card`), KEY `idx_building` (`building_no`, `unit_no`, `room_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='居民信息表';

身份证号必须加唯一索引,这是硬需求,一个人只能有一条档案。楼栋、单元、门牌号联合索引是给"按楼栋筛选居民"的查询用的,网格员经常要"查一下3栋2单元全部住户"这种操作,没索引这张表过万条数据后查询会明显变慢。

健康上报表的设计有个关键点:不是只存最新状态,而是保留每次上报的历史记录,这样能回溯轨迹。

CREATE TABLE `health_report` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `resident_id` BIGINT NOT NULL COMMENT '关联居民ID', `temperature` DECIMAL(4,1) COMMENT '体温,如36.5', `symptom_desc` VARCHAR(255) COMMENT '症状描述,多个症状逗号分隔', `health_code` TINYINT DEFAULT 0 COMMENT '健康码:0绿码 1黄码 2红码', `travel_risk` TINYINT DEFAULT 0 COMMENT '行程卡是否带星:0否 1是', `report_date` DATE NOT NULL COMMENT '上报日期', `report_time` TIME NOT NULL COMMENT '上报时间', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_resident_date` (`resident_id`, `report_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康上报记录表';

这里要注意DECIMAL(4,1)存体温,最多99.9度,够用;症状字段用字符串而非关联表,是因为现实场景里症状就是"发烧、咳嗽"这种短文本打标,拆关联表反而把简单问题复杂化了。report_date和report_time分开存,日期类型单独一列,后面统计"某天某栋的上报人数"时SQL写起来非常爽,不用对DATETIME做函数转换。

异常预警表我单独列出来说,因为它的状态流转最容易在开发后期被忽略:

CREATE TABLE `alert_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `resident_id` BIGINT NOT NULL, `alert_type` TINYINT NOT NULL COMMENT '预警类型:0体温异常 1黄码 2红码 3行程带星', `alert_level` TINYINT DEFAULT 1 COMMENT '等级:1一般 2严重 3紧急', `alert_content` VARCHAR(500) COMMENT '预警说明', `status` TINYINT DEFAULT 0 COMMENT '处理状态:0待处理 1处理中 2已闭环', `handler_id` BIGINT COMMENT '处理人ID', `handle_remark` VARCHAR(500) COMMENT '处理意见', `handle_time` DATETIME COMMENT '处理时间', PRIMARY KEY (`id`), KEY `idx_status_level` (`status`, `alert_level`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='异常预警表';

2.3 MySQL8.0的字符集、时区与连接配置

MySQL8.0和旧版本有几个明显的差异点,配置不对会在项目上线时给你来一记闷棍。

首先是字符集。MySQL8.0默认字符集已经是utf8mb4了,但如果你用命令行手动建库,还是建议显式指定:

CREATE DATABASE community_health DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

注意utf8mb4和utf8的区别,utf8在MySQL里最多3字节,存不了生僻字和部分表情符号。居民姓名里有生僻字不稀奇,身份证号本身是数字没问题,但留言、备注字段偶尔会出现特殊字符,用utf8直接报错,用utf8mb4一劳永逸。

其次是时区。MySQL8.0的时区默认是SYSTEM,和SpringBoot默认的UTC不匹配时,DATETIME类型的数据读写就会出现8小时偏差。我这里的方案是治标又治本:

  • MySQL连接串显式带时区参数:jdbc:mysql://localhost:3306/community_health?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8&useSSL=false
  • SpringBoot的jackson配置同时指定:spring.jackson.time-zone=GMT+8

最后是驱动版本。用SpringBoot2.x时不要顺手引了mysql-connector-java的旧版本,8.x的驱动和8.0的JDBC协议是配套的。我项目里用的是:

<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>

3. 后端实现:SpringBoot2 + MyBatis-Plus的CRUD流水线

3.1 工程结构与分层设计

项目用的是标准的Maven多模块单工程结构,没有拆微服务——中小社区系统还没到需要微服务的体量,拆了反而徒增部署复杂度。结构如下:

community-health-system ├── src/main/java/com/community │ ├── controller # 控制层,只做参数接收和结果返回 │ ├── service # 业务层,核心逻辑全部在这里 │ │ └── impl │ ├── mapper # MyBatis-Plus数据访问层 │ ├── entity # 实体类 │ ├── dto # 请求/响应数据对象 │ ├── vo # 视图对象 │ ├── config # 配置类:MyBatisPlus、WebMvc、CORS │ ├── common # 通用返回结果、异常处理、常量 │ └── utils # 工具类 └── src/main/resources ├── application.yml └── mapper # XML文件目录

有一个设计细节值得展开:dto和vo必须和entity分开,虽然麻烦一点,但这个习惯能救你。举个例子,居民表的entity里有deleted逻辑删除字段、create_time、update_time这些和业务无关的字段,如果直接把entity返回给前端,等于把底裤都露出来了。而且新增居民和查询居民列表需要的字段完全不一样,用一个对象硬抗,到最后就是每个接口的字段都在"一锅乱炖"。

3.2 通用CRUD基类的设计与实现

MyBatis-Plus的核心价值在于它把单表CRUD给包圆了,但如果你每个Service都直接继承ServiceImpl、每个Mapper都继承BaseMapper,虽然省了代码,却很容易写出大而空的Service。我的做法是在这个基础上再包一层,把项目中重复性极高的"分页条件查询""新增前重复性校验""逻辑删除"操作收拢成通用基类。

先看Mapper层,我的做法是对公共字段做一个实体基类:

@Data public class BaseEntity { @TableId(type = IdType.AUTO) private Long id; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; @TableLogic @TableField(select = false) private Integer deleted; }

这里有个关键配置:@TableLogic标记逻辑删除字段后,MyBatis-Plus会自动把所有SELECT拼上WHERE deleted=0,所有DELETE变成UPDATE deleted=1。项目里的居民表、公告表所有涉及删除的操作都不走物理删除。为什么要这么做?社区数据有审计需求,哪天管理员误删了一条居民记录,物理删了就真的找不回来了,逻辑删除至少能留个标记,后面排查数据问题还有补救空间。

create_time和update_time用@TableField(fill = ...)自动填充,配合自定义的MetaObjectHandler:

@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()); } }

Service层我做了个BaseService接口和对应实现,把第三方的IService再抽象一层:

public interface BaseService<T> extends IService<T> { Page<T> pageQuery(int pageNum, int pageSize, Wrapper<T> queryWrapper); T getByIdWithCheck(Long id); void saveWithCheck(T entity); }

saveWithCheck的作用是统一处理保存前的业务校验,各业务Service继承后重写校验方法即可。很多初学者容易犯的错,是在Controller里写校验逻辑——参数格式校验放Controller还能接受,但是"这个身份证号是否已经存在""这个楼栋号是否存在"这类业务校验一定要下沉到Service层,否则所有调用方都要重复写一遍校验,漏了一个地方就是后患。

3.3 业务层的关键逻辑:健康上报的"最新状态"设计

健康上报业务有个比较揪心的点:居民一天可能上报多次,但首页展示、出入登记判断用的都是"最新一条"。直接把所有记录丢给前端,前端自己过滤重复数据,这个方案看起来很省事,但数据量上来后接口响应会越来越慢,而且前端逻辑稍有不慎就会取错数据。

我的方案是主表+状态冗余:health_report表存每一条上报历史,居民表的health_status字段冗余保存最新健康状态。当居民提交新上报时,事务内做两件事:插入上报记录,同时更新居民表的健康状态。这样读取"当前健康状态"时就不用子查询取最新记录,一条单表查询就能拿到。

关键代码大致长这样:

@Transactional(rollbackFor = Exception.class) public ReportResult submitReport(ReportRequest request) { Resident resident = baseService.getByIdWithCheck(request.getResidentId()); HealthReport report = new HealthReport(); report.setResidentId(resident.getId()); report.setTemperature(request.getTemperature()); report.setHealthCode(request.getHealthCode()); report.setSymptomDesc(request.getSymptomDesc()); report.setTravelRisk(request.getTravelRisk() != null ? request.getTravelRisk() : 0); report.setReportDate(LocalDate.now()); report.setReportTime(LocalTime.now()); healthReportMapper.insert(report); // 根据上报内容更新居民最新状态 int status = resolveHealthStatus(request); ResidentUpdate update = new ResidentUpdate(); update.setId(resident.getId()); update.setHealthStatus(status); residentMapper.updateById(update); // 如果有异常,自动生成预警记录 if (status != 0) { generateAlert(resident, request, status); } return new ReportResult(report.getId(), status); }

generateAlert里根据预警类型生成alert_record数据,同时按规则给提醒内容拼装好被隔离的字段信息。

注意我要专门强调@Transactional。MyBatis-Plus的ServiceImpl自带saveOrUpdate等方法的默认事务行为,但你自己组合两个以上的写操作时务必要在方法上显式加事务,否则前一步插入了上报记录、后一步更新居民状态报错,就会留下"上报了但状态没变"的脏数据。这个坑我在早期版本里真实踩过,上线后接到网格员反馈说"有人上报发烧了但系统状态还是绿的",查了半天发现就是事务缺失。

3.4 登录鉴权与角色权限:Spring Security还是拦截器?

坦率地说,这个项目的登录鉴权我最后没用Spring Security,用的是JWT + 拦截器。原因很实际:系统角色只有三种(管理员、网格员、居民),接口数量也就四五十个,Spring Security的过滤器链和配置体系在这里完全是大炮打蚊子,配置复杂度还会拖慢开发进度。

实现方案很直接:

  1. 登录接口校验用户名密码,通过后生成JWT Token返回,Token里带userId和role。
  2. 自定义AuthInterceptor实现HandlerInterceptor,拦截除登录、公告查询外的所有请求。
  3. 从请求头拿到Token,解析校验后把userId、role放进ThreadLocal上下文,后续Service直接取。
  4. 权限控制通过自定义注解@RequireRole在Controller方法上标角色,拦截器里判断是否放行。

关键配置:

@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/login", "/api/notice/public/**"); } }

JWT工具类用io.jsonwebtoken:jjwt,版本要选0.9.x以上的。有个细节:0.9.x的Jwts.parser()和1.x版本的parserBuilder()API差别很大,网上教程版本混乱,建议直接用0.9.1并引入对应的jjwt-impl、jjwt-jackson依赖,参考官方仓库的样例代码是最稳妥的。

密码存储千万别用明文。我用的是Spring Security中的BCryptPasswordEncoder(只引加密工具类,不用整个Security框架),注册和改密时加密存储,登录时用matches方法校验。BCrypt的优点是每次生成盐值不同,同样的密码存出来哈希值都不同,彩虹表直接报废。

4. 前端实现:Vue3组合式API下的后台管理页面

4.1 Vue3工程搭建与关键目录

前端我用的Vite + Vue3 + TypeScript + Pinia + Element Plus组合。Vite的启动速度和热更新体验比Webpack好太多,2026年了没有理由再去折腾Webpack那套冷启动慢速开发流。工程结构:

frontend ├── src │ ├── api # 接口请求封装 │ ├── assets │ ├── components # 通用组件 │ ├── router # 路由与导航守卫 │ ├── stores # Pinia状态管理 │ ├── views # 页面组件 │ │ ├── login │ │ ├── dashboard │ │ ├── resident │ │ ├── report │ │ ├── alert │ │ ├── vaccinate │ │ └── notice │ ├── utils # 请求封装、鉴权工具 │ ├── App.vue │ └── main.ts └── vite.config.ts

有些教程会让你把请求工具直接写进每个view里,我强烈不推荐。我的做法是统一封装一个request.ts,基于axios实例,统一处理BaseURL、Token注入和响应拦截:

import axios from 'axios' import { ElMessage } from 'element-plus' import { useUserStore } from '../stores/user' const request = axios.create({ baseURL: '/api', timeout: 15000 }) request.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response?.status === 401) { const userStore = useUserStore() userStore.logout() window.location.href = '/login' } ElMessage.error(error.message || '网络错误') return Promise.reject(error) } ) export default request

Vite的代理配置解决本地开发跨域:

// vite.config.ts server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }

4.2 组合式API的组织方式:不要再写一坨setup了

Vue3的<script setup>语法把组件逻辑收敛得很舒服,但很多从Vue2转过来的人还是习惯把所有逻辑像流水账一样写在一个大块setup里,组件代码三五百行起步,维护起来非常痛苦。我项目里的做法是按业务模块拆分组合式函数。

以居民管理页面为例,views/resident/index.vue只做视图拼接和调库:

<script setup lang="ts"> import { useResidentList } from './useResidentList' const { loading, list, total, queryForm, fetchList, handleDelete } = useResidentList() </script>

useResidentList.ts里才是核心逻辑:

import { ref, onMounted } from 'vue' import { getResidentPage, deleteResident } from '../../api/resident' export function useResidentList() { const loading = ref(false) const list = ref([]) const total = ref(0) const queryForm = ref({ pageNum: 1, pageSize: 10, keyword: '', buildingNo: '', healthStatus: undefined }) const fetchList = async () => { loading.value = true try { const data = await getResidentPage(queryForm.value) list.value = data.records total.value = data.total } finally { loading.value = false } } const handleDelete = async (id: number) => { await deleteResident(id) fetchList() } onMounted(fetchList) return { loading, list, total, queryForm, fetchList, handleDelete } }

为什么要把逻辑抽出去?因为列表页和新增/编辑弹窗共用很多数据操作,比如新增成功后要刷新列表,弹窗组件也可以直接调用fetchList。逻辑抽出去后,两个组件共享一套函数,不需要搞EventBus或全局变量那套复杂通信。Vue3组合式API就是这个理念——逻辑复用比代码分层更根本。

4.3 Element Plus表格+弹窗:最常见的页面模式

后台管理系统的页面模式高度相似:顶部筛选栏、中间表格、右侧操作按钮、弹窗表单。Element Plus把这块组件配套得很完善,但有几个使用细节容易踩坑。

表格列的自定义模板要用#default插槽,例如状态列:

<el-table-column label="健康状态" width="100"> <template #default="{ row }"> <el-tag :type="statusTagType(row.healthStatus)"> {{ statusText(row.healthStatus) }} </el-tag> </template> </el-table-column>

Element Plus的el-table在数据量超过几百条时默认开启虚拟滚动?其实没有。社区系统居民表撑死也就一两万条,默认分页后单页就10条,性能完全没问题。真正需要操心的是不要在el-table里嵌套太多el-select、el-input这种动态组件,每行一个组件实例,渲染开销会成倍增长。有需要批量编辑的场景,改成点击行进入编辑模式更合理。

弹窗表单我用el-dialog + el-form,配套校验规则:

const rules = { name: [{ required: true, message: '请输入姓名', trigger: 'blur' }], idCard: [ { required: true, message: '请输入身份证号', trigger: 'blur' }, { pattern: /(^\d{15}$)|(^\d{17}([0-9]|X|x)$)/, message: '身份证格式不正确', trigger: 'blur' } ], phone: [ { required: true, message: '请输入手机号', trigger: 'blur' }, { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' } ] }

身份证号正则有个坑:末尾可能有X,正则里大小写都要兼容,([0-9]|X|x)这样写。很多现成的正则直接把X漏了,前端校验通过,后端也校验通过,结果存进去一条烂数据。

4.4 权限路由:动态路由还是路由守卫?

前端权限控制我用了简单可靠的方式:在路由元信息里标记角色,导航守卫里判断。不搞动态路由那套,因为系统角色固定、页面数量有限,动态路由在这里是过度设计。

// router/index.ts const routes = [ { path: '/login', component: () => import('../views/login/index.vue') }, { path: '/', component: () => import('../layout/index.vue'), redirect: '/dashboard', children: [ { path: 'resident', component: () => import('../views/resident/index.vue'), meta: { roles: ['admin', 'grid'] } }, { path: 'report', component: () => import('../views/report/index.vue'), meta: { roles: ['admin', 'grid'] } }, { path: 'vaccinate', component: () => import('../views/vaccinate/index.vue'), meta: { roles: ['admin'] } } ] } ]

导航守卫:

router.beforeEach((to, from, next) => { const userStore = useUserStore() if (to.path === '/login') { next() return } if (!userStore.token) { next('/login') return } const roles = to.meta.roles as string[] | undefined if (roles && !roles.includes(userStore.role)) { next('/403') return } next() })

这里要强调的是:前端路由守卫只是用户体验层面的控制,不是安全控制。真正的权限必须由后端接口的@RequireRole强制执行。前端挡住入口只是不让用户点进去,但恶意用户完全可以绕过前端直接调后端接口,所以两端权限必须同时存在。

5. 前后端联调与部署:从本地到服务器的完整路径

5.1 CORS、Token与Nginx配置

本地开发时Vite代理把跨域解决了,但部署到服务器后是Nginx托管前端静态文件、反向代理后端接口,配置要注意几个点。

后端CORS策略,我用配置类统一处理:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("http://localhost:3000", "http://your-domain.com") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }

不要用allowedOrigins("*")配合allowCredentials(true),浏览器会直接拒绝这种组合。要用allowedOriginPatterns显式列出可信来源。其实部署后前端和接口都走同域(Nginx代理),CORS配置只在特殊调试场景才用得上,但保险起见还是配置好。

Nginx配置的核心片段:

server { listen 80; server_name your-domain.com; root /var/www/community-frontend; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

try_files配合/index.html是Vue Router的history模式必须的。很多人部署后刷新页面就404,就是没配这段。后端接口用/api前缀统一区分,Nginx反代时去掉前缀交给SpringBoot——但注意我后端的context-path如果没有特殊配置,接口路径本身就是/api/**开头的,那么proxy_pass http://127.0.0.1:8080;直接透传即可,不要画蛇添足改写URI。

5.2 MySQL8.0的Docker部署与数据持久化

服务器上装MySQL8.0,我推荐用Docker直接跑,省去一堆系统依赖的麻烦。生产环境的容器命令:

docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=YourStrongPassword \ -e MYSQL_DATABASE=community_health \ -v /data/mysql/conf:/etc/mysql/conf.d \ -v /data/mysql/data:/var/lib/mysql \ --restart=always \ mysql:8.0

两个-v卷是重点。数据目录必须挂到宿主机,否则容器删了数据就全没了。配置文件目录挂载出来后,可以自定义my.cnf的调优参数,比如max_connections、innodb_buffer_pool_size这类关键参数。

用Docker部署MySQL最容易踩的坑是本地工具连不上。排查思路:

  • 确认端口映射:docker ps看0.0.0.0:3306->3306/tcp是否正常。
  • 确认MySQL8.0的认证插件:MySQL8.0默认用caching_sha2_password,如果你的客户端(特别是老版本的Navicat)不支持,就需要在容器里把用户改成mysql_native_password:
ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY 'YourStrongPassword'; FLUSH PRIVILEGES;

这个问题在项目初期困扰了我好几个小时,后来干脆统一在初始化脚本里改了认证方式,避免团队里不同的数据库工具引发差异化问题。

5.3 前端打包与后端部署

前端打包:

npm run build

产物在dist/目录,把里面的内容直接拷到服务器的/var/www/community-frontend即可。

后端打包:

mvn clean package -DskipTests

生产环境我用的是java -jar直接跑,配上systemd服务管理,崩溃了能自动拉起。服务配置:

[Unit] Description=Community Health System After=network.target [Service] Type=simple User=www-data WorkingDirectory=/opt/community-health ExecStart=/usr/bin/java -Xms512m -Xmx1024m -jar community-health.jar Restart=always RestartSec=10 [Install] WantedBy=multi-user.target

Java进程的堆内存设置要结合服务器配置,2G内存的机器给-Xmx1024m就够了,别贪多,否则系统其他服务会被拖死。

6. 我踩过的坑与排查思路:这几个细节能救你一命

6.1 JSON日期格式导致的"8小时穿越"

现象:前端展示的上报时间比实际时间晚了8个小时。

排查链路:先是怀疑前端时区,把前端显示的原始值打印出来发现接口返回的JSON字符串里的时间确实不对。然后查后端,发现SpringBoot默认用Jackson序列化LocalDateTime时,时区用的是系统默认时区。由于服务器是UTC时间,Jackson把LocalDateTime(不带时区的概念)按UTC序列化后,前端拿到字符串按本地时区解析,就会差8小时。

解决办法先排查数据入库有没有问题。查了数据库,发现MySQL本身按DATETIME存的是正确时间,问题出在Jackson序列化配置上。在application.yml里加上:

spring: jackson: time-zone: GMT+8 date-format: yyyy-MM-dd HH:mm:ss

这里的time-zone不只是序列化生效,反序列化请求体里的时间字段也会用这个时区解析。另外LocalDateTime不支持date-format这种全局格式,还需要在字段上加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")或者在application.yml统一配置spring.jackson.serialization.write-dates-as-timestamps=false,这个决定了序列化是输出字符串还是时间戳。最好两个都配上,实测最保险。

6.2 逻辑删除字段遇到唯一索引的冲突

这是MyBatis-Plus非常经典的坑。我在居民表上给id_card加了唯一索引,又启用了逻辑删除。问题来了:删除一条居民记录后,deleted字段变成了1,但唯一索引仍然生效。重新导入同一个身份证号的居民时,因为旧记录还在(只是逻辑删除),插入新记录直接违反唯一索引,报错。

官方对这个问题的处理方案是:把唯一索引改成联合唯一索引,把逻辑删除标记加进去:

ALTER TABLE `resident` DROP INDEX `uk_id_card`, ADD UNIQUE KEY `uk_id_card_deleted` (`id_card`, `deleted`);

但这样索引就不能唯一约束"非删除"记录了,因为deleted字段的值并不唯一(同一身份证号删除两次,两条记录的deleted都是1)。而且业务上一般只删除一次,这个方案能覆盖大多数场景。更好的方案是换成deleted字段存时间戳或UUID:删除时deleted存当前时间戳,这样(id_card, deleted)在多次删除时也不会冲突,同时保留了删除时间信息。

我这里用了第二种方案,把deleted类型从TINYINT改成BIGINT,默认0表示未删除,删除时置为当前时间戳毫秒值。@TableLogic注解照样能工作,只是值从1变成了一个长整型。注意@TableField(select = false)要在实体上配合使用,避免查询时把大数字暴露出去。

6.3 MyBatis-Plus分页插件不生效

现象:自定义的分页查询返回了全表数据,total也是0,但SQL语句里根本没有LIMIT。

排查过程:分页查询分为Interceptor方式和插件方式,我确认我的配置是官方推荐的三步:

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

配置看似没错,但就是不分页。最后排查到原因:我的分页查询用到了Page<T>对象,但Service层返回时把Page对象重新new了一个,没有使用传入的Page实例。换句话说,分页参数和查询没有绑定。

核心点在于MyBatis-Plus的分页功能是依赖Page对象实例贯穿查询的。你在Controller里创建Page对象后,必须把这个Page对象传给selectPage方法,返回结果也必须是这个对象。如果你在中间环节又new了一个新Page,查询器拿到的就不是分页参数,自然就全表查询了。

更隐蔽的坑是多数据源场景下分页方言没配对。项目的MySQL是8.0,但如果你在开发环境用了H2内存库跑测试,DbType.MYSQL对H2生成的方言SQL有可能不兼容,同样表现为分页失效或SQL语法错误。

6.4 Vue3响应式丢失:reactive整个数组替换

这是Vue3新手最常见的响应式陷阱。我早期版本里写过类似代码:

const list = reactive([]) const fetchList = async () => { const data = await getResidentPage(queryForm.value) list = data.records // 直接替换整个数组 }

运行时发现页面数据死活不更新,但打印出来数据是有的。原因在于reactive代理的是对象内部属性,直接把list这个变量重新赋值,相当于把响应式对象换成了一个普通数组,Vue3无法拦截这个引用替换。

解决办法是不要替换整个数组,而是替换内容:

const list = ref([]) const fetchList = async () => { list.value = data.records }

或者如果用reactive:

const state = reactive({ list: [] }) const fetchList = async () => { state.list = data.records }

直接改对象里的属性,响应性不会丢。我建议如果只有一层数组,直接ref就完了,省事且几乎没有性能差异。

6.5 文件上传与静态资源路径坑

这个系统有一个导入导出功能,涉及Excel文件上传和导出,还有公告里的图片附件。后端用了MultipartFile接收上传,本地存储到服务器一个目录,但上传成功后前端访问图片总是404。

排查后发现问题在两处。第一,SpringBoot的静态资源映射默认只映射classpath:/static/等目录,外部磁盘路径需要自己注册:

@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/uploads/**") .addResourceLocations("file:/var/www/community-uploads/"); }

第二,application.yml里要有上传目录的配置项:

file: upload-dir: /var/www/community-uploads

然后业务代码里通过@Value("${file.upload-dir}")注入,拼装文件保存路径和访问URL。上传的图片文件名一定别用原名,会有中文乱码和路径穿越风险,我用了UUID.randomUUID()重命名,保留扩展名。

7. 最后一个实际操作建议

看完这么多技术细节,真正动起手来的时候,我的体会是先把数据库表结构和接口定义写完,再去写前后端代码。很多项目翻车就翻在表结构没想清楚就开写后端,后端接口没定好就开写前端,结果联调时处处碰壁,返工成本比写代码本身还高。

如果你准备拿这个项目当毕业设计或者练手项目,我建议顺序是这样的:先花两天把ER图和接口文档定下来,再花三天把后端的CRUD和基础分页跑通,然后花一周做前端页面,最后留时间做联调和部署。按照这个节奏,一个月左右完成是现实的。

另外多说一句,这套代码的通用性很强。把"疫情信息"的字段换一换,就是一套"社区报修管理"或"物业巡检管理",底层的居民管理、权限体系、分页查询、部署方案几乎不用动。学习价值就在于把SpringBoot2 + Vue3 + MyBatis-Plus这条链路完整跑通一次,下一次开发任何管理系统,你都会有一种"看山还是山"的轻松感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询