多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

SpringBoot2+Vue3+MyBatis-Plus实战:社区疫情管理系统开发全流程

SpringBoot2+Vue3+MyBatis-Plus实战:社区疫情管理系统开发全流程 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8useSSLfalseSpringBoot的jackson配置同时指定spring.jackson.time-zoneGMT8最后是驱动版本。用SpringBoot2.x时不要顺手引了mysql-connector-java的旧版本8.x的驱动和8.0的JDBC协议是配套的。我项目里用的是dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency3. 后端实现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 deleted0所有DELETE变成UPDATE deleted1。项目里的居民表、公告表所有涉及删除的操作都不走物理删除。为什么要这么做社区数据有审计需求哪天管理员误删了一条居民记录物理删了就真的找不回来了逻辑删除至少能留个标记后面排查数据问题还有补救空间。create_time和update_time用TableField(fill ...)自动填充配合自定义的MetaObjectHandlerComponent 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 BaseServiceT extends IServiceT { PageT pageQuery(int pageNum, int pageSize, WrapperT 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的过滤器链和配置体系在这里完全是大炮打蚊子配置复杂度还会拖慢开发进度。实现方案很直接登录接口校验用户名密码通过后生成JWT Token返回Token里带userId和role。自定义AuthInterceptor实现HandlerInterceptor拦截除登录、公告查询外的所有请求。从请求头拿到Token解析校验后把userId、role放进ThreadLocal上下文后续Service直接取。权限控制通过自定义注解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 requestVite的代理配置解决本地开发跨域// 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 langts import { useResidentList } from ./useResidentList const { loading, list, total, queryForm, fetchList, handleDelete } useResidentList() /scriptuseResidentList.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健康状态 width100 template #default{ row } el-tag :typestatusTagType(row.healthStatus) {{ statusText(row.healthStatus) }} /el-tag /template /el-table-columnElement 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_PASSWORDYourStrongPassword \ -e MYSQL_DATABASEcommunity_health \ -v /data/mysql/conf:/etc/mysql/conf.d \ -v /data/mysql/data:/var/lib/mysql \ --restartalways \ 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_passwordALTER 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] DescriptionCommunity Health System Afternetwork.target [Service] Typesimple Userwww-data WorkingDirectory/opt/community-health ExecStart/usr/bin/java -Xms512m -Xmx1024m -jar community-health.jar Restartalways RestartSec10 [Install] WantedBymulti-user.targetJava进程的堆内存设置要结合服务器配置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: GMT8 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-timestampsfalse这个决定了序列化是输出字符串还是时间戳。最好两个都配上实测最保险。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; } }配置看似没错但就是不分页。最后排查到原因我的分页查询用到了PageT对象但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 }或者如果用reactiveconst 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这条链路完整跑通一次下一次开发任何管理系统你都会有一种看山还是山的轻松感。
返回列表