最近帮一个旅游行业的客户梳理了一套区域旅游网站的方案聊到榆林的时候突然发现这类“地方特色旅游管理”的系统其实特别有代表性。榆林本身有红石峡、镇北台、波浪谷这些自然和人文景观还有非常独特的陕北民俗和饮食文化但大多数小城市的旅游资源都卡在同一个问题上线上展示零散线下管理靠Excel游客想规划行程找不到一个可靠的信息源。所以基于SpringBootVue的榆林特色旅游网站管理系统本质上要解决的不只是一个“能看”的官网而是一个集展示、管理、预订、内容维护于一体的业务工具。这篇文章就围绕这个系统的完整设计与实现来拆从需求到表结构从前端交互到后端接口再到部署上线遇到的问题一次性写透给准备做类似旅游系统的朋友做一个参考。1. 项目整体设计与需求拆解1.1 榆林旅游网站的定位不只是“展示页”很多第一次接触这类项目的朋友会把旅游网站等同于一个带几张图片、几段介绍的公司官网。但实际做下来你会发现真正能落地运营的旅游网站系统核心是有“管理能力”。榆林特色旅游网站这个项目它的价值在于把游客端和管理端放在同一个体系里游客能看到景点介绍、旅游线路、当地美食、民俗活动管理员能在后台维护这些内容、处理订单、管理评论和公告。拆开来看游客端的核心需求是“找信息”和“定行程”管理端的核心需求是“改内容”和“看数据”。如果没有后台每次景点信息更新都要让开发人员改代码这在运营上是不现实的。这个项目的设计思路就是把C端展示和B端管理做成一个整体用一套数据结构支撑两个入口。1.2 核心功能模块划分从实际业务场景出发我习惯把这类系统切成六个模块这也是和客户反复确认后才定下来的范围。景点信息管理景点的文字介绍、图片、开放时间、门票信息、所在区域、推荐等级。旅游线路管理一日游、两日游这类打包线路包含途经景点、行程天数、价格、成团人数。美食推荐模块榆林本地特色菜、小吃店、餐饮店位置很多旅游网站会忽略这块但游客对“吃”的关注度其实非常高。民宿/酒店推荐住宿信息录入和展示和线路、景点形成闭环。用户与评论系统游客注册登录后可以对景点和线路发表评论管理员可以审核或删除评论。公告通知管理网站首页的公告栏、活动通知、节假日须知这类动态内容。我在设计的时候会把“系统管理”单独拎出来包含管理员账号、角色权限、操作日志。虽然这个模块用户看不到但它是整个系统安全性的基础尤其是当多个运营人员分工维护内容的时候权限控制能避免误操作。1.3 需求分析阶段容易忽略的细节有一个极易踩坑的地方是景点信息里的图片。很多初版设计里图片就是一个URL字段但真实运营场景中一个景点往往需要多张图封面图、详情图、实景图。如果不提前设计成独立子表后面扩展会非常痛苦。我在这个项目里把景点图片拆成了单独的一张表和景点主表是一对多关系这样前台轮播、后台缩略图都能灵活取数。另外就是搜索功能。榆林景点有很明显的区域分布特征比如榆阳区、神木市、府谷县这些地方各有不同景点用户很可能带着“我想去神木玩”这种需求来搜索。所以数据表设计时要预留区域字段搜索接口要支持按区域、按景点名称、按标签组合筛选。不做这一步后期补会非常麻烦。2. 技术选型为什么是SpringBootVueMyBatisMySQL2.1 这套组合解决了什么问题标题里明写SpringBootVueMyBatisMySQL这确实也是目前中小型管理系统最稳妥的组合之一。我不是说这是唯一选择但从项目落地的角度它的优势非常明显。SpringBoot解决的是后端开发效率问题。自动配置、内嵌Tomcat、简化依赖管理开发人员不需要花大量时间在XML配置上能把精力放在业务逻辑本身。Vue解决的是前端交互复杂度和组件复用问题。游客端和管理端虽然共用一个后端API但前端展示逻辑完全不同Vue的组件化模型非常适合这类多页面应用。MyBatis是轻量级持久层框架。相比JPAMyBatis对于复杂SQL的控制力更强特别是旅游系统里经常出现的多表关联查询、统计报表SQL手写SQL更直观也更容易调优。MySQL是关系型数据库的稳妥选择。社区活跃、运维成本低、数据一致性有保障对于旅游网站的订单、用户、评论这类强结构化数据非常合适。2.2 前后端分离架构的取舍做这套系统我用了前后端分离的结构前端项目和后端项目完全独立通过JSON格式的API通信。好处是开发时可以并行推进我负责后端的同时另一个同事负责前端两个人只要提前约定好接口文档效率会高很多。但前后端分离也有代价最主要的就是跨域问题。前端的开发服务器跑在8080端口后端的接口跑在8081端口浏览器会拦截跨域请求。解决办法我在后面部署章节会详细说这里先提一句生产环境中用Nginx反向代理就能解决开发环境则在后端配置CorsFilter。2.3 系统架构分层与包结构规划后端代码我按经典的四层结构组织Controller层接收前端请求校验参数格式调用Service业务逻辑返回结果。Service层核心业务处理比如下单时的库存判断、评论发布时的敏感词过滤。Mapper层DAO层数据库访问操作定义接口和SQL映射。实体层entity/dto数据库表字段对应的Java对象以及前端交互用的数据传输对象。以后端项目的包为例com.yulin.tourism ├── controller │ ├── ScenicController.java │ ├── LineController.java │ ├── UserController.java │ └── AdminController.java ├── service │ ├── ScenicService.java │ ├── LineService.java │ └── CommentService.java ├── mapper │ ├── ScenicMapper.java │ ├── LineMapper.java │ └── CommentMapper.java ├── entity │ ├── Scenic.java │ ├── ScenicImage.java │ ├── TravelLine.java │ └── User.java ├── config │ ├── CorsConfig.java │ └── WebMvcConfig.java ├── common │ ├── Result.java │ └── ResultCode.java └── TurismApplication.java这样的分层结构最大的好处是如果有人想在这个基础上二次开发他能非常快地找到对应功能的代码位置不需要花一整天去猜类之间的关系。3. 数据库设计一张表一张表地拆解3.1 核心数据表结构数据库是整个旅游系统的地基表设计的好坏直接决定了后续开发是否顺畅。这个项目我总共设计了10张核心表挑几张重点的说明。景点表 scenicCREATE TABLE scenic ( id INT PRIMARY KEY AUTO_INCREMENT, scenic_name VARCHAR(100) NOT NULL COMMENT 景点名称, scenic_area VARCHAR(50) COMMENT 所属区域, ticket_price DECIMAL(10,2) DEFAULT 0 COMMENT 门票价格, open_time VARCHAR(50) COMMENT 开放时间, description TEXT COMMENT 景点介绍, cover_image VARCHAR(255) COMMENT 封面图, recommend_level INT DEFAULT 0 COMMENT 推荐等级 0-5, status TINYINT DEFAULT 1 COMMENT 状态 1上架 0下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );注意几个细节门票价格用DECIMAL而不是FLOAT避免浮点数精度问题。推荐等级用纯数字排序时可以直接ORDER BY不需要字符串转换。状态字段用TINYINT上架下架逻辑非常常见布尔值语义不够灵活。景点图片表 scenic_imageCREATE TABLE scenic_image ( id INT PRIMARY KEY AUTO_INCREMENT, scenic_id INT NOT NULL, image_url VARCHAR(255) NOT NULL, sort_order INT DEFAULT 0, image_type TINYINT DEFAULT 1 COMMENT 1封面 2详情 3实景, FOREIGN KEY (scenic_id) REFERENCES scenic(id) ON DELETE CASCADE );这里用了外键加级联删除是为了保证删除景点时图片数据不会残留成垃圾数据。前台展示时按scenic_id查询再按sort_order排序就能得到固定的图片展示顺序。旅游线路表 travel_lineCREATE TABLE travel_line ( id INT PRIMARY KEY AUTO_INCREMENT, line_name VARCHAR(100) NOT NULL, line_days INT NOT NULL COMMENT 行程天数, line_price DECIMAL(10,2) NOT NULL, line_desc TEXT, start_area VARCHAR(50), cover_image VARCHAR(255), status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );线路和景点是多对多的关系一条线路会包含多个景点一个景点也可以出现在多条线路里所以要额外建一张线路景点关联表 line_scenicCREATE TABLE line_scenic ( id INT PRIMARY KEY AUTO_INCREMENT, line_id INT NOT NULL, scenic_id INT NOT NULL, day_index INT DEFAULT 1 COMMENT 第几天, sort_order INT DEFAULT 1 COMMENT 当天第几个景点 );day_index和sort_order这两个字段特别重要它们决定了“第一天上午去哪个景点、下午去哪个景点”的行程顺序展示没有这个字段前端展示行程安排就只能乱序输出体验很差。3.2 订单数据设计的关键点考虑到这是一个旅游系统订单表是必不可少的。但旅游订单和普通商品订单又不太一样核心差异点在于一个订单可能包含多个人成人和儿童价格不同。订单和线路关联需要记录下单时的线路快照线路名、价格否则线路后续改了价格历史订单就说不清了。我在订单表里加了snapshot_line_name和snapshot_line_price字段专门存下单时的线路名称和单价。这个设计很多人一开始不理解直到运营改了一次价格之后才发现历史订单数据乱了。这就是典型的“快照模式”电商里叫“冗余存储”简单且有效。订单表ordersCREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单编号, user_id INT NOT NULL, line_id INT NOT NULL, adult_count INT DEFAULT 1, child_count INT DEFAULT 0, total_price DECIMAL(10,2) NOT NULL, snapshot_line_name VARCHAR(100), snapshot_line_price DECIMAL(10,2), link_name VARCHAR(50) COMMENT 联系人, link_phone VARCHAR(20) COMMENT 联系电话, order_status TINYINT DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已完成, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );订单号的生成我推荐用时间戳加随机数的方式比如yyyyMMddHHmmss 4位随机数单机场景下够用也不会泄露订单量这类业务信息。3.3 用户表和评论表用户表的设计核心其实是登录方式。小程序类项目一般用微信登录网站类项目一般用手机号加密码这个项目用的就是手机号加密码密码用bcrypt加密存储而不是MD5。为什么不用MD5因为MD5是摘要算法不是加密算法现在用彩虹表很容易反查弱密码。Spring Security自带的BCryptPasswordEncoder就能用几行代码接入。评论表的设计要注意的是“评论对象”的概念CREATE TABLE comment ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, target_type TINYINT COMMENT 1景点 2线路, target_id INT NOT NULL, content VARCHAR(500) NOT NULL, rating INT DEFAULT 5 COMMENT 评分1-5, status TINYINT DEFAULT 1 COMMENT 1显示 0隐藏, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );用target_type来区分评论的是景点还是线路而不是分开建两张评论表对于这类轻量级评论系统是更好的设计查询时只需要带上target_type和target_id两个条件。4. 核心功能实现与代码解析4.1 后端统一返回格式前后端分离的项目最忌讳每个接口返回结构都不一样。我在项目里定义了一个统一返回类Result所有接口都返回固定的JSON结构{ code: 200, message: success, data: {} }对应的Java类Data public 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再取data字段不管哪个页面写逻辑都是一样的套路维护成本非常低。错误码的语义也要提前约定好比如401代表未登录403代表没有权限500是系统异常。不要把业务异常也统一用500返回前端会傻傻分不清楚。4.2 景点列表的分页与条件查询旅游网站的景点列表页常见的需求是按区域筛、按关键词搜、分页展示。对应的后端接口逻辑用MyBatis实现Mapper接口ListScenic selectScenicList(Param(area) String area, Param(keyword) String keyword, Param(offset) Integer offset, Param(limit) Integer limit); int countScenicList(Param(area) String area, Param(keyword) String keyword);对应的XML映射文件select idselectScenicList resultTypecom.yulin.tourism.entity.Scenic SELECT id, scenic_name, scenic_area, ticket_price, cover_image, recommend_level, open_time, description FROM scenic where status 1 if testarea ! null and area ! AND scenic_area #{area} /if if testkeyword ! null and keyword ! AND scenic_name LIKE CONCAT(%, #{keyword}, %) /if /where ORDER BY recommend_level DESC, create_time DESC LIMIT #{offset}, #{limit} /select为什么查询条件用where标签而不是直接拼WHERE 11因为where标签会自动处理掉第一个条件前面的AND生成出来的SQL更干净。同时注意查询列表和查询总数是两个方法这也是分页查询的标准写法总数量用于前端渲染分页组件。LIKE模糊查询要注意性能问题。这里用的是CONCAT(%, #{keyword}, %)在数据量不大的阶段完全没问题。如果以后景点数据量超过几万条再考虑引入ES或者MySQL全文索引现阶段不要过度优化。4.3 线路详情的“多对多”组装线路详情的后端接口稍微复杂一些因为它要返回线路基本信息 关联的景点列表还要按天分组。我实现的方式是先查线路基本信息再查关联景点然后在Service层组装。这段逻辑的核心SQL是关联查询select idselectScenicListByLineId resultTypecom.yulin.tourism.entity.Scenic SELECT s.id, s.scenic_name, s.scenic_area, s.ticket_price, s.open_time, s.description, s.cover_image FROM scenic s INNER JOIN line_scenic ls ON s.id ls.scenic_id WHERE ls.line_id #{lineId} ORDER BY ls.day_index ASC, ls.sort_order ASC /select很多初学者会纠结到底是在SQL里组装还是在业务代码里组装。我的经验是简单的关联查询比如线路关联景点交给JOIN去解决复杂的业务组装比如订单详情包含线路快照、联系人信息、景点清单则在Service层分步组装。SQL的JOIN嵌套太深SQL本身会变得难以维护而且MySQL的优化器对多层JOIN并不总是那么聪明。4.4 后台登录与权限控制管理后台必须做权限控制这是整套系统的安全底线。我用的是拦截器加Token的方案不引入Spring Security那么重的框架。具体做法是用户登录成功后生成一个UUID作为Token把Token存入Redis同时设置过期时间然后要求前端每次请求都在Header里带上这个Token。登录接口的核心逻辑PostMapping(/admin/login) public Result? login(RequestBody LoginRequest req) { // 1. 根据账号查管理员 Admin admin adminMapper.selectByUsername(req.getUsername()); // 2. 校验密码BCrypt匹配 if (admin null || !BCryptPasswordEncoder.matches(req.getPassword(), admin.getPassword())) { return Result.error(500, 用户名或密码错误); } // 3. 生成Token并存入Redis String token UUID.randomUUID().toString().replaceAll(-, ); redisTemplate.opsForValue().set(admin:token: token, admin.getId().toString(), 2, TimeUnit.HOURS); return Result.success(token); }Token的有效期我给的是2小时管理端如果长时间不操作就要求重新登录这个可以根据项目实际情况调整。拦截器里面做的事情很简单就是校验Header里的Token是否在Redis中存在不存在就直接返回401让前端跳转登录页。这样做的好处是不用改动每个Controller的方法逻辑横切关注点统一处理。4.5 图片上传功能的实现旅游系统的图片上传是刚需。管理员需要在后台上传景点图片、线路封面图。实现方式是前端用Element UI的Upload组件把文件POST到后端接口后端接收到文件后存储到服务器的一个指定目录然后返回文件的访问URL。上传接口的核心实现PostMapping(/admin/upload) public ResultString upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return Result.error(500, 文件不能为空); } // 校验文件类型 String originalFilename file.getOriginalFilename(); String suffix originalFilename ! null ? originalFilename.substring(originalFilename.lastIndexOf(.)) : ; if (!Arrays.asList(.jpg, .jpeg, .png, .gif).contains(suffix.toLowerCase())) { return Result.error(500, 仅支持图片格式); } // 生成文件名避免中文和重名 String fileName UUID.randomUUID().toString() suffix; String datePath new SimpleDateFormat(yyyyMMdd).format(new Date()); File dir new File(uploadPath datePath); if (!dir.exists() !dir.mkdirs()) { return Result.error(500, 目录创建失败); } file.transferTo(new File(dir.getAbsolutePath() / fileName)); String url /uploads/ datePath / fileName; return Result.success(url); }文件名用UUID重命名彻底避开了中文文件名和重名覆盖的问题。按日期分子目录的好处是单个目录下的文件数量不会爆炸也方便出问题时按时间段排查。图片上传目录要放在静态资源能访问的地方否则浏览器无法直接显示这个需要在配置类里设置资源映射。5. 前端Vue实现要点5.1 项目初始化与路由设计前端项目我用的Vue CLI创建版本是Vue2如果你用Vue3问题也不大写法换成Composition API就行。路由设计上分成两组游客端路由和管理端路由。// 游客端路由 const routes [ { path: /, component: HomeView, meta: { title: 首页 } }, { path: /scenic, component: ScenicListView, meta: { title: 景点列表 } }, { path: /scenic/:id, component: ScenicDetailView, meta: { title: 景点详情 } }, { path: /line, component: LineListView, meta: { title: 旅游线路 } }, { path: /line/:id, component: LineDetailView, meta: { title: 线路详情 } }, { path: /food, component: FoodView, meta: { title: 特色美食 } }, { path: /login, component: LoginView, meta: { title: 登录 } } ]; // 管理端路由 const adminRoutes [ { path: /admin/login, component: AdminLogin, meta: { title: 管理登录 } }, { path: /admin/dashboard, component: DashboardView, meta: { title: 数据看板, requiresAuth: true } }, { path: /admin/scenic, component: ScenicManage, meta: { title: 景点管理, requiresAuth: true } }, { path: /admin/line, component: LineManage, meta: { title: 线路管理, requiresAuth: true } }, { path: /admin/comment, component: CommentManage, meta: { title: 评论管理, requiresAuth: true } } ];每次路由切换时用路由守卫检查当前路由有没有requiresAuth标识如果有就去读取本地存储的TokenToken不存在就跳转到登录页。前端这边的守卫只是一个交互层面的过滤真正的安全校验还得靠后端的拦截器这个理念要记牢。5.2 Axios封装统一处理错误和Token前端所有的HTTP请求我都封装在一个request.js文件里统一配置baseURL、超时时间、请求头以及响应拦截器。响应拦截器是前后端配合的关键后端返回的code不是200时前端直接弹出错误提示而不用每个页面都写一遍。同时401状态码会被拦截器统一捕获并跳转登录页。import axios from axios; const request axios.create({ baseURL: /api, timeout: 10000 }); // 请求拦截器 request.interceptors.request.use(config { const token localStorage.getItem(admin_token); if (token) { config.headers[Authorization] token; } return config; }); // 响应拦截器 request.interceptors.response.use( response { const res response.data; if (res.code ! 200) { Message.error(res.message); if (res.code 401) { router.push(/admin/login); } return Promise.reject(new Error(res.message)); } return res; }, error { Message.error(服务器连接失败); return Promise.reject(error); } );要注意baseURL设置为/api是配合开发环境的代理配置。开发环境里Vue CLI的devServer会配置代理把/api开头的请求转发到后端8081端口这样开发时不存在跨域问题。生产环境的Nginx也采用同样的转发规则。5.3 景点轮播图的实现方式景点详情页的轮播图我用的是Element UI的el-carousel组件数据来源于景区详情接口返回的imageList数组。前端不需要做任何复杂的逻辑只需要保证后端接口返回的数据结构稳定。template el-carousel v-ifimageList.length 0 height400px el-carousel-item v-for(item, index) in imageList :keyindex img :srcitem.imageUrl stylewidth:100%;height:100%;object-fit:cover; / /el-carousel-item /el-carousel /templateobject-fit: cover这个CSS属性值得专门说一下它的作用是让图片在固定宽高的容器里按比例缩放并裁剪多余部分不会因为图片原始比例不一样导致布局被撑破做旅游类图片展示的必备属性。5.4 后台管理页面的表格与表单管理端最多的页面就是“表格弹窗表单”的组合。景点管理页用el-table展示数据el-dialog做新增和编辑el-form做数据录入。这里有一个操作细节新增和编辑用的是同一个弹窗组件通过判断当前是否有选中行来决定是新增还是编辑。表单校验方面的细节也很大景点名称必填、门票价格必须是非负数、封面图片必须上传这些可以通过el-form的rules规则实现也可以在提交时用JS手动校验。我建议简单的字段用el-form的rules像“图片已上传”这种自定义校验逻辑就写在提交函数里容易控制。真正的目的是用户体验不要用一长串校验规则把用户整无语重要字段保证必填其他字段能放就放。6. 项目运行部署与性能优化实践6.1 本地开发环境搭建步骤这里把整套环境跑起来的步骤完整写一遍跟着操作就能把项目启动成功。假设你的电脑已经装好了JDK8、Maven3.6、Node.js14、MySQL5.7。第一步创建数据库并导入初始化SQL。SQL文件里包含了建表语句和基本测试数据在MySQL命令行执行source /你的路径/tourism.sql就能完成。第二步修改后端配置文件application.yml把数据库地址、用户名、密码改成你自己的。第三步后端启动。在项目根目录执行mvn spring-boot:run看到Started TurismApplication的日志说明启动成功。第四步前端依赖安装。进入frontend目录执行npm install这个过程可能比较慢建议用国内镜像源。第五步修改前端vue.config.js里的代理转发配置确认目标端口是后端端口。第六步执行npm run serve启动前端开发服务器浏览器访问localhost:8080就能看到效果。启动过程中最常见的问题有两个一个是MySQL密码加密规则不兼容需要在连接串上加上allowPublicKeyRetrievaltrue另一个是端口占用后端默认8081如果被占用在application.yml里改掉即可。6.2 数据库连接池与线程配置SpringBoot默认的HikariCP连接池是好选择不需要额外引入依赖配置信息直接写在application.yml里spring: datasource: url: jdbc:mysql://localhost:3306/tourism?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000连接池大小一般不需要调得很大20个连接对于这个规模的项目完全足够再大开高了对MySQL本身也是一种压力。serverTimezoneAsia/Shanghai这个参数要加上否则Java 8及以上的时区处理会和MySQL默认时区不一致查询出来的时间会差8小时。这个问题出现的频率相当高很多新人在排查日期误差时浪费了大量时间。6.3 Nginx反向代理与前端部署整理的部署方案是后端用jar包方式运行前端npm打包成静态文件然后用Nginx统一接收请求并反向代理到后端接口。核心Nginx配置server { listen 80; server_name your-domain.com; # 前端静态文件 location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8081/; 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 /uploads/ { alias /var/tourism/uploads/; } }try_files配置特别重要因为Vue是单页应用路由由前端控制如果不配置try_files直接访问/scenic/1这样的深层页面会404。proxy_pass后面的斜杠也有讲究http://127.0.0.1:8081/带斜杠表示把/api前缀去掉再转发例如请求/api/scenic/list会转发成http://127.0.0.1:8081/scenic/list。6.4 数据量变大后的索引优化旅游网站运营半年到一年后数据量会稳步增长特别是评论表和订单表。如果发现接口响应变慢第一步不是加缓存而是看慢查询日志和SQL执行计划。我的经验是先用EXPLAIN分析SQL看有没有走索引。针对这个项目最简单的索引优化方案如下ALTER TABLE scenic ADD INDEX idx_area (scenic_area); ALTER TABLE scenic ADD INDEX idx_recommend (recommend_level); ALTER TABLE comment ADD INDEX idx_target (target_type, target_id); ALTER TABLE orders ADD INDEX idx_user (user_id); ALTER TABLE orders ADD INDEX idx_order_no (order_no);评论表经常会按“某个景点的评论列表”来查询所以联合索引idx_target性价比很高。订单表按用户查询历史订单user_id索引是必须的。当评论数据超过10万条时再考虑把评论列表放到Redis缓存里但现阶段加索引已经足够解决大部分性能问题不要把系统搞得太复杂。7. 高频问题与踩坑排查实录7.1 跨域请求被浏览器拦截怎么办开发环境下前端页面部署在localhost:8080后端接口在localhost:8081浏览器会发起CORS预检请求如果后端没有配置允许跨域请求就会被拦截。解决办法是在后端配置一个CorsFilterConfiguration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }有一点需要注意如果前端请求里带了自定义Header比如Authorization那么allowedHeader里必须能匹配上否则预检请求会失败。把allowedHeader直接配置成*是省心但也要知道它代表允许所有请求头。生产环境走Nginx代理后其实没有跨域问题这个配置主要服务于开发环境。7.2 MyBatis驼峰映射导致查询字段为null我写SQL时习惯给数据库字段加下划线比如scenic_name、open_time但Java实体类里用的是驼峰命名scenicName、openTime。MyBatis默认情况下不会自动把下划线转驼峰导致查询结果里部分字段为null。解决方式在application.yml里开启全局配置mybatis: configuration: map-underscore-to-camel-case: true这是一行配置解决大量问题。如果你用的是MyBatis-Plus这个配置默认就是开启的。如果不想改全局配置也可以在SQL里给每个字段起别名但那样每个查询都要写别名太啰嗦了不值得。7.3 中文乱码从URL到数据库的完整链路出现中文乱码要按链路排查。第一步看数据库连接串有没有带characterEncodingutf8第二步看服务器响应头有没有设置UTF-8第三步看前端页面meta标签有没有charsetutf8。最隐蔽的坑是MySQL数据库本身和表的字符集不是UTF-8建库时要显式指定CREATE DATABASE tourism DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;注意用utf8mb4而不是utf8因为MySQL的utf8是utf8mb3的别名最多只能存3字节字符一些生僻字和特殊符号存不进去。旅游系统的景点介绍里可能会写陕北民歌里的特殊字符用utf8mb4更稳妥。7.4 文件上传成功但浏览器无法访问这是部署环境最容易遇到的问题。后端上传成功后返回了/uploads/20250912/xxx.jpg结果浏览器访问这个地址显示404。原因就是这个目录没有暴露成可访问的静态资源。Spring Boot 需要添加静态资源映射Configuration public class WebMvcConfig implements WebMvcConfigurer { Value(${upload.path}) private String uploadPath; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/uploads/**) .addResourceLocations(file: uploadPath); } }这里有一个容易出错的地方addResourceLocations参数必须以file:开头告诉Spring这是一个文件系统路径而不是classpath路径。配置完成后要重启应用然后访问http://localhost:8081/uploads/20250912/xxx.jpg验证效果。生产环境如果用了Nginx就不需要后端来处理静态资源直接把/uploads/位置映射到磁盘目录性能更好。7.5 后台接口被人未授权访问怎么办开发阶段为了方便测试有些接口可能没加拦截器保护上线前一定要回头检查。我给后台管理接口统一加了一个/admin/**的路径前缀然后在拦截器里统一拦截这个前缀下的所有请求。如果哪一天你发现有人能通过/admin/scenic/delete?id1这种地址直接删数据那基本就是拦截器路径没配好把/admin/**写成了/admin或者只拦截了POST请求没有拦截GET请求。还有一个容易忽略的点是图片上传接口也需要登录之后才能调用否则任何人都可以往你的服务器上扔文件这样服务器的磁盘会被垃圾文件塞满甚至可能被传可执行文件上去。权限控制不只是保护数据也是保护你的服务器。8. 从这套系统延伸出的几点思考8.1 一套模板如何复制到更多场景做完榆林这个项目后我最大的感受是旅游系统的业务逻辑其实高度相似。如果你现在想给另一个城市做一套类似的网站改的核心区域就是景点数据、线路数据、介绍的文案和图片而用户系统、订单系统、后台管理权限这些模块几乎可以原封不动地复用。这套模式可以延伸成“城市美食推荐系统”“博物馆预约管理系统”“民宿预订平台”——数据结构做微调核心代码基本不用动。这也是为什么一开始设计数据库时要尽量保持字段语义通用不要在某个表里写死跟榆林强关联的内容业务数据的特异性放在数据本身而不是代码逻辑里。8.2 我踩过最深的坑低估了内容运营的工作量开发这套系统时我一度以为代码写完就大功告成了但实际上内容录入才是最耗时的事情。榆林的景点图片需要一张一张地找、裁剪、压缩景点介绍需要一段一段地整理校准线路价格要和地接社反复确认。一块钱的门票差异都会导致用户投诉。所以如果你真的要把这个系统用起来至少得提前准备好这几个东西每个景点的封面图和至少三张详情图、每条线路的图文介绍和报价方案、热门美食的推荐文案。图片建议统一压缩到500KB以内保持900x600的宽高比既能保证展示效果又不会因为图片过大拖慢页面加载速度。8.3 后续迭代可以怎么加这个项目的基础版本做完之后如果继续迭代方向我在实践中觉得比较有价值的是增加线路收藏功能让用户下次登录还能看到自己之前关注过的线路。增加站内消息通知线路成团或者价格变化时通知用户。前台的搜索框增强联想提示功能用户输入“红”就能提示“红石峡”。后台增加数据统计报表按月统计访问量、订单量、热门景点排行给运营决策做参考。不要一上来就规划十几个模块先把核心链路跑通让内容先丰富起来一个能稳定运行并且有真实内容在更新的旅游网站比一个堆砌了无数功能却一个景点介绍都没有的空壳有价值得多。最后说一句经验的废话但也是真话代码可以慢慢写但数据准备和运营规划要尽早开始这是一个内容和功能并重的系统缺了哪一块都立不起来。
阅读完成 · 觉得有帮助?