做了这么多年web开发,交到手上的旅游网站项目不算少,但喀什这个选题第一次接手时还是让我多看了几眼。不是技术有多难,而是旅游网站这层皮底下,要装的东西比想象中多:景点管理、线路规划、酒店民宿、活动运营、后台权限,甚至还有多语言展示的需求。SpringBoot+Vue+MyBatis+MySQL这套组合,恰好就是干这种活最顺手的班子。这篇文章我想把这个项目的完整拆解过程写下来,从需求梳理到数据库设计,从后端接口到前端页面,从部署上线到常见坑位排查,尽量给一套能直接照着落地的方案。
1. 项目全景梳理:一个旅游网站系统到底要装下哪些东西
1.1 核心需求解析:从游客视角倒推功能模块
做旅游网站系统,最容易犯的错就是上来就画表结构、写接口,做到一半发现漏了需求,返工成本极高。我的习惯是先站在游客角度把整个浏览路径走一遍:一个游客访问喀什旅游网站,他要干什么?
首先是浏览。打开首页看喀什有什么好玩的,这就要有景点展示,最好是图文并茂,有简介、有图片、有开放时间和门票信息。光看景点还不够,得知道怎么玩,这就需要旅游线路或者说行程规划功能,比如"喀什古城一日游""帕米尔高原两日游",每条线路要有行程安排、价格、包含项目。看完线路,游客可能会想订酒店,所以住宿模块也跑不掉,至少得展示酒店列表、房型、价格、剩余房间数。最后他可能要咨询、可能要报名、可能要留下自己的需求,这就要有留言咨询或者订单功能。
管理员这边呢?要维护景点信息、管理线路、处理订单、管理留言、配置轮播图广告,最好还能看到一些简单的统计数据。如果游客身份和后台账号体系打通,那还有注册登录、权限控制这一层。把这些模块摊开来看,系统的功能边界就清晰了。
1.2 角色权限与业务流程:用户端和管理端的分工设计
这个系统我采用的是经典的双端结构,游客用户和管理员共用一套SpringBoot后端,前端Vue里面通过路由和权限判断区分访问入口。游客端访问的是门户页面,对应的是景点列表、线路详情、在线预订、留言板这些公开接口;管理端访问的是后台页面,对应的是数据管理接口,这些接口全部要求登录,而且还要细分角色——超级管理员可以管所有东西,内容管理员只能维护景点和线路,订单管理员只能处理订单,权限数据用一张简单的用户角色表控制,用SpringBoot的拦截器加一个自定义注解就能搞定,不需要引入特别重的安全框架。
业务流主要是两条线:一条是游客浏览线路产生预订意向,提交订单后状态变为"待确认",管理员确认后变为"已确认",游客到店核销后变为"已完成";另一条是游客留言咨询,管理员回复后状态关闭。这两条业务线覆盖了绝大多数旅游网站的日常运转场景,开发量可控,演示起来也完整。
2. 技术选型与工程架构:为什么偏偏是这套组合
2.1 技术栈优势分析:SpringBoot+Vue+MyBatis+MySQL的搭配逻辑
SpringBoot在Java web领域的地位不用多说,约定大于配置,内嵌Tomcat,打成一个jar包扔服务器上就能跑。喀什旅游网站这种管理系统,业务逻辑不算特别复杂,但涉及的数据表有十几张,事务管理、接口开发、参数校验这些基础设施,SpringBoot能提供最成熟的解决方案。Vue这边,组件化开发适合这种前端界面比较多的项目,景点展示页、后台管理页、表单弹窗、分页列表,写成组件复用起来效率很高。MyBatis做数据持久层,灵活度比JPA高,SQL可以精确控制,尤其是我这种习惯手动优化SQL的人,写起来很顺手。MySQL就不用说了,旅游网站这个数据量级,单库单表完全够用,成本低,部署简单,文档也多。
实话说,这套组合不是最炫的,但它是目前最不容易出问题的搭配之一。换SpringCloud那套微服务架构来做这个项目属于过度设计,光服务拆分、注册中心、配置中心就要消耗大量时间,跟项目体量不匹配;换MyBatis-Plus也能做,但题目要求的核心是MyBatis,而且手写SQL能让你对每一条查询都心里有数。Vue选了2.x还是3.x?我这次用的是Vue 2.6 + Element UI,不是Vue 3上不了台面,而是Element UI在Vue 2下生态最成熟,后台管理类的页面组件现成的太多,适合快速交付。Vue 3 + Element Plus当然也可以,如果你不急着上线、想顺手练新技术,那完全没问题。
2.2 工程结构划分:后端分层与前端目录设计的落地方式
后端我按标准的三层架构来组织,controller包放接口入口,service包处理业务逻辑,dao包放MyBatis的Mapper接口,实体类放在entity包,再用一个common包放统一返回结果、异常处理、工具类。这样分层的好处是职责清晰:Controller只做参数接收和结果返回,不写业务;Service里面是核心逻辑,比如下单时检查库存、计算价格;DAO只负责数据库交互,SQL写在XML文件里,跟Java代码分离。
springboot-kashi/ ├── controller/ │ ├── AdminController.java │ ├── ScenicSpotController.java │ ├── TravelRouteController.java │ └── OrderController.java ├── service/ │ ├── ScenicSpotService.java │ ├── TravelRouteService.java │ └── OrderService.java ├── mapper/ │ ├── ScenicSpotMapper.java │ ├── TravelRouteMapper.java │ └── OrderMapper.java ├── entity/ │ ├── ScenicSpot.java │ ├── TravelRoute.java │ ├── Order.java │ └── User.java ├── common/ │ ├── Result.java │ └── GlobalExceptionHandler.java └── resources/ ├── application.yml └── mapper/ ├── ScenicSpotMapper.xml └── TravelRouteMapper.xml前端Vue项目用vue-cli创建,src目录下views放页面组件,components放公共组件,router配置路由,store用Vuex管理用户状态和登录信息,api目录统一封装axios请求。页面和管理后台分成两个路由模块,游客端有首页、景点列表、景点详情、线路列表、线路详情、订单提交、登录注册这些页面;管理端有登录、仪表盘、景点管理、线路管理、订单管理、留言管理这些页面。整个工程看起来规规矩矩,维护起来不会迷路。
2.3 开发环境与版本选择:2025年这个时间点怎么搭配
开发环境我建议用JDK 8或者JDK 11,SpringBoot用2.7.x版本,这是目前兼容性最稳的组合。SpringBoot 3.x要求JDK 17起步,虽然新特性多,但部分老依赖兼容性有坑,如果不是必须上,项目求稳还是2.7.x更省心。MySQL用8.0版本,如果服务器上装的是5.7也完全没问题。Node环境用14以上的版本都行,vue-cli 4.x或者5.x都能正常创建项目。IDE方面后端用IDEA,前端也用IDEA或者VSCode都行,我个人习惯IDEA一把梭。
数据库连接池这块,SpringBoot 2.7默认用的是HikariCP,性能非常好,不用额外换。MyBatis这边配合mybatis-spring-boot-starter 2.2.x版本,它会自动装配SqlSessionFactory,你要做的只是在application.yml里指定mapper XML文件的位置,简单到几乎不用配置。
3. 数据库设计与核心表结构:一张表一张表地抠细节
3.1 核心表设计:景点表、线路信息表、订单表的字段规划
数据库我起名kashi_tourism,一共设计了十张表。这里挑核心的三张表展开讲。景点表scenic_spot,字段有id主键、name景点名称、summary一句话简介、description详细描述、cover_image封面图、gallery_images图集、ticket_price门票价格、open_time开放时间、address地址、longitude纬度、latitude经度、status上架状态、create_time创建时间。喀什这边的景点像喀什古城、香妃园、艾提尕尔清真寺、帕米尔高原的慕士塔格峰、白沙湖,每一处都可以把经纬度存下来,前端可以对接地图组件,作为后续扩展点。
线路信息表travel_route,核心字段有id、name线路名称、route_type线路类型,比如一日游还是多日游、days行程天数、price价格、group_size成团人数、trip_details行程详情,这个字段我用TEXT类型,里面放的是行程安排的富文本或者分段JSON数据、include_items费用包含、schedule出发日期排期、status上下架状态。旅行线路是旅游网站的核心商品,排期这个字段特别重要,它决定了游客能不能下单,我们在下订单的时候会检查排期里还有没有剩余名额。
订单表travel_order,这是整个系统里最关键的业务表。字段有id、order_no订单编号、user_id下单用户、route_id线路id、travel_date出行日期、participant_count出行人数、total_price总金额、contact_name联系人、contact_phone联系电话、remark备注、status订单状态、create_time下单时间。订单编号我用时间戳加随机数的方式生成,避免并发下单时撞号。total_price不直接存单价乘以人数的运算结果,而是在后端根据线路当前价格和出行人数计算好再插入,防止前端传参篡改价格。
3.2 关联表与基础表设计:用户表、留言表、轮播图表、角色表
用户表system_user包含id、username、password、real_name、phone、role角色标识、status账号状态。密码不能明文存储,我使用SpringSecurity自带的BCryptPasswordEncoder做加密,注册时加密存储,登录时用matches方法校验。喀什旅游网站的注册登录,对游客来说门槛越低越好,所以我只留了用户名、密码两个必填项,其他信息选填。
留言表guestbook有id、user_id、content留言内容、reply_content回复内容、create_time、reply_time、status状态。轮播图表banner有id、title标题、image_url图片地址、link_url跳转链接、sort排序、status状态。角色权限这块没单独建表,而是在用户表里用role字段区分,取值assigned至admin或editor或operator,配合后端拦截器做功能级权限控制,简单的小项目用这种方式就够。
表之间的关联关系用外键逻辑体现,不建物理外键。订单关联用户和线路,留言关联用户,这样在删数据的时候不会被外键约束卡住,开发期改数据方便,性能上也有一丢丢提升。实际查询时用JOIN或者分步查询来组织数据,MyBatis的XML里可以写得很灵活。
3.3 SQL初始化脚本编写:建库建表与基础数据准备
建表SQL我统一用utf8mb4字符集,这个比utf8字符集更稳妥,它支持emoji和生僻字,旅游网站用户留言这种场景难免会有特殊字符。给你看一下核心的建表语句风格:
CREATE DATABASE IF NOT EXISTS kashi_tourism DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE kashi_tourism; CREATE TABLE scenic_spot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT '景点名称', summary VARCHAR(255) COMMENT '一句话简介', description TEXT COMMENT '详细描述', cover_image VARCHAR(255) COMMENT '封面图', gallery_images TEXT COMMENT '图集,JSON数组', ticket_price DECIMAL(10,2) DEFAULT 0.00 COMMENT '门票价格', open_time VARCHAR(100) COMMENT '开放时间', address VARCHAR(255) COMMENT '地址', longitude DECIMAL(10,7) COMMENT '经度', latitude DECIMAL(10,7) COMMENT '纬度', status TINYINT DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT='景点信息表';初始数据直接INSERT几条喀什代表性景点的记录,再把默认管理员账号插进去,密码用BCrypt加密后的字符串。这一份初始化脚本放在项目的sql目录下,部署到新环境时一次执行到位,不会漏这漏那。
4. 后端核心逻辑实现:SpringBoot+MyBatis里的那些关键代码
4.1 统一返回结构与全局异常处理的搭建
开发前后端分离项目,统一的返回结构能省掉很多联调扯皮。我用一个Result类封装所有接口返回值,结构固定在code、message、data三个字段,code为200表示成功,401表示未登录,403表示无权限,500表示服务器异常。前端axios的响应拦截器统一判断code,不是200就弹ElMessage提示,不用每个接口单独写错误处理。
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.message = "success"; result.data = data; return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.code = code; result.message = message; return result; } }全局异常处理用@RestControllerAdvice注解实现,捕获业务异常、参数校验异常、兜底异常三种类型。业务异常是我自定义的BusinessException,用在比如"线路已下架不可下单""订单状态不允许取消"这类场景;参数校验异常是MethodArgumentNotValidException,把具体的校验错误信息提取出来返回给前端。有了这层处理,接口代码中就不需要到处写try-catch,逻辑干净很多。
4.2 MyBatis Mapper接口与XML映射文件:景点列表与搜索的实现细节
MyBatis的Mapper接口和XML文件,命名要保持一一对应。拿景点列表查询来说,Mapper接口里写:
public interface ScenicSpotMapper { List<ScenicSpot> selectPageList(@Param("keyword") String keyword, @Param("offset") int offset, @Param("limit") int limit); int count(@Param("keyword") String keyword); ScenicSpot selectById(@Param("id") Long id); }XML文件里,景点分页列表查询我用了动态SQL,关键词可以同时匹配名称、简介、地址三个字段。分页查询的offset和limit在Java代码里计算好传进来,这样就不用引入PageHelper插件了,少一个依赖就少一份版本冲突的风险。虽然网上关于MyBatis分页插件的教程非常多,但手写limit分页对于这个项目的复杂度来说,已经够用且可控性最强。
<select id="selectPageList" resultType="com.kashi.entity.ScenicSpot"> SELECT id, name, summary, cover_image, ticket_price, open_time, address, status FROM scenic_spot <where> <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR summary LIKE CONCAT('%', #{keyword}, '%') OR address LIKE CONCAT('%', #{keyword}, '%')) </if> </where> ORDER BY create_time DESC LIMIT #{offset}, #{limit} </select>这种写法有几个好处。第一个是SQL执行计划可控,索引怎么走一清二楚;第二个是关键词搜索在数据量几百条以内性能完全够,等真到了几十万条景点数据的那天,项目早就该上Elasticsearch了,但现在不用为这个提前买单;第三个是代码评审的时候别人一眼能看懂,团队协作成本低。
4.3 旅游线路下单业务:事务、库存与价格校验的完整链路
下单是整个系统中业务逻辑最重的一块,我把完整流程走一遍。前端提交的数据包括线路id、出行日期、出行人数、联系人信息。后端接收后先从数据库核验线路是否存在且处于上架状态,然后根据countRouteOrders接口统计该线路当前日期的已报名人数,加上本次出行人数不能超过线路的成团人数上限。价格计算用线路单价乘以出行人数得出总金额,再生成订单号插入订单表。
@Transactional(rollbackFor = Exception.class) public Order createOrder(OrderCreateRequest request) { TravelRoute route = travelRouteMapper.selectById(request.getRouteId()); if (route == null || route.getStatus() != 1) { throw new BusinessException("线路不存在或已下架"); } Integer booked = orderMapper.countByRouteAndDate(route.getId(), request.getTravelDate()); if (booked + request.getParticipantCount() > route.getGroupSize()) { throw new BusinessException("该出行日期剩余名额不足"); } BigDecimal totalPrice = route.getPrice() .multiply(BigDecimal.valueOf(request.getParticipantCount())); Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setRouteId(route.getId()); order.setTravelDate(request.getTravelDate()); order.setParticipantCount(request.getParticipantCount()); order.setTotalPrice(totalPrice); order.setStatus(0); // 待确认 orderMapper.insert(order); return order; }这里最关键的是@Transactional注解,它保证了下单过程中从查询到插入是一个原子操作,中途任何一步抛异常整个事务回滚,不会出现人数统计了但订单没插入的脏数据。价格用BigDecimal而不是double计算,是因为double的浮点精度在金额计算上会产生0.01级别的误差,这在涉及钱的场景是绝对不能接受的。
4.4 登录鉴权与拦截器配置:加密、会话与权限控制
登录这块我没有引入SpringSecurity全家桶,只用BCryptPasswordEncoder做密码加密,配合一个自定义拦截器做登录态校验。用户登录成功后将用户对象存入session,同时前端拿到用户信息停留在内存,每次请求通过拦截器校验session中是否存在用户。权限控制在拦截器里做,后台接口的路径以/admin/开头,admin和editor角色可以访问景点和线路管理接口,admin和operator角色才可以访问订单管理接口,具体在拦截器里根据路径和角色做个匹配。
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String uri = request.getRequestURI(); if (uri.startsWith("/admin/")) { User user = (User) request.getSession().getAttribute("user"); if (user == null) { response.setStatus(401); return false; } if (uri.startsWith("/admin/order/") && !"admin".equals(user.getRole()) && !"operator".equals(user.getRole())) { response.setStatus(403); return false; } } return true; } }实际部署时有人会问,前后端分离的session模式跨域怎么处理?我在后端配置了CorsRegistry允许前端域名跨域携带cookie,这样session在前后端两个端口间也能维持。如果你不想跟session较劲,也可以用JWT,把token放在请求头里,两种方案在这个项目里都能落地。
5. 前端Vue页面开发:从路由配置到交互实现的完整过程
5.1 Vue项目搭建与路由组织:游客端与管理后台的页面结构
前端项目我用Vue CLI初始化,装好vue-router、vuex、axios、element-ui四个核心依赖。路由这块分两个模块,游客端的路由不设权限,管理端的路由统一挂在/admin路由下,并且在这个路由的beforeEach钩子里检查用户是否登录,没有登录就跳转到登录页。
const router = new VueRouter({ mode: 'history', routes: [ { path: '/', component: HomePage }, { path: '/scenic', component: ScenicList }, { path: '/scenic/:id', component: ScenicDetail }, { path: '/route', component: RouteList }, { path: '/route/:id', component: RouteDetail }, { path: '/login', component: LoginPage }, { path: '/admin', component: AdminLayout, meta: { requiresAuth: true }, children: [ { path: '', component: Dashboard }, { path: 'scenic', component: ScenicManage }, { path: 'route', component: RouteManage }, { path: 'order', component: OrderManage }, { path: 'guestbook', component: GuestbookManage } ] } ] }); router.beforeEach((to, from, next) => { if (to.matched.some(record => record.meta.requiresAuth)) { const user = store.state.user; if (!user) { next('/login'); } else { next(); } } else { next(); } });路由守卫配好之后,管理后台就不会被未登录的人直接访问到。页面层面游客端我用的是自己写的公共样式,后台管理页面全部基于Element UI的el-card、el-table、el-form这些组件搭建,开发效率非常高。
5.2 景点与线路页面的数据渲染:Axios请求封装与组件复用
Axios封装一个request工具,baseURL指向后端接口地址,响应拦截器里统一处理Result结构,code不是200时弹出错误提示并reject。游客端景点列表页是一个典型的列表渲染场景,页面加载时调用scenic接口拿景点数据,用v-for渲染卡片,封面图用el-image组件,点击卡片跳转详情页。
景点详情页拿id调接口,把description字段的内容展示在正文区域。这里的description字段我建议在后端保存时保留基本的HTML格式,比如分段用p标签包装一下,前端就可以用v-html渲染出排版效果。喀什景点的历史文化和民俗介绍内容都比较长,分段排版比纯文本连在一起可读性强得多。
线路详情页比景点详情复杂一点,因为涉及下单表单。页面顶部是线路介绍,中部是行程详情,底部固定一个预订面板,显示价格、出发日期选择器和出行人数选择器,用户填好信息点击立即预订,调order接口提交订单。提交成功后跳转到订单列表页展示订单信息。
5.3 后台管理页面的交互逻辑:表格、弹窗、表单校验的落地方式
后台管理的核心场景就是表格加弹窗加表单。景点管理页面上,上面是搜索栏,中间是表格,表格操作列有编辑和删除按钮,右上角是新增按钮,点新增或编辑就弹出一个弹窗,里面放表单。表单校验用Element UI自带的rules属性,景点名称必填、价格必须是数字这类规则写好,提交前触发表单校验,校验通过才调接口。
删除场景为了防止误操作,我用this.$confirm弹出确认框,用户确认后再调删除接口。订单管理页面操作列根据订单状态动态显示可用操作,待确认状态的订单可以执行确认和取消操作,已确认的可以执行完成操作,其它状态不显示按钮。这种交互逻辑不算复杂,但体感很完善,演示或者实际使用时都挑不出毛病。
5.4 轮播图与首页装修:提升旅游网站视觉体验的细节
旅游网站和普通的管理系统不一样,首页的视觉效果直接影响用户对目的地的第一印象。首页顶部我是放了轮播图组件,后台可以在Banner管理页面配置轮播图片和跳转链接。轮播图组件我用Element UI的el-carousel,设置自动播放和指示器,图片比例统一裁成1920x600,保证不同分辨率下不变形。
在查看景点和线路列表时,我为每个卡片设计了大图预览的交互,鼠标悬停时显示遮罩层和"查看详情"按钮。另外,首页做了热门景点和精选线路两个推荐区域,后台可以在管理页面设置推荐排序字段,数据量少时就按创建时间排,数据多了可以加一个权重值字段。这些看着是锦上添花的小功能,实际上对旅游网站用户留存很有帮助。
6. 部署上线与常见问题排查:把这些坑提前踩平
6.1 环境部署:前端打包、后端打包与nginx反向代理配置
部署是整个项目落地最关键的一环。后端执行mvn clean package -DskipTests,打出jar包后直接扔到服务器上,执行nohup java -jar kashi-tourism.jar &。这里的-DskipTests跳过单元测试,打包速度快,如果你写了测试代码想跑一遍再上线,那就去掉这个参数。application.yml里我用profile区分开发环境和生产环境,生产环境的数据库连接、上传路径这些通过application-prod.yml单独配置,打包时用spring.profiles.active指定。
前端执行npm run build,生成dist目录,里面的静态文件部署到nginx的html目录下。nginx配置一个server块,同时做静态文件服务和反向代理,静态文件请求直接走nginx,/api开头的接口请求proxy_pass转发到Java服务的8080端口。history模式的路由还要配置try_files,否则刷新页面时nginx会返回404。
server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html/kashi; index index.html; 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; } }6.2 高频踩坑记录:跨域问题、日期格式、图片上传与文件路径
跨域问题主要出现在本地联调阶段,前端跑8080端口(或者你改成devServer的其它端口),后端跑8080,两者端口不一致就触发跨域。我在后端加了一个全局的跨域配置类,实现WebMvcConfigurer接口重写addCorsMappings方法,允许所有来源携带凭证,本地开发时也可以用vue.config.js里的proxy把请求代理到后端。生产环境用了nginx反向代理后,前端和后端在同一个域下,就不存在跨域问题了。
日期格式是另一个高频坑。MySQL的datetime和Java的LocalDateTime在序列化时格式不一致,前端拿到的是"2025-03-15T10:30:00"这种带T的格式,展示起来很难看。解决方式有两个,一是加@JsonFormat注解指定pattern为yyyy-MM-dd HH:mm:ss,二是配置Jackson的全局格式。我采用的是在application.yml里配置全局的JackSon日期格式,一处搞定所有接口。
图片上传这个功能看着简单但容易出幺蛾子。本项目图片上传路径我在配置文件中设置了一个自定义属性,比如upload.dir=/data/kashi/upload,Controller接收MultipartFile后保存到该目录,再把拼接好的访问路径返回给前端。nginx里做upload目录的静态资源映射,否则图片上传成功但前端访问不到。注意上传文件的类型和大小限制,application.yml里设置spring.servlet.multipart.max-file-size=10MB,防止有人传超大文件打满磁盘。图片文件名我用UUID重新生成,避免中文名和重复名引起的各种乱码和缓存问题。
6.3 常见问题速查表:从数据库连接失败到接口404的问题定位
做项目过程中我自己也踩了不少雷,整理了一份问题速查表,每个都快记不得查了多少遍,索性列出来让你们少走弯路。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 启动报连接数据库失败 | MySQL未启动或密码错误 | 检查mysql服务状态,核对application.yml账号密码 |
| mapper XML报Invalid bound statement | XML文件没被扫描或namespace错误 | 检查mybatis.mapper-locations配置,核对namespace |
| 前端接口404 | 请求路径和方法不匹配 | 核对controller的RequestMapping与axios路径 |
| 跨域请求被拦截 | 后端未配置CORS | 配置WebMvcConfigurer跨域类 |
| 图片上传成功但访问404 | nginx未映射上传目录 | 在nginx中为上传目录单独加location |
| 中文乱码 | 数据库字符集不是utf8mb4 | 建库用utf8mb4,连接URL加characterEncoding |
| 刷新页面404 | history模式路由未配置 | nginx加try_files规则 |
| 接口token失效 | session过期 | 调整session超时时间或改用JWT |
6.4 性能优化与安全加固:这个阶段还值得做的几件小事
项目上线前,有几件成本不高但效果明显的事情值得做。第一件是数据库连接池参数调优,HikariCP默认配置对多数小项目够用,但我习惯把maximumPoolSize改成20,minimumIdle改成5,这样高峰期并发请求不会全部排队等连接。第二件是给热点查询字段加索引,比如scenic_spot表的status字段、travel_order表的user_id字段、travel_route表的status字段,这几个字段在查询条件中频繁出现,没有索引的话全表扫描随着数据增长会越来越慢。
第三件是接口层的参数校验统一化,用@Valid注解配合实体类字段上的@NotBlank、@NotNull注解替代手动if判断,代码简洁且不容易漏判。第四件是上传文件类型白名单校验,只允许jpg、png、gif、webp这些图片类型,防止有人上传jsp或者exe文件到服务器上,这是个很基本但很多人忽略的安全隐患。
关于MyBatis缓存这块我再提一句。MyBatis自带一级缓存和二级缓存,一级缓存是SqlSession级别的,默认开启;二级缓存是Mapper级别的,需要配置开启。旅游网站这个项目里,景点和线路数据是典型的读多写少,开启二级缓存确实能提升一点查询效率,但要注意缓存失效策略,管理员更新景点信息后要执行flushCache让旧缓存失效,否则前台展示的还是旧数据。我建议在需要高并发读的查询Mapper上开启二级缓存,同时确保所有增删改操作都在同一个命名空间下,避免缓存不一致。如果你的项目部署在多台服务器上,二级缓存默认的本地缓存方案就会出现每台机器的缓存各自独立的问题,这种场景就直接关掉,用Redis做分布式缓存才是正解。
7. 项目复盘与扩展方向:做到这种程度还能往哪走
项目全部撸完之后,我坐下来回味了一下这套系统的可扩展空间。如果你的课程设计、毕业设计或者实际商用项目想在此基础上继续深挖,有几个方向很有价值。第一个是接入在线支付,喀什旅游线路和酒店预订如果能够微信或支付宝直接付款,那整个闭环就完整了,实现流程大概是前端调起支付、后端接收回调验签、更新订单状态,做出来跟真实商业项目就没什么差别了。
第二个是地图集成,把景点表的经纬度字段利用起来,接入百度地图或者高德地图JavaScript API,做一个喀什旅游地图页面,景点以标记形式展示在地图上,点击标记弹出景点信息,视觉效果非常好,评审或者展示的时候很加分。第三个是数据可视化,后台加几个图表页面,用ECharts展示订单量趋势、热门景点排行、线路销售占比这些数据,管理员看运营情况一目了然,也是当前web应用的一个主流卖点。
我个人在这个项目落地过程里最深的一个体会是:技术选型不是越新越好,而是越合适越好。SpringBoot+Vue+MyBatis+MySQL这套组合单看每一个都不是最前沿的,但组合在一起,开发效率、稳定性、上手难度、网络资源丰富程度全部拉满,对中小型旅游网站系统来说是一个几乎不会错的选择。
最后分享一个小技巧,项目里所有的配置项,比如数据库连接、上传路径、前端接口地址,尽量不要硬编码在代码里,全部放进配置文件并通过不同环境profile区分。这个习惯在一开始就养好,等将来项目换服务器、改环境的时候你就知道有多省心了。这套系统从零搭建到完整交付我用了大概两周多的时间,中间有两天花在调样式和移动端适配的细节上,其余时间基本就是按部就班地写接口、写页面。跟着这篇拆解走下来,给你同样的开发周期做出一套完整的喀什旅游网站管理系统,我觉得问题不大。