简介在Java Web开发中SSMSpringSpringMVCMyBatis作为经典的三层架构组合配合SpringBoot的自动配置与内嵌容器既能保留清晰的代码分层又能大幅降低配置成本是单体项目高效落地的务实之选。本文从基础概念入手梳理SSM与SpringBoot的三种集成形态并围绕工程搭建、核心业务闭环、前后端分离联调与云端部署展开实践解析。内容涵盖多条件检索、订单事务、JWT身份认证、文件上传、跨域处理等高频技术点同时针对“springboot版本太高”引发的依赖冲突、IDEA新建项目找不到目标版本等现实痛点给出选型建议。无论你是刚接触SSM框架组合还是想通过旅游网站巩固后端技能这套从零到上线的方法论都能提供清晰可复用的参考路径。1. 项目定位与技术选型为什么是“SSMSpringBoot”这套组合1.1 SSM和SpringBoot到底是啥关系要说这个项目得先解决一个绕不开的问题SSM和SpringBoot名字里都有Spring看起来像两个阵营怎么组合到一起去我见过不少同学在简历里写“基于SSMSpringBoot的XX系统”结果面试官一问就露馅自己都说不清两者的边界。先理一下概念。SSM指的是Spring SpringMVC MyBatis这是Java Web开发里非常经典的一套组合Spring管对象和依赖SpringMVC管请求分发MyBatis管数据库操作。在SpringBoot还没普及的年代搭一个SSM项目要在web.xml、spring-mvc.xml、spring-mybatis.xml里写一大堆配置稍微漏一个扫包路径启动就报错。而SpringBoot的出现把这些配置全部变成了约定和自动化内嵌Tomcat开箱即用。那“SSMSpringBoot”到底怎么理解我这两年做项目总结下来有三种常见形态第一种老项目升级。原来的SSM工程太大直接推倒重来成本太高就保留原本的Controller、Service、Mapper分层和MyBatis的mapper.xml把那一堆XML配置替换成SpringBoot的自动配置和application.yml这是最常见的企业改造方式。第二种也是大家在简历和面试中最常表达的用SpringBoot作为整个项目的基础框架它负责依赖管理、自动配置、内嵌容器、统一配置这些“后勤工作”业务代码仍然严格按照SSM那一套三层架构来写——Controller接收请求、Service处理业务、Mapper对接数据库。SpringBoot是外壳SSM是灵魂。第三种双系统并存。一个项目里SSM老模块继续跑SpringBoot新模块单独启动通过网关或者Nginx分流这个一般在大型系统过渡期才用单体旅游网站没必要这么干。咱们这个旅游网站项目采用的是第二种形态这也是我推荐大多数人的一种方式。理由是SpringBoot帮你省掉大量环境配置的时间让你把精力放在业务逻辑上而SSM的三层架构是一个非常好的代码组织习惯尤其对于学习阶段的人来说能清楚看到一次请求从前端到数据库再返回的完整路径。1.2 功能模块与数据库设计一个“功能齐全”的旅游网站功能全在哪我列一下这个项目的功能范围先看整体图景前台面向游客和注册用户用户注册登录、景点浏览、景点多条件搜索、热门路线推荐、酒店信息展示、门票预订、旅游攻略发布、评论点赞、个人中心订单查看、收藏管理。后台面向管理员景点管理增删改查、图片上传、酒店管理、路线管理、订单管理发货/取消/退款、用户管理、评论审核、基础数据统计。这些功能拆开来看每一个都是常见的业务模块但合在一起就覆盖了一个后端系统最核心的几个能力用户认证、商品检索、交易订单、内容发布、审核管理。数据库设计是整个项目的基石我一般会建这么几张核心表表名用途关键字段user用户表id, username, password, nickname, avatar, email, rolespot景点表id, name, description, address, price, image, city, categoryhotel酒店表id, name, address, price, level, image, descriptionroute旅游路线表id, title, days, price, spots关联景点Id集合, introductionorders订单表id, order_no, user_id, item_type, item_id, title, price, quantity, status, create_timecomment评论表id, user_id, spot_id, content, create_time, like_countfavorite收藏表id, user_id, spot_id, create_timearticle攻略表id, user_id, title, content_html, cover_image, status, view_count订单表的设计有个小细节值得说。很多初学者会为门票、酒店、路线各建一张订单表这会导致后续统计数据非常痛苦。更合理的做法是只建一张订单表用item_type字段区分订单类型1-景点门票2-酒店3-路线用item_id关联具体商品再用title和price字段把下单时的信息快照保存下来。这样后台订单管理、用户订单历史、销售统计都只需要查这一张表逻辑简单得多。景点表和路线表之间可以建一张中间表route_spot来关联路线包含哪些景点也可以简单点在route表里用一个varchar字段存景点ID列表。小项目用后者开发效率高大项目还是建议用中间表方便将来做路线缓存和查询优化。1.3 为什么不用微服务也不用纯JSP模板这个项目规模完全不需要上Spring Cloud那套微服务。如果一个单体项目被强行拆成多个服务首先面临的问题就是服务间调用、分布式事务、配置中心这些复杂度会瞬间淹没业务本身对学习者和中小团队来说得不偿失。同样的道理SSM框架里的MyBatis也是个务实的选择。它比JPA更直观SQL自己控制优化起来心里有数。可能有人会问为什么不直接用MyBatis-PlusMyBatis-Plus确实能省不少CRUD代码但在这类“为了彻底理解框架原理”的综合性项目里我更推荐用原生MyBatis把每一条SQL都写明白尤其是多表联查、动态SQL这些核心场景。等这个项目跑通之后你再看MyBatis-Plus会觉得非常简单。前端部分现在的主流做法是前后端分离。项目可以拆成两个工程后端是SpringBoot提供JSON接口前端是VueViteVue3或纯HTML/CSS/JS。搜索热词里也出现了“vue3连接ssm框架”、“springboot vue前后端分离”说明这是当前大家最关心的方式。如果你对前端不太熟也可以先用Thymeleaf模板引擎把页面渲染出来后端逻辑完全不受影响。不管是哪种前端形态后端这套SSMSpringBoot的骨架都是通用的。2. 工程搭建SpringBoot如何装下SSM的所有家当2.1 创建工程与依赖选型创建SpringBoot工程很简单用IDEA自带的Spring Initializr或者直接去 start.spring.io 生成一个压缩包导入。需要注意的一点是SpringBoot版本的选择这个坑我踩过不止一次。热词里提到了“springboot版本太高”和“idea新建项目没有springboot 3.4.3选项”说得就是这个问题。SpringBoot从2.x升级到3.x是一次大版本变化3.x要求JDK17以上并且把javax包替换成了jakarta包很多老教程里的import javax.servlet.*在3.x里直接编译不通过。如果你的环境是JDK8那就老老实实用SpringBoot 2.7.x如果本机装了JDK17选SpringBoot 3.2.x或者3.3.x都可以但要注意配套的MyBatis starter、MySQL驱动也要选兼容版本。我一般建议用SpringBoot 2.7.x JDK8做这个项目理由有两点第一大量老教程、老项目、面试题的代码都是基于这套组合遇到问题查资料容易第二它已经足够稳定第三方组件兼容性也最好对学习阶段来说是低摩擦路线。pom.xml核心依赖大致长这样parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies !-- Web场景启动器包含SpringMVC和内嵌Tomcat -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis整合SpringBoot的starter -- dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency !-- MySQL驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency !-- Redis -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- 分页插件 -- dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency !-- Lombok减少实体类的getter/setter代码 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency !-- 参数校验 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency /dependencies用2.7.x这一套JDK8直接跑不会有javax/jakarta的混乱问题。2.2 目录结构与注解落地工程建好之后包结构我一般这样规划com.example.travel ├── TravelApplication.java // 启动类 ├── controller/ // 控制层接收请求 │ ├── UserController.java │ ├── SpotController.java │ ├── OrderController.java │ ├── AdminController.java │ └── ... ├── service/ // 业务层接口定义 │ ├── UserService.java │ ├── SpotService.java │ └── impl/ │ ├── UserServiceImpl.java │ └── SpotServiceImpl.java ├── mapper/ // MyBatis Mapper接口 │ ├── UserMapper.java │ └── SpotMapper.java ├── entity/ // 实体类对应数据库表 │ ├── User.java │ └── Spot.java ├── dto/ // 数据传输对象请求参数、响应体 │ ├── LoginRequest.java │ └── SpotQuery.java ├── config/ // 配置类 │ ├── JwtInterceptor.java │ ├── CorsConfig.java │ └── WebMvcConfig.java └── common/ // 公共类 ├── Result.java // 统一返回封装 ├── JwtUtil.java └── BizException.java启动类上最核心的两个注解是SpringBootApplication和MapperScan。SpringBootApplication本身就是一个组合注解它包含了SpringBootConfiguration、EnableAutoConfiguration和ComponentScan意思就是告诉Spring Boot这里是程序入口去做自动配置去扫描当前包及其子包下的Bean。而MapperScan是MyBatis的工作方式它需要扫描到所有Mapper接口然后为它们生成代理实现。SpringBootApplication MapperScan(com.example.travel.mapper) public class TravelApplication { public static void main(String[] args) { SpringApplication.run(TravelApplication.class, args); } }如果你忘了加MapperScan或者只扫描了有Mapper注解的接口启动时SpringBoot不会报错但一旦有Service注入Mapper就会报一个“找不到类型为XxxMapper的Bean”的错这个排查点要记住。再夸一下这套注解体系。SSM时代你要在xml里配置包扫描、配置视图解析器、配置事务管理器现在的RestController、Service、Autowired、Transactional就把这些功能全部用注解方式注入了。以下几个常用注解在这个项目里出现频率很高建议对照实际代码体会一下注解作用使用位置RestController返回JSON数据相当于Controller ResponseBody所有Controller类RequestMapping/GetMapping/PostMapping映射请求路径和方法Controller方法RequestParam / PathVariable接收请求参数 / 路径占位符参数Controller方法参数RequestBody接收JSON请求体并反序列化为对象Controller方法参数Service标记业务层组件交给Spring管理Service实现类Autowired按类型注入依赖Controller、Service中的字段Transactional开启事务管理Service方法或类Configuration标记配置类等价于Spring的xml配置文件配置类上2.3 配置文件数据源、MyBatis、Redis、上传路径application.yml是整个项目的中枢。SpringBoot的一大好处就是把SSM时代分散在多个XML文件里的配置集中到了一个文件里。下面是我这个项目实际在用的配置你新建项目时可以按这个思路来填。server: port: 8080 servlet: context-path: /api spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/travel_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root redis: host: localhost port: 6379 database: 0 servlet: multipart: max-file-size: 10MB max-request-size: 20MB mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.travel.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 自定义配置文件上传保存路径 upload: dir: D:/upload/逐个说下关键项。datasource配置了MySQL的连接信息注意url里的serverTimezone参数MySQL 8.0之后必须带时区否则启动时会报时区错误。map-underscore-to-camel-case设置为true可以把数据库里user_name这种下划线字段自动映射成Java实体里的userName省去在resultMap里写一堆映射的麻烦。log-impl用StdOutImpl可以在控制台打印每一条SQL开发阶段强烈建议开启排查问题会方便非常多。upload.dir不是SpringBoot内置的配置是我自定义的用来存放用户上传的景点图片、攻略封面等文件。注意路径最好是绝对路径不要在项目代码目录下存上传文件不然打成jar包后你会找不到文件在哪。这个问题后面讲部署的时候还会再提。2.4 一次请求的完整生命周期有了一张完整的配置图景之后我们用最典型的“查看景点详情”这个功能把一次请求从头到尾走一遍。前端Vue发起请求axios.get(/api/spot/12).then(res { this.spot res.data.data; });SpringBoot的DispatcherServlet拦截到/api/spot/12请求根据RequestMapping的匹配交给SpotController里的方法RestController RequestMapping(/spot) public class SpotController { Autowired private SpotService spotService; GetMapping(/{id}) public Result getSpotById(PathVariable Integer id) { Spot spot spotService.getSpotWithDetail(id); return Result.success(spot); } }这里RestController把返回值自动转成JSON。PathVariable把URL路径里的12解析成id参数。接下来请求进入Service层Service public class SpotServiceImpl implements SpotService { Autowired private SpotMapper spotMapper; Override public Spot getSpotWithDetail(Integer id) { Spot spot spotMapper.selectDetailById(id); // 这里可以补充浏览量增加、缓存处理等逻辑 return spot; } }Service里面调用Mapper接口实际的SQL在resources/mapper/SpotMapper.xml里select idselectDetailById resultTypecom.example.travel.entity.Spot select * from spot where id #{id} /where /selectMyBatis拿到SQL后通过JDBC查询MySQL结果集自动封装成Spot对象然后一层层原路返回最终由SpringBoot的HttpMessageConverter转成JSON字符串响应给前端。这条链路你走一遍就能彻底明白SSM三层架构的价值——Controller只负责接收参数和返回结果Service专注业务逻辑Mapper只做数据访问。修改任何一层不影响到其他层这也是这个项目值得仔细体会的地方。3. 核心功能实战从登录到下单的完整业务闭环3.1 用户注册登录密码加密与JWT用户模块是整个系统的入口。注册登录看起来简单但有一个细节很容易被忽视密码绝对不能明文存库。之前我看到不少新手项目user表里password字段直接存的是明文这种一旦数据库泄露所有用户账号全完蛋。正确做法是用BCrypt加密Spring Security包里提供了BCryptPasswordEncoder单独引入这个类就行不会拖上整个Security框架。// 注册时加密密码 String encodedPwd new BCryptPasswordEncoder().encode(password); user.setPassword(encodedPwd); // 登录时校验密码 boolean matches new BCryptPasswordEncoder().matches(rawPassword, dbUser.getPassword());BCrypt的好处是每次加密结果都不同天然带盐而且校验速度可控暴力破解成本高。项目里不要自己写MD5加盐方案了用现成就好。登录成功之后前后端分离模式下一般用JWTJSON Web Token保持登录状态。思路是登录成功后后端生成一个token返回给前端前端后续请求在Header里带上Authorization: Bearer 后端拦截器解析这个token验证身份。JwtUtil大概长这样public class JwtUtil { private static final String SECRET travel-app-secret-key; private static final long EXPIRE_TIME 7 * 24 * 60 * 60 * 1000L; public static String createToken(Integer userId, String username) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Integer getUserId(String token) { Claims claims Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); return Integer.valueOf(claims.getSubject()); } }然后写一个拦截器拦截所有需要登录的接口对白名单如/spot/list、/spot/detail这些公开查询放行。Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (OPTIONS.equals(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); try { Integer userId JwtUtil.getUserId(token); request.setAttribute(userId, userId); return true; } catch (Exception e) { // token过期或非法 } } response.setStatus(401); return false; } }3.2 景点多条件检索MyBatis动态SQL PageHelper分页旅游网站的核心是景点展示但只做简单的列表查询是不够的用户会按城市、按景点类别、按价格区间搜索。这种多条件组合查询是MyBatis动态SQL的主场。假设SpotQuery有这三个字段city、category、maxPrice。select idsearchSpots resultTypecom.example.travel.entity.Spot select id, name, city, category, price, image, description from spot where if testcity ! null and city ! and city #{city} /if if testcategory ! null and category ! and category #{category} /if if testmaxPrice ! null and price lt; #{maxPrice} /if /where order by create_time desc /select标签会自动处理AND前缀问题——如果第一个条件为空第二个条件命中了它会把开头的AND自动去掉。这是MyBatis非常实用的特性。另外注意SQL中小于号要写成避免XML解析出错。分页是这个项目必然要做的。我用的PageHelper插件在Controller或者Service里调用PageHelper.startPage(pageNum, pageSize); ListSpot list spotMapper.searchSpots(query); PageInfoSpot pageInfo new PageInfo(list);PageHelper通过MyBatis拦截器动态改SQL在查询前注入了LIMIT语句并且自动生成了一条COUNT查询最后PageInfo里能拿到总记录数、总页数、当前页数据前端分页组件直接能用。这里要记住一个原则PageHelper.startPage之后必须紧跟第一条查询语句中间不要穿插其他数据库操作否则分页信息会被错误的SQL拦截下来产生诡异的结果。3.3 在线预订库存、订单、事务订单模块是整个项目业务复杂度最高的地方。以景点门票预订为例下单涉及的逻辑包括校验用户是否登录、检查景点是否存在、检查库存是否充足、扣减库存、生成订单、返回订单号。这串流程最核心的问题是“并发”。想象一个热门景点一天只有100张票用户A和用户B同时在最后一秒下单。如果只是先查库存再扣库存两个请求都查到库存还有1张然后都执行了扣减就出现了超卖。解决超卖最简单可靠的方式是乐观锁在库存表加一个version字段update spot set stock stock - 1, version version 1 where id #{id} and version #{oldVersion}执行这个update时如果version匹配影响行数为1表示抢购成功如果version不匹配影响行数为0表示库存已经被别人改了需要返回“手速慢了”的提示。这种方案不需要引入复杂的悲观锁就能解决绝大多数单体项目的并发问题。事务是订单模块的另一个重点。在SpringBoot里用Transactional注解声明事务Transactional(rollbackFor Exception.class) public Order createOrder(OrderCreateRequest request, Integer userId) { // 1. 检查参数 // 2. 扣减库存 // 3. 生成订单记录 // 4. 返回订单信息 }注意rollbackFor Exception.class这个写法。SpringBoot默认的Transactional有个很坑的机制只对RuntimeException回滚对CheckedException比如抛出Exception或IOException不回滚。如果不加rollbackFor很多业务异常会导致事务提交了一半数据就乱了。这是我见过项目里最常见的Bug之一。还要注意事务自调用失效的问题。如果在一个类里的某个方法调用了另一个带Transactional的方法事务是不生效的。因为Spring的事务是通过AOP代理实现的内部调用不走代理。所以事务方法要放在ServiceImpl类里从外部Controller调用进来才是正确的姿势。3.4 评论点赞与攻略发布评论和攻略是旅游网站互动性的核心。评论表相对简单但要注意一个设计细节评论状态字段。用户发表的评论不要直接展示先存为待审核状态管理员在后台审核通过后再展示。这个字段在早期可能看不出价值但当评论量大起来或者被恶意刷广告时你就知道审核机制有多重要了。点赞功能如果直接操作数据库每点一次赞做一次insert/update在高并发场景下压力很大。做法可以是点赞记录写入Redis的Set集合用集合记录某个景点的所有点赞用户ID点赞数直接从Redis取size定期同步到MySQL。用户点击点赞时用SADD把userId加进去如果返回1说明点赞成功返回0说明已经点过赞了。攻略模块涉及富文本内容。后端存HTML前端展示时一定要进行XSS转义——否则用户提交一段带