
简介一套基于SSMSpringSpringMVCMyBatis的学校访客登记系统毕业设计源码包面向Java初学者及毕业设计、课程设计、期末大作业人群。系统覆盖访客信息录入、身份核验、访问权限管理、历史记录查询、报警通知等核心场景可帮助快速搭建一个完整可演示的前后端项目。包内共957个文件压缩包约35.84MB以Java源码78个java与78个class、JSP页面114个jsp、前端资源85个js、37个css、32个html、271个gif、数据库脚本1个sql、71张jpg图片以及xml配置、jar依赖、PPT/Word文档等为主导入IDE即可运行。已有37人学习或浏览。项目包含家庭来访申请、访客登记、学生登记、教职工登记等控制类对应Controller、Service、DAO层的完整链路从预览可见各业务模块划分清晰配套数据库表与演示材料可清晰展示SSM在三层架构中的分工与MyBatis持久化写法便于二次开发、毕业设计答辩准备和期末大作业参考。1. 基于SSM的学校访客登记系统为什么校门口比写字楼更需要一套可追溯的登记流程学校访客登记系统听起来像是毕业设计的常见选题但放到真实场景里它解决的是校门口那个被保安握到发烫的纸质登记本访客姓名潦草难辨、手机号少一位、几点进校几点离校全凭回忆真出了纠纷连追溯路径都没有。基于SSM——即Spring、SpringMVC、MyBatis这套Java后端组合——来做这个系统把访客预约、门卫审批、进出记录、黑名单落到数据库里正好覆盖中小学和高校院系的门禁管理需求。这套方案适合两类人一是正在做课程设计或毕业设计的学生想找一个能讲清架构、能跑通全流程的题目二是打算绕开低代码平台、用自己可控的方案改造门卫流程的学校信息化老师。下文按做这类项目的时间线展开先立住SSM的数据骨架再串起业务链路最后把新手最容易翻车的五个坑逐一拆开。2. 先搭SSM骨架三层架构、六张表和一份能直接跑的目录结构2.1 Spring在访客系统里管什么IoC注入与事务边界开发这个系统时选SSM而不是Spring Boot的两个实际原因一是多数学校机房和课程设计的验收环境还停留在Tomcat 8加JDK 1.8SSM的手写XML配置在这些环境里更稳妥二是SSM的包结构能让评分老师和同事一眼看清Controller、Service、Dao三层职责划分。用IDEA新建Maven工程后先按如下结构建包src/main/java/com/school/visitor/ ├── controller/ // SpringMVC的Controller层只做请求接收和参数整理 │ ├── VisitorController.java │ └── SystemUserController.java ├── service/ // 业务层接口 │ ├── VisitorService.java │ └── impl/VisitorServiceImpl.java ├── dao/ // MyBatis的Mapper接口对应XML里的SQL │ ├── VisitorMapper.java │ └── SystemUserMapper.java └── entity/ ├── Visitor.java └── SystemUser.java src/main/resources/ ├── spring/ // Spring与SpringMVC的XML配置 │ ├── applicationContext.xml │ └── spring-mvc.xml └── mapper/ // 真正书写SQL的XML文件 ├── VisitorMapper.xml └── SystemUserMapper.xml这个目录结构对应SSM最经典的分层Controller关注的是HTTP请求和响应Service关注的是业务规则Dao关注的是数据库操作。访客登记这种系统业务并不复杂把职责拆到这个粒度已经足够不需要再引入额外的service接口实现类数量膨胀。IoC在这里最直观的体现是Controller里不会出现new VisitorServiceImpl()而是通过Autowired让Spring容器把已经管理好的Service实例注入进来。这样做的好处是后面如果把ServiceImpl换成另一个实现类做测试Controller一行都不用改。RestController RequestMapping(/api/visitor) public class VisitorController { Autowired private VisitorService visitorService; PostMapping(/register) public Result register(RequestBody Visitor visitor) { if (visitor.getName() null || visitor.getName().trim().isEmpty()) { return Result.error(姓名不能为空); } if (visitor.getIdCard() null) { return Result.error(身份证号不能为空); } return visitorService.register(visitor); } }上面这段代码的逻辑很简单请求体里的JSON被反序列化成Visitor对象Controller只做参数非空校验真正业务逻辑全部交给visitorService.register()。注意这里我没有写Validated这类JSR303校验是因为课程设计阶段手写校验更容易讲清楚每一步在做什么。Result是一个自定义的响应体通常包含code、message、data三个字段success()和error()是它的静态工厂方法。Spring的XML配置决定了这个项目能不能在Tomcat里正常启动。关键配置都集中在applicationContext.xml里下面这段是数据源和SqlSessionFactory的配置很多新手在这里直接把数据库连接串写错导致启动时一片红色报错。!-- 数据源用Druid比默认的dbcp更容易看到连接池状态 -- bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property namedriverClassName valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/school_visitor?useUnicodetrueamp;characterEncodingutf8amp;serverTimezoneAsia/Shanghai/ property nameusername valueroot/ property namepassword value123456/ /bean !-- SqlSessionFactory交给MyBatis-Spring整合包 -- bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ property nametypeAliasesPackage valuecom.school.visitor.entity/ /bean !-- 扫描Dao接口生成Mapper代理对象 -- bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.school.visitor.dao/ /bean这里的几个参数值得说清楚。mapperLocations指定MyBatis的SQL XML文件放在classpath:mapper/下如果你的Mapper XML放在resources/mapper目录里这行就不用动如果放错位置启动时MyBatis会提示Invalid bound statement (not found)。typeAliasesPackage让SQL XML里能直接用Visitor这样的类名而不用写完全限定名。serverTimezoneAsia/Shanghai是MySQL 8.x必须加的否则日期类型会报时间差错误。事务配置也是Spring在这个系统里的重头戏。访客登记、审批、签离这几步都涉及写操作只要中途抛异常就必须整体回滚否则会出现“登记成功但访问码没生成”这种奇怪状态。bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:advice idtxAdvice transaction-managertransactionManager tx:attributes tx:method nameregister* propagationREQUIRED/ tx:method nameapprove* propagationREQUIRED/ tx:method namesignOut* propagationREQUIRED/ tx:method nameget* read-onlytrue/ tx:method namepage* read-onlytrue/ /tx:attributes /tx:adviceREQUIRED表示如果当前没有事务就新建一个read-onlytrue则给只读查询优化。事务通知还需要配合aop:config切面指定作用范围一般切到com.school.visitor.service.impl.*包。这里要特别记住事务是加在Service实现类方法上的不是加在Controller上的。开始阶段先把这套配置跑通后面写业务代码时就不需要每个方法去手动开启事务。2.2 MySQL下的六张核心表从访客预约到黑名单访客登记系统虽然业务不复杂但表设计决定了后续开发是顺手还是折腾。这个项目一般会涉及六张核心表它们以visitor表为业务中心其余都是为它服务的关联表。表名作用关键字段sys_user门卫和保卫处账号username,password,rolevisitor访客登记主表name,id_card,phone,statusvisit_purpose受访人及来访事由visitor_id,host_name,reasonaccess_log进出校门记录visitor_id,in_time,out_timeblacklist黑名单管理id_card,reason,operator_idsys_config可配置项比如每日最大访客数config_key,config_valuevisit_purpose单独拆分出来而不是塞进visitor表是因为一个访客可能一天内多次进校、不同时段访问不同部门单独存成明细更方便扩展。sys_config用来控制一些门卫每天都可能调整的参数比如“今天只允许预约访客进校”或“单日访客上限”把它放进数据库而不是硬编码在Java里后续改配置不用重新打包。下面这张visitor表的建表语句是整个系统的核心字段含义和约束设计直接影响后续代码的写法。CREATE TABLE visitor ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, name VARCHAR(50) NOT NULL COMMENT 访客姓名, id_card VARCHAR(18) NOT NULL COMMENT 身份证号, phone VARCHAR(11) DEFAULT NULL COMMENT 联系电话, visit_date DATE NOT NULL COMMENT 预约来访日期, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待审核 1已通过 2已拒绝 3已签离, access_code VARCHAR(16) DEFAULT NULL COMMENT 放行访问码, approver_id BIGINT DEFAULT NULL COMMENT 审批人ID关联sys_user, approve_time DATETIME DEFAULT NULL COMMENT 审批时间, create_time DATETIME NOT NULL COMMENT 登记时间, leave_time DATETIME DEFAULT NULL COMMENT 签离时间, PRIMARY KEY (id), UNIQUE KEY uk_idcard_visitdate (id_card, visit_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT访客登记表;这张表的几个设计点都要知道为什么。首先id_card和visit_date组成唯一索引这是为了挡住同一个身份证同一天重复登记即使你的Java代码忘了防重判断数据库这一层也能兜住。其次status用TINYINT而不是VARCHAR是为了查询速度和状态机控制Java端的枚举类可以跟它对应。第三access_code是给门卫核验用的随机码不放在预约信息里单独生成而是审批通过后才生成这样没通过的访客拿不到有效码。第四approve_time和leave_time分开审批时间和实际签离时间各司其职报表统计时才能算出“访客在校内停留了多久”。需要注意的是id_card这里用VARCHAR(18)而不是CHAR(18)是因为虽然身份证号固定18位但可能存在测试数据或历史数据的位数差异VARCHAR在存储上不会浪费空间查询时也不用担心尾部空格问题。而name字段用VARCHAR(50)其实偏保守国内少数民族姓名加上中间点可能超过20个字符50足够。phone用VARCHAR(11)是多数系统里的约定俗成但如果要兼容座机号建议放宽到VARCHAR(20)。2.3 MyBatis的XML映射把条件查询写成动态SQLMyBatis在这个系统里承担的是SQL与Java对象的映射。访客查询往往不是固定条件门卫想查今天所有已放行的访客管理员想查某个时间段来过的人黑名单人员想查历史记录条件组合很多。如果每种组合都写一个方法代码会翻倍所以使用动态SQL。mapper namespacecom.school.visitor.dao.VisitorMapper resultMap idVisitorMap typeVisitor id columnid propertyid/ result columnname propertyname/ result columnid_card propertyidCard/ result columnphone propertyphone/ result columnvisit_date propertyvisitDate/ result columnstatus propertystatus/ result columnaccess_code propertyaccessCode/ result columnapprover_id propertyapproverId/ result columnapprove_time propertyapproveTime/ result columncreate_time propertycreateTime/ result columnleave_time propertyleaveTime/ /resultMap select idsearch resultMapVisitorMap SELECT id, name, id_card, phone, visit_date, status, access_code, approver_id, approve_time, create_time, leave_time FROM visitor where if testkeyword ! null and keyword ! AND (name LIKE CONCAT(%, #{keyword}, %) OR id_card LIKE CONCAT(%, #{keyword}, %)) /if if teststatus ! null AND status #{status} /if if teststartDate ! null AND visit_date gt; #{startDate} /if if testendDate ! null AND visit_date lt; #{endDate} /if /where ORDER BY create_time DESC /select /mapper这段XML的要点在where和if标签。where标签会自动去掉第一个条件前的AND所以哪怕只有一个条件拼进去生成的SQL也不会出现语法错误。keyword参数用LIKE CONCAT(%, #{keyword}, %)这里必须写成CONCAT而不是%#{keyword}%因为后者在MyBatis预编译时不会把参数拼接到字符串字面量里而且存在SQL注入隐患。gt;是XML中大于等于号的转义写法直接用会让XML解析器报错。关于if teststatus ! null这里有一个细节如果status传的是0很多人会误以为整数0会被当成空值跳过条件实际上MyBatis的OGNL表达式里status ! null对0返回的是true所以status0待审核的记录能被正常过滤出来。为了避免这种歧义我习惯在Mapper接口里把所有条件参数封装成一个VisitorQuery对象而不是散着传多个参数代码可读性高很多也方便后续增加排序字段。3. 把SSM流程串起来从登记、审批到签离的完整链路3.1 访客登记Controller接收请求到Service做防重复校验访客登记的入口是Controller但真正的业务判断在Service层。这段业务代码要解决三个问题一是同一身份证同一天不能重复登记二是登记时给访客一个初始状态三是登记失败时必须有明确的错误信息返回给前端。Service public class VisitorServiceImpl implements VisitorService { Autowired private VisitorMapper visitorMapper; Override Transactional(rollbackFor Exception.class) public Result register(Visitor visitor) { Integer count visitorMapper.countByIdCardAndDate( visitor.getIdCard(), visitor.getVisitDate()); if (count 0) { return Result.error(该身份证当天已登记请勿重复提交); } visitor.setStatus(0); visitor.setCreateTime(LocalDateTime.now()); visitor.setAccessCode(generateAccessCode()); visitorMapper.insert(visitor); return Result.success(登记成功访问码 visitor.getAccessCode()); } }这段代码最核心的是先查询再插入。countByIdCardAndDate对应Mapper XML里的一个简单查询统计id_card和visit_date完全匹配的记录数。如果在count为0时插入正常情况下不会出问题但在高并发场景下两个请求同时进来两个查询都返回0都会走到插入这时唯一索引uk_idcard_visitdate就会发挥作用数据库会拒绝第二次插入并抛出DuplicateKeyException。想要把这个异常转换成友好的提示可以在insert那里捕获DuplicateKeyException回滚事务并提示“请勿重复提交”。这里生成的accessCode我一般用UUID去掉横线后取前16位或者用SecureRandom生成8位数字。不建议用Math.random()拼字符串因为可预测性太强门卫验码的安全性会打折扣。还有个细节是Transactional(rollbackFor Exception.class)Spring默认只对RuntimeException回滚如果业务代码里抛的是受检异常不加rollbackFor会出现“报错但数据照常写入”的诡异现象。我在这个项目里统一加上rollbackFor Exception.class等于把所有异常都纳入回滚范围。3.2 门卫审批状态流转与访问码放行登记进来的访客并不会直接进校门卫在系统里看到申请后需要核实身份和事由通过或拒绝。审批动作在Service层要处理的不是简单的UPDATE而是一个状态机约束只有待审核的访客才能被审批已经审批过的不能再审批通过后要生成放行访问码拒绝时要记录原因。Override Transactional(rollbackFor Exception.class) public Result approve(Long id, Integer status, Long approverId) { Visitor visitor visitorMapper.selectById(id); if (visitor null) { return Result.error(访客记录不存在); } if (visitor.getStatus() ! 0) { return Result.error(当前状态不可审批请刷新后重试); } visitor.setStatus(status); visitor.setApproverId(approverId); visitor.setApproveTime(LocalDateTime.now()); if (status 1) { visitor.setAccessCode(generateAccessCode()); } visitorMapper.updateById(visitor); return Result.success(status 1 ? 审批通过放行码已生成 : 已拒绝该访客申请); }这段代码里有一个值得展开的细节先selectById查出来再updateById这个过程不是原子的。如果两个门卫同时点击审批A把状态改成已通过B再查时已经变成已通过会被状态判断拦住。但更极端的情况是A和B同时读到的都是状态0A改成通过B也改成通过就可能出现审批时间被后写的覆盖而访问码生成两次。虽然在实际学校场景里两个门卫同时操作同一单的概率很低但更稳妥的做法是在updateById的SQL里带上AND status 0这样的条件让修改依赖数据库的行锁而不是应用层的判断。这里还涉及一个字段设计问题approve_time是DATETIME在没有审批时是NULL这样查询里可以用leave_time IS NULL找出还在校内的人。如果当初设计时这一列随便给了一个默认值后面写在场查询时就要多考虑几个分支。3.3 签离与在场查询让门卫的防线延伸到楼内签离是访客出校时的动作门卫在系统里录入访问码系统确认该访客确实通过审批且尚未签离然后写入leave_time。签离在真实场景里的意义不只是记录还能和门禁闸机联动或用来按小时统计校内人员动向。Override Transactional(rollbackFor Exception.class) public Result signOut(String accessCode) { Visitor visitor visitorMapper.selectByAccessCode(accessCode); if (visitor null) { return Result.error(访问码不存在); } if (visitor.getStatus() ! 1) { return Result.error(该访客未通过审批不能签离); } if (visitor.getLeaveTime() ! null) { return Result.error(该访客已完成签离请勿重复操作); } visitor.setLeaveTime(LocalDateTime.now()); visitorMapper.updateById(visitor); return Result.success(签离成功); }签离这块最容易出错的是访问码和状态的连锁校验顺序必须先判断访问码存在再判断状态最后判断是否已签离。如果三个判断顺序乱了比如先判断状态为1那么一个不存在的访问码会直接返回“未通过审批”门卫会误以为系统出了问题。这种错误信息会直接影响门卫对系统的信任。所以我在代码里把校验顺序写成“存在性 → 状态 → 唯一性”每一步的错误信息都不同排障时能直接定位。在场查询属于高频访问操作SQL写起来要同时考虑status和leave_time两个条件。SELECT v.name, v.visit_date, v.reason, v.phone, v.leave_time FROM visitor v WHERE v.status 1 AND v.visit_date CURDATE() AND v.leave_time IS NULL ORDER BY v.approve_time ASC;这个SQL的边界条件要和2.2节表设计对应status1表示已通过但不能只看它因为昨天通过且昨天就该离开的人如果今天还在校内visit_date已经不是今天就不会出现在结果里。如果学校要求跨天滞留的访客也要被追踪到这行SQL就要改成visit_date CURDATE()而不是 CURDATE()。用approve_time ASC排序可以让门卫按进校先后顺序查看先放行的人排在前面。3.4 用PageHelper分页查询访客记录参数与边界当系统中访客记录逐渐增多分页查询是必经之路。SSM项目里最常用的分页方案是PageHelper它基于MyBatis拦截器实现使用上非常简单但边界条件不少。public PageResultVisitor page(int pageNum, int pageSize, String keyword, Integer status) { PageHelper.startPage(pageNum, pageSize); ListVisitor list visitorMapper.search(keyword, status); PageInfoVisitor pageInfo new PageInfo(list); return PageResult.success(pageInfo.getList(), pageInfo.getTotal(), pageInfo.getPages()); }PageHelper.startPage(pageNum, pageSize)这行的作用是向当前线程注入一个分页参数MyBatis拦截器会在下一次查询时自动在SQL末尾拼接LIMIT。因此这行后面必须马上跟着你要分页的那条Mapper查询中间不能夹着其他查询或逻辑否则拦截器会把分页参数应用到错误的查询上。这里也建议在page方法开头校验pageNum至少为1、pageSize在10到100之间防止前端传一个负数把数据库拖垮。PageInfo对象里除了list还有total、pages、pageNum、pageSize、hasNextPage等属性这些是一次分页查询结果应该返回给前端的完整信息。常见错误是只返回list不返回total导致前端无法计算总页数分页条只能显示“首页/上一页/下一页”翻到最后一页时也不知道什么时候该停。合理做法是直接把PageInfo转成前端需要的JSON结构传出去而不是让前端再去猜测总数。4. SSM项目落地避坑五条让新手反复翻车的真实记录4.1 中文字符变问号CharacterEncodingFilter的注册顺序现象用Postman提交一个访客姓名数据库中存储的却是“???”前端页面显示也是乱码。原因Tomcat默认使用ISO-8859-1读取请求参数而页面和数据库都是UTF-8。很多人在SpringMVC配置里只写了RequestMapping和produces application/json;charsetutf-8但请求参数进入Controller之前已经被Tomcat用错误编码解析了。解决在web.xml里注册Spring的CharacterEncodingFilter并且把它放在所有Filter的第一位同时设置forceEncodingtrue让请求和响应的编码都被强制覆盖。filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping这里一个隐蔽的坑是forceEncodingfalse时Spring只设置请求编码而不设置响应编码前端拿到的JSON中文仍可能乱码。所以forceEncoding必须为true。另一个相关坑是如果JSP页面里还手写了pageEncodingUTF-8但web.xml没配mime-mappingTomcat对.jsp文件的输出还是ISO-8859-1这种情况需要在spring-mvc.xml里配置mvc:annotation-driven并显式指定消息转换器。4.2 Transactional失效数据库没回滚的三种原因现象登记访客时先插入记录再抛出一个RuntimeException事务结束后数据库里仍然多了一条记录也就是说这行插入没有回滚。原因事务没生效通常有四种情况。第一Transactional标注在private或protected方法上Spring代理无法拦截。第二方法所在类没有被Spring扫描到也就是说这个类不在component-scan的范围里。第三同一个ServiceImpl类内部方法之间直接调用比如A方法调用B方法B上标了Transactional但因为走的不是代理对象而是原始对象注解失效。第四事务管理器配置的是DataSourceTransactionManager但数据源没有注入到同一个事务管理器里或者你在XML里配置了事务通知但切面没正确指向。解决首先把业务方法写成public其次保证ServiceImpl在component-scan包范围内第三类内部调用时把B抽到另一个Service里或者让A自己处理事务最后检查XML里的tx:advice和aop:config是否指向了同一个transactionManager。这个坑的典型表现是代码从头到尾没报错查一下数据库才发现数据进去了。排查时可以给dataSource配置Druid的stat过滤器打开SQL日志看实际执行时间点同时确认日志中是否出现Creating new transaction with name这一类Spring事务日志。如果没有出现日志说明代理根本没生效。4.3 PageHelper分页失效startPage和首条Mapper查询之间的距离现象调用page()方法后返回的list是全量数据而不是pageSize条或者第一页返回了第二页的数据。原因PageHelper.startPage()依赖ThreadLocal存储分页参数它只对下一次查询生效。如果你在startPage()和visitorMapper.search()之间执行了其他Mapper查询或者调用了带有查询逻辑的业务方法分页参数就会跑到那条查询上真正的目标查询反而没有分页。解决严格保证startPage()后面紧跟目标Mapper查询语句中间不做任何其他数据库操作。public PageResultVisitor page(int pageNum, int pageSize, String keyword, Integer status) { PageHelper.startPage(pageNum, pageSize); // 这里只允许出现这一条查询 ListVisitor list visitorMapper.search(keyword, status); PageInfoVisitor pageInfo new PageInfo(list); return PageResult.success(pageInfo.getList(), pageInfo.getTotal(), pageInfo.getPages()); }另外一个容易被忽略的细节是如果search方法内部先做了一次selectCount之类的操作分页参数也可能被那个查询消费掉。所以在MyBatis的search语句里要确认不包含多余的子查询尤其是那些先执行再返回的辅助SQL。4.4 字段approver_id映射成approverId失败驼峰映射没开启现象Visitor实体类里的approverId属性始终为null但数据库里明确有值同表的其他字段比如phone、name都能正常映射。原因数据库列名approver_id使用下划线命名Java属性approverId使用驼峰命名。MyBatis默认情况下不会自动把approver_id映射到approverId它只认完全同名的列和属性。在2.3的XML里我写了resultMap手动映射所以没问题但如果你直接在applicationContext.xml里配置SqlSessionFactory并且让MyBatis自动映射实体类不开驼峰映射就会漏掉。解决在SqlSessionFactoryBean的配置里显式设置MyBatis配置对象。bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ property nametypeAliasesPackage valuecom.school.visitor.entity/ property nameconfiguration bean classorg.apache.ibatis.session.Configuration property namemapUnderscoreToCamelCase valuetrue/ /bean /property /beanmapUnderscoreToCamelCase设为true后id_card会自动映射到idCardapprove_time映射到approveTime这行配置能省掉大批手写resultMap的重复劳动。但是如果你同时用了resultMap并且某个字段没有写在映射里无论mapUnderscoreToCamelCase是true还是false这个字段都映射不上。也就是说resultMap的优先级高于自动映射。排错时先看是不是这个原因再去看字段名是否拼错。4.5 同一身份证同一天重复登记唯一索引与防重判断的兜底现象在Postman里对同一个接口连发两个相同的请求数据库中出现两条相同id_card且相同visit_date的记录。原因Service层先count再insert的防重逻辑只保证单线程下有效两个并发请求同时查询时计数都为0于是都进入插入逻辑。解决第一步必须在数据库层加唯一索引也就是2.2节建表时那个UNIQUE KEY uk_idcard_visitdate第二步在Service层捕获DuplicateKeyException给出友好提示。try { visitorMapper.insert(visitor); } catch (DuplicateKeyException e) { throw new BizException(该身份证当天已登记请勿重复提交); }这里要留意的是如果insert方法被Transactional包裹捕获了DuplicateKeyException后如果不重新抛出事务回滚相关的异常Spring会认为事务正常完成但此时数据库已经拒绝了插入。更稳妥的做法是像上面代码那样捕获后转成自定义业务异常再抛出让事务真正回滚。5. 把SSM访客系统跑起来之后一次压测、一条SQL和一串自查习惯系统上线前至少要跑一次压测确认它能扛住学校上下课高峰的并发登记。学校场景有个特点早上8点到9点访客集中进校门卫在几分钟内可能连续核验几十到上百人。用JMeter对一个查询接口做压测时Tomcat默认线程池是200Druid连接池初始配置是20MySQL默认最大连接数是151这三个数字决定了系统的吞吐上限。我的经验是连接池大小设成50左右就够了不要无脑调大因为每个连接都占用MySQL内存反而拖垮查询性能。压测结束后要习惯性地看数据库慢查询日志大部分系统的性能瓶颈都藏在SQL里。访客登记系统最常见的慢SQL是SELECT * FROM visitor WHERE name LIKE CONCAT(%, keyword, %)一旦数据量突破几万条这个查询会把整张表扫一遍。如果门卫查询时按姓名搜索频率高我一般会在name字段上加普通索引并明确告诉前端搜索时必须同时携带visit_date范围让数据库优先用日期索引过滤出当天数据再在结果集里做LIKE匹配这个优化能把查询时间从几百毫秒降到几十毫秒。每次改完代码后我习惯先走一遍自查清单第一事务是否只在Service层开启有没有Controller里调多个Service导致事务边界不明确的隐患第二Mapper XML里的#{param}有没有被拼字符串若看到${param}就说明有人写了不安全的SQL拼接第三PageHelper.startPage()是否紧挨着目标查询第四数据库连接串是否带了characterEncodingutf8和serverTimezone第五前端传过来的日期字符串有没有在Service层统一格式化成LocalDate再传给数据库。这套基于SSM的访客登记系统做到这里剩下的安全功能比如登录验证码、密码加密、操作日志可以继续按同样的分层模式往里面填。做这类项目最有价值的不是把代码跑通而是知道每一层配置在什么时候会失效以及为什么失效。希望你在自己动手时也能少踩几个我当年踩过的坑第一步先把数据库表建对后面写起代码会顺手很多。本文还有配套的精品资源点击获取