做旅游网站的系统并不少见但把一个旅游网站扎扎实实落在喀什这个特定地域上要考虑的事情就多了景点分散、旅游旺季集中、民俗文化元素多、图文内容量大。最近我完成了一个基于 Java SpringBoot Vue3 MyBatis 的喀什旅游网站系统前后端分离数据库用 MySQL。整套项目从表结构设计到前后端联调再到部署走了不少弯路也沉淀了一些比较实用的做法。这篇文章就把这套系统的完整做法拆开讲清楚——适合正在做前后端分离实战项目的初中级开发者也适合把旅游类应用当毕业设计或面试项目的人参考。我会把技术选型的理由、数据库建模的关键字段、后端接口的写法、前端组件的组织方式以及部署上线的坑一条条捋出来。1. 项目整体设计与技术选型思路1.1 业务需求先于技术选型喀什旅游网站要解决什么问题我在动手前其实列了一个需求清单。用户端需要景点信息浏览、旅游线路查看与下单、酒店/民宿查询、旅游资讯阅读、个人收藏与评论管理端则需要景点管理、线路管理、酒店管理、文章发布、订单处理、用户管理。这些需求听起来跟普通旅游网站差不多但落到喀什场景下就有很多实际细节需要考虑。比如景点信息不是简单一个名称加一段文字就能撑住。喀什这边文化景点多像古城、清真寺、巴扎这类地方游客很关心开放时间、门票价格、交通方式、适合游玩的季节、拍照打卡点位置这些信息在展示页上要分层呈现。旅游线路也不是只给一个名字游客会关注天数、出行方式、住宿标准、成团人数、价格区间还得能做多条件筛选。所以数据库建模和接口设计必须从这些真实业务点出发不能只做增删改查。我当时还整理了两个端的功能边界。管理端只走后台逻辑不做复杂交互用户端把浏览、搜索、下单、评论这条主链路做顺畅。说白了这个系统的核心就是“信息展示 在线预订 内容管理”想清楚这一点后面的技术选型就有依据了。1.2 为什么是 SpringBoot Vue3 MyBatis MySQL技术栈的选择我并没有追新而是把开发效率、可控性和团队上手难度综合考量了一遍。SpringBoot 负责后端工程化省去了大量 XML 配置和依赖版本冲突问题内嵌 Tomcat 也方便本地开发和后期部署。MyBatis 的核心价值在于 SQL 完全自己掌控旅游网站这种业务往往需要多表查询、条件组合、报表统计MyBatis 的动态 SQL 写起来非常顺手不像 JPA 那样在某些复杂查询上绕来绕去。Vue3 选择它的理由是组合式 API。这个项目里搜索页、详情页都有大量业务逻辑要复用比如景点筛选、分页加载、状态管理用 Composition API 可以把这些逻辑抽成自定义 hook比 Vue2 的 options API 写起来清爽很多。再加上 Vite 的开发服务器速度是真的快热更新几乎无感迭代效率明显提升。MySQL 就不用多说了成熟稳定、社区资料多、运维成本低这个量级的旅游网站用 MySQL 完全够用。至于前后端分离最大好处是开发和部署互不阻塞。前端开发时用 Vite 代理接口后端只管提供 RESTful API上线后前端构建成静态文件丢给 Nginx后端打成 jar 包独立运行后续扩容也很方便。1.3 工程目录怎么拆分后端我采用经典的分层结构包名按模块功能划分com.kashgar.tourism ├── controller # 接口层 ├── service # 业务层 ├── mapper # MyBatis 数据访问层 ├── entity # 数据库实体 ├── dto # 接口入参出参对象 ├── config # 配置类拦截器、跨域、MyBatis ├── common # 统一返回结果、异常处理、工具类 └── enums # 业务状态枚举前端用 Vue3 Vite 构建目录结构是这样的src ├── api # 接口请求封装 ├── assets # 静态资源 ├── components # 公共组件 ├── router # 路由配置 ├── store # Pinia 状态管理 ├── utils # 工具函数 └── views # 页面组件前后端目录分开后各自职责清晰后端专注接口与数据一致性前端专注交互与展示。联调时只要约定好接口文档就可以并行推进不用互相等待。实际开发中我们基本没出现“前端等接口、后端等页面”的尴尬状态。2. 数据库建模一套能撑住核心业务的表设计2.1 核心表清单与字段定义旅游网站的数据模型其实比较标准但我还是花了心思在一些关键字段上。用户表、景点表、酒店表、线路表、文章表、订单表、评论表、收藏表基本覆盖了核心业务。以景点表和线路表为例字段设计我考虑了很久。CREATE TABLE attraction ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 景点名称, category VARCHAR(50) COMMENT 分类自然/人文/民俗, cover_image VARCHAR(255) COMMENT 封面图, images TEXT COMMENT 详情图列表JSON数组, address VARCHAR(255) COMMENT 详细地址, longitude DECIMAL(10, 6) COMMENT 经度, latitude DECIMAL(10, 6) COMMENT 纬度, open_time VARCHAR(100) COMMENT 开放时间, ticket_price DECIMAL(10, 2) COMMENT 门票价格, description TEXT COMMENT 景点介绍, status TINYINT DEFAULT 1 COMMENT 状态1上架 0下架, deleted TINYINT DEFAULT 0 COMMENT 逻辑删除, create_time DATETIME COMMENT 创建时间, update_time DATETIME COMMENT 更新时间 );线路表需要注意的字段更多。旅游线路是组合商品不是简单的一条记录所以我用了一个线路主表加行程明细表的设计。主表存基础信息线路名称、价格、天数、出发城市、成团人数、封面图、简介明细表存每天的行程安排包括景点顺序、餐饮住宿说明。CREATE TABLE travel_line ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(200) NOT NULL COMMENT 线路名称, days INT COMMENT 行程天数, price DECIMAL(10, 2) COMMENT 价格, departure_city VARCHAR(50) COMMENT 出发城市, cover_image VARCHAR(255) COMMENT 封面图, highlights TEXT COMMENT 线路亮点, status TINYINT DEFAULT 1 COMMENT 上架状态, deleted TINYINT DEFAULT 0, create_time DATETIME, update_time DATETIME );订单表我特别加了下单来源和支付状态字段方便后续对账。订单也不是简单的用户 ID 线路 ID因为一条线路可能有多名出行人所以我拆了订单主表和订单乘客表。主表记录订单编号、用户 ID、总价、支付状态、下单时间乘客表记录每个出行人的姓名、证件号、电话。这样做的好处是后续开发退款、改签这类逻辑时有足够的数据支撑。2.2 字段设计里容易被忽略的三个细节第一是图片存储。很多新手会把图片字段设计成image VARCHAR(255)一张图对应一个字段。但旅游景点详情至少有五六张图这样设计就尴尬了。我最后选择了images TEXT存 JSON 字符串前端直接解析渲染后端接口返回时就是一个数组。这个方案在数据一致性上没有关联表那么严格但胜在查询快、代码简单对这种规模的数据量完全够用。第二是状态字段不能省。景点和线路有上架/下架状态订单有待支付/已支付/已取消/已完成状态这些字段不只是业务逻辑需要还直接影响查询效率和后续统计。我在订单表上加了状态索引管理后台查待处理订单时走索引秒级返回如果不加索引订单量上来后按状态查询就会全表扫描体验很差。第三是逻辑删除字段。我一开始也没太在意后来发现运营把一条线路误删了后台直接就没法恢复。加了deleted字段之后所有查询语句统一带上WHERE deleted 0既保留数据又能实现软删除安全性高很多。2.3 索引设计不是越多越好索引设计我踩过坑。刚开始为了“优化”给每个可能查询的字段都加了索引结果插入数据时明显变慢磁盘占用也变大。后来遵循了最左前缀原则把高频查询条件组合成联合索引比如景点的category status线路的status price订单的user_id status。还要提醒一句字段类型和排序规则要保持统一否则索引会失效。比如订单状态字段用 TINYINT那就所有表里都应该用 TINYINT不要出现一条表用status字符串、另一条用 TINYINT 的情况MyBatis 映射和 SQL 查询时很不方便。3. 后端核心实现SpringBoot MyBatis3.1 RESTful 接口与统一返回结构后端接口我全部采用 RESTful 风格资源名用名词动作交给 HTTP Method。比如景点列表是GET /api/attractions添加景点是POST /api/attractions更新时用PUT /api/attractions/{id}删除用DELETE。这种风格的好处是接口语义清晰前端对接时不用猜。为了让前端拿到统一格式的响应我封装了ResultTpublic class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }这个类看起来简单实际作用很大。前端可以统一拦截code如果为 200 就正常返回数据否则提示错误消息。开发时不需要每个接口都做异常分支处理后端用全局异常处理器把业务异常转成 Result 返回即可。代码量减少了联调时也少了很多无谓的沟通。3.2 多条件动态 SQL 实战线路筛选旅游网站最常见的查询场景就是线路筛选用户勾选“3天以内”“1000元以下”“从乌鲁木齐出发”后端要能组合查询。这种需求用 MyBatis 动态 SQL 非常合适。我写了一个TravelLineMapper.xml核心查询如下select idsearchLines resultTypecom.kashgar.tourism.entity.TravelLine SELECT * FROM travel_line where if testkeyword ! null and keyword ! AND (name LIKE CONCAT(%, #{keyword}, %) OR highlights LIKE CONCAT(%, #{keyword}, %)) /if if testdays ! null AND days lt; #{days} /if if testmaxPrice ! null AND price lt; #{maxPrice} /if if testdepartureCity ! null and departureCity ! AND departure_city #{departureCity} /if AND deleted 0 AND status 1 /where ORDER BY create_time DESC /selectwhere标签会自动处理掉第一个条件前面的AND省去拼接 SQL 时的烦恼。需要注意的小细节是 XML 中小于号要写成lt;我第一次写的时候直接在 XML 里用了结果启动就报错。另外模糊搜索用CONCAT(%, #{keyword}, %)而不是直接写在字符串里这样可以避免 SQL 注入风险。3.3 下单与库存并发控制订单模块最核心的问题是并发。旅游线路通常有成团人数限制比如一条线路最多收 30 人如果两个人同时下单都判断当前人数小于 30就会超卖。我采用的方案是数据库乐观锁在下单时执行更新语句并带上库存条件UPDATE travel_line SET current_count current_count 1 WHERE id #{id} AND current_count max_count;更新返回的影响行数为 1 表示扣减成功为 0 则表示成团人数已满需要提示用户。这个方案不用锁表性能好也足够应对旅游网站这种并发量。我在 Service 层用Transactional包住整个下单流程先扣减人数再创建订单任何一个步骤失败都会整体回滚避免出现订单成功但人数没扣、或者反过来数据不一致的情况。事务这里要特别提醒SpringBoot 的事务默认只在 Runtime 异常时回滚受检异常不会回滚。如果业务里需要捕获受检异常后仍回滚得在Transactional上指定rollbackFor Exception.class。这是我实际踩过的坑少写这个属性测试时发现数据脏了还排查了半天。3.4 MyBatis 一级/二级缓存要不要开市面上很多资料吹 MyBatis 二级缓存但我实际项目中基本不依赖它。一级缓存是默认开启的作用范围是同一个 SqlSession也就是一次请求会话内重复查询相同语句会命中缓存这个很安全没必要关。二级缓存是 Mapper 级别的看起来很美但跨 SqlSession 共享的数据一旦在业务里被修改很容易出现脏读。旅游网站的数据更新频率不算低景点信息、线路价格随时可能被运营调整开了二级缓存就可能让用户看到老价格。我更推荐用 Redis 做真正的业务缓存比如把首页热门景点列表缓存 10 分钟查询接口先走 Redis没有命中再查数据库。这样可控性强缓存失效也能精确控制。MyBatis 的二级缓存我不会说完全不用但如果用了一定得确认业务实体类的序列化没问题并且接受它可能带来的短暂脏数据风险。4. 前端实现Vue3 组件化开发与体验优化4.1 Vite 初始化与路由懒加载前端用 Vite 初始化项目只需要一条命令npm create vitelatest kashgar-front -- --template vue。注意这里要选 JavaScript还是 TypeScript取决于你有没有类型基础。我选的是 JavaScript项目节奏快团队上手简单。路由配置上用了懒加载const routes [ { path: /, name: Home, component: () import(/views/Home.vue), meta: { title: 喀什旅游 } }, { path: /attraction/:id, name: AttractionDetail, component: () import(/views/AttractionDetail.vue), meta: { title: 景点详情 } } ];懒加载不是炫技是真实需要。旅游网站首屏图片本来就多如果首包把所有页面代码都下载下来用户打开很慢。拆包之后首屏只加载首页代码进入详情页时才加载对应的 JS 文件页面打开速度提升非常明显。我在项目里还用路由守卫设置了页面标题配合meta.title切路由时自动更新浏览器标签页标题不用每个页面手动写。4.2 Pinia 管理登录态与用户信息Vue3 项目里状态管理我用的是 Pinia相比 Vuex 上手难度低很多没有 mutation 那层概念逻辑更直观。我建了一个 user store存储 token、用户基本信息、收藏数量import { defineStore } from pinia; export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: {} }), actions: { setLogin(data) { this.token data.token; this.userInfo data.userInfo; localStorage.setItem(token, data.token); }, logout() { this.token ; this.userInfo {}; localStorage.removeItem(token); } } });登录成功之后前端把 token 放进请求头后端通过 JWT 校验身份。Pinia 的 state 是响应式的页面里展示用户昵称、头像、收藏数都会自动更新。一个小技巧初始化 token 时要先从 localStorage 里读刷新页面才不回退到未登录状态。这个我一开始没做刷新一次就掉登录差点以为后端 token 有效期写错了。4.3 组件封装景点卡片、搜索筛选、图片懒加载页面里重复性最高的就是景点卡片和线路卡片。我把它们抽成公共组件数据格式统一。比如AttractionCard.vue接收一个attraction对象内部渲染封面图、名称、门票价格标签、适合游玩的季节标签。这样首页列表、搜索结果页、推荐板块都能复用同一个组件样式一致修改也只需改一处。搜索筛选组件是另一个重点。喀什旅游线路筛选条件多我把筛选栏做成独立组件对外抛出筛选条件变化事件。父组件监听事件后重新请求接口组件内部保持纯粹不关心数据从哪来。图片懒加载我写了一个自定义指令v-lazy通过 IntersectionObserver 监听图片是否进入视口进入后再加载真实图片。旅游网站图片多不用懒加载的话页面滚动卡顿非常明显。4.4 地图与旅游特色交互喀什的景点分散很多游客其实需要一个“景点地图视角”。我在景点列表页接入了高德地图用经纬度字段在地图上打点点击标记点弹出景点名称和简介再点击跳转详情页。实现起来不复杂引入高德 JS API初始化地图循环景点数据添加 Marker 即可。地图接入有个实际细节喀什有部分景点名称会涉及多个写法或别名我在地图搜索和后台数据里使用的名称做了统一避免标记点搜不到。另一个点不是所有景点都有准确的经纬度我在地图页做了一个降级方案没有坐标的景点只出现在列表里不在地图上显示这样不会因为坐标异常导致地图定位跑到奇怪的地方。5. 前后端联调、部署上线与性能优化5.1 跨域与开发环境代理前后端分离开发跨域问题是绕不开的。后端我配置了全局跨域但生产环境其实更推荐通过 Nginx 反向代理来解决而不是在接口层放开跨域。开发阶段我在 Vite 里配置了代理所有/api请求转发到本地后端的 8080 端口// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } });这种方式的好处是开发环境下前端请求 URL 和生产环境一致都写/api/xxx不需要在代码里判断环境切换域名。后端也不用额外处理跨域请求头联调起来少了一大堆麻烦。我见过不少项目里前端前端到处写http://localhost:8080/api/xxx上线时再全局搜替换非常痛苦用代理可以完全规避。5.2 JWT 登录认证从签发到校验用户登录这块我没用 Session选了 JWT。登录接口验证用户名密码后后端用密钥生成 token里面附带用户 ID 和过期时间。前端拿到 token 后存储起来每次请求通过拦截器放到 Authorization 头里。Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); } // 解析 token校验有效性 if (!JwtUtil.verify(token)) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } return true; } }拦截器要特别注意放行登录、注册、景点列表等公开接口否则用户还没登录就什么都访问不了。我在配置类里注册拦截器时用excludePathPatterns把公开接口排除掉。生产环境里 JWT 的密钥不要硬编码在代码里放到配置文件中并且一定用强密钥不然 token 丢失后人人都能伪造。5.3 Nginx jar 部署方案部署方案很直接前端构建出 dist 文件夹后端打成 jar 包。前端静态文件交给 Nginx后端用java -jar启动。server { listen 80; server_name your-domain.com; root /var/www/kashgar; index 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; } location / { try_files $uri $uri/ /index.html; } }核心是try_files $uri $uri/ /index.html。前端路由是 history 模式用户直接访问/line/123这种页面时Nginx 找不到对应的物理文件必须回退到 index.html交给前端路由处理。如果不加这条刷新页面就会 404这是前后端分离项目最常见的部署事故之一。静态资源目录记得配浏览器缓存后端接口走location /api/反向代理不会冲突。5.4 性能优化清单上线前我做了几轮性能优化不用等到大数据量才考虑图片全部压缩后再上传封面图控制在 200KB 以内列表页加载速度快很多。MySQL 连接池参数调整maximum-pool-size设置为 20避免默认配置并发不够。Nginx 开启 gzip静态文件体积能减少 60% 以上。列表查询强制走索引explain 查看执行计划发现深分页查询慢就改成游标分页。首页热点数据加 Redis 缓存设置了 10 分钟过期数据库压力明显下降。旅游网站的内容以图片和文字为主图片和 HTTP 层面的优化收益最大。有时候用户反馈“网站慢”未必是服务器不行可能是图片没压缩、没开缓存这类问题排查起来比单纯加配置更花时间。6. 实战中最容易踩的坑和排查思路6.1 中文乱码不只是编码问题喀什旅游网站的文本内容中文和维吾尔文名称都可能出现如果字符集没配好显示出来就是一堆问号。我在建库时指定了utf8mb4连接 URL 也加了characterEncodingutf8前端页面统一meta charsetUTF-8基本就不会乱码。要提一个容易漏的环节JSON 序列化时某些字符可能被转义前端看到的是\uXXXX而不是真实文字这类问题一般出在后端响应头少了charsetUTF-8要么就是在 SpringBoot 配置里显式声明。6.2 MySQL 时区报错与时间字段项目启动后连接数据库抛异常提示时区问题。原因是 MySQL 驱动 8.0 后对时区要求变严格了解决方法是 JDBC URL 加上serverTimezoneAsia/Shanghai。但这就够了吗不够数据库连接池里的时间字段如果用了默认值要考虑数据库所在服务器时区是否需要同步调整。我给所有时间字段设置了DEFAULT CURRENT_TIMESTAMP并统一使用DATETIME类型避免TIMESTAMP的 2038 年问题实用主义做法。6.3 Vue3 响应式丢失写轮播图组件时我用reactive定义了一个图片列表然后直接解构给子组件用const state reactive({ images: [], index: 0 }); const { images, index } state;结果子组件修改index后视图完全不更新。这就是典型的响应式丢失问题reactive对象解构出来的属性是普通值脱离了代理。正确做法是使用toRefs或者直接使用ref。我用ref重写后问题解决。这个坑很容易发生初学者尤其要注意。6.4 MyBatis 查询结果字段为 null 或映射错位MyBatis 自动映射依赖数据库字段名下划线转驼峰。如果数据库字段是create_time实体类是createTime需要在配置文件开启mybatis: configuration: map-underscore-to-camel-case: true不开启的话查询结果显示 createTime 一直是 null但数据库里明明有值。我在项目里因为这个问题排查了很久看着 SQL 没问题、数据也有就是返回的 JSON 缺字段。开启驼峰映射后一切正常。另外多表联查时有相同字段名建议给 SQL 里的列起别名避免 MyBatis 映射到错误的实体属性。6.5 页面刷新 404 与接口请求 404 的区分部署完成后最怕遇到 404。处理这两个问题要分开看待。前端页面刷新 404大概率是 history 路由没有try_files回退接口请求 404先确认 Nginx 的location /api/有没有生效再看后端 jar 里的接口路径是否正确最后查 controller 类上有没有加RequestMapping(/api)。我遇到过固执地认为 Nginx 配置错了查了半天最后发现是后端接口路径少了/api前缀前后端约定不一致这个低级错误排查最费时间。6.6 表名与字段名的保留字冲突设计订单表时我一开始把表名命名为order结果多条 SQL 执行都报语法错误。原因很简单order是 MySQL 的保留字需要加反引号才能用。后面我把表名改成了orders一劳永逸。这个坑大家第一次做项目很容易触发建议建表前先过一遍保留字列表别踩了再改改表名涉及到的代码改动量非常大。最后分享一点个人体会这套喀什旅游网站系统做完我最有感触的一点是旅游网站的代码本身不一定多难难在把业务规则想清楚。比如下单和成团人数的并发控制、搜索条件的动态 SQL、图片资源的性能开销这些才是真正体现功力的地方。如果你也要做同类项目建议从数据库建模开始多花时间一张表设计错了后期改接口的成本是十倍。另外别迷信框架的自动配置。MyBatis 的驼峰映射要手动开事务回滚规则要自己确认Nginx 路由回退要自己写。把这些细节一个个验证过项目才算真正稳了。我在开发过程中还试过一次把收藏功能和评论功能合并成一个通用模块结果发现业务语义完全不同最后还是拆开实现。该拆的地方拆、该合的地方合这还是得靠对业务的理解不是靠技术多先进能解决的。