前阵子朋友公司前台还在用纸笔登记访客,字迹潦草、信息不全不说,访客到底找谁、几点进来、几点离开、有没有预约过,全靠一张纸根本查不出来。我问他为什么不换套系统,他说市面上现成的要么太贵,要么功能太“重”,自己又不会开发。刚好我手头就有一套做过完整落地的企业级来访管理系统,SpringBoot+Vue+MyBatis+MySQL这套经典架构,前后端分离,源码完整可以直接跑。今天把系统从设计到部署的完整过程拆开讲一遍,包括表结构怎么建、动态SQL怎么写、状态机怎么控制、前端路由权限怎么配、部署时Nginx要注意什么,最后把项目里踩过的高频坑一起整理出来。这东西特别适合拿来当毕业设计、课程设计,或者公司内部做轻量级行政管理系统的参考模板。
1. 项目整体设计与技术选型
1.1 这套技术栈背后是怎么取舍的
先聊最核心的问题:为什么用SpringBoot+Vue+MyBatis,而不是SpringCloud微服务,也不是JPA/Hibernate。
来访管理系统听起来叫“企业级”,但实际并发量真没那么高。一个几百人规模的企业,高峰期一天来访登记可能也就几十到上百条,这个量级用单体应用完全够。如果一上来就上微服务,拆分服务、注册中心、配置中心、网关、分布式事务一套搞下来,光维护成本就把项目拖垮了。技术选型不是越高级越好,而是越匹配业务场景越好。SpringBoot的优势就是开箱即用、生态成熟、招人便宜,配合内置的Tomcat跑一个单体服务,轻松扛住这个量级。
持久层选MyBatis而不选JPA,核心原因是SQL可控性。来访管理涉及大量的多表联查和条件查询,比如按访客姓名、手机号、访问部门、预约时间段、状态等多个条件组合查列表。MyBatis可以直接写原生SQL,配合动态SQL标签可以精确控制每一条语句,SQL调优的时候能清楚地看到执行计划。JPA虽然开发效率高,但当查询条件组合复杂到一定程度,自动生成的SQL往往不是最优的,想优化还得回退到自定义Query(JPQL或者原生SQL),绕一圈反而不如直接用MyBatis省心。
前端用Vue是因为后台管理系统的开发效率需求非常明确,Vue的双向绑定、组件化开发,加上Element UI(或Vue3的Element Plus)这种现成的UI组件库,表格、表单、弹窗、分页这些高频组件拖过来就能用,开发速度极快。整个项目技术栈的匹配度很高:后端一个SpringBoot应用,前端一个Vue单页应用,数据库一个MySQL实例,部署简单,维护直观。
| 维度 | SpringBoot+MyBatis单体 | SpringCloud微服务 | JPA/Hibernate |
|---|---|---|---|
| 开发效率 | 高,适合业务CRUD密集场景 | 低,基础设施成本高 | 较高,但复杂查询控制力弱 |
| SQL可控性 | 强,原生SQL+动态SQL | 强,但跨服务联查麻烦 | 弱,自动SQL不一定最优 |
| 部署运维 | 一个Jar包搞定 | 需要部署多个服务 | 同左(单体部署) |
| 适用场景 | 中小型后台管理系统 | 大型复杂业务系统 | 简单业务快速开发 |
1.2 功能模块与业务流程怎么串
系统整体可以拆成六个核心模块:预约管理、审批管理、通行登记、黑名单、统计报表、系统管理。预约管理负责访客在线上填写来访信息、选择被访人、预约到访时间;审批管理给被访人或者行政人员审批预约;通行登记是门卫端操作,支持直接登记临时访客,也支持把已审批通过的预约转换为实际通行记录;黑名单用于拦截有风险记录的访客;统计报表用来分析每天、每周、每月的访问量、各部门接待量、访客平均滞留时间;系统管理管用户、角色、菜单权限、基础数据字典。
业务流程是这样的:访客通过H5页面或者前台小程序提交预约申请,填写姓名、手机号、身份证、来访事由、预计到访时间、被访人;被访人在后台收到待审批记录,通过或驳回;审批通过后,访客在约定时间到公司门口,门卫在登记端输入手机号或者直接扫预约二维码,调出预约信息核对后签入;离开时再签离,系统自动生成完整通行记录。如果预约了没来,到第二天定时任务把状态更新为超时失效。临时来访不走预约流程,门卫直接登记信息,但也要过黑名单校验,进入后给被访人发通知。
这套流程最关键的设计思想是“状态可追踪”。纸质登记本的问题不只是记录麻烦,更在于信息是静态的,无法判断一个访客现在是不是还在公司里。系统引入状态流转之后,每次状态变更都有操作人、操作时间,出现安全事件时可以精确回溯访客的完整轨迹。这也是为什么我要在系统里专门设计一张状态流转相关的逻辑,而不是简单在访客表上直接改字段。
2. 数据库设计与MyBatis核心实现
2.1 核心表结构设计与字段陷阱
系统核心表包括访客信息表(visitor)、员工表(staff)、预约单表(appointment)、通行记录表(visit_record)、审批记录表(approval_log)、黑名单表(blacklist)、用户表(sys_user)、角色表(sys_role)等。这里给出一版可以直接用的核心建表SQL片段,重点看字段类型和索引设计。
CREATE TABLE `visitor` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `visitor_no` varchar(32) NOT NULL COMMENT '访客编号,格式YYYYMMDD+6位序列', `name` varchar(50) NOT NULL COMMENT '访客姓名', `phone` varchar(20) NOT NULL COMMENT '手机号,必须用varchar,不能用int', `id_card` varchar(18) DEFAULT NULL COMMENT '身份证号', `company` varchar(100) DEFAULT NULL COMMENT '来访单位', `black_flag` tinyint(1) 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_phone` (`phone`), KEY `idx_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='访客基础信息表';几个字段设计的经验:
手机号一定用varchar(20)而不要用int或bigint。可能有人觉得手机号是数字就顺手用了int,但Java里int最大值21亿多,手机号11位直接溢出,bigint虽然不溢出,但没法做手机号前几位模糊查询的索引优化,而且也没有必要用数值类型去存一个不参与算术运算的字符串。身份证号同理,必须用varchar,而且还存在X结尾的情况。
状态字段统一用tinyint并加注释,比如visitor表里的black_flag,visit_record表里的status字段(0待签入,1已签入,2已签离,3已超时,4已取消)。数据库注释写清楚了,后端起项目的人不需要翻代码才知道这个字段含义。
外键不要物理建。我曾经见过有人把预约单、通行记录之间全部建了物理外键,结果业务调整要改状态的时候各种约束冲突。逻辑外键就够了,通过联查来保证数据完整性,应用层控制业务关系,这也是大多数企业项目的常见做法。
关于索引,通行记录表要按访客手机号和访问时间高频查询,所以建议给visit_record表建(phone, plan_time)联合索引。统计报表经常按天、按部门聚合,可以给record表加create_date字段,冗余一个小字段,避免在大表上直接用函数DATE(create_time),否则索引会失效。
2.2 多条件查询的动态SQL写法
来访管理列表查询可以说是典型的“多条件可选”场景:查询条件可能是姓名、手机号、部门、状态、日期范围,而且条件不固定。这种情况下最忌讳的方式是在Java代码里手工拼SQL字符串,拼错了多一个AND,调半天才发现是SQL语法问题。
MyBatis的动态SQL就是专门解决这个问题的。核心就是<where>标签配合<if>,<where>会自动处理掉第一个条件前的AND或者OR,不用自己操心。下面给一段访客记录联查的XML写法,这是整套系统里最常用的一个查询。
<select id="selectVisitRecordPage" resultType="com.example.vo.VisitRecordVO"> SELECT vr.id, vr.visitor_no, v.name AS visitor_name, v.phone, v.company, s.name AS staff_name, s.department, vr.plan_time, vr.sign_in_time, vr.sign_out_time, vr.status FROM visit_record vr LEFT JOIN visitor v ON vr.visitor_id = v.id LEFT JOIN staff s ON vr.staff_id = s.id <where> <if test="name != null and name != ''"> AND v.name LIKE CONCAT('%', #{name}, '%') </if> <if test="phone != null and phone != ''"> AND v.phone = #{phone} </if> <if test="deptId != null"> AND s.department_id = #{deptId} </if> <if test="status != null"> AND vr.status = #{status} </if> <if test="startTime != null"> AND vr.create_time >= #{startTime} </if> <if test="endTime != null"> AND vr.create_time <= #{endTime} </if> </where> ORDER BY vr.create_time DESC </select>这里有一个很容易踩的坑:像<和>这种字符在XML里不能直接写,必须转义成<和>,或者用<![CDATA[ ... ]]>包裹。我自己早期写的时候忘了转义,XML解析直接报错,找了好半天。
再一个就是多参数Mapper方法一定要加@Param注解。比如上面的查询对应接口:
List<VisitRecordVO> selectVisitRecordPage(@Param("name") String name, @Param("phone") String phone, @Param("deptId") Long deptId, @Param("status") Integer status, @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime);不加@Param的时候,MyBatis虽然会用arg0、param1这类默认名字绑定,但可读性极差,多个参数时稍不注意就报“Parameter 'name' not found”错误。老老实实全部用@Param标注,XML里面引用的名字和注解名字保持一致,这个类错误基本可以杜绝。
还有全局配置里一定要开驼峰映射,在application.yml里配置map-underscore-to-camel-case: true,这样数据库的create_time才能自动映射到Java属性createTime。如果不配置,返回的对象时间字段全为null,前端列表空白一片。
2.3 分页插件和MyBatis缓存的使用边界
分页是列表查询的标配。我用的是PageHelper,用法非常固定:在Mapper查询前调用PageHelper.startPage(pageNum, pageSize),紧接着执行查询,插件会自动给SQL追加limit语句。代码长这样:
PageHelper.startPage(pageNum, pageSize); List<VisitRecordVO> list = visitRecordMapper.selectVisitRecordPage(name, phone, deptId, status, startTime, endTime); PageInfo<VisitRecordVO> pageInfo = new PageInfo<>(list);这里有个使用铁律:startPage后面必须紧跟第一条要执行的查询,中间不能夹带其它查询语句,否则分页会作用到错误的查询上,导致数据错乱。不要在循环里调用startPage,如果循环里每查一次都调用,分页参数会被覆盖,查出来的数据也是乱的。
关于MyBatis缓存,我的建议就一句话:默认配置够用就行,不要轻易开二级缓存。一级缓存是SqlSession级别的,同一个Session内多次查询相同SQL会走缓存,正常解决问题。二级缓存是namespace级别的,默认关闭。看着好像能提升性能,但在多表联查场景下隐患很大:比如查询visit_record的Mapper开启二级缓存,另一个Mapper联查了visitor表并修改了visitor的数据,visit_record的缓存是不会自动失效的,查出来的结果可能是脏数据。项目里真正需要加速的热点数据,比如首页统计数字,直接用Redis做业务缓存,比用MyBatis二级缓存可控得多。
还有一个面试常问也经常在项目中遇到的问题:@Update执行特别慢。排查思路很固定,第一步先用日志打印出执行的SQL,放到数据库客户端里执行并用EXPLAIN看执行计划,重点看是不是没有走索引造成全表扫描。有一个很典型的场景:根据访客姓名更新记录,name字段没建索引,更新几条数据触发全表扫描;解决方案是给name建索引,或者改造成根据id更新。另一个场景是事务范围过大,一个更新方法里先查了一堆数据又更新了许多行,事务迟迟不提交导致行锁冲突,解决方法是缩小事务边界,只把必要的更新包在事务里。
3. 后端业务实现的关键环节
3.1 登录鉴权与权限控制
后台系统肯定要登录,我用的是JWT+拦截器的方案,没有直接上Spring Security,原因很简单:这套系统权限模型不算复杂,用Spring Security需要配置SecurityFilterChain、UserDetailsService、PasswordEncoder等一堆组件,学习成本高而且不直观。JWT+HandlerInterceptor的方案写起来快,逻辑也清晰:登录成功后签发token,前端每次请求把token放到Authorization头里,后端写一个拦截器校验token并解析出当前用户。
核心代码如下,token生成我用的jjwt库,密码加密用的BCrypt。
// 登录接口 @PostMapping("/login") public Result login(@RequestBody LoginDTO dto) { SysUser user = sysUserService.getByUsername(dto.getUsername()); if (user == null || !BCrypt.checkpw(dto.getPassword(), user.getPassword())) { return Result.error("用户名或密码错误"); } String token = Jwts.builder() .setSubject(user.getId().toString()) .claim("username", user.getUsername()) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000L)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); return Result.success(token); } // 拦截器校验 public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BizException(401, "未登录"); } Claims claims = Jwts.parser().setSigningKey(secretKey) .parseClaimsJws(token.replace("Bearer ", "")).getBody(); request.setAttribute("userId", Long.valueOf(claims.getSubject())); return true; } }BCrypt这里多说一句,为什么不用MD5。MD5加盐虽然也能用,但BCrypt自带随机盐、计算速度可控,抗暴力破解能力明显更强。密码长度相同的两个用户即使密码一样,BCrypt生成的密文也不同,安全性高一个量级。
鉴权这块还要补充一个思路:前端菜单和按钮的显示可以由后端返回权限标识控制,但后端接口也必须做权限校验,不能只靠前端隐藏按钮。我在项目里用一个自定义注解@RequirePermission("visit:approve")加在审批接口上,拦截器里检查当前用户的权限集合是否包含这个标识。只在前端控制而不在后端校验,使用Postman直接调接口就能绕过菜单限制,这是很多初学项目常见的安全漏洞。
3.2 访客流程的状态机与并发控制
业务状态流转是整套系统的核心逻辑。预约单的状态包括:待审批、已通过、已驳回、已取消、已签入、已签离、超时失效。通行记录状态在预约单的基础上再拆分。
写状态流转的时候,我特别强调一个规范:所有状态变更必须用“CAS式更新”,也就是在SQL的where条件里带上当前期望状态,更新成功返回1才代表状态变更成功。举例:
// 审批通过:只有当前状态是“待审批”的预约单才能变更为“已通过” int rows = appointmentMapper.updateStatusByIdAndStatus(appointmentId, AppointmentStatus.APPROVED.getCode(), AppointmentStatus.PENDING_APPROVAL.getCode()); if (rows == 0) { throw new BizException("预约单状态已变更,请刷新后重试"); }UPDATE appointment SET status = #{newStatus}, approve_time = NOW(), approver_id = #{approverId} WHERE id = #{id} AND status = #{expectStatus}这个写法的价值在并发场景下特别明显。假设同一个预约单,审批人A和审批人B同时打开审批页面,A点了通过,B也点了通过。如果代码是先查状态、判断、再更新,中间没有锁保护,可能出现两个请求都判断“当前待审批”,产生重复审批。用update ... where status = 待审批,数据库的行锁和条件更新天然保证了只有一个请求能更新成功,第二个请求更新0行,直接提示操作失败。这比在Java代码里写synchronized或者分布式锁简单得多,也不容易出错。
超时失效采用定时任务批量处理。项目里用Spring的@Scheduled注解,每半小时执行一次,把到访时间已经过去且状态仍为“已通过”的预约单批量更新为“超时失效”。这个批量更新不要一次性UPDATE全表,建议带上id范围分批处理,每批限制几百条,避免长事务锁住大量行,影响正常的登记操作。
3.3 文件上传、通知与黑名单辅助功能
来访登记经常需要上传访客的身份证照片或者人脸照片,这个用SpringBoot的MultipartFile接口基本是标配。上传后的文件建议单独放在一个目录,比如/data/visit/upload/,然后通过配置虚拟路径映射访问,而不是把图片放到项目的resources目录下。项目目录里的文件会在重新部署时被清理掉,到时候历史照片全部丢失,这是真踩过的坑。
spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB # 自定义上传目录映射 file: upload-dir: /data/visit/upload/再写一个WebMvcConfigurer把磁盘路径映射成URL:
@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${file.upload-dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadDir); } }通知功能我抽象了一个MessageSender接口,实现类可以接短信网关、也可以测试环境直接打日志。生产环境接短信服务的时候只需要新增一个实现类,不需要改动业务代码。
黑名单模块的关键逻辑在登记和预约时都会执行:根据访客手机号或身份证号去blacklist表比对,命中后直接阻断操作并提示。手机号黑名单的判断优先级高于姓名,原因很好理解,身份证号可能记错,但手机号是预约和登记的核心关联键。
4. 前端Vue实现要点
4.1 项目初始化与前端依赖
前端用Vue构建后台管理界面,项目初始化我建议直接使用Vue CLI或者Vite创建。Vite启动速度确实比Webpack快不少,但在Vue 2生态里还是Vue CLI更稳;如果选Vue 3,直接用Vite就行,配合Element Plus组件库,开发体验很好。
依赖安装阶段最容易踩的坑是Node版本不匹配。老一点的Vue 2项目装node-sass的时候,如果Node版本过新,node-sass编译会直接失败。解决办法是用nvm管理Node版本,并在项目里固定engines字段,或者直接用sass新版的Dart Sass替代node-sass,安装基本不会再出问题。安装依赖时遇到卡顿,先换镜像源:
npm config set registry https://registry.npmmirror.com npm install初始化完成之后建议先搭好目录结构,按views(页面)、router(路由)、api(接口请求)、store(状态管理)、utils(工具)分层。后台管理系统页面一多,如果全部堆在一个文件里,后期维护成本非常高。
4.2 路由守卫与路由参数传递
登录鉴权在前端也有一套配合逻辑。路由配置使用全局前置守卫beforeEach,每次路由跳转前检查当前用户token是否存在。没有token且目标路由不是登录页,就重定向到登录页;有token但是访问登录页,就重定向到首页。
import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/login', component: () => import('@/views/Login.vue') }, { path: '/', component: () => import('@/layout/Index.vue'), meta: { requiresAuth: true } } ] }) router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else { next() } })动态路由这块,如果是给不同角色分配不同的菜单权限,可以在登录后从后端拉取菜单数据,再用router.addRoute()动态注册。注意动态添加路由后刷新页面会丢失,需要把菜单权限存到localStorage或者Pinia里,刷新时重新根据权限重建路由。这个功能如果做不好,就会出现“登录后进入页面刷新就404”的经典问题。
路由参数传递也是一个容易被忽略的点。跳转详情页时用query传参,比如router.push({ path: '/visit/detail', query: { id: record.id } }),刷新页面参数还在URL上不会丢;如果用params传参,刷新后参数直接丢失,页面拿不到ID就渲染空白。这里建议统一用query传业务参数。
4.3 Axios封装与跨域拦截
前端所有HTTP请求必须走一个统一的Axios实例,便于统一处理token注入、错误提示、超时等逻辑。我在utils/request.js里写了一套简单的封装:
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + 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 }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(error.response?.data?.message || '网络异常') return Promise.reject(error) } ) export default request开发环境跨域问题我建议直接用Vite或Vue CLI的proxy配置,后端不需要额外处理CORS。Vite在vite.config.js里配置:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样前端请求/api/login,开发服务器会自动转发到http://localhost:8080/api/login,浏览器看到的还是同源的请求,不存在跨域。生产环境再交给Nginx做反向代理,这是前后端分离项目最常见的部署模式。
5. 部署上线与生产环境配置
5.1 MySQL初始化与JDBC连接配置
项目使用的数据库是MySQL,开发环境可以本地安装,生产环境一般用云数据库或者自建MySQL。安装完成后第一件事不是建表,而是确认数据库字符集。建议统一使用utf8mb4,它比utf8多支持emoji等四字节字符,访客信息里万一有人名字里带生僻字或者特殊符号,utf8mb4都不会乱码。
CREATE DATABASE visit_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;执行项目的init.sql建表脚本,生成基础数据和默认管理员账号。SpringBoot的数据库连接配置重点在JDBC URL参数,不同版本驱动参数略有差异,我用的MySQL 8.0驱动配置如下:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/visit_system?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: yourpassword hikari: maximum-pool-size: 10 minimum-idle: 5 connection-timeout: 30000serverTimezone=Asia/Shanghai和useSSL=false这两个参数建议务必写上。不写时区,Java 8的LocalDateTime和数据库datetime之间的转换会差8小时,所有记录时间都会不对;不关SSL,测试环境连接时经常报证书相关错误,调试体验很差。
5.2 后端打包与Linux服务器部署
后端打包在项目根目录执行:
mvn clean package -Dmaven.test.skip=true打包好的jar在target目录下,名称类似visit-system-1.0.0.jar。生产环境用application-prod.yml配置文件,启动的时候通过--spring.profiles.active=prod指定。生产配置和开发配置的区别主要是数据库地址、日志级别、上传目录地址、以及是否开启Swagger等调试功能。
启动命令我一般这么写:
nohup java -Xms256m -Xmx512m -jar visit-system-1.0.0.jar \ --spring.profiles.active=prod \ --server.port=8080 \ > logs/app.log 2>&1 &JVM内存参数根据服务器配置调整,小型项目256M到512M足够了,不要盲目给到1G或2G,反而会造成资源浪费。日志输出重定向到文件,方便排查问题。
这里有个常见问题:开发环境连接数据库正常,部署到服务器后报Communications link failure或者连接超时。排查顺序是:先ping通不通,再telnet ip 3306端口通不通,最后看MySQL配置里的bind-address是不是绑定了127.0.0.1只允许本机连接。云服务器还要检查安全组是否放行了3306端口。顺序不要乱,很多新人一上来就怀疑代码问题,实际上八成是网络和端口问题。
5.3 前端构建与Nginx反向代理
前端构建命令:
npm run build构建产物在dist目录,里面是纯静态文件。部署时我用Nginx托管静态文件,并把/api路径反向代理到后端服务。Nginx配置如下,这是前后端分离项目的标准模板:
server { listen 80; server_name visit.example.com; # 前端静态文件 root /opt/visit/dist; index index.html; # 解决Vue history路由刷新404 location / { try_files $uri $uri/ /index.html; } # API反向代理 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; } # 静态资源缓存 location /assets/ { expires 7d; add_header Cache-Control "public"; } }try_files $uri $uri/ /index.html这一行是用来解决Vue使用history路由时,刷新子页面404的问题。原理是Nginx发现磁盘上找不到对应的静态文件,就回退到index.html,由前端路由接管渲染。如果前端用了hash路由,这一行不加也可以,但后台管理系统从URL美观和SEO角度来说,用history还是更常规的选择。
注意location /api/的proxy_pass末尾带了/,表示转发时去掉/api前缀。比如前端请求/api/login,后端实际收到的是/login。如果后端接口本身没有context-path,这种写法是对的;如果后端controller里写的是@RequestMapping("/api/login"),那proxy_pass末尾就不要带/。这个斜杠问题容易导致接口404,前后端联调时要注意一致性。
6. 常见问题排查与避坑实录
6.1 高频问题速查表
项目开发和部署过程中,我整理了一张高频问题速查表。这些问题的出现频率非常高,几乎是每个做这套架构的人都会遇到至少一两项:
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 后端启动报端口被占用 | 上一次进程没有完全停止,或端口被其它程序占用 | lsof -i:8080查占用进程,kill 掉后重启 |
| 前端请求接口404 | 代理路径配置错误、后端context-path不一致 | 核对 Nginx/api/斜杠规则和 proxy_pass 结尾斜杠 |
| 接口返回数据中文乱码 | 数据库字符集不是 utf8mb4、JDBC 未指定 UTF-8 | 统一在 URL 加characterEncoding=utf8,数据库改 utf8mb4 |
| 分页查询结果重复或丢失 | PageHelper.startPage 与查询之间夹了其它 SQL | 保证 startPage 后紧跟目标查询,循环中禁止调用 startPage |
| 列表查询报SQL语法错误 | <if>条件组合导致多了不必要的 AND | 使用<where>标签包裹,由框架自动处理第一个 AND |
| 时间字段相差8小时 | 数据库和应用的时区配置不一致 | JDBC URL 加serverTimezone=Asia/Shanghai |
| 上传文件过大被拒绝 | SpringBoot 默认单文件限制 1MB | 配置spring.servlet.multipart.max-file-size |
| Vite 启动后页面访问失败 | Node 版本过高或依赖缓存异常 | 用 nvm 切到指定 Node 版本,清理 node_modules 重装 |
| MySQL 连接报 SSL 或公钥检索错误 | 驱动版本与数据库配置不匹配 | URL 加useSSL=false&allowPublicKeyRetrieval=true |
| 登录接口调通但页面仍跳登录 | token 没被 Axios 拦截器正确注入 | 检查 request.js 中 Authorization 头格式和后端解析是否一致 |
6.2 几条靠经验换来的开发建议
除了速查表里的具体问题,还有几条开发层面的建议,是在实际项目里踩过不少坑总结出来的。
第一,列表查询不要写select *,写全字段。可能有人觉得这样省事,但后续表结构增加字段时,select *会把新字段也查出来,如果VO里没有对应字段,不管是Map映射还是JDBC驱动都会出现各种奇怪的问题。更严重的是,select *会让查询带上很多不必要的大字段,数据量上来后网络传输和内存开销都很难看。
第二,警惕N+1查询问题。比如查询通行记录列表需要显示被访人姓名,最直观的做法是查完记录后循环查员工表,一条记录查一次。记录多的时候数据库会被打爆。正确做法是联查一次性查出关联字段,或者用IN批量查询。MyBatis的<foreach>标签配合IN查询是常见的优化手段。
<foreach collection="staffIds" item="staffId" open="(" separator="," close=")"> #{staffId} </foreach>第三,前端隐藏按钮不等于安全。权限控制必须以后端为准,前端只是提升用户体验。项目里出现过开发人员通过浏览器控制台手动调用接口修改审批状态的测试,如果后端不做权限校验,这种行为完全能绕过界面限制。
第四,事务不要乱加。我在审批通过逻辑里一开始给整个方法加了@Transactional,后来发现方法里调用了短信通知接口,短信服务响应慢的时候事务一直挂着不提交,数据库连接被白白占用。现在的做法是事务只包裹状态变更相关的数据库操作,外部通知放在事务提交后异步发送,拆分成两个独立方法。
第五,定时任务记得批处理。批量更新超时预约单时,如果一次UPDATE影响几万行数据,行锁范围太大,会影响正常业务的写入。我把定时任务改成了分批循环处理,每批500条,执行完一批提交一次,耗时和锁粒度都可控。
最后分享一个实用的小技巧:黑名单校验在登记接口里用AOP做统一拦截。定义一个@BlacklistCheck("phone")注解,标注在切换控制器的方法参数上,AOP切面自动从参数里取出手机号,执行黑名单比对,命中就抛异常阻断流程。这样每个需要校验的接口不用重复写判断逻辑,后续要调整黑名单校验规则也只需要改一个切面。我这套系统做完之后最直观的感受是,访客管理这种业务看着简单,但把状态流转、权限校验、数据联动这几个点做扎实,还是需要不少细致功夫的。希望这篇拆解能帮到正在做类似系统或者准备拿这个方向做毕设的朋友少走弯路。