在乡镇基层做信息化,最常被问到的一句话就是:这套系统到底能干什么?说真的,乡村政务办公系统和城市里的OA不太一样,它不需要多炫酷的界面、多复杂的流程,核心诉求就四个字:少跑腿、好留痕。公文能在线流转,通知能一次触达全村,群众来访有登记有跟进,日常工作台账随时能翻出来。之前我完整梳理了一套基于SpringBoot2+Vue3+MyBatis-Plus+MySQL8.0的乡村政务办公系统源码,从数据库建模到前后端联调,再到最后部署上线,整个过程里能沉淀下来的思路和坑都不少。这篇文章就按我实际看过、跑过的顺序,把这套系统的模块切分、表结构设计、核心接口逻辑、前端页面组织方式,以及部署过程中值得注意的细节一条条讲清楚。如果你正准备做类似的毕设项目,或者刚接手乡镇信息化系统的二次开发,这篇文章基本可以当一份“踩平了路”的参考手册来用。
1. 乡村政务办公系统的需求切分与技术选型权衡
1.1 政务场景里真正跑不掉的五个核心模块
很多人一听到“政务办公系统”,第一反应是做个花里胡哨的大屏或者一堆图表。但真正在乡镇基层用起来,高频场景其实非常固定。我从这套源码里拆出来的核心模块是五个:公文管理、通知公告、群众来访、工作台账、系统权限。
公文管理解决的是纸质文件来回送签的问题。以前一份文件要打印出来,分管领导签字、主要领导批示、办公室存档,文件在楼层之间跑一天。数字化之后,核心在于状态流转:草稿、待审核、已签发、已归档,每一步都要有操作人和时间记录。这个模块的关键不是页面多好看,而是状态机要设计得清晰。
通知公告模块更偏向“下发”场景。乡镇工作里,通知要发到村、发到站所、发到具体经办人,传统做法是微信群接龙,结果经常出现“收到刷屏,重要信息被顶掉”。系统里做成已读未读统计,谁没看一目了然,这个对基层来说非常实用。
群众来访登记是乡村政务里很有代表性的场景。来访人姓名、电话、反映事项、涉及部门、处理进度、回访结果,本质是一张带状态的生命周期表。难点在于“进展记录”需要支持多次追加,而不是覆盖式更新,这直接影响表结构设计。
工作台账模块属于典型的“留痕”需求。日常检查记录、整改情况、工作周报,按月按类别归档,方便年底考核时快速调取。这个模块对查询性能有一点要求,因为台账数据量会随着时间线性增长,索引和分页要做好。
系统权限模块放在最后说,是因为它虽然不直接产生业务价值,却是整个系统的骨架。乡村政务系统使用人员跨度大,有领导、有科室经办人、有窗口接待员、有时还有村里的代办员,不同角色能看到的菜单和数据范围必须严格区分,否则政务数据就容易出现越权访问的问题。
1.2 为什么是SpringBoot2+Vue3+MyBatis-Plus+MySQL8.0这套组合
这套技术栈放在2024年看,不算最新,但胜在稳。SpringBoot2经过多年迭代,网上资料多、问题排查方案全,对于小团队或单人开发来说,遇到bug能快速找到答案比使用新技术带来的快感重要得多。SpringBoot3虽然已经推出,但对JDK版本和部分第三方库的兼容要求更高,在政务类项目里没必要冒这个险。
Vue3是前端选型的必然趋势。Vue2已经停止维护,而Vue3的组合式API对于中后台系统那种“一个页面里塞表格、表单、弹窗、上传组件”的场景,代码组织方式比Options API更清晰。配合Vite做开发服务器,冷启动速度比Webpack快了一个量级,改一行代码保存后页面几乎秒级刷新,这对开发体验的提升非常明显。
MyBatis-Plus选它不是因为它的代码生成器,而是因为LambdaQueryWrapper。政务系统里单表的增删改查占了大半,MyBatis-Plus可以让你不写SQL就完成大部分操作。但要注意,联表查询和复杂统计仍然需要手写XML,千万不要因为偷懒而强行用Wrapper拼接JOIN,维护起来非常痛苦。
MySQL8.0相比5.7带来的实际好处是窗口函数和JSON字段。在台账统计、来访数据按月汇总这些场景里,窗口函数写起来又短又清晰;而一些灵活的扩展信息,比如自定义表单字段,用JSON类型存储比拆成十几张扩展表舒服得多。另外8.0默认的字符集是utf8mb4,对生僻字、特殊符号的支持很友好,不用再像5.7那样建库时手动指定。
所以这套组合的逻辑很简单:用SpringBoot2保证后端生态的成熟度,用MyBatis-Plus削减单表操作的开发量,用Vue3提升前端代码的可维护性,用MySQL8.0覆盖数据存储和统计需求。四个东西单独看都不是最前沿的,合在一起却是做这类管理系统最稳的答案。
2. 数据库建模:一张表一张表把政务场景落下来
2.1 用户权限四件套:用户、角色、菜单、关联表
这套系统的权限模型用的是标准的RBAC,也就是“用户-角色-菜单”三层。为什么不用更复杂的组织数据权限?因为在乡村政务场景下,人员基数不大,通常一个乡镇也就几十到上百个账号,部门划分也比较固定,做细到数据行的权限控制反而会让系统操作变得繁琐,基层人员接受度会下降。
用户表的核心字段不需要多,能支撑登录和身份识别就够了:用户ID、用户名、密码(BCrypt加密)、姓名、手机号、部门ID、状态(启用/禁用)、创建时间。密码字段的加密绝对不能省,政务系统涉及居民个人信息,明文密码一旦泄露,责任问题非常严重。
角色表比较简单,字段就三个:角色ID、角色名称、角色编码。角色编码很重要,后端做接口权限的时候,可以通过编码判断当前用户是否属于某个特定角色,比如ADMIN、OFFICE、CLERK。不要用角色名称做判断,名称会被改,编码是唯一的。
菜单表需要注意父子层级关系,用parent_id自关联。字段包括:菜单ID、父菜单ID、菜单名称、路由路径、前端组件路径、权限标识、排序号、是否显示。这里有一个容易忽略的点:权限标识要和后端接口的权限注解对应起来,比如“gov:document:audit”代表审核公文权限,前端按钮根据这个标识决定是否渲染。
用户角色表和角色菜单表是两张纯关联表,各只需两个字段加一个自增主键。MySQL的联合主键也可以,但加一个自增ID在后续做批量插入和删除时更灵活。权限相关的查询核心SQL就两条:根据用户ID查出角色编码列表,根据用户ID查出菜单列表。这两条SQL建议直接写在XML里,用JOIN实现,不要拆成多次单表查询然后循环拼接,性能和代码可读性都会更好。
2.2 业务主表设计:公文、通知、来访、台账
公文表是整个系统里字段最多、状态逻辑最复杂的表,核心字段包括:公文标题、发文文号、紧急程度、密级、正文内容(TEXT类型)、发起人ID、当前处理人ID、状态、创建时间、归档时间。状态这个字段建议用TINYINT类型存储,用数字字典映射:0草稿、1待审核、2已签发、3已归档。为什么不直接用字符串?因为在索引和枚举查询性能上,数字类型更高效,而且改状态名称时不需要动数据库。
通知公告表和公文表有相似之处,但更轻量。核心字段:标题、内容、发布人ID、发布时间、是否置顶、状态。另外要单独建一张通知接收表,字段包括:通知ID、接收人ID、是否已读、阅读时间。这张表的作用就是支撑“已读未读统计”功能,每次查询统计时,通过COUNT判断接收人总数和已读人数。注意,接收人可以是单个用户,也可以是某个角色,为了简化关联,可以在接收表里加一个receiver_type字段区分。
群众来访登记表的核心字段:来访人姓名、联系电话、身份证号、来访日期、反映事项、涉及部门、当前处理状态、登记人ID、最近跟进时间、办结时间。状态可以规划为:1待分派、2处理中、3已办结、4已回访。关键设计在跟进记录子表,每次处理都要单独插入一条记录,包含跟进人、跟进内容、跟进时间、下一步计划。这样保证操作留痕,也方便后续做满意度回访时查看完整处理链路。
工作台账表相对模板化:台账名称、台账类型(检查记录/整改记录/工作周报)、责任部门、责任人ID、所属月份、内容摘要、附件ID列表、创建时间。这类表业务逻辑不复杂,但数据量上来了之后,按月查询的需求比较集中,所以必需要在所属月份字段上建索引。附件ID列表用VARCHAR存逗号分隔的ID串即可,不要过度设计成中间表。
2.3 MySQL8.0初始化时的实用细节
数据库初始化入坑最多的地方不是表结构,而是字符集和时区。建库语句要明确指定字符集:
CREATE DATABASE gov_office DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;这里的utf8mb4_general_ci排序规则对中文支持友好,而且排序性能比utf8mb4_unicode_ci略好,对于政务系统里大量中文内容的LIKE查询来说更合适。
时区问题也值得花一分钟说清楚。MySQL8.0的默认时区是系统时区,如果后面JDBC连接串不指定serverTimezone,很可能在插入时间字段时报错或者时间差8小时。为了从根源上避免问题,在启动MySQL实例时直接指定时区:
docker run -d --name mysql8 -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=yourpassword \ -e TZ=Asia/Shanghai \ -v /data/mysql:/var/lib/mysql \ mysql:8.0这段命令是Docker部署MySQL8.0最常用的方式。注意挂载了宿主机目录/data/mysql做数据持久化,容器删了数据也还在,这是生产环境必须养成的习惯。如果是Windows本地开发,直接下载MySQL8.0的ZIP包解压安装也可以,但一定要记住在my.ini里配置character-set-server=utf8mb4和default-time-zone='+08:00'这两行。
另外,MySQL8.0默认的认证插件是caching_sha2_password,JDBC连接时偶尔会遇到“Public Key Retrieval is not allowed”的报错,解决方案是在JDBC连接URL里加上allowPublicKeyRetrieval=true。这个问题后面在部署部分还会详细讲。
3. 后端服务:SpringBoot2与MyBatis-Plus的配合节奏
3.1 工程分包与基础配置
拿到源码之后,第一件事不是急着跑起来,而是先看包结构。一个清晰的包结构能让你快速定位到任何一处逻辑。一般会看到这样的划分:controller(接口层)、service(业务层)、mapper(数据访问层)、entity(实体类)、config(配置类)、common(公共工具和统一返回结构)。
controller层只做三件事:接收参数、调用service、返回结果。业务逻辑不要写在controller里,这是新手最容易犯的错。service层负责具体业务,比如公文流转的状态判断、审批记录的追加。mapper层除了继承BaseMapper之外,复杂SQL通过XML文件维护。
application.yml里几个必须注意的配置项:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/gov_office?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: yourpassword jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0driver-class-name这一行经常有人写错。MySQL8.0对应的是com.mysql.cj.jdbc.Driver,带cj,不是之前的com.mysql.jdbc.Driver。如果你用的是MySQL5.7,那才是老的那个驱动类名,这一点在碰到ClassNotFoundException时优先检查。
jackson的date-format配置要特别强调,它解决的是LocalDateTime序列化问题。如果不配置,LocalDateTime默认会被序列化成“2024-06-01T10:30:00”这种带T的ISO格式,前端拿到之后如果想直接显示,还得再格式化一步,很麻烦。按照上面配置,接口返回的就是“2024-06-01 10:30:00”,符合国内使用习惯。
3.2 登录鉴权怎么处理才不显得重
政务系统不需要像互联网应用那样搞OAuth2、SSO那一套复杂的东西,一套轻量的JWT加拦截器方案就足够了。登录接口先校验用户名密码,密码用BCryptPasswordEncoder校验,通过后生成一个JWT令牌返回给前端,前端存在本地存储里,每次请求通过header带过来。
拦截器的写法很常规,核心逻辑是放行登录接口和静态资源,其余接口全部校验token:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { token = token.substring(7); try { Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); request.setAttribute("username", claims.get("username")); return true; } catch (Exception e) { response.setStatus(401); return false; } } response.setStatus(401); return false; } }JwtUtil里建议把过期时间设置为2小时,政务人员工作时间连续操作,2小时基本够用。如果过期了前端收到401响应,跳转到登录页重新登录就行,不需要做无感的续期刷新逻辑,除非你把应用做成7x24小时在线的自助查询系统。另外,token里只放用户ID和用户名两个字段就够了,不要塞角色列表和部门信息进去,否则token会很长,而且角色发生变化时还要等token过期才生效,反而碍事。
需要提醒的一点:拦截器只做了“是否登录”的校验,做不了“是否有权限”的校验。权限校验要在需要控制访问的方法上加注解,比如自定义的@RequirePermission("gov:document:audit"),配合拦截器里加载的用户角色列表来判断。如果不想引入额外的权限框架,直接在拦截器里维护一个“接口路径-所需权限标识”的映射表也可以,只是维护成本会随着接口数量增加而上升。
3.3 MyBatis-Plus 在政务系统里的正确用法
MyBatis-Plus最香的功能是LambdaQueryWrapper。举个例子,分页查询待办公文列表,附带关键字搜索:
Page<Document> page = new Page<>(current, size); LambdaQueryWrapper<Document> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Document::getStatus, 1) .like(StringUtils.hasText(keyword), Document::getTitle, keyword) .orderByDesc(Document::getCreateTime); documentMapper.selectPage(page, wrapper);这一段代码就把“条件查询+分页+排序”全搞定了,不需要写任何SQL。逻辑删除字段配置好之后,MyBatis-Plus会自动在所有查询里追加deleted=0条件,删除操作变成UPDATE语句,这个机制非常好,但要注意下面这些坑。
第一个坑是逻辑删除字段和唯一约束冲突。比如用户表里把用户名做了唯一索引,逻辑删除一个用户之后再新插入一个同名用户,会触发唯一索引冲突。原因很好理解:老的那条数据物理上还在表里,deleted字段是1而不是0,唯一索引依然生效。解决思路有三个:把唯一索引改成联合索引(username + deleted)、或者删除时把username拼接一个随机后缀、或者在应用层面做存在性判断。我推荐第一种方案,它还允许同一个用户被反复删除重建多次,符合实际的操作习惯。
第二个坑是自动填充。政务系统里几乎每张表都有create_time和update_time字段,MyBatis-Plus提供了MetaObjectHandler接口,实现insertFill和updateFill方法就能自动填充。这样插入和更新时就不用手动去set时间了,每张表的INSERT和UPDATE语句都少两个赋值操作,代码看起来清爽很多,也能避免某张表漏填时间字段导致的空数据。
第三个坑是分页插件必须显式配置。MyBatis-Plus的分页功能不是开箱即用,需要注入一个PaginationInnerInterceptor:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }maxLimit设置上限是防止有接口被恶意调用时一次查出全表数据把内存打爆,政务内网系统也要养成这个习惯。
3.4 报表统计类SQL:该写XML还是写Wrapper
公文流转统计、台账月度汇总、来访事件分部门统计,这些报表需求在政务系统里占比不低。我建议这类SQL统一写到XML里,不要用Wrapper去拼JOIN和GROUP BY,因为可读性会随复杂度急剧下降。
举个例子,统计每个部门每月的来访登记数量:
<select id="countVisitByDept" resultType="map"> SELECT d.dept_name AS deptName, DATE_FORMAT(v.visit_date, '%Y-%m') AS month, COUNT(*) AS total FROM gov_visit v LEFT JOIN sys_user u ON v.handler_id = u.user_id LEFT JOIN sys_dept d ON u.dept_id = d.dept_id WHERE v.visit_date BETWEEN #{startTime} AND #{endTime} GROUP BY d.dept_name, DATE_FORMAT(v.visit_date, '%Y-%m') ORDER BY month DESC, total DESC </select>这里用了DATE_FORMAT函数对时间字段做格式化分组,比直接在Java里循环拼接高效得多。而且结果集是一个Map,接收很灵活。报表SQL的返回值一律用Map或者自定义DTO,不要用实体类去接——实体类的字段是固定的,容不下动态列。
需要注意的一个性能点:LEFT JOIN的基础表数据量如果超过几十万,报表SQL会明显变慢,这时候要在visit_date和涉及的关联字段上建索引。政务系统的数据量长期看不会像互联网项目那么大,建了索引基本就够用,不需要引入Elasticsearch这类重量级组件,免得把简单系统搞复杂了。
4. Vue3前端:页面是皮,状态与权限是骨
4.1 Vite初始化和目录规划
前端工程是基于Vite创建的Vue3项目,目录结构一看就是中后台项目的标准做法:
src/ api/ # 按模块拆分的接口请求 assets/ # 静态资源 components/ # 通用组件 router/ # 路由配置 store/ # Pinia状态管理 views/ # 页面组件 login/ # 登录页 layout/ # 主布局(侧边栏+顶栏) document/ # 公文管理 notice/ # 通知公告 visit/ # 群众来访 ledger/ # 工作台账 system/ # 系统管理开发调试阶段npm run dev就够了,Vite默认端口是5173,和后端的8080端口不同,所以必须做代理。在vite.config.js里配置:
export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这里把前端所有以/api开头的请求代理到后端8080端口,后端controller的RequestMapping前缀就是/api。这样开发时无需后端额外做CORS处理,前后端分离联调很顺畅。
4.2 动态菜单与路由守卫
动态菜单的意思是:不同角色登录后看到的左侧菜单不一样。这个功能需要后端配合,登录成功后会调用一个获取菜单接口,返回当前用户的菜单树。前端拿到这棵树,通过router.addRoute动态添加路由。
菜单接口的返回结构一般是:
[ { "id": 1, "menuName": "公文管理", "path": "/document", "component": "document/index", "children": [ { "id": 2, "menuName": "公文列表", "path": "/document/list", "component": "document/list" } ] } ]前端在路由守卫里先判断是否已经动态添加过路由,如果没添加过,就调接口、递归构建RouteRecordRaw数组、finally添加进去然后next({ ...to, replace: true })重新进入目标路由。这一步有一个非常容易踩的坑:刷新页面后Pinia里的菜单状态会丢失,如果不在路由守卫里做“重新拉取菜单并恢复”的逻辑,刷新之后用户会被直接踢回登录页或者页面空白。所以在路由守卫里要做两层判断:有没有token、菜单有没有加载过。
4.3 表格、表单、弹窗、上传组件的组合节奏
Vue3中后台页面的核心就是Element Plus的组件组合。一个标准化页面通常是el-table做列表展示,右上角一个el-button触发新增,弹窗里放el-form,提交后刷新表格。
在实际编码中,最容易出错的地方是弹窗表单的数据回显。用ref定义一个form对象,新增时重置为空,编辑时把行数据通过props或者直接赋值给form对象。不要图省事直接把整行数据丢给reactive表单,否则会出现修改表单内容时表格里同步变化的问题,因为响应式对象默认是深层次的引用关系。正确做法是编辑时用对象展开复制:
form.value = { ...row }这样切断了引用,修改弹窗里的内容不会波及到表格行数据。
上传组件在政务系统里用得非常多,比如公文附件、台账证明材料。el-upload在上传成功后的回调里,需要把后端返回的文件ID存到表单字段里,而不是把整个file对象塞进去。后端接收文件的接口返回的应该是一个文件ID和访问URL,表单提交时只需要提交文件ID列表。
还有一个必须注意的响应式陷阱:使用reactive定义对象,然后直接用一个新的对象整体赋值,页面有时不会更新,因为reactive的代理对象在整体赋值时会丢失响应式。我习惯的表单状态定义方式是用ref包裹一个普通对象,操作时用form.value.xxx,这样反而更少踩坑。
4.4 Axios封装与接口对接
Axios封装的核心思想是把token注入、错误处理、响应拦截统一收口。文档里通常能看到这样一个common/request.js:
const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('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 } if (res.code === 401) { router.push('/login') } ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) }, error => { ElMessage.error(error.message || '网络异常') return Promise.reject(error) } )这里有一个设计细节:后端统一返回的JSON结构应该固定为{ code, message, data },code为200表示成功、401表示未登录、500表示业务异常。这样前端只需在拦截器里统一处理一次,每个接口的具体请求函数只需要关心data部分,代码非常干净。
接口请求函数的命名建议和URL对应,比如src/api/document.js里export一个getDocumentPage(params)函数,对应GET /api/document/page。在createWebHistory路由模式下,URL直接用例如/document/list,配合nginx部署时记得做history模式下的try_files重写,否则刷新页面会404。
5. 从源码到上线:本地跑通与服务器部署的关键动作
5.1 本地开发环境清单
拿到源码,先核对环境版本,版本不匹配通常在第一步就跑不起来。后端要求JDK8或JDK11(注意SpringBoot2.7对JDK8支持良好),Maven 3.6以上。前端要求Node.js 16或更高版本,npm 8以上。数据库是MySQL8.0,本地可以用安装版,也可以用Docker起。如果机器上装了多个JDK,建议在IDEA里为每个项目指定具体的JDK版本,避免出现“编译能过,一运行就报UnsupportedClassVersionError”的情况。
前端启动前先npm install,这一步经常因为网络原因卡住,建议配置淘宝镜像源。如果node_modules已经存在但你切换了Node版本,最好的处理方式是删掉node_modules和package-lock.json重新安装,避免产生二进制兼容问题。
5.2 数据库初始化与初始账号
源码的doc或sql目录下一般会有一个init.sql脚本,包含建库、建表、插入初始数据。执行时要特别注意顺序:先建库、再切库、然后建表、最后插入数据。如果在Navicat里直接执行整个脚本,记得在脚本开头加上USE gov_office,否则会默认建到当前选中的库里,导致后面连接时报“Table doesn't exist”。
初始账号通常在脚本里已经写好,密码是BCrypt加密后的内容,比如初始密码是admin123,数据库存的是一长串加密字符串。如果你想改初始密码,不要直接改VARCHAR字段里的密文,而是新写一段代码调用PasswordEncoder加密后生成新密文再替换,否则登录时密码校验永远对不上。
5.3 后端打包与前端构建
后端打包很简单,在项目根目录执行:
mvn clean package -DskipTests打出来的jar包在target目录下。启动时用java -jar即可:
java -jar gov-office-1.0.0.jar --spring.profiles.active=prod这里用profiles区分开发环境和生产环境是很有必要的。开发环境连本机数据库,生产环境连服务器数据库,两边的连接串和账号密码都不同。不用Spring profiles的后果就是每次部署都要临时改配置文件,改完忘了改回来,下次本地启动全崩。
前端构建执行npm run build,产物在dist目录。dist目录里的静态文件需要放到nginx的html目录下,或者单独指定root路径:
server { listen 80; server_name yourdomain.com; root /opt/gov-web/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }这里最关键的配置是try_files $uri $uri/ /index.html,它解决的是前端history路由模式下刷新页面404的问题。location /api/的proxy_pass后面带了/api/路径,这样请求会原样转发到后端接口,不需要后端再单独配跨域。
5.4 服务器上Docker启动MySQL8.0的完整命令
服务器上如果不喜欢用安装包方式部署MySQL,Docker是最省心的方式。前面给过基础命令,这里再说一下生产环境建议加的几个参数:
docker run -d \ --name mysql8 \ --restart=always \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=yourpassword \ -e TZ=Asia/Shanghai \ -v /opt/mysql/data:/var/lib/mysql \ -v /opt/mysql/conf:/etc/mysql/conf.d \ -v /opt/mysql/log:/var/log/mysql \ mysql:8.0挂载了conf目录之后,可以在宿主机/opt/mysql/conf下写一个my.cnf文件,比如设置max_allowed_packet=64M,避免后续导入大SQL文件时出现“Packet too large”的报错。数据库文件备份时直接备份/opt/mysql/data目录即可。
启动之后进入容器验证时区:
docker exec -it mysql8 mysql -uroot -p SELECT NOW();如果显示的是北京时间,说明TZ参数生效了。这一步做完再启动后端,时间字段就不会有8小时偏差。
6. 我实际踩过的一些坑与最终处理方案
6.1 前后端联调时的跨域问题
开发阶段跨域问题经常来回折腾。一种情况是前端配了proxy,后端也配了CORS,结果前端请求经过代理转发到后端时带了Origin头,后端的CORS配置又允许了跨域,但部分浏览器在某些情况下会对重复的跨域头报错。最好的处理方式是:开发阶段用前端代理,后端不配置CORS;生产环境走nginx反向代理,也不涉及跨域。前端代理配置中changeOrigin设为true,这样后端看到的请求仿佛来自同源,Cookie和鉴权头都能正常携带。
如果确实需要在后端配置CORS,比如有一些第三方系统要直接调用后端接口,配置时要放行所有需要跨域的路径,但要注意allowedOrigins不要设置为*,因为携带凭证的跨域请求不允许通配符。这个细节在政务类系统对接上级平台时特别重要。
6.2 LocalDateTime序列化格式的坑
这个坑在前后端联调的时候就会暴露。后端实体用了LocalDateTime,前端Axios拿到的JSON里时间是“2024-06-01T10:30:00”,页面上一显示就带一个T,不伦不类。网上很多方案是加@JsonFormat注解,但要在每个时间字段上都加,容易遗漏。我在工程里统一在application.yml的jackson配置里解决(前面已经给出配置),全局生效,新增实体类不需要再操心时间格式。
如果接口返回的时间还需要带上时区信息,比如IOT设备上报场景,可以再加一个配置项:spring.jackson.serialization.write-dates-as-timestamps=false,确保LocalDateTime不被序列化成时间戳数字。配置之后所有时间字段的输出格式都会被全局统一,省心很多。
6.3 MySQL8.0驱动的ClassNotFoundException
后端启动时报错java.lang.ClassNotFoundException: com.mysql.jdbc.Driver,大概率是pom.xml里mysql-connector-java的版本太老,或者驱动类名写错了。MySQL8.0对应驱动类名是com.mysql.cj.jdbc.Driver,连5.7的驱动包来连8.0的库,就算类名写对也会有协议兼容问题。看一下最稳妥的依赖:
<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>SpringBoot2.7的依赖管理会自动匹配一个兼容版本,不需要手动写版本号。如果用的SpringBoot2.5之前的版本,可能要显式指定8.0.33,否则默认拉到的驱动可能连不上8.0数据库。
6.4 Element Plus按需引入样式丢失
用了unplugin-auto-import和unplugin-vue-components做按需引入,表格、表单组件的基本用法没有样式问题,但ElMessage、ElMessageBox这种函数式组件必须额外引入样式。解决办法是在main.js里加:
import 'element-plus/es/components/message/style/css' import 'element-plus/es/components/message-box/style/css'这个问题最坑的地方在于,本地dev服务器上一切正常,打包上线后才会发现提示弹窗光秃秃的没有样式,因为dev模式下样式自动注入,生产构建时按需引入的组件样式链路没接上。所以构建完成后一定要先本地Preview一遍,再部署服务器。
6.5 Vue3中ref与reactive的选择强迫症
这个不算bug,但影响编码效率和代码一致性。在Vue3里,表格和表单数据用ref定义,路由和Pinia store用reactive定义。我见过一份代码里同事混合用,同一个页面表格用ref,详情弹窗的form用reactive,赋值时一会儿.value一会儿不带.value,很容易漏掉value导致页面不更新。定个团队规范:表单、列表、详情这类数据统一用ref+对象形式,只有组件内部跨函数共享的复杂响应式状态才用reactive。这样代码审查的时候一眼就能看出数据流。
6.6 逻辑删除字段的全局影响
最后提醒一句逻辑删除配置生效后的隐藏影响。很多新手配好了logic-delete-field就以为万事大吉,但实际上如果某张表要保留历史数据供报表统计,比如来访登记表,逻辑删除会导致报表统计数字和真实删除记录之间出现偏差。因此并非每张表都适合逻辑删除。像用户表、角色表这种配置类数据,逻辑删除很有价值;像来访记录、操作日志这种流水型数据,建议物理删除或者只做归档,不做逻辑删除,否则数据量大了之后,所有查询都要带着deleted=0的条件,索引和统计都会受影响。
这个取舍在政务系统里要提前想清楚,因为它影响的是长期运行的数据一致性和查询性能。
最后再分享一个我重复操作多次后的体会:乡村政务办公系统这种项目,真正拉开差距的地方往往不在代码本身,而在对业务状态的理解和对数据安全的把握。同一个功能模块,表设计时多考虑一个状态字段,后续就能少写不少补丁式的逻辑。这套源码里可参考的不仅是SpringBoot2和Vue3的组合写法,更是那种“把基层办公场景翻译成数据结构”的思路。如果有条件,建议拿到源码后先从删掉代码生成器的默认模板开始起步,手写两三个模块,把权限、分页、文件上传这些基础能力吃透,之后再去看生成器产物会轻松很多。